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, Play } from "lucide-react"; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select" 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 = 2, initialZoom = 1, vesicaData, dateFormat, ...props }) => { const canvasRef = useRef(null); const ticksCanvasRef = useRef(null); const [state, setState] = useState({ isDragging: false, isLongPress: false, isPanningTimeline: false, customLineTimestamp: null, panOffset: 0, zoomLevel: initialZoom, }); const longPressTimerRef = useRef(null); // 长按计时器 const dragTempXRef = useRef(null); // 拖拽时的临时x坐标 const animationFrameRef = useRef(null); // 动画帧ID const panStartXRef = useRef(0); // 拖拽开始的x坐标 const panStartOffsetRef = useRef(0); // 拖拽开始时的panOffset const panTempOffsetRef = useRef(0); // 拖拽时的临时偏移 const hasDraggedTimelineRef = useRef(false); // 拖拽标记,使用ref避免闪烁 const actualVesicaData = vesicaData ?? []; // 使用ref存储最新的状态值,避免闭包陷阱 const stateRef = useRef(state); const propsRef = useRef({ startDate, endDate, currentDate }); // 每次渲染时更新ref的值 stateRef.current = state; propsRef.current = { startDate, endDate, currentDate }; // 缩放处理函数 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) { const rect = ticksCanvasRef.current.getBoundingClientRect(); const centerX = rect.width / 2; const mouseOffsetFromCenter = mouseX - centerX; const zoomChange = newZoom / state.zoomLevel; newPanOffset = state.panOffset + mouseOffsetFromCenter * (1 - zoomChange); } 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)); 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; redraw(); }, [state.customLineTimestamp, state.zoomLevel, state.panOffset, startDate, endDate, currentDate, redraw]); const { radius, d } = calcVersicaUni(boxSize); console.log(radius, d); 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) { // 直接重绘,使用临时位置 // 将屏幕坐标转换为时间戳 if (startDate && endDate && dragTempXRef.current !== null) { const rect = ticksCanvasRef.current?.getBoundingClientRect(); if (rect) { 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 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); console.log(`绘制实例数量: ${instants_countRef.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(); // 鼠标滚轮缩放和平移 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); // 使用ResizeObserver监听canvas尺寸变化 const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { const { width: new_width, height: new_height } = entry.contentRect; // 获取设备像素比例以支持高分屏 const dpr = window.devicePixelRatio || 1; const actualWidth = Math.floor(new_width * dpr); const actualHeight = Math.floor(new_height * dpr); // 更新canvas的实际像素尺寸 canvasRef.current!.width = actualWidth; canvasRef.current!.height = actualHeight; // 设置CSS显示尺寸 canvasRef.current!.style.width = new_width + 'px'; canvasRef.current!.style.height = new_height + 'px'; // 更新刻度线canvas的尺寸 setupTicksCanvas(ticksCanvasRef.current!, new_width, new_height, dpr); // ResizeObserver中需要立即重绘,因为setupTicksCanvas会清空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 = (new_width - 40) * dpr; // 时间轴结束坐标 current_uniforms.current.zoomLevel = stateRef.current.zoomLevel; current_uniforms.current.panOffset = stateRef.current.panOffset * dpr; // 重新生成实例数据以适应新的canvas尺寸 const { buffer: new_instants_bf, count: new_count } = createVesicaInstances(gl, actualVesicaData, actualWidth, actualHeight, dpr); // 更新实例buffer引用和数量 if (instants_bfRef.current) { gl.deleteBuffer(instants_bfRef.current); } instants_bfRef.current = new_instants_bf; instants_countRef.current = new_count; updateUniforms(current_uniforms.current); gl.viewport(0, 0, actualWidth, actualHeight); render(); } }); resizeObserver.observe(canvasRef.current!); 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; resizeObserver.disconnect(); // 清理WebGL资源 if (vaoRef.current) gl.deleteVertexArray(vaoRef.current); if (vertex_bfRef.current) gl.deleteBuffer(vertex_bfRef.current); if (uniform_bfRef.current) gl.deleteBuffer(uniform_bfRef.current); if (instants_bfRef.current) gl.deleteBuffer(instants_bfRef.current); if (programRef.current) gl.deleteProgram(programRef.current); } }, [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]); 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) }); } console.log(`生成vesica实例 (DPR=${dpr}): 数量=${instants.length}`); 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 console.log(`vesica参数: w=${w}, h=${h}, radius=${radius.toFixed(2)}, d=${d.toFixed(2)}`); console.log(`验证: d < 2*radius? ${d} < ${2 * radius} = ${d < 2 * radius}`); console.log(`验证: r²-d² = ${radius * radius} - ${d * d} = ${radius * radius - d * d}`); 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 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 baseTickInterval = 60; // 基础刻度间距 const scaledTickInterval = baseTickInterval * zoomLevel; // 主刻度参数 const majorTickHeight = 7; const majorTickInterval = scaledTickInterval; // 子刻度参数 - 只在缩放级别足够大时显示 const minorTickHeight = 3; const minorTickInterval = scaledTickInterval / 5; // 每个主刻度之间5个子刻度 const showMinorTicks = zoomLevel > 1.0 && minorTickInterval > 8; // 缩放级别大于1.0且间距足够大时显示子刻度 // 计算时间范围 let timeRange = 0; let majorTimeStep = 0; if (startDate && endDate) { timeRange = endDate.getTime() - startDate.getTime(); // 根据当前可见时间范围确定合适的时间步长 // 考虑缩放级别,但时间步长本身保持离散的固定值 const visibleTimeRange = timeRange / zoomLevel; if (visibleTimeRange < 1800000) { // 可见范围小于30分钟 majorTimeStep = 300000; // 5分钟间隔 } else if (visibleTimeRange < 7200000) { // 可见范围小于2小时 majorTimeStep = 900000; // 15分钟间隔 } else if (visibleTimeRange < 43200000) { // 可见范围小于12小时 majorTimeStep = 3600000; // 1小时间隔 } else if (visibleTimeRange < 259200000) { // 可见范围小于3天 majorTimeStep = 21600000; // 6小时间隔 } else if (visibleTimeRange < 1209600000) { // 可见范围小于2周 majorTimeStep = 86400000; // 1天间隔 } else if (visibleTimeRange < 5184000000) { // 可见范围小于2个月 majorTimeStep = 604800000; // 1周间隔 } else { // 可见范围超过2个月 majorTimeStep = 2592000000; // 1个月间隔 } } // 计算可见时间窗口(考虑缩放和平移) 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.strokeStyle = '#888'; // ctx.lineWidth = 2; // ctx.beginPath(); // 计算实际需要覆盖的范围 // let lineStartX = startX; // let lineEndX = endX; // // 如果有时间数据,根据可见刻度范围扩展线条 // if (startDate && endDate) { // const startTickTime = Math.floor(visibleStartTime / majorTimeStep) * majorTimeStep; // const endTickTime = visibleEndTime + majorTimeStep; // // 计算第一个和最后一个刻度的屏幕位置 // const firstTickProgress = (startTickTime - visibleStartTime) / (visibleEndTime - visibleStartTime); // const lastTickProgress = (endTickTime - visibleStartTime) / (visibleEndTime - visibleStartTime); // const firstTickX = startX + firstTickProgress * timelineWidth; // const lastTickX = startX + lastTickProgress * timelineWidth; // // 扩展线条以覆盖所有刻度,但限制在合理范围内 // lineStartX = Math.max(0, Math.min(startX, firstTickX - 20)); // lineEndX = Math.min(width, Math.max(endX, lastTickX + 20)); // } // ctx.moveTo(lineStartX, centerY); // ctx.lineTo(lineEndX, centerY); // ctx.stroke(); 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'; let label = ''; const tickTime = new Date(customLineTimestamp); if (majorTimeStep < 86400000) { // 小于1天,显示时间 label = tickTime.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); } else if (majorTimeStep < 2592000000) { // 小于30天,显示日期 label = tickTime.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }); } else { // 大于30天,显示月份 label = tickTime.toLocaleDateString('zh-CN', { year: 'numeric', month: 'short' }); } // ctx.fillText(label || '自定义位置', customLineX, centerY + 22); } } // 绘制子刻度(如果启用) if (showMinorTicks && startDate && endDate) { ctx.strokeStyle = '#ccc'; ctx.lineWidth = 1; const minorTimeStep = majorTimeStep / 5; const startTickTime = Math.floor(visibleStartTime / minorTimeStep) * minorTimeStep; const endTickTime = visibleEndTime + minorTimeStep; for (let tickTime = startTickTime; tickTime <= endTickTime; tickTime += minorTimeStep) { // 计算这个时间点在屏幕上的位置 const timeProgress = (tickTime - visibleStartTime) / (visibleEndTime - visibleStartTime); const x = startX + timeProgress * timelineWidth; // 只绘制在可见区域内的刻度 if (x < startX - 10 || x > endX + 10) continue; // 跳过主刻度位置 const timeFromStart = tickTime - visibleStartTime; if (Math.abs(timeFromStart % majorTimeStep) < minorTimeStep * 0.1) continue; // 绘制子刻度线 ctx.beginPath(); // ctx.moveTo(x, centerY - minorTickHeight / 2); // ctx.lineTo(x, centerY + minorTickHeight / 2); ctx.moveTo(x, 16 - minorTickHeight); ctx.lineTo(x, 16); ctx.stroke(); } } // 绘制主刻度 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 / majorTimeStep) * majorTimeStep; const endTickTime = visibleEndTime + majorTimeStep; for (let tickTime = startTickTime; tickTime <= endTickTime; tickTime += majorTimeStep) { // 计算这个时间点在屏幕上的位置 const timeProgress = (tickTime - visibleStartTime) / (visibleEndTime - visibleStartTime); const x = startX + timeProgress * timelineWidth; // 只绘制在可见区域内的刻度 if (x < startX - 50 || x > endX + 50) continue; // 绘制主刻度线 ctx.beginPath(); // ctx.moveTo(x, centerY - majorTickHeight / 2); // ctx.lineTo(x, centerY + majorTickHeight / 2); ctx.moveTo(x, 16 - majorTickHeight); ctx.lineTo(x, 16); ctx.stroke(); // 绘制时间标签 let label = ''; const tickDate = new Date(tickTime); // 根据时间间隔选择合适的显示格式 if (majorTimeStep < 86400000) { // 小于1天,显示时间 label = tickDate.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); } else if (majorTimeStep < 2592000000) { // 小于30天,显示日期 label = tickDate.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }); } else { // 大于30天,显示月份 label = tickDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'short' }); } ctx.fillText(label, x, 16 - majorTickHeight / 2 + 4); } } // 绘制当前时间指示器 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 = currentDate.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); ctx.fillText(currentLabel, currentX, centerY - 22); } } // 绘制缩放级别指示器(调试用) // ctx.fillStyle = '#888'; // ctx.font = '10px Arial'; // ctx.textAlign = 'left'; // ctx.textBaseline = 'top'; // ctx.fillText(`缩放: ${zoomLevel.toFixed(2)}x`, 10, 10); // ctx.fillText(`子刻度: ${showMinorTicks ? '显示' : '隐藏'} (间距: ${minorTickInterval.toFixed(1)})`, 10, 25); // 恢复context状态 ctx.restore(); }