import React, { useRef, useEffect, useState, useCallback } from "react"; import vsSource from './glsl/timeline/vert.glsl'; import fsSource from './glsl/timeline/frag.glsl'; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { ChevronLeft, ChevronRight, Pause, Play } from "lucide-react"; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select" import { useTimeline } from "@/hooks/use-timeline"; interface Uniforms { startTimestamp: number; // Unix 时间戳开始 endTimestamp: number; // Unix 时间戳结束 currentTimestamp: number; // 当前时间戳 radius: number; d: number; timelineStartX: number; // 时间轴在屏幕上的开始X坐标 timelineEndX: number; // 时间轴在屏幕上的结束X坐标 viewportSize: [number, number]; zoomLevel: number; // 当前缩放级别 panOffset: number; // 当前平移偏移 } interface Instants { position: Float32Array; color: Float32Array; } interface VesicaDataPoint { timestamp: number; // Unix 时间戳 color?: [number, number, number, number]; // RGBA 颜色,默认为白色 } interface Props extends React.HTMLAttributes { boxSize?: [number, number]; startDate?: Date; endDate?: Date; currentDate?: Date; onDateChange?: (date: Date) => void; onPlay?: () => void; onPause?: () => void; minZoom?: number; // 最小缩放级别 maxZoom?: number; // 最大缩放级别 initialZoom?: number; // 初始缩放级别 vesicaData?: VesicaDataPoint[]; // vesica 实例数据 dateFormat?: (timestamp: number) => string; // 自定义时间格式化函数 } interface Status { isDragging: boolean; isLongPress: boolean; isPanningTimeline: boolean; customLineTimestamp: number | null; // 改为存储时间戳而不是屏幕坐标 panOffset: number; zoomLevel: number; } interface RedrawOverrides { startDate?: Date; endDate?: Date; currentDate?: Date; zoomLevel?: number; panOffset?: number; customLineTimestamp?: number | null; isDragging?: boolean; } // 默认的vesica数据示例 const getDefaultVesicaData = (startDate?: Date, endDate?: Date): VesicaDataPoint[] => { if (!startDate || !endDate) return []; const start = startDate.getTime(); const end = endDate.getTime(); const timeRange = end - start; // 创建一些示例数据点 return [ { timestamp: start + timeRange * 0.1, color: [1.0, 0.2, 0.2, 0.8] // 红色 }, { timestamp: start + timeRange * 0.25, color: [0.2, 1.0, 0.2, 0.8] // 绿色 }, { timestamp: start + timeRange * 0.4, color: [0.2, 0.2, 1.0, 0.8] // 蓝色 }, { timestamp: start + timeRange * 0.6, color: [1.0, 1.0, 0.2, 0.8] // 黄色 }, { timestamp: start + timeRange * 0.75, color: [1.0, 0.2, 1.0, 0.8] // 紫色 }, { timestamp: start + timeRange * 0.9, color: [0.2, 1.0, 1.0, 0.8] // 青色 } ]; }; function SelectDemo() { return ( ) } export const Timeline: React.FC = ({ startDate, endDate, currentDate, onDateChange, onPlay, onPause, boxSize = [4, 8], minZoom = 0.5, maxZoom = 8, initialZoom = 1, vesicaData, dateFormat, ...props }) => { const canvasRef = useRef(null); const ticksCanvasRef = useRef(null); const { isPlaying, togglePlay } = useTimeline({ initialDate: currentDate ?? new Date(), onDateChange(date) { onDateChange?.(date); } }) const [state, setState] = useState({ isDragging: false, isLongPress: false, isPanningTimeline: false, customLineTimestamp: currentDate?.getTime() ?? new Date().getTime(), panOffset: 0, zoomLevel: initialZoom, }); const longPressTimerRef = useRef(null); const dragTempXRef = useRef(null); const animationFrameRef = useRef(null); const panStartXRef = useRef(0); const panStartOffsetRef = useRef(0); const panTempOffsetRef = useRef(0); const hasDraggedTimelineRef = useRef(false); const actualVesicaData = vesicaData ?? []; // 使用ref存储最新的状态值,避免闭包陷阱 const stateRef = useRef(state); const propsRef = useRef({ startDate, endDate, currentDate }); // 每次渲染时更新ref的值 stateRef.current = state; propsRef.current = { startDate, endDate, currentDate }; const forward = useCallback(() => { const newDate = new Date(state.customLineTimestamp! + 360000); setState({ ...state, customLineTimestamp: newDate.getTime() }); onDateChange?.(newDate); }, [state.customLineTimestamp]) // 缩放处理函数 const handleZoom = (delta: number, mouseX?: number) => { const zoomFactor = 1.15; // 调整缩放因子,使缩放更平滑 const newZoom = delta > 0 ? Math.min(state.zoomLevel * zoomFactor, maxZoom) : Math.max(state.zoomLevel / zoomFactor, minZoom); // 如果缩放级别没有变化,直接返回 if (newZoom === state.zoomLevel) return; // 基于鼠标位置的缩放(保持鼠标位置下的内容不变) let newPanOffset = state.panOffset; if (mouseX !== undefined && ticksCanvasRef.current && startDate && endDate) { const rect = ticksCanvasRef.current.getBoundingClientRect(); const startX = 40; // 时间轴左边距,与drawTicks保持一致 const endX = rect.width - 40; // 时间轴右边距 const timelineWidth = endX - startX; // 计算总时间范围 const timeRange = endDate.getTime() - startDate.getTime(); // 计算当前可见时间窗口(缩放前) const currentVisibleTimeRange = timeRange / state.zoomLevel; const currentTimePerPixel = currentVisibleTimeRange / timelineWidth; const currentPanTimeOffset = -state.panOffset * currentTimePerPixel; const originalCenterTime = startDate.getTime() + timeRange / 2; const currentCenterTime = originalCenterTime + currentPanTimeOffset; const currentVisibleStartTime = currentCenterTime - currentVisibleTimeRange / 2; // 计算鼠标位置对应的时间点 const mouseRelativeX = mouseX - startX; const mouseTimeProgress = mouseRelativeX / timelineWidth; const mouseTime = currentVisibleStartTime + mouseTimeProgress * currentVisibleTimeRange; // 计算缩放后的可见时间窗口 const newVisibleTimeRange = timeRange / newZoom; const newTimePerPixel = newVisibleTimeRange / timelineWidth; // 计算新的中心时间,使鼠标位置对应的时间点保持不变 const newVisibleStartTime = mouseTime - mouseTimeProgress * newVisibleTimeRange; const newCenterTime = newVisibleStartTime + newVisibleTimeRange / 2; const newPanTimeOffset = newCenterTime - originalCenterTime; newPanOffset = -newPanTimeOffset / newTimePerPixel; // 应用边界限制 const maxPanOffset = 100 * newZoom; newPanOffset = Math.max(-maxPanOffset, Math.min(maxPanOffset, newPanOffset)); } setState({ ...state, zoomLevel: newZoom, panOffset: newPanOffset }); }; // 平移处理函数 const handlePan = (deltaX: number) => { // 计算平移边界限制 const maxPanOffset = 100 * state.zoomLevel; // 最大平移距离与缩放级别相关 const newOffset = Math.max(-maxPanOffset, Math.min(maxPanOffset, state.panOffset + deltaX)); // const newOffset = state.panOffset + deltaX setState({ ...state, panOffset: newOffset }); }; // 重置缩放和平移 const resetView = () => { setState({ ...state, zoomLevel: initialZoom, panOffset: 0 }); }; const vaoRef = useRef(null); const vertex_bfRef = useRef(null); const uniform_bfRef = useRef(null); const instants_bfRef = useRef(null); const programRef = useRef(null); const instants_countRef = useRef(0); // 统一的重绘函数,支持临时状态覆盖 const redraw = useCallback((overrides: RedrawOverrides = {}) => { if (!canvasRef.current || !ticksCanvasRef.current) return; const rect = ticksCanvasRef.current.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return; const dpr = window.devicePixelRatio || 1; const currentState = stateRef.current; const currentProps = propsRef.current; drawTicks( ticksCanvasRef.current, rect.width, rect.height, dpr, overrides.startDate ?? currentProps.startDate, overrides.endDate ?? currentProps.endDate, overrides.currentDate ?? currentProps.currentDate, overrides.zoomLevel ?? currentState.zoomLevel, overrides.panOffset ?? currentState.panOffset, overrides.customLineTimestamp ?? currentState.customLineTimestamp, overrides.isDragging ?? currentState.isDragging ); }, []); // 只在特定情况下监听重绘 useEffect(() => { // 避免拖拽时的重复重绘 const currentState = stateRef.current; if ((currentState.isDragging && currentState.isLongPress) || currentState.isPanningTimeline) return; // 使用requestAnimationFrame优化重绘性能 const rafId = requestAnimationFrame(() => { redraw(); }); return () => { cancelAnimationFrame(rafId); }; }, [state.customLineTimestamp, state.zoomLevel, state.panOffset, startDate, endDate, currentDate, redraw]); const { radius, d } = calcVersicaUni(boxSize); const current_uniforms = useRef({ startTimestamp: 0, endTimestamp: 0, currentTimestamp: 0, radius: radius, d: d, timelineStartX: 0, timelineEndX: 0, viewportSize: [0, 0], zoomLevel: initialZoom, panOffset: 0, }) useEffect(() => { if (!canvasRef.current || !ticksCanvasRef.current) return; const { radius, d } = calcVersicaUni(boxSize); const handleMouseClick = (e: MouseEvent) => { const rect = ticksCanvasRef.current?.getBoundingClientRect(); if (!rect) return; const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; const centerY = rect.height / 2; const currentState = stateRef.current; // 如果刚刚完成了时间轴拖拽,则不处理点击事件 // 注意:现在只检查是否已经实际拖拽过,而不是检查isPanningTimeline状态 if (hasDraggedTimelineRef.current) { return; } const timelineStartX = 40; const timelineEndX = rect.width - 40; const timelineRange = 20; // 检查是否在时间轴区域内点击 const isInTimelineArea = mouseX >= timelineStartX && mouseX <= timelineEndX && Math.abs(mouseY - centerY) <= timelineRange; // 检查是否点击了现有的自定义竖线附近 let isNearCustomLine = false; if (currentState.customLineTimestamp !== null && startDate && endDate) { // 根据时间戳计算当前屏幕位置 const timeRange = endDate.getTime() - startDate.getTime(); const visibleTimeRange = timeRange / currentState.zoomLevel; const timelineWidth = timelineEndX - timelineStartX; const timePerPixel = visibleTimeRange / timelineWidth; const panTimeOffset = -currentState.panOffset * timePerPixel; const originalCenterTime = startDate.getTime() + timeRange / 2; const newCenterTime = originalCenterTime + panTimeOffset; const visibleStartTime = newCenterTime - visibleTimeRange / 2; const timeProgress = (currentState.customLineTimestamp - visibleStartTime) / visibleTimeRange; const customLineScreenX = timelineStartX + timeProgress * timelineWidth; isNearCustomLine = Math.abs(mouseX - customLineScreenX) <= 10; } // 只在时间轴区域内点击且不是竖线附近时创建指示器 if (isInTimelineArea && !isNearCustomLine) { // 根据当前的缩放和平移状态计算点击位置对应的时间戳 if (startDate && endDate) { const timelineWidth = timelineEndX - timelineStartX; const relativeX = mouseX - timelineStartX; const progress = relativeX / timelineWidth; // 基于可见时间窗口计算时间戳 const timeRange = endDate.getTime() - startDate.getTime(); const visibleTimeRange = timeRange / currentState.zoomLevel; const timePerPixel = visibleTimeRange / timelineWidth; const panTimeOffset = -currentState.panOffset * timePerPixel; const originalCenterTime = startDate.getTime() + timeRange / 2; const newCenterTime = originalCenterTime + panTimeOffset; const visibleStartTime = newCenterTime - visibleTimeRange / 2; const selectedTimestamp = visibleStartTime + progress * visibleTimeRange; setState(prevState => ({ ...prevState, customLineTimestamp: selectedTimestamp, isLongPress: false, isDragging: false })); // 通知父组件 if (onDateChange) { onDateChange(new Date(selectedTimestamp)); } } } } // 在useEffect内部定义事件处理函数,确保正确的作用域和清理 // 鼠标按下处理 - 检测是否点击了自定义竖线,或创建新竖线,或开始拖拽时间轴 const handleMouseDown = (e: MouseEvent) => { const rect = ticksCanvasRef.current?.getBoundingClientRect(); if (!rect) return; const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; const centerY = rect.height / 2; const currentState = stateRef.current; // 定义时间轴区域 - 与drawTicks中的参数保持一致 const timelineStartX = 40; const timelineEndX = rect.width - 40; const timelineRange = 20; // 减小范围,让判断更精确 // 检查是否在时间轴的主要区域内(优先级最高) const isInTimelineArea = mouseX >= timelineStartX && mouseX <= timelineEndX && Math.abs(mouseY - centerY) <= timelineRange; // 检查是否点击了现有的自定义竖线附近 let isNearCustomLine = false; if (currentState.customLineTimestamp !== null && startDate && endDate) { // 根据时间戳计算当前屏幕位置 const timeRange = endDate.getTime() - startDate.getTime(); const visibleTimeRange = timeRange / currentState.zoomLevel; const timelineWidth = timelineEndX - timelineStartX; const timePerPixel = visibleTimeRange / timelineWidth; const panTimeOffset = -currentState.panOffset * timePerPixel; const originalCenterTime = startDate.getTime() + timeRange / 2; const newCenterTime = originalCenterTime + panTimeOffset; const visibleStartTime = newCenterTime - visibleTimeRange / 2; const timeProgress = (currentState.customLineTimestamp - visibleStartTime) / visibleTimeRange; const customLineScreenX = timelineStartX + timeProgress * timelineWidth; isNearCustomLine = Math.abs(mouseX - customLineScreenX) <= 10; } if (isNearCustomLine) { // 处理自定义竖线拖拽(长按激活) longPressTimerRef.current = setTimeout(() => { setState(prevState => ({ ...prevState, isLongPress: true, isDragging: true })); redraw({ isDragging: true }); }, 100); } else if (isInTimelineArea) { // 记录鼠标按下位置,但不立即开启拖拽模式 // 等待鼠标移动超过阈值时再开启拖拽 panStartXRef.current = mouseX; panStartOffsetRef.current = currentState.panOffset; panTempOffsetRef.current = currentState.panOffset; hasDraggedTimelineRef.current = false; // 重置拖拽标记 } else { // 不在任何特殊区域,不执行任何操作 // 移除在时间轴区域外创建竖线的逻辑,因为现在通过click事件处理 } }; // 鼠标移动处理 - 拖拽自定义竖线或拖拽时间轴 const handleMouseMove = (e: MouseEvent) => { const rect = ticksCanvasRef.current?.getBoundingClientRect(); if (!rect) return; const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; const centerY = rect.height / 2; const currentState = stateRef.current; // 处理指示器拖拽 if (currentState.isDragging && currentState.isLongPress) { // 限制竖线在时间轴范围内 const startX = 40; const endX = rect.width - 40; const clampedX = Math.max(startX, Math.min(endX, mouseX)); // 使用ref存储临时位置,避免频繁状态更新 dragTempXRef.current = clampedX; // 使用requestAnimationFrame优化重绘 if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } animationFrameRef.current = requestAnimationFrame(() => { if (ticksCanvasRef.current && dragTempXRef.current !== null && startDate && endDate) { const rect = ticksCanvasRef.current.getBoundingClientRect(); const timelineStartX = 40; const timelineEndX = rect.width - 40; const timelineWidth = timelineEndX - timelineStartX; const relativeX = dragTempXRef.current - timelineStartX; const progress = relativeX / timelineWidth; // 基于可见时间窗口计算时间戳 const timeRange = endDate.getTime() - startDate.getTime(); const visibleTimeRange = timeRange / currentState.zoomLevel; const timePerPixel = visibleTimeRange / timelineWidth; const panTimeOffset = -currentState.panOffset * timePerPixel; const originalCenterTime = startDate.getTime() + timeRange / 2; const newCenterTime = originalCenterTime + panTimeOffset; const visibleStartTime = newCenterTime - visibleTimeRange / 2; const dragTimestamp = visibleStartTime + progress * visibleTimeRange; redraw({ customLineTimestamp: dragTimestamp, isDragging: true }); } }); return; } // 处理时间轴拖拽 if (currentState.isPanningTimeline) { const deltaX = mouseX - panStartXRef.current; const newOffset = panStartOffsetRef.current + deltaX; // 如果移动距离超过阈值,标记为已拖拽(使用ref避免频繁状态更新) if (Math.abs(deltaX) > 3) { hasDraggedTimelineRef.current = true; } // 应用边界限制 const maxPanOffset = 100 * currentState.zoomLevel; const clampedOffset = Math.max(-maxPanOffset, Math.min(maxPanOffset, newOffset)); // 存储临时偏移 panTempOffsetRef.current = clampedOffset; // 直接重绘,不使用requestAnimationFrame以减少延迟 if (ticksCanvasRef.current) { redraw({ panOffset: panTempOffsetRef.current }); } return; } // 检查是否应该开始时间轴拖拽(移动距离阈值检查) if (!currentState.isPanningTimeline && !currentState.isDragging && panStartXRef.current !== 0) { const deltaX = mouseX - panStartXRef.current; const dragThreshold = 8; // 拖拽阈值:8像素 // 检查是否在时间轴区域内 const timelineStartX = 40; const timelineEndX = rect.width - 40; const timelineRange = 20; const isInTimelineArea = mouseX >= timelineStartX && mouseX <= timelineEndX && Math.abs(mouseY - centerY) <= timelineRange; // 只有在时间轴区域内且移动距离超过阈值时才开启拖拽 if (isInTimelineArea && Math.abs(deltaX) > dragThreshold) { setState(prevState => ({ ...prevState, isPanningTimeline: true })); } } updateCursorStyle(); }; // 鼠标抬起处理 - 结束拖拽 const handleMouseUp = () => { const currentState = stateRef.current; // 清除长按计时器 if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current); longPressTimerRef.current = null; } // 取消待处理的动画帧 if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } // 处理指示器拖拽结束 if (currentState.isDragging && currentState.isLongPress && dragTempXRef.current !== null) { // 将最终的屏幕坐标转换为时间戳并保存 if (startDate && endDate && ticksCanvasRef.current) { const rect = ticksCanvasRef.current.getBoundingClientRect(); const timelineStartX = 40; const timelineEndX = rect.width - 40; const timelineWidth = timelineEndX - timelineStartX; const relativeX = dragTempXRef.current - timelineStartX; const progress = relativeX / timelineWidth; // 基于可见时间窗口计算时间戳 const timeRange = endDate.getTime() - startDate.getTime(); const visibleTimeRange = timeRange / currentState.zoomLevel; const timePerPixel = visibleTimeRange / timelineWidth; const panTimeOffset = -currentState.panOffset * timePerPixel; const originalCenterTime = startDate.getTime() + timeRange / 2; const newCenterTime = originalCenterTime + panTimeOffset; const visibleStartTime = newCenterTime - visibleTimeRange / 2; const finalTimestamp = visibleStartTime + progress * visibleTimeRange; setState(prevState => ({ ...prevState, customLineTimestamp: finalTimestamp, isDragging: false, isLongPress: false })); } else { setState(prevState => ({ ...prevState, isDragging: false, isLongPress: false })); } dragTempXRef.current = null; } else if (currentState.isPanningTimeline) { // 处理时间轴拖拽结束 // 提交临时偏移到真实状态并重置时间轴拖拽状态 const finalOffset = panTempOffsetRef.current; setState(prevState => ({ ...prevState, panOffset: finalOffset, isPanningTimeline: false })); // 立即用最终偏移重绘,确保显示正确 redraw({ panOffset: finalOffset }); // 光标样式由updateCursorStyle统一管理 // 延迟重置拖拽标记和临时偏移 setTimeout(() => { hasDraggedTimelineRef.current = false; panTempOffsetRef.current = 0; // 现在可以安全重置了 }, 50); } else { // 只重置拖拽状态 setState(prevState => ({ ...prevState, isDragging: false, isLongPress: false })); } // 重置拖拽起始位置(重要:避免后续误判) panStartXRef.current = 0; }; // 更新光标样式的通用函数 const updateCursorStyle = () => { const currentState = stateRef.current; if (!ticksCanvasRef.current) return; if (currentState.isPanningTimeline) { // 当且仅当正在拖拽时间轴时显示抓取中光标 ticksCanvasRef.current.style.cursor = 'grabbing'; } else { // 其余所有情况均显示十字光标 ticksCanvasRef.current.style.cursor = 'crosshair'; } }; // 鼠标悬停处理 - 动态改变鼠标样式 const handleMouseOver = (e: MouseEvent) => { updateCursorStyle(); }; // 鼠标移出处理 const handleMouseOut = () => { // 光标样式由updateCursorStyle统一管理,不需要特殊处理 }; // 获取设备像素比例以支持高分屏 const dpr = window.devicePixelRatio || 1; const displayWidth = canvasRef.current.clientWidth; const displayHeight = canvasRef.current.clientHeight; // 设置WebGL canvas的实际像素尺寸(考虑高分屏) const actualWidth = Math.floor(displayWidth * dpr); const actualHeight = Math.floor(displayHeight * dpr); canvasRef.current.width = actualWidth; canvasRef.current.height = actualHeight; // 设置CSS显示尺寸 canvasRef.current.style.width = displayWidth + 'px'; canvasRef.current.style.height = displayHeight + 'px'; // 设置刻度线canvas的尺寸 setupTicksCanvas(ticksCanvasRef.current, displayWidth, displayHeight, dpr); // 初始绘制刻度线,确保canvas设置完成后立即显示 redraw(); // 更新uniform数据 current_uniforms.current.viewportSize = [actualWidth, actualHeight]; current_uniforms.current.radius = radius * dpr; // 调整radius以适应像素密度 current_uniforms.current.d = d * dpr; // 调整d以适应像素密度 current_uniforms.current.timelineStartX = 40 * dpr; // 时间轴开始坐标 current_uniforms.current.timelineEndX = (displayWidth - 40) * dpr; // 时间轴结束坐标 current_uniforms.current.zoomLevel = state.zoomLevel; current_uniforms.current.panOffset = state.panOffset * dpr; // 设置时间戳范围 if (startDate && endDate) { current_uniforms.current.startTimestamp = startDate.getTime(); current_uniforms.current.endTimestamp = endDate.getTime(); } if (currentDate) { current_uniforms.current.currentTimestamp = currentDate.getTime(); } // 鼠标滚轮缩放和平移 const handleWheel = (e: WheelEvent) => { e.preventDefault(); const rect = ticksCanvasRef.current?.getBoundingClientRect(); if (!rect) return; const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; const centerY = rect.height / 2; // 只在时间轴区域内滚动时才处理 const timelineStartX = 40; const timelineEndX = rect.width - 40; const timelineRange = 30; // 扩大一点范围 const isInTimelineArea = mouseX >= timelineStartX && mouseX <= timelineEndX && Math.abs(mouseY - centerY) <= timelineRange; if (isInTimelineArea) { // 设置阈值,避免微小滚动触发操作 const deltaThreshold = 3; const deltaX = e.deltaX; const deltaY = e.deltaY; // 判断主要滚动方向和是否超过阈值 if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > deltaThreshold) { // 水平滚动:平移操作 // 反转方向并增加速度,使滚动更跟手 handlePan(-deltaX * 2); // 反转方向,增加速度倍数 } else if (Math.abs(deltaY) > Math.abs(deltaX) && Math.abs(deltaY) > deltaThreshold) { // 垂直滚动:缩放操作 handleZoom(-deltaY, mouseX); } } }; // 触摸缩放和平移 let lastTouchDistance = 0; let lastTouchX = 0; let isPanning = false; const handleTouchStart = (e: TouchEvent) => { if (e.touches.length === 2) { // 双指缩放 const dx = e.touches[0].clientX - e.touches[1].clientX; const dy = e.touches[0].clientY - e.touches[1].clientY; lastTouchDistance = Math.sqrt(dx * dx + dy * dy); } else if (e.touches.length === 1) { // 单指平移 lastTouchX = e.touches[0].clientX; isPanning = true; } }; const handleTouchMove = (e: TouchEvent) => { e.preventDefault(); if (e.touches.length === 2) { // 双指缩放 const dx = e.touches[0].clientX - e.touches[1].clientX; const dy = e.touches[0].clientY - e.touches[1].clientY; const distance = Math.sqrt(dx * dx + dy * dy); if (lastTouchDistance > 0) { const scale = distance / lastTouchDistance; const delta = scale > 1 ? 1 : -1; handleZoom(delta * 50); } lastTouchDistance = distance; } else if (e.touches.length === 1 && isPanning) { // 单指平移 const deltaX = e.touches[0].clientX - lastTouchX; handlePan(deltaX); lastTouchX = e.touches[0].clientX; } }; const handleTouchEnd = () => { lastTouchDistance = 0; isPanning = false; }; // 双击重置 const handleDoubleClick = () => { resetView(); }; ticksCanvasRef.current?.addEventListener('wheel', handleWheel, { passive: false }); ticksCanvasRef.current?.addEventListener('touchstart', handleTouchStart); ticksCanvasRef.current?.addEventListener('touchmove', handleTouchMove, { passive: false }); ticksCanvasRef.current?.addEventListener('touchend', handleTouchEnd); ticksCanvasRef.current?.addEventListener('dblclick', handleDoubleClick); ticksCanvasRef.current?.addEventListener('mouseover', handleMouseOver); ticksCanvasRef.current?.addEventListener('mouseout', handleMouseOut); // 添加自定义竖线相关的事件监听器 ticksCanvasRef.current?.addEventListener('mousedown', handleMouseDown); ticksCanvasRef.current?.addEventListener('mousemove', handleMouseMove); ticksCanvasRef.current?.addEventListener('click', handleMouseClick); ticksCanvasRef.current?.addEventListener('mouseup', handleMouseUp); // 添加全局鼠标抬起事件,防止鼠标移出canvas后拖拽卡住 document.addEventListener('mouseup', handleMouseUp); return () => { // 移除事件监听器 ticksCanvasRef.current?.removeEventListener('wheel', handleWheel); ticksCanvasRef.current?.removeEventListener('touchstart', handleTouchStart); ticksCanvasRef.current?.removeEventListener('touchmove', handleTouchMove); ticksCanvasRef.current?.removeEventListener('touchend', handleTouchEnd); ticksCanvasRef.current?.removeEventListener('dblclick', handleDoubleClick); ticksCanvasRef.current?.removeEventListener('mouseover', handleMouseOver); ticksCanvasRef.current?.removeEventListener('mouseout', handleMouseOut); // 移除自定义竖线相关的事件监听器 ticksCanvasRef.current?.removeEventListener('mousedown', handleMouseDown); ticksCanvasRef.current?.removeEventListener('mousemove', handleMouseMove); ticksCanvasRef.current?.removeEventListener('click', handleMouseClick); ticksCanvasRef.current?.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mouseup', handleMouseUp); // 清理长按计时器 if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current); } // 清理动画帧 if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } // 重置临时状态(不需要调用setState,因为组件即将卸载或重新初始化) panTempOffsetRef.current = 0; } }, [boxSize, actualVesicaData]); // 监听状态变化,更新uniform数据并重新渲染 useEffect(() => { if (!programRef.current || !uniform_bfRef.current) return; // 更新缩放和平移相关的uniform current_uniforms.current.zoomLevel = state.zoomLevel; current_uniforms.current.panOffset = state.panOffset * (window.devicePixelRatio || 1); // 更新时间戳数据 if (startDate && endDate) { current_uniforms.current.startTimestamp = startDate.getTime(); current_uniforms.current.endTimestamp = endDate.getTime(); } if (currentDate) { current_uniforms.current.currentTimestamp = currentDate.getTime(); } // 获取WebGL上下文并更新uniform const canvas = canvasRef.current; if (canvas) { const gl = canvas.getContext('webgl2') as WebGL2RenderingContext; if (gl) { const program = programRef.current; gl.useProgram(program); // 更新uniform数据 gl.bindBuffer(gl.UNIFORM_BUFFER, uniform_bfRef.current); const uniformData = new Float32Array([ current_uniforms.current.startTimestamp, current_uniforms.current.endTimestamp, current_uniforms.current.currentTimestamp, current_uniforms.current.radius, current_uniforms.current.d, current_uniforms.current.timelineStartX, current_uniforms.current.timelineEndX, 0.0, // padding current_uniforms.current.viewportSize[0], current_uniforms.current.viewportSize[1], current_uniforms.current.zoomLevel, current_uniforms.current.panOffset ]); gl.bufferData(gl.UNIFORM_BUFFER, uniformData, gl.DYNAMIC_DRAW); // 重新渲染 gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); gl.bindVertexArray(vaoRef.current); gl.bindBufferBase(gl.UNIFORM_BUFFER, 0, uniform_bfRef.current); gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, instants_countRef.current); gl.bindVertexArray(null); } } }, [state.zoomLevel, state.panOffset, startDate, endDate, currentDate]); useEffect(() => { if (!canvasRef.current) return; const dpr = window.devicePixelRatio || 1; const displayWidth = canvasRef.current.clientWidth; const displayHeight = canvasRef.current.clientHeight; // 设置WebGL canvas的实际像素尺寸(考虑高分屏) const actualWidth = Math.floor(displayWidth * dpr); const actualHeight = Math.floor(displayHeight * dpr); canvasRef.current.width = actualWidth; canvasRef.current.height = actualHeight; const gl = (canvasRef.current.getContext('webgl2', { antialias: true, // 启用抗锯齿 alpha: true, // 启用alpha通道以支持透明度 premultipliedAlpha: false, // 不使用预乘alpha depth: false, // 不需要深度缓冲 stencil: false, // 不需要模板缓冲 preserveDrawingBuffer: false // 不保留绘制缓冲区 }) as WebGL2RenderingContext); if (!gl) { console.error('WebGL2 not supported'); return; } const program = createProgram(gl); if (!program) { console.error('Failed to create program'); return; } // 绑定uniform buffer到着色器程序 const uniformBlockIndex = gl.getUniformBlockIndex(program, 'Uniforms'); if (uniformBlockIndex !== gl.INVALID_INDEX) { gl.uniformBlockBinding(program, uniformBlockIndex, 0); } const vao = gl.createVertexArray(); if (!vao) { console.error('Failed to create vertex array'); return; } gl.bindVertexArray(vao); const vertex_bf = defaultVb(gl); const { buffer: instants_bf, count: actualInstanceCount } = createVesicaInstances(gl, actualVesicaData, actualWidth, actualHeight, dpr); const uniform_bf = defaultUb(gl, current_uniforms.current); gl.bindVertexArray(null); gl.bindBuffer(gl.ARRAY_BUFFER, null); gl.bindBuffer(gl.UNIFORM_BUFFER, null); vaoRef.current = vao; vertex_bfRef.current = vertex_bf; uniform_bfRef.current = uniform_bf; instants_bfRef.current = instants_bf; programRef.current = program; instants_countRef.current = actualInstanceCount; // 使用实际生成的实例数量 function render() { gl.clearColor(0, 0, 0, 0); // 深灰背景,便于看到刻度 gl.clear(gl.COLOR_BUFFER_BIT); gl.useProgram(program); gl.bindVertexArray(vaoRef.current); // 绑定uniform buffer gl.bindBufferBase(gl.UNIFORM_BUFFER, 0, uniform_bfRef.current); gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, instants_countRef.current); gl.bindVertexArray(null); } function updateUniforms(uniforms: Uniforms) { gl.bindBuffer(gl.UNIFORM_BUFFER, uniform_bfRef.current!); const uniformData = new Float32Array([ uniforms.startTimestamp, uniforms.endTimestamp, uniforms.currentTimestamp, uniforms.radius, uniforms.d, uniforms.timelineStartX, uniforms.timelineEndX, 0.0, // padding - 填充以对齐到8字节边界 uniforms.viewportSize[0], uniforms.viewportSize[1], uniforms.zoomLevel, uniforms.panOffset ]); gl.bufferData(gl.UNIFORM_BUFFER, uniformData, gl.DYNAMIC_DRAW); } // TODO: 可以通过props传入自定义的时间轴刻度数据 // 或使用useImperativeHandle暴露更新方法 // 初始化uniform数据并渲染 updateUniforms(current_uniforms.current); gl.viewport(0, 0, actualWidth, actualHeight); render(); }, [canvasRef.current]); return (
{/* */}
); } function createProgram(gl: WebGL2RenderingContext) { const [vs, fs] = createShader(gl)!; const prog = gl.createProgram(); gl.attachShader(prog, vs); gl.attachShader(prog, fs); gl.linkProgram(prog); if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { console.error('Failed to link program'); console.error(gl.getProgramInfoLog(prog)); // 清理着色器对象 gl.deleteShader(vs); gl.deleteShader(fs); return null; } // 程序链接成功后,删除着色器对象以释放内存 gl.deleteShader(vs); gl.deleteShader(fs); return prog; } function createShader(gl: WebGL2RenderingContext) { const vs = gl.createShader(gl.VERTEX_SHADER); if (!vs) { console.error('Failed to create vertex shader'); return null; } gl.shaderSource(vs, vsSource); gl.compileShader(vs); if (!gl.getShaderParameter(vs, gl.COMPILE_STATUS)) { console.error('Failed to compile vertex shader:', gl.getShaderInfoLog(vs)); gl.deleteShader(vs); return null; } const fs = gl.createShader(gl.FRAGMENT_SHADER); if (!fs) { console.error('Failed to create fragment shader'); gl.deleteShader(vs); return null; } gl.shaderSource(fs, fsSource); gl.compileShader(fs); if (!gl.getShaderParameter(fs, gl.COMPILE_STATUS)) { console.error('Failed to compile fragment shader:', gl.getShaderInfoLog(fs)); gl.deleteShader(vs); gl.deleteShader(fs); return null; } return [vs, fs] as [WebGLShader, WebGLShader]; } function defaultVb(gl: WebGL2RenderingContext) { const plane = new Float32Array([ -1, -1, -1, 1, 1, -1, 1, 1, ]); const vertex_bf = gl.createBuffer()!; gl.bindBuffer(gl.ARRAY_BUFFER, vertex_bf); gl.bufferData(gl.ARRAY_BUFFER, plane, gl.STATIC_DRAW); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); gl.vertexAttribDivisor(0, 0) return vertex_bf; } function defaultUb(gl: WebGL2RenderingContext, uniforms: Uniforms) { const ub = gl.createBuffer()!; gl.bindBuffer(gl.UNIFORM_BUFFER, ub); // std140布局:需要添加填充以正确对齐 const uniformData = new Float32Array([ uniforms.startTimestamp, uniforms.endTimestamp, uniforms.currentTimestamp, uniforms.radius, uniforms.d, uniforms.timelineStartX, uniforms.timelineEndX, 0.0, // padding - 填充以对齐到8字节边界 uniforms.viewportSize[0], uniforms.viewportSize[1], uniforms.zoomLevel, uniforms.panOffset ]); gl.bufferData(gl.UNIFORM_BUFFER, uniformData, gl.DYNAMIC_DRAW); return ub; } // 时间轴刻度生成函数 function generateTimelineMarks(startX: number, y: number, interval: number, count: number): Instants[] { const marks = []; for (let i = 0; i < count; i++) { marks.push({ position: new Float32Array([startX + i * interval, y]), color: new Float32Array([0.8, 0.8, 0.8, 1]) // 统一的灰色刻度 }); } return marks; } function createVesicaInstances( gl: WebGL2RenderingContext, vesicaData: VesicaDataPoint[], canvasWidth: number, canvasHeight: number, dpr: number ): { buffer: WebGLBuffer, count: number } { const centerY = Math.floor(canvasHeight * 0.5); // 垂直居中 const instants: Instants[] = []; // 为每个时间戳数据点创建一个 vesica 实例 // 位置信息将在着色器中根据时间戳计算,这里只存储时间戳作为position.x for (const dataPoint of vesicaData) { const defaultColor = [1.0, 1.0, 1.0, 1.0]; // 默认白色 const color = dataPoint.color || defaultColor; instants.push({ position: new Float32Array([dataPoint.timestamp, centerY]), // x存储时间戳,y存储屏幕Y坐标 color: new Float32Array(color) }); } const instants_bf = gl.createBuffer()!; gl.bindBuffer(gl.ARRAY_BUFFER, instants_bf); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(instants.flatMap(i => [...i.position, ...i.color])), gl.DYNAMIC_DRAW); gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 24, 0); // 6 floats * 4 bytes = 24 bytes stride gl.vertexAttribDivisor(1, 1); gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2, 4, gl.FLOAT, false, 24, 8); // 2 floats * 4 bytes = 8 bytes offset gl.vertexAttribDivisor(2, 1); return { buffer: instants_bf, count: instants.length }; } function calcVersicaUni(box_size: [number, number]) { const [w, h] = [box_size[0], box_size[1]]; // 正确的vesica参数: // radius: 每个圆的半径,应该稍大于高度的一半以包含整个形状 // d: 两个圆心之间的距离,必须 < 2*radius const radius = Math.max(w * 0.6, h * 0.6); // 确保能包含形状 const d = Math.min(w * 0.4, radius * 1.5); // 确保 d < 2*radius return { radius, d, } } function setupTicksCanvas(canvas: HTMLCanvasElement, width: number, height: number, dpr: number) { // 设置canvas的实际像素尺寸(考虑高分屏) const actualWidth = Math.floor(width * dpr); const actualHeight = Math.floor(height * dpr); canvas.width = actualWidth; canvas.height = actualHeight; // 设置CSS显示尺寸 canvas.style.width = width + 'px'; canvas.style.height = height + 'px'; // 触发重绘(通过更新一个状态来触发useEffect) // 这里我们不需要手动触发,因为canvas尺寸变化会自然触发重绘 } function levelInfo(level: number) { // 根据缩放级别确定时间轴级别 if (level >= 6.0) { // 日视角:主刻度为小时,子刻度为分钟 return { level: 'day', majorStep: 3600000, // 1小时 = 3600000ms minorStep: 600000, // 10分钟 = 600000ms,在1小时内有6个子刻度 majorTickHeight: 8, minorTickHeight: 4, showMinorTicks: true, majorLabelFormat: (date: Date) => date.toLocaleTimeString('zh-CN', { hour: '2-digit' }), minorLabelFormat: (date: Date) => date.toLocaleTimeString('zh-CN', { minute: '2-digit' }) }; } else { // 月视角:主刻度为日期,子刻度为小时 return { level: 'month', majorStep: 86400000, // 1天 = 86400000ms minorStep: 7200000, // 2小时 = 7200000ms majorTickHeight: 7, minorTickHeight: 3, showMinorTicks: level > 1.2, // 缩放级别大于1.2时显示子刻度 majorLabelFormat: (date: Date) => date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }), minorLabelFormat: (date: Date) => date.toLocaleTimeString('zh-CN', { hour: '2-digit' }) }; } } function drawTicks( canvas: HTMLCanvasElement, width: number, height: number, dpr: number, startDate?: Date, endDate?: Date, currentDate?: Date, zoomLevel: number = 1, panOffset: number = 0, customLineTimestamp?: number | null, isDragging?: boolean ) { const ctx = canvas.getContext('2d', { alpha: true, antialias: true, }) as CanvasRenderingContext2D; // 保存当前context状态 ctx.save(); // 重置变换矩阵并缩放以适应高分屏 ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 清除canvas ctx.clearRect(0, 0, width, height); // 时间轴参数 const centerY = height / 2; const startX = 40; // 左边距 const endX = width - 40; // 右边距 const timelineWidth = endX - startX; // 获取当前级别信息 const levelConfig = levelInfo(zoomLevel); const { level, majorStep, minorStep, majorTickHeight, minorTickHeight, showMinorTicks, majorLabelFormat, minorLabelFormat } = levelConfig; // 计算时间范围 let timeRange = 0; if (startDate && endDate) { timeRange = endDate.getTime() - startDate.getTime(); } // 计算可见时间窗口(考虑缩放和平移) let visibleStartTime = startDate ? startDate.getTime() : 0; let visibleEndTime = endDate ? endDate.getTime() : 0; if (startDate && endDate) { // 缩放改变可见时间范围 const visibleTimeRange = timeRange / zoomLevel; // 平移改变可见时间窗口的中心点 const timePerPixel = visibleTimeRange / timelineWidth; const panTimeOffset = -panOffset * timePerPixel; // 计算可见窗口的中心时间 const originalCenterTime = startDate.getTime() + timeRange / 2; const newCenterTime = originalCenterTime + panTimeOffset; // 基于缩放后的时间范围和新的中心点计算可见窗口 visibleStartTime = newCenterTime - visibleTimeRange / 2; visibleEndTime = newCenterTime + visibleTimeRange / 2; } ctx.fillStyle = '#111'; ctx.fillRect(0, 0, width, 16); // 绘制自定义竖线(如果存在) if (customLineTimestamp !== null && customLineTimestamp !== undefined && startDate && endDate) { // 根据时间戳计算屏幕位置 const timeProgress = (customLineTimestamp - visibleStartTime) / (visibleEndTime - visibleStartTime); const customLineX = startX + timeProgress * timelineWidth; // 只在可见区域内绘制 if (customLineX >= startX - 20 && customLineX <= endX + 20) { ctx.strokeStyle = isDragging ? '#ff6666' : '#ff4444'; // 拖拽时加粗颜色 ctx.lineWidth = isDragging ? 4 : 2; // 拖拽时加粗线条 ctx.beginPath(); ctx.moveTo(customLineX, centerY - 20); ctx.lineTo(customLineX, centerY + 20); ctx.stroke(); // 绘制上方的三角形指示器 ctx.fillStyle = isDragging ? '#ff6666' : '#ff4444'; ctx.beginPath(); const triangleSize = 6; const triangleY = 7; // 在时间轴顶部 ctx.moveTo(customLineX, triangleY); ctx.lineTo(customLineX - triangleSize, triangleY - triangleSize); ctx.lineTo(customLineX + triangleSize, triangleY - triangleSize); ctx.closePath(); ctx.fill(); // 绘制自定义竖线标签 ctx.fillStyle = isDragging ? '#ff6666' : '#ff4444'; ctx.font = 'bold 10px Arial'; ctx.textBaseline = 'bottom'; const tickTime = new Date(customLineTimestamp); const label = majorLabelFormat(tickTime); ctx.fillText(label, customLineX, centerY - 22); } } // 绘制子刻度(如果启用) if (showMinorTicks && startDate && endDate) { ctx.strokeStyle = '#ccc'; ctx.lineWidth = 1; // 更精确的子刻度时间范围计算 const startTickTime = Math.floor(visibleStartTime / minorStep) * minorStep; const endTickTime = Math.ceil(visibleEndTime / minorStep) * minorStep; // 根据缩放级别动态调整子刻度间距 const minMinorTickSpacing = zoomLevel >= 8 ? 6 : zoomLevel >= 6 ? 8 : 12; let lastMinorTickX = -minMinorTickSpacing; let minorTickCount = 0; // 添加计数器用于调试 for (let tickTime = startTickTime; tickTime <= endTickTime; tickTime += minorStep) { // 计算这个时间点在屏幕上的位置 const timeProgress = (tickTime - visibleStartTime) / (visibleEndTime - visibleStartTime); const x = startX + timeProgress * timelineWidth; // 只绘制在可见区域内的刻度,扩大边界以避免边缘漏画 if (x < startX - 20 || x > endX + 20) continue; // 跳过主刻度位置 - 使用容差来处理浮点数精度问题 const timeDiffFromMajor = tickTime % majorStep; const tolerance = 1000; // 减小容差到1秒,避免误判 const isAtMajorTick = Math.abs(timeDiffFromMajor) < tolerance || Math.abs(timeDiffFromMajor - majorStep) < tolerance; if (isAtMajorTick) continue; // 检查子刻度间距,避免过于密集 const spacingFromLast = x - lastMinorTickX; if (spacingFromLast >= minMinorTickSpacing) { // 绘制子刻度线 ctx.beginPath(); ctx.moveTo(x, 16 - minorTickHeight); ctx.lineTo(x, 16); ctx.stroke(); lastMinorTickX = x; minorTickCount++; } } // 仅在调试模式下输出错误信息 if (minorTickCount === 0 && showMinorTicks && zoomLevel >= 6) { console.warn(`子刻度绘制异常: 缩放级别${zoomLevel.toFixed(1)}, 未绘制任何子刻度`); } } // 绘制主刻度 if (startDate && endDate) { ctx.strokeStyle = '#666'; ctx.lineWidth = 1.5; ctx.font = '10px Arial'; ctx.fillStyle = '#666'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; // 基于可见时间窗口计算刻度 const startTickTime = Math.floor(visibleStartTime / majorStep) * majorStep; const endTickTime = visibleEndTime + majorStep; // 计算文字间距,避免重叠 const minLabelSpacing = 60; // 最小标签间距(像素) let lastLabelX = -minLabelSpacing; // 上一个标签的位置 for (let tickTime = startTickTime; tickTime <= endTickTime; tickTime += majorStep) { // 计算这个时间点在屏幕上的位置 const timeProgress = (tickTime - visibleStartTime) / (visibleEndTime - visibleStartTime); const x = startX + timeProgress * timelineWidth; // 只绘制在可见区域内的刻度 if (x < startX - 50 || x > endX + 50) continue; // 绘制主刻度线 ctx.beginPath(); ctx.moveTo(x, 16 - majorTickHeight); ctx.lineTo(x, 16); ctx.stroke(); // 检查文字间距,避免重叠 if (x - lastLabelX >= minLabelSpacing) { // 绘制时间标签 const tickDate = new Date(tickTime); const label = majorLabelFormat(tickDate); ctx.fillText(label, x, 16 - majorTickHeight / 2 + 4); lastLabelX = x; } } } // 绘制当前时间指示器 if (currentDate && startDate && endDate) { // 计算当前时间在可见时间窗口中的位置 const currentTime = currentDate.getTime(); const timeProgress = (currentTime - visibleStartTime) / (visibleEndTime - visibleStartTime); const currentX = startX + timeProgress * timelineWidth; if (currentX >= startX - 20 && currentX <= endX + 20) { // 绘制当前时间线 ctx.strokeStyle = '#ff4444'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(currentX, centerY - 20); ctx.lineTo(currentX, centerY + 20); ctx.stroke(); // 绘制上方的三角形指示器 ctx.fillStyle = '#ff4444'; ctx.beginPath(); const triangleSize = 6; const triangleY = 16; // 在时间轴顶部 ctx.moveTo(currentX, triangleY); ctx.lineTo(currentX - triangleSize, triangleY - triangleSize); ctx.lineTo(currentX + triangleSize, triangleY - triangleSize); ctx.closePath(); ctx.fill(); // 绘制当前时间标签 ctx.fillStyle = '#ff4444'; ctx.font = 'bold 10px Arial'; ctx.textBaseline = 'bottom'; const currentLabel = majorLabelFormat(currentDate); ctx.fillText(currentLabel, currentX, centerY - 22); } } ctx.restore(); }