mosaicmap/app/timeline.tsx
2025-07-21 21:27:35 +08:00

1547 lines
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

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<HTMLDivElement> {
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 (
<Select>
<SelectTrigger className="w-20">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Fruits</SelectLabel>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="blueberry">Blueberry</SelectItem>
<SelectItem value="grapes">Grapes</SelectItem>
<SelectItem value="pineapple">Pineapple</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
)
}
export const Timeline: React.FC<Props> = ({
startDate,
endDate,
currentDate,
onDateChange,
onPlay,
onPause,
boxSize = [4, 8],
minZoom = 0.5,
maxZoom = 8,
initialZoom = 1,
vesicaData,
dateFormat,
...props
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const ticksCanvasRef = useRef<HTMLCanvasElement>(null);
const { isPlaying, togglePlay } = useTimeline({
initialDate: currentDate ?? new Date(),
onDateChange(date) {
onDateChange?.(date);
}
})
const [state, setState] = useState<Status>({
isDragging: false,
isLongPress: false,
isPanningTimeline: false,
customLineTimestamp: currentDate?.getTime() ?? new Date().getTime(),
panOffset: 0,
zoomLevel: initialZoom,
});
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null);
const dragTempXRef = useRef<number | null>(null);
const animationFrameRef = useRef<number | null>(null);
const panStartXRef = useRef<number>(0);
const panStartOffsetRef = useRef<number>(0);
const panTempOffsetRef = useRef<number>(0);
const hasDraggedTimelineRef = useRef<boolean>(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<WebGLVertexArrayObject | null>(null);
const vertex_bfRef = useRef<WebGLBuffer | null>(null);
const uniform_bfRef = useRef<WebGLBuffer | null>(null);
const instants_bfRef = useRef<WebGLBuffer | null>(null);
const programRef = useRef<WebGLProgram | null>(null);
const instants_countRef = useRef<number>(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<Uniforms>({
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 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();
// 鼠标滚轮缩放和平移
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 (
<div className={cn(props.className, "w-full h-12 flex flex-row")}>
<div className="h-full flex flex-row items-center px-3 gap-2" style={{ boxShadow: '8px 0 24px rgba(0, 0, 0, 0.15), 4px 0 12px rgba(0, 0, 0, 0.1)' }}>
<Button variant="secondary" size="icon" className="size-5">
<ChevronLeft size={10} />
</Button>
<Button variant="secondary" size="icon" className="size-5" onClick={() => togglePlay()}>
{isPlaying ? <Pause size={10} /> : <Play size={10} />}
</Button>
<Button variant="secondary" size="icon" className="size-5" onClick={() => forward()}>
<ChevronRight size={10} />
</Button>
{/* <SelectDemo /> */}
</div>
<div className={cn("relative", "w-full h-full")}>
<canvas ref={canvasRef} className="w-full h-full absolute inset-0" />
<canvas
ref={ticksCanvasRef}
className="w-full h-full absolute inset-0 select-none"
style={{ touchAction: 'manipulation' }}
/>
</div>
</div>
);
}
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();
}