forked from 77media/video-flow
401 lines
15 KiB
TypeScript
401 lines
15 KiB
TypeScript
'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>
|
||
);
|
||
}
|