video-flow-b/components/ui/drama-line-chart.tsx
2025-07-25 11:41:12 +08:00

401 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { TrendingUp, Eye, EyeOff } from 'lucide-react';
interface DataPoint {
x: number;
y: number;
label?: string;
}
interface DramaLineChartProps {
data?: DataPoint[];
width?: number;
height?: number;
onDataChange?: (data: DataPoint[]) => void;
className?: string;
showLabels?: boolean;
title?: string;
showToggleButton?: boolean;
}
export function DramaLineChart({
data: initialData,
width,
height = 120,
onDataChange,
className = '',
showLabels = true,
title = '戏剧张力线',
showToggleButton = true
}: DramaLineChartProps) {
// Mock 数据 - 模拟一个经典的戏剧结构x轴表示时间进度
const mockData: DataPoint[] = [
{ x: 0, y: 20, label: '0s' },
{ x: 15, y: 35, label: '1' },
{ x: 30, y: 45, label: '2s' },
{ x: 45, y: 65, label: '3s' },
{ x: 60, y: 85, label: '4s' },
{ x: 75, y: 70, label: '5s' },
{ x: 90, y: 40, label: '6s' },
{ x: 100, y: 25, label: '7s' },
{ x: 100, y: 25, label: '8s' },
];
const [data, setData] = useState<DataPoint[]>(initialData || mockData);
const [isDragging, setIsDragging] = useState(false);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [isVisible, setIsVisible] = useState(!showToggleButton);
const [hoveredPoint, setHoveredPoint] = useState<number | null>(null);
const [containerWidth, setContainerWidth] = useState(width || 320);
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// 动态计算容器宽度
useEffect(() => {
const updateWidth = () => {
if (containerRef.current && !width) {
setContainerWidth(containerRef.current.offsetWidth - 8); // 减去padding
}
};
updateWidth();
window.addEventListener('resize', updateWidth);
return () => window.removeEventListener('resize', updateWidth);
}, [width]);
// 计算SVG坐标
const padding = 20;
let chartWidth = (width || containerWidth) - padding * 4;
chartWidth = showToggleButton ? chartWidth : (width || containerWidth) - padding * 2;
const chartHeight = height - padding * 2;
const getPointPosition = useCallback((point: DataPoint) => {
return {
x: padding + (point.x / 100) * chartWidth,
y: padding + (1 - point.y / 100) * chartHeight
};
}, [chartWidth, chartHeight, padding]);
// 根据鼠标位置计算数据点
const getDataPointFromMouse = useCallback((clientX: number, clientY: number) => {
if (!svgRef.current) return null;
const rect = svgRef.current.getBoundingClientRect();
const x = clientX - rect.left;
const y = clientY - rect.top;
const dataX = Math.max(0, Math.min(100, ((x - padding) / chartWidth) * 100));
const dataY = Math.max(0, Math.min(100, (1 - (y - padding) / chartHeight) * 100));
return { x: dataX, y: dataY };
}, [chartWidth, chartHeight, padding]);
// 处理鼠标按下
const handleMouseDown = useCallback((e: React.MouseEvent, index: number) => {
e.preventDefault();
setIsDragging(true);
setDragIndex(index);
}, []);
// 处理鼠标移动
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!isDragging || dragIndex === null) return;
const newPoint = getDataPointFromMouse(e.clientX, e.clientY);
if (!newPoint) return;
const newData = [...data];
// 保持x坐标不变只允许改变y坐标
newData[dragIndex] = { ...newData[dragIndex], y: newPoint.y };
setData(newData);
onDataChange?.(newData);
}, [isDragging, dragIndex, data, getDataPointFromMouse, onDataChange]);
// 处理鼠标抬起
const handleMouseUp = useCallback(() => {
setIsDragging(false);
setDragIndex(null);
}, []);
// 添加全局事件监听
useEffect(() => {
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}
}, [isDragging, handleMouseMove, handleMouseUp]);
// 生成路径字符串
const pathData = data.map((point, index) => {
const pos = getPointPosition(point);
return index === 0 ? `M ${pos.x} ${pos.y}` : `L ${pos.x} ${pos.y}`;
}).join(' ');
// 生成平滑曲线路径
const smoothPathData = React.useMemo(() => {
if (data.length < 2) return pathData;
const points = data.map(getPointPosition);
let path = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
const next = points[i + 1];
if (i === 1) {
// 第二个点
const cp1x = prev.x + (curr.x - prev.x) * 0.3;
const cp1y = prev.y;
const cp2x = curr.x - (curr.x - prev.x) * 0.3;
const cp2y = curr.y;
path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${curr.x} ${curr.y}`;
} else if (i === points.length - 1) {
// 最后一个点
const cp1x = prev.x + (curr.x - prev.x) * 0.3;
const cp1y = prev.y;
const cp2x = curr.x - (curr.x - prev.x) * 0.3;
const cp2y = curr.y;
path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${curr.x} ${curr.y}`;
} else {
// 中间的点
const prevPrev = points[i - 2];
const cp1x = prev.x + (curr.x - prevPrev.x) * 0.15;
const cp1y = prev.y + (curr.y - prevPrev.y) * 0.15;
const cp2x = curr.x - (next.x - prev.x) * 0.15;
const cp2y = curr.y - (next.y - prev.y) * 0.15;
path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${curr.x} ${curr.y}`;
}
}
return path;
}, [data, getPointPosition, pathData]);
// 切换显示状态
const toggleVisibility = () => {
setIsVisible(!isVisible);
};
return (
<div className={`${className}`} ref={containerRef}>
{/* 切换按钮 */}
{showToggleButton && (
<motion.button
onClick={toggleVisibility}
className="absolute top-2 left-2 z-20 p-2 rounded-lg bg-black/20 backdrop-blur-sm
border border-white/20 text-white/80 hover:text-white hover:bg-black/30
transition-all duration-200"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
title={isVisible ? '隐藏戏剧线' : '显示戏剧线'}
>
<motion.div
animate={{ rotate: isVisible ? 0 : 180 }}
transition={{ duration: 0.3 }}
>
{isVisible ? (
<Eye className="w-4 h-4" />
) : (
<EyeOff className="w-4 h-4" />
)}
</motion.div>
</motion.button>
)}
{/* 折线图容器 */}
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className={showToggleButton ? "absolute bottom-0 left-0 z-10 pointer-events-auto" : "relative w-full pointer-events-auto"}
>
<div className={showToggleButton ? "w-full rounded-lg bg-black/20 backdrop-blur-sm p-3" : "w-full p-2"}>
{/* 标题 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.3 }}
className="flex items-center gap-2 mb-2"
>
<TrendingUp className="w-4 h-4 text-blue-400" />
<span className="text-sm font-medium text-white/90">{title}</span>
<span className="text-xs text-white/50">Drag to adjust the tension value</span>
</motion.div>
{/* SVG 图表 */}
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, duration: 0.4, ease: "easeOut" }}
className="relative"
>
<svg
ref={svgRef}
width={width || containerWidth}
height={height}
className="overflow-visible cursor-crosshair"
style={{ maxWidth: '100%', height: 'auto' }}
>
{/* 网格线 */}
<defs>
<pattern
id="grid"
width={chartWidth / 10}
height={chartHeight / 5}
patternUnits="userSpaceOnUse"
>
<path
d={`M 0 0 L ${chartWidth / 10} 0 M 0 0 L 0 ${chartHeight / 5}`}
fill="none"
stroke="rgba(255,255,255,0.1)"
strokeWidth="0.5"
/>
</pattern>
{/* 渐变 */}
<linearGradient id="lineGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#fff" stopOpacity="0.4" />
<stop offset="50%" stopColor="#fff" stopOpacity="0.4" />
<stop offset="100%" stopColor="#fff" stopOpacity="0.4" />
</linearGradient>
{/* 区域渐变 */}
<linearGradient id="areaGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.3" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0.05" />
</linearGradient>
</defs>
{/* 主线条 */}
<motion.path
d={smoothPathData}
fill="none"
stroke="url(#lineGradient)"
strokeWidth="2"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 1.2, delay: 0.4, ease: "easeInOut" }}
/>
{/* 数据点 */}
{data.map((point, index) => {
const pos = getPointPosition(point);
const isHovered = hoveredPoint === index;
const isDraggingThis = dragIndex === index;
return (
<g key={index}>
{/* 点的光环效果 */}
{(isHovered || isDraggingThis) && (
<motion.circle
cx={pos.x}
cy={pos.y}
r="8"
fill="none"
stroke="#3b82f6"
strokeWidth="1"
opacity="0.5"
initial={{ scale: 0 }}
animate={{ scale: [1, 1.5, 1] }}
transition={{ duration: 1, repeat: Infinity }}
/>
)}
{/* 主要的点 */}
<motion.circle
cx={pos.x}
cy={pos.y}
r={isHovered || isDraggingThis ? "5" : "3"}
fill="#3b82f680"
stroke="white"
strokeWidth="1"
className="cursor-grab active:cursor-grabbing"
onMouseDown={(e) => handleMouseDown(e, index)}
onMouseEnter={() => setHoveredPoint(index)}
onMouseLeave={() => setHoveredPoint(null)}
whileHover={{ scale: 1.3 }}
whileTap={{ scale: 0.9 }}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{
delay: 0.5 + index * 0.1,
duration: 0.3,
type: "spring",
stiffness: 300
}}
/>
{/* 数值标签 */}
{(isHovered || isDraggingThis) && showLabels && (
<motion.g
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 5 }}
>
<rect
x={pos.x - 15}
y={pos.y - 25}
width="30"
height="16"
rx="8"
fill="rgba(0,0,0,0.8)"
stroke="rgba(255,255,255,0.2)"
strokeWidth="0.5"
/>
<text
x={pos.x}
y={pos.y - 15}
textAnchor="middle"
className="text-xs fill-white font-medium"
>
{Math.round(point.y)}
</text>
</motion.g>
)}
</g>
);
})}
{/* 时间轴标签 - 替代原来的底部标签 */}
{showLabels && data.map((point, index) => {
const pos = getPointPosition(point);
// 只显示部分标签避免重叠
const shouldShow = index === 0 || index === data.length - 1 || index % 2 === 0;
return shouldShow ? (
<motion.text
key={`time-${index}`}
x={pos.x}
y={height - 5}
textAnchor="middle"
className="text-xs fill-white/60"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8 + index * 0.1 }}
>
{point.label}
</motion.text>
) : null;
})}
</svg>
</motion.div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}