1546 lines
60 KiB
TypeScript
1546 lines
60 KiB
TypeScript
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,
|
||
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, loading, setTime, currentDatetime: currentDate } = useTimeline({})
|
||
const [state, setState] = useState<Status>({
|
||
isDragging: false,
|
||
isLongPress: false,
|
||
isPanningTimeline: false,
|
||
customLineTimestamp: currentDate?.getTime() ?? null,
|
||
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);
|
||
setTime(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 ?? undefined,
|
||
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;
|
||
|
||
// 规整到最近的6分钟整数时间
|
||
const sixMinutesInMs = 6 * 60 * 1000; // 6分钟 = 360000毫秒
|
||
const roundedTimestamp = Math.round(selectedTimestamp / sixMinutesInMs) * sixMinutesInMs;
|
||
|
||
setState(prevState => ({
|
||
...prevState,
|
||
customLineTimestamp: roundedTimestamp,
|
||
isLongPress: false,
|
||
isDragging: false
|
||
}));
|
||
|
||
// 通知父组件
|
||
if (onDateChange) {
|
||
onDateChange(new Date(roundedTimestamp));
|
||
}
|
||
|
||
// 使用setTime更新时间轴状态
|
||
setTime(new Date(roundedTimestamp));
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// 在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;
|
||
|
||
// 规整到最近的6分钟整数时间
|
||
const sixMinutesInMs = 6 * 60 * 1000; // 6分钟 = 360000毫秒
|
||
const roundedFinalTimestamp = Math.round(finalTimestamp / sixMinutesInMs) * sixMinutesInMs;
|
||
|
||
setState(prevState => ({
|
||
...prevState,
|
||
customLineTimestamp: roundedFinalTimestamp,
|
||
isDragging: false,
|
||
isLongPress: false
|
||
}));
|
||
|
||
// 使用setTime更新时间轴状态
|
||
setTime(new Date(roundedFinalTimestamp));
|
||
} else {
|
||
setState(prevState => ({ ...prevState, isDragging: false, isLongPress: false }));
|
||
}
|
||
dragTempXRef.current = null;
|
||
} else if (currentState.isPanningTimeline) {
|
||
// 处理时间轴拖拽结束
|
||
// 提交临时偏移到真实状态并重置时间轴拖拽状态
|
||
const finalOffset = panTempOffsetRef.current;
|
||
setState(prevState => ({
|
||
...prevState,
|
||
panOffset: finalOffset,
|
||
isPanningTimeline: false
|
||
}));
|
||
|
||
// 立即用最终偏移重绘,确保显示正确
|
||
redraw({
|
||
panOffset: finalOffset
|
||
});
|
||
|
||
// 光标样式由updateCursorStyle统一管理
|
||
|
||
// 延迟重置拖拽标记和临时偏移
|
||
setTimeout(() => {
|
||
hasDraggedTimelineRef.current = false;
|
||
panTempOffsetRef.current = 0; // 现在可以安全重置了
|
||
}, 50);
|
||
} else {
|
||
// 只重置拖拽状态
|
||
setState(prevState => ({ ...prevState, isDragging: false, isLongPress: false }));
|
||
}
|
||
|
||
// 重置拖拽起始位置(重要:避免后续误判)
|
||
panStartXRef.current = 0;
|
||
};
|
||
|
||
// 更新光标样式的通用函数
|
||
const updateCursorStyle = () => {
|
||
const currentState = stateRef.current;
|
||
if (!ticksCanvasRef.current) return;
|
||
|
||
if (currentState.isPanningTimeline) {
|
||
// 当且仅当正在拖拽时间轴时显示抓取中光标
|
||
ticksCanvasRef.current.style.cursor = 'grabbing';
|
||
} else {
|
||
// 其余所有情况均显示十字光标
|
||
ticksCanvasRef.current.style.cursor = 'crosshair';
|
||
}
|
||
};
|
||
|
||
// 鼠标悬停处理 - 动态改变鼠标样式
|
||
const handleMouseOver = (e: MouseEvent) => {
|
||
updateCursorStyle();
|
||
};
|
||
|
||
// 鼠标移出处理
|
||
const handleMouseOut = () => {
|
||
// 光标样式由updateCursorStyle统一管理,不需要特殊处理
|
||
};
|
||
|
||
// 获取设备像素比例以支持高分屏
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const displayWidth = canvasRef.current.clientWidth;
|
||
const displayHeight = canvasRef.current.clientHeight;
|
||
|
||
// 设置WebGL canvas的实际像素尺寸(考虑高分屏)
|
||
const actualWidth = Math.floor(displayWidth * dpr);
|
||
const actualHeight = Math.floor(displayHeight * dpr);
|
||
|
||
canvasRef.current.width = actualWidth;
|
||
canvasRef.current.height = actualHeight;
|
||
|
||
// 设置CSS显示尺寸
|
||
canvasRef.current.style.width = displayWidth + 'px';
|
||
canvasRef.current.style.height = displayHeight + 'px';
|
||
|
||
// 设置刻度线canvas的尺寸
|
||
setupTicksCanvas(ticksCanvasRef.current, displayWidth, displayHeight, dpr);
|
||
|
||
// 初始绘制刻度线,确保canvas设置完成后立即显示
|
||
redraw();
|
||
|
||
// 更新uniform数据
|
||
current_uniforms.current.viewportSize = [actualWidth, actualHeight];
|
||
current_uniforms.current.radius = radius * dpr; // 调整radius以适应像素密度
|
||
current_uniforms.current.d = d * dpr; // 调整d以适应像素密度
|
||
current_uniforms.current.timelineStartX = 40 * dpr; // 时间轴开始坐标
|
||
current_uniforms.current.timelineEndX = (displayWidth - 40) * dpr; // 时间轴结束坐标
|
||
current_uniforms.current.zoomLevel = state.zoomLevel;
|
||
current_uniforms.current.panOffset = state.panOffset * dpr;
|
||
|
||
// 设置时间戳范围
|
||
if (startDate && endDate) {
|
||
current_uniforms.current.startTimestamp = startDate.getTime();
|
||
current_uniforms.current.endTimestamp = endDate.getTime();
|
||
}
|
||
if (currentDate) {
|
||
current_uniforms.current.currentTimestamp = currentDate.getTime();
|
||
}
|
||
|
||
// 鼠标滚轮缩放和平移
|
||
const handleWheel = (e: WheelEvent) => {
|
||
e.preventDefault();
|
||
const rect = ticksCanvasRef.current?.getBoundingClientRect();
|
||
if (!rect) return;
|
||
|
||
const mouseX = e.clientX - rect.left;
|
||
const mouseY = e.clientY - rect.top;
|
||
const centerY = rect.height / 2;
|
||
|
||
// 只在时间轴区域内滚动时才处理
|
||
const timelineStartX = 40;
|
||
const timelineEndX = rect.width - 40;
|
||
const timelineRange = 30; // 扩大一点范围
|
||
|
||
const isInTimelineArea = mouseX >= timelineStartX && mouseX <= timelineEndX &&
|
||
Math.abs(mouseY - centerY) <= timelineRange;
|
||
|
||
if (isInTimelineArea) {
|
||
// 设置阈值,避免微小滚动触发操作
|
||
const deltaThreshold = 3;
|
||
const deltaX = e.deltaX;
|
||
const deltaY = e.deltaY;
|
||
|
||
// 判断主要滚动方向和是否超过阈值
|
||
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > deltaThreshold) {
|
||
// 水平滚动:平移操作
|
||
// 反转方向并增加速度,使滚动更跟手
|
||
handlePan(-deltaX * 2); // 反转方向,增加速度倍数
|
||
} else if (Math.abs(deltaY) > Math.abs(deltaX) && Math.abs(deltaY) > deltaThreshold) {
|
||
// 垂直滚动:缩放操作
|
||
handleZoom(-deltaY, mouseX);
|
||
}
|
||
}
|
||
};
|
||
|
||
// 触摸缩放和平移
|
||
let lastTouchDistance = 0;
|
||
let lastTouchX = 0;
|
||
let isPanning = false;
|
||
|
||
const handleTouchStart = (e: TouchEvent) => {
|
||
if (e.touches.length === 2) {
|
||
// 双指缩放
|
||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||
lastTouchDistance = Math.sqrt(dx * dx + dy * dy);
|
||
} else if (e.touches.length === 1) {
|
||
// 单指平移
|
||
lastTouchX = e.touches[0].clientX;
|
||
isPanning = true;
|
||
}
|
||
};
|
||
|
||
const handleTouchMove = (e: TouchEvent) => {
|
||
e.preventDefault();
|
||
if (e.touches.length === 2) {
|
||
// 双指缩放
|
||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||
|
||
if (lastTouchDistance > 0) {
|
||
const scale = distance / lastTouchDistance;
|
||
const delta = scale > 1 ? 1 : -1;
|
||
handleZoom(delta * 50);
|
||
}
|
||
lastTouchDistance = distance;
|
||
} else if (e.touches.length === 1 && isPanning) {
|
||
// 单指平移
|
||
const deltaX = e.touches[0].clientX - lastTouchX;
|
||
handlePan(deltaX);
|
||
lastTouchX = e.touches[0].clientX;
|
||
}
|
||
};
|
||
|
||
const handleTouchEnd = () => {
|
||
lastTouchDistance = 0;
|
||
isPanning = false;
|
||
};
|
||
|
||
// 双击重置
|
||
const handleDoubleClick = () => {
|
||
resetView();
|
||
};
|
||
|
||
ticksCanvasRef.current?.addEventListener('wheel', handleWheel, { passive: false });
|
||
ticksCanvasRef.current?.addEventListener('touchstart', handleTouchStart);
|
||
ticksCanvasRef.current?.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||
ticksCanvasRef.current?.addEventListener('touchend', handleTouchEnd);
|
||
ticksCanvasRef.current?.addEventListener('dblclick', handleDoubleClick);
|
||
ticksCanvasRef.current?.addEventListener('mouseover', handleMouseOver);
|
||
ticksCanvasRef.current?.addEventListener('mouseout', handleMouseOut);
|
||
|
||
// 添加自定义竖线相关的事件监听器
|
||
ticksCanvasRef.current?.addEventListener('mousedown', handleMouseDown);
|
||
ticksCanvasRef.current?.addEventListener('mousemove', handleMouseMove);
|
||
ticksCanvasRef.current?.addEventListener('click', handleMouseClick);
|
||
ticksCanvasRef.current?.addEventListener('mouseup', handleMouseUp);
|
||
// 添加全局鼠标抬起事件,防止鼠标移出canvas后拖拽卡住
|
||
document.addEventListener('mouseup', handleMouseUp);
|
||
|
||
|
||
|
||
return () => {
|
||
// 移除事件监听器
|
||
ticksCanvasRef.current?.removeEventListener('wheel', handleWheel);
|
||
ticksCanvasRef.current?.removeEventListener('touchstart', handleTouchStart);
|
||
ticksCanvasRef.current?.removeEventListener('touchmove', handleTouchMove);
|
||
ticksCanvasRef.current?.removeEventListener('touchend', handleTouchEnd);
|
||
ticksCanvasRef.current?.removeEventListener('dblclick', handleDoubleClick);
|
||
ticksCanvasRef.current?.removeEventListener('mouseover', handleMouseOver);
|
||
ticksCanvasRef.current?.removeEventListener('mouseout', handleMouseOut);
|
||
|
||
// 移除自定义竖线相关的事件监听器
|
||
ticksCanvasRef.current?.removeEventListener('mousedown', handleMouseDown);
|
||
ticksCanvasRef.current?.removeEventListener('mousemove', handleMouseMove);
|
||
ticksCanvasRef.current?.removeEventListener('click', handleMouseClick);
|
||
ticksCanvasRef.current?.removeEventListener('mouseup', handleMouseUp);
|
||
document.removeEventListener('mouseup', handleMouseUp);
|
||
|
||
// 清理长按计时器
|
||
if (longPressTimerRef.current) {
|
||
clearTimeout(longPressTimerRef.current);
|
||
}
|
||
|
||
// 清理动画帧
|
||
if (animationFrameRef.current) {
|
||
cancelAnimationFrame(animationFrameRef.current);
|
||
}
|
||
|
||
// 重置临时状态(不需要调用setState,因为组件即将卸载或重新初始化)
|
||
panTempOffsetRef.current = 0;
|
||
|
||
|
||
}
|
||
}, [boxSize, actualVesicaData]);
|
||
|
||
// 监听状态变化,更新uniform数据并重新渲染
|
||
useEffect(() => {
|
||
if (!programRef.current || !uniform_bfRef.current) return;
|
||
|
||
// 更新缩放和平移相关的uniform
|
||
current_uniforms.current.zoomLevel = state.zoomLevel;
|
||
current_uniforms.current.panOffset = state.panOffset * (window.devicePixelRatio || 1);
|
||
|
||
// 更新时间戳数据
|
||
if (startDate && endDate) {
|
||
current_uniforms.current.startTimestamp = startDate.getTime();
|
||
current_uniforms.current.endTimestamp = endDate.getTime();
|
||
}
|
||
if (currentDate) {
|
||
current_uniforms.current.currentTimestamp = currentDate.getTime();
|
||
}
|
||
|
||
// 获取WebGL上下文并更新uniform
|
||
const canvas = canvasRef.current;
|
||
if (canvas) {
|
||
const gl = canvas.getContext('webgl2') as WebGL2RenderingContext;
|
||
if (gl) {
|
||
const program = programRef.current;
|
||
gl.useProgram(program);
|
||
|
||
// 更新uniform数据
|
||
gl.bindBuffer(gl.UNIFORM_BUFFER, uniform_bfRef.current);
|
||
const uniformData = new Float32Array([
|
||
current_uniforms.current.startTimestamp,
|
||
current_uniforms.current.endTimestamp,
|
||
current_uniforms.current.currentTimestamp,
|
||
current_uniforms.current.radius,
|
||
current_uniforms.current.d,
|
||
current_uniforms.current.timelineStartX,
|
||
current_uniforms.current.timelineEndX,
|
||
0.0, // padding
|
||
current_uniforms.current.viewportSize[0],
|
||
current_uniforms.current.viewportSize[1],
|
||
current_uniforms.current.zoomLevel,
|
||
current_uniforms.current.panOffset
|
||
]);
|
||
gl.bufferData(gl.UNIFORM_BUFFER, uniformData, gl.DYNAMIC_DRAW);
|
||
|
||
// 重新渲染
|
||
gl.clearColor(0, 0, 0, 0);
|
||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||
gl.bindVertexArray(vaoRef.current);
|
||
gl.bindBufferBase(gl.UNIFORM_BUFFER, 0, uniform_bfRef.current);
|
||
gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, instants_countRef.current);
|
||
gl.bindVertexArray(null);
|
||
}
|
||
}
|
||
|
||
|
||
|
||
}, [state.zoomLevel, state.panOffset, startDate, endDate, currentDate]);
|
||
|
||
useEffect(() => {
|
||
if (!canvasRef.current) return;
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const displayWidth = canvasRef.current.clientWidth;
|
||
const displayHeight = canvasRef.current.clientHeight;
|
||
|
||
// 设置WebGL canvas的实际像素尺寸(考虑高分屏)
|
||
const actualWidth = Math.floor(displayWidth * dpr);
|
||
const actualHeight = Math.floor(displayHeight * dpr);
|
||
|
||
canvasRef.current.width = actualWidth;
|
||
canvasRef.current.height = actualHeight;
|
||
const gl = (canvasRef.current.getContext('webgl2', {
|
||
antialias: true, // 启用抗锯齿
|
||
alpha: true, // 启用alpha通道以支持透明度
|
||
premultipliedAlpha: false, // 不使用预乘alpha
|
||
depth: false, // 不需要深度缓冲
|
||
stencil: false, // 不需要模板缓冲
|
||
preserveDrawingBuffer: false // 不保留绘制缓冲区
|
||
}) as WebGL2RenderingContext);
|
||
if (!gl) {
|
||
console.error('WebGL2 not supported');
|
||
return;
|
||
}
|
||
|
||
const program = createProgram(gl);
|
||
if (!program) {
|
||
console.error('Failed to create program');
|
||
return;
|
||
}
|
||
|
||
// 绑定uniform buffer到着色器程序
|
||
const uniformBlockIndex = gl.getUniformBlockIndex(program, 'Uniforms');
|
||
if (uniformBlockIndex !== gl.INVALID_INDEX) {
|
||
gl.uniformBlockBinding(program, uniformBlockIndex, 0);
|
||
}
|
||
|
||
const vao = gl.createVertexArray();
|
||
|
||
if (!vao) {
|
||
console.error('Failed to create vertex array');
|
||
return;
|
||
}
|
||
gl.bindVertexArray(vao);
|
||
|
||
const vertex_bf = defaultVb(gl);
|
||
const { buffer: instants_bf, count: actualInstanceCount } = createVesicaInstances(gl, actualVesicaData, actualWidth, actualHeight, dpr);
|
||
const uniform_bf = defaultUb(gl, current_uniforms.current);
|
||
|
||
gl.bindVertexArray(null);
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, null);
|
||
gl.bindBuffer(gl.UNIFORM_BUFFER, null);
|
||
|
||
vaoRef.current = vao;
|
||
vertex_bfRef.current = vertex_bf;
|
||
uniform_bfRef.current = uniform_bf;
|
||
instants_bfRef.current = instants_bf;
|
||
programRef.current = program;
|
||
instants_countRef.current = actualInstanceCount; // 使用实际生成的实例数量
|
||
|
||
function render() {
|
||
gl.clearColor(0, 0, 0, 0); // 深灰背景,便于看到刻度
|
||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||
|
||
gl.useProgram(program);
|
||
gl.bindVertexArray(vaoRef.current);
|
||
|
||
// 绑定uniform buffer
|
||
gl.bindBufferBase(gl.UNIFORM_BUFFER, 0, uniform_bfRef.current);
|
||
|
||
gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, instants_countRef.current);
|
||
gl.bindVertexArray(null);
|
||
}
|
||
|
||
function updateUniforms(uniforms: Uniforms) {
|
||
gl.bindBuffer(gl.UNIFORM_BUFFER, uniform_bfRef.current!);
|
||
const uniformData = new Float32Array([
|
||
uniforms.startTimestamp,
|
||
uniforms.endTimestamp,
|
||
uniforms.currentTimestamp,
|
||
uniforms.radius,
|
||
uniforms.d,
|
||
uniforms.timelineStartX,
|
||
uniforms.timelineEndX,
|
||
0.0, // padding - 填充以对齐到8字节边界
|
||
uniforms.viewportSize[0],
|
||
uniforms.viewportSize[1],
|
||
uniforms.zoomLevel,
|
||
uniforms.panOffset
|
||
]);
|
||
gl.bufferData(gl.UNIFORM_BUFFER, uniformData, gl.DYNAMIC_DRAW);
|
||
}
|
||
|
||
|
||
// TODO: 可以通过props传入自定义的时间轴刻度数据
|
||
// 或使用useImperativeHandle暴露更新方法
|
||
|
||
// 初始化uniform数据并渲染
|
||
updateUniforms(current_uniforms.current);
|
||
|
||
gl.viewport(0, 0, actualWidth, actualHeight);
|
||
render();
|
||
|
||
// 清理函数:当组件卸载或重新初始化时清理WebGL资源
|
||
return () => {
|
||
console.log('Cleaning up timeline WebGL resources...');
|
||
|
||
if (gl) {
|
||
// 清理WebGL资源
|
||
if (programRef.current) {
|
||
gl.deleteProgram(programRef.current);
|
||
programRef.current = null;
|
||
}
|
||
if (vaoRef.current) {
|
||
gl.deleteVertexArray(vaoRef.current);
|
||
vaoRef.current = null;
|
||
}
|
||
if (vertex_bfRef.current) {
|
||
gl.deleteBuffer(vertex_bfRef.current);
|
||
vertex_bfRef.current = null;
|
||
}
|
||
if (uniform_bfRef.current) {
|
||
gl.deleteBuffer(uniform_bfRef.current);
|
||
uniform_bfRef.current = null;
|
||
}
|
||
if (instants_bfRef.current) {
|
||
gl.deleteBuffer(instants_bfRef.current);
|
||
instants_bfRef.current = null;
|
||
}
|
||
}
|
||
|
||
// 重置实例计数
|
||
instants_countRef.current = 0;
|
||
}
|
||
}, [canvasRef.current]);
|
||
|
||
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();
|
||
} |