mosaicmap/app/timeline.tsx
2025-07-20 02:11:17 +08:00

1545 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, 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<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 = 2,
initialZoom = 1,
vesicaData,
dateFormat,
...props
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const ticksCanvasRef = useRef<HTMLCanvasElement>(null);
const [state, setState] = useState<Status>({
isDragging: false,
isLongPress: false,
isPanningTimeline: false,
customLineTimestamp: null,
panOffset: 0,
zoomLevel: initialZoom,
});
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null); // 长按计时器
const dragTempXRef = useRef<number | null>(null); // 拖拽时的临时x坐标
const animationFrameRef = useRef<number | null>(null); // 动画帧ID
const panStartXRef = useRef<number>(0); // 拖拽开始的x坐标
const panStartOffsetRef = useRef<number>(0); // 拖拽开始时的panOffset
const panTempOffsetRef = useRef<number>(0); // 拖拽时的临时偏移
const hasDraggedTimelineRef = useRef<boolean>(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<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;
redraw();
}, [state.customLineTimestamp, state.zoomLevel, state.panOffset, startDate, endDate, currentDate, redraw]);
const { radius, d } = calcVersicaUni(boxSize);
console.log(radius, d);
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) {
// 直接重绘,使用临时位置
// 将屏幕坐标转换为时间戳
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 (
<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">
<Play size={10} />
</Button>
<Button variant="secondary" size="icon" className="size-5">
<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)
});
}
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();
}