diff --git a/app/page.tsx b/app/page.tsx
index 1f2aec4..a10e1ab 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -15,7 +15,8 @@ import {
} from '@/components/ui/sidebar'
import { MapComponent } from '@/components/map-component';
import { ThemeToggle } from '@/components/theme-toggle';
-import { Timeline } from '@/app/timeline';
+// import { Timeline } from '@/app/timeline';
+import { Timeline } from '@/app/tl';
import { cn } from '@/lib/utils';
import { useTimeline } from '@/hooks/use-timeline';
import { useEffect } from 'react'
@@ -62,7 +63,7 @@ export default function Page() {
-
+ /> */}
+
diff --git a/app/tl.tsx b/app/tl.tsx
new file mode 100644
index 0000000..4f61732
--- /dev/null
+++ b/app/tl.tsx
@@ -0,0 +1,277 @@
+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, HomeIcon, Pause, Play } from "lucide-react";
+
+
+import { useTimeline } from "@/hooks/use-timeline";
+import { Timeline as TimelineEngine, ZoomMode, TimelineConfig } from "@/lib/timeline";
+import { Separator } from "@/components/ui/separator";
+
+interface Uniforms {
+ startTimestamp: number; // Unix 时间戳开始
+ endTimestamp: number; // Unix 时间戳结束
+ currentTimestamp: number; // 当前时间戳
+ radius: number;
+ d: number;
+ timelineStartX: number; // 时间轴在屏幕上的开始X坐标
+ timelineEndX: number; // 时间轴在屏幕上的结束X坐标
+ viewportSize: [number, number];
+ zoomLevel: number; // 当前缩放级别
+ panOffset: number; // 当前平移偏移
+}
+
+interface Instants {
+ position: Float32Array;
+ color: Float32Array;
+}
+
+interface VesicaDataPoint {
+ timestamp: number; // Unix 时间戳
+ color?: [number, number, number, number]; // RGBA 颜色,默认为白色
+}
+
+interface Props extends React.HTMLAttributes {
+ boxSize?: [number, number];
+ startDate?: Date;
+ endDate?: Date;
+ currentDate?: Date;
+ onDateChange?: (date: Date) => void;
+ onPlay?: () => void;
+ onPause?: () => void;
+ minZoom?: number; // 最小缩放级别
+ maxZoom?: number; // 最大缩放级别
+ initialZoom?: number; // 初始缩放级别
+ vesicaData?: VesicaDataPoint[]; // vesica 实例数据
+ dateFormat?: (timestamp: number) => string; // 自定义时间格式化函数
+ timelineConfig?: TimelineConfig; // 时间轴配置
+}
+
+export const Timeline: React.FC = React.memo(({
+ startDate,
+ endDate,
+ currentDate,
+ onDateChange,
+ onPlay,
+ onPause,
+ boxSize = [4, 8],
+ minZoom = 0.5,
+ maxZoom = 8,
+ initialZoom = 1,
+ vesicaData,
+ dateFormat,
+ timelineConfig,
+ ...props
+}) => {
+ const { isPlaying, togglePlay } = useTimeline({})
+ const canvasRef = useRef(null);
+ const ticksCanvasRef = useRef(null);
+ const timelineEngineRef = useRef(null);
+ const [timeStep, setTimeStep] = useState(360000);
+ const [viewportInfo, setViewportInfo] = useState({
+ centerTime: 0,
+ timeRange: 0,
+ visibleRange: [0, 0] as [number, number],
+ currentLevel: null as any
+ });
+
+ // 定时器效果 - 当播放时每隔指定时间执行操作
+ useEffect(() => {
+ let intervalId: NodeJS.Timeout | null = null;
+
+ if (isPlaying) {
+ intervalId = setInterval(() => {
+ // 执行时间前进操作
+ if (timelineEngineRef.current) {
+ // timelineEngineRef.current.forwardTimeMark(timeStep);
+ timelineEngineRef.current.playAndEnsureMarkInView(timeStep)
+ }
+ }, 1000); // 每秒执行一次,你可以根据需要调整这个间隔
+ }
+
+ return () => {
+ if (intervalId) {
+ clearInterval(intervalId);
+ }
+ };
+ }, [isPlaying, timeStep]);
+
+ useEffect(() => {
+ if (!ticksCanvasRef.current) return;
+
+ const canvas = ticksCanvasRef.current;
+
+ // 计算初始时间范围
+ const now = Date.now();
+ const defaultStartTime = startDate ? startDate.getTime() : now - 24 * 60 * 60 * 1000; // 默认24小时前
+ const defaultEndTime = endDate ? endDate.getTime() : now + 24 * 60 * 60 * 1000; // 默认24小时后
+ const defaultCenterTime = currentDate ? currentDate.getTime() : now;
+
+ // 合并配置
+ const config: TimelineConfig = {
+ initialCenterTime: defaultCenterTime,
+ initialTimeRange: (defaultEndTime - defaultStartTime) / 2, // 使用时间范围的一半作为初始范围
+ highlightWeekends: false,
+ zoomMode: ZoomMode.MousePosition,
+ zoomSensitivity: 0.001,
+ colors: {
+ background: 'transparent', // 使用透明背景,让父容器背景显示
+ grid: '#333333',
+ majorTick: '#cccccc',
+ minorTick: '#cccccc',
+ primaryLabel: '#b4b4b4',
+ secondaryLabel: '#b4b4b4',
+ currentTime: '#ff0000'
+ },
+ sizes: {
+ majorTickHeight: 8,
+ minorTickHeight: 4,
+ labelOffset: 3,
+ primaryFontSize: 10,
+ secondaryFontSize: 10
+ },
+ ...timelineConfig
+ };
+
+ try {
+ timelineEngineRef.current = new TimelineEngine(canvas, config);
+ const updateViewportInfo = () => {
+ if (timelineEngineRef.current) {
+ const info = timelineEngineRef.current.getViewportInfo();
+ setViewportInfo(info);
+
+ if (onDateChange) {
+ onDateChange(new Date(info.centerTime));
+ }
+ }
+ };
+
+ const interval = setInterval(updateViewportInfo, 100);
+
+ return () => {
+ clearInterval(interval);
+ if (timelineEngineRef.current) {
+ timelineEngineRef.current.destroy();
+ timelineEngineRef.current = null;
+ }
+ };
+ } catch (error) {
+ console.error('Failed to initialize timeline engine:', error);
+ }
+ }, [startDate, endDate, currentDate, initialZoom, timelineConfig, onDateChange]);
+
+ // 处理画布大小变化
+ useEffect(() => {
+ const handleResize = () => {
+ if (timelineEngineRef.current && ticksCanvasRef.current) {
+ timelineEngineRef.current.render();
+ }
+ };
+
+ window.addEventListener('resize', handleResize);
+ return () => window.removeEventListener('resize', handleResize);
+ }, []);
+
+ const handleHome = useCallback(() => {
+ if (timelineEngineRef.current) {
+ timelineEngineRef.current.goToTime(new Date().getTime())
+ }
+ }, []);
+
+ // 控制按钮功能
+ const handlePrevious = useCallback(() => {
+ if (timelineEngineRef.current) {
+ timelineEngineRef.current.playBackwardAndEnsureMarkInView(timeStep)
+ }
+ }, [timeStep]);
+
+ const handleNext = useCallback(() => {
+ if (timelineEngineRef.current) {
+ timelineEngineRef.current.playAndEnsureMarkInView(timeStep)
+ }
+ }, [timeStep]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+});
\ No newline at end of file
diff --git a/components/map-component.tsx b/components/map-component.tsx
index b7d4291..6ee4839 100644
--- a/components/map-component.tsx
+++ b/components/map-component.tsx
@@ -19,6 +19,8 @@ interface MapComponentProps {
export function MapComponent({
style = 'https://api.maptiler.com/maps/019817f1-82a8-7f37-901d-4bedf68b27fb/style.json?key=hj3fxRdwF9KjEsBq8sYI',
+ // style = 'https://api.maptiler.com/maps/landscape/style.json?key=hj3fxRdwF9KjEsBq8sYI',
+ // style = 'https://api.maptiler.com/tiles/land-gradient-dark/tiles.json?key=hj3fxRdwF9KjEsBq8sYI',
// center = [103.851959, 1.290270],
// zoom = 11
imgBitmap: propImgBitmap,
diff --git a/lib/timeline.ts b/lib/timeline.ts
new file mode 100644
index 0000000..0812377
--- /dev/null
+++ b/lib/timeline.ts
@@ -0,0 +1,1211 @@
+/**
+ * 实时时间轴组件 - TypeScript + Canvas API 实现
+ * 用于展示真实时间坐标轴,适合实时数据可视化
+ */
+
+import { format } from "date-fns";
+
+/** 缩放模式枚举 */
+enum ZoomMode {
+ /** 以鼠标位置为中心缩放 */
+ MousePosition = 'mouse',
+ /** 以视口中心缩放 */
+ ViewportCenter = 'center',
+ /** 标记模式 */
+ MarkMode = 'mark'
+}
+
+/** 时间格式化级别 */
+enum TimeFormatLevel {
+ Minute = 'minute', // 分钟级别
+ FiveMinutes = '5min', // 5分钟级别
+ TenMinutes = '10min', // 10分钟级别
+ HalfHour = '30min', // 半小时级别
+ Hour = 'hour', // 小时级别
+ ThreeHours = '3hour', // 3小时级别
+ SixHours = '6hour', // 6小时级别
+ Day = 'day', // 天级别
+}
+
+/** 刻度级别配置 */
+interface ScaleLevel {
+ /** 级别标识 */
+ level: TimeFormatLevel;
+ /** 主刻度时间间隔(毫秒) */
+ majorInterval: number;
+ /** 次要刻度数量 */
+ minorTicks: number;
+ /** 最小像素间距(避免刻度过密) */
+ minPixelDistance: number;
+ /** 最大像素间距(避免刻度过疏) */
+ maxPixelDistance: number;
+ /** 格式化函数 */
+ formatter: (date: Date) => { primary: string; secondary?: string };
+}
+
+/** 单个刻度信息 */
+interface Tick {
+ /** 时间值(时间戳) */
+ timestamp: number;
+ /** 屏幕位置 */
+ position: number;
+ /** 是否为主刻度 */
+ isMajor: boolean;
+ /** 主标签文本 */
+ primaryLabel?: string;
+ /** 次标签文本(如日期) */
+ secondaryLabel?: string;
+}
+
+/** 时间标记 */
+interface TimeMark {
+ /** 时间戳 */
+ timestamp: number;
+ /** 标记颜色 */
+ color?: string;
+ /** 标记标签 */
+ label?: string;
+ /** 标记类型 */
+ type?: 'custom' | 'event' | 'milestone';
+}
+
+/** 时间轴配置 */
+interface TimelineConfig {
+ /** 初始中心时间(时间戳,默认当前时间) */
+ initialCenterTime?: number;
+ /** 初始显示范围(毫秒,默认1小时) */
+ initialTimeRange?: number;
+ /** 缩放模式 */
+ zoomMode?: ZoomMode;
+ /** 缩放灵敏度 */
+ zoomSensitivity?: number;
+ /** 时区偏移(分钟) */
+ timezoneOffset?: number;
+ /** 颜色配置 */
+ colors?: {
+ background?: string;
+ grid?: string;
+ majorTick?: string;
+ minorTick?: string;
+ primaryLabel?: string;
+ secondaryLabel?: string;
+ currentTime?: string;
+ weekend?: string;
+ timeMark?: string;
+ timeMarkLabel?: string;
+ };
+ /** 尺寸配置 */
+ sizes?: {
+ majorTickHeight?: number;
+ minorTickHeight?: number;
+ labelOffset?: number;
+ primaryFontSize?: number;
+ secondaryFontSize?: number;
+ timeMarkHeight?: number;
+ timeMarkWidth?: number;
+ };
+ /** 是否显示当前时间线 */
+ showCurrentTime?: boolean;
+ /** 是否高亮周末 */
+ highlightWeekends?: boolean;
+ /** 时间标记列表 */
+ timeMarks?: TimeMark[];
+}
+
+/** 时间工具类 */
+class TimeUtils {
+ /** 获取时间的分钟起点 */
+ static getMinuteStart(timestamp: number): number {
+ const date = new Date(timestamp);
+ date.setSeconds(0, 0);
+ return date.getTime();
+ }
+
+ /** 获取时间的小时起点 */
+ static getHourStart(timestamp: number): number {
+ const date = new Date(timestamp);
+ date.setMinutes(0, 0, 0);
+ return date.getTime();
+ }
+
+ /** 获取时间的天起点 */
+ static getDayStart(timestamp: number): number {
+ const date = new Date(timestamp);
+ date.setHours(0, 0, 0, 0);
+ return date.getTime();
+ }
+
+ /** 获取最近的刻度时间 */
+ static getNearestTick(timestamp: number, interval: number): number {
+ return Math.floor(timestamp / interval) * interval;
+ }
+
+ /** 判断是否为周末 */
+ static isWeekend(timestamp: number): boolean {
+ const day = new Date(timestamp).getDay();
+ return day === 0 || day === 6;
+ }
+
+ /** 格式化时间 */
+ static format(date: Date, pattern: string): string {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ const hours = String(date.getHours()).padStart(2, '0');
+ const minutes = String(date.getMinutes()).padStart(2, '0');
+ const seconds = String(date.getSeconds()).padStart(2, '0');
+
+ return pattern
+ .replace('YYYY', String(year))
+ .replace('MM', month)
+ .replace('DD', day)
+ .replace('HH', hours)
+ .replace('mm', minutes)
+ .replace('ss', seconds);
+ }
+
+ /** 获取月份名称 */
+ static getMonthName(month: number, short: boolean = true): string {
+ const months = short ?
+ ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] :
+ ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
+ return months[month];
+ }
+
+ /** 获取星期名称 */
+ static getWeekdayName(day: number, short: boolean = true): string {
+ const weekdays = short ?
+ ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] :
+ ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
+ return weekdays[day];
+ }
+}
+
+/** 视口管理器 */
+class Viewport {
+ /** 视口中心对应的时间戳 */
+ private centerTime: number;
+ /** 时间范围(毫秒) */
+ private timeRange: number;
+ /** 视口宽度 */
+ private width: number;
+ /** 视口高度 */
+ private height: number;
+
+ /** 时间范围的最小和最大限制 */
+ private readonly MIN_RANGE = 240 * 1000;
+ private readonly MAX_RANGE = 10 * 24 * 60 * 60 * 1000;
+
+ constructor(width: number = 800, height: number = 100) {
+ this.centerTime = Date.now();
+ this.timeRange = 60 * 60 * 1000; // 默认显示1小时
+ this.width = width;
+ this.height = height;
+ }
+
+ /** 获取缩放级别(像素/毫秒) */
+ getZoomLevel(): number {
+ return this.width / this.timeRange;
+ }
+
+ /** 将时间转换为屏幕坐标 */
+ timeToScreen(timestamp: number): number {
+ const offset = timestamp - this.centerTime;
+ const ratio = offset / this.timeRange;
+ return this.width / 2 + ratio * this.width;
+ }
+
+ /** 将屏幕坐标转换为时间 */
+ screenToTime(x: number, nearest: boolean = false, ticks?: Tick[]): number {
+ const ratio = (x - this.width / 2) / this.width;
+ const time = this.centerTime + ratio * this.timeRange;
+
+ if (nearest && ticks && ticks.length > 0) {
+ // 找到距离点击位置最近的刻度
+ let nearestTick: Tick | null = null;
+ let minDistance = Infinity;
+
+ for (const tick of ticks) {
+ const distance = Math.abs(tick.position - x);
+ if (distance < minDistance) {
+ minDistance = distance;
+ nearestTick = tick;
+ }
+ }
+
+ // 如果最近的刻度在阈值内(20像素),则返回该刻度的时间戳
+ if (nearestTick && minDistance < 3) {
+ return nearestTick.timestamp;
+ }
+ }
+
+ return time;
+ }
+
+ /** 获取可见时间范围 */
+ getVisibleRange(): [number, number] {
+ const halfRange = this.timeRange / 2;
+ return [
+ this.centerTime - halfRange,
+ this.centerTime + halfRange
+ ];
+ }
+
+ /** 平移视口 */
+ pan(deltaX: number): void {
+ const timeDelta = -(deltaX / this.width) * this.timeRange;
+ this.centerTime += timeDelta;
+ }
+
+ /** 以视口中心缩放 */
+ zoom(factor: number): void {
+ this.timeRange /= factor;
+ this.timeRange = Math.max(this.MIN_RANGE, Math.min(this.MAX_RANGE, this.timeRange));
+ }
+
+ /** 以指定位置为中心缩放 */
+ zoomAt(factor: number, screenX: number): void {
+ const timeAtMouse = this.screenToTime(screenX);
+ this.zoom(factor);
+ const newTimeAtMouse = this.screenToTime(screenX);
+ const timeCorrection = timeAtMouse - newTimeAtMouse;
+ this.centerTime += timeCorrection;
+ }
+
+ /** 更新视口尺寸 */
+ updateSize(width: number, height: number): void {
+ this.width = width;
+ this.height = height;
+ }
+
+ /** 跳转到指定时间 */
+ goToTime(timestamp: number): void {
+ this.centerTime = timestamp;
+ }
+
+ /** 设置显示范围 */
+ setTimeRange(range: number): void {
+ this.timeRange = Math.max(this.MIN_RANGE, Math.min(this.MAX_RANGE, range));
+ }
+
+ // Getters
+ getCenterTime(): number { return this.centerTime; }
+ getTimeRange(): number { return this.timeRange; }
+ getWidth(): number { return this.width; }
+ getHeight(): number { return this.height; }
+}
+
+/** 刻度管理器 */
+class ScaleManager {
+ private scaleLevels: ScaleLevel[];
+ private currentLevel: ScaleLevel | null = null;
+
+ constructor() {
+ this.scaleLevels = [
+ {
+ level: TimeFormatLevel.Minute,
+ majorInterval: 60 * 1000, // 1分钟
+ minorTicks: 4, // 每15秒一个次刻度
+ minPixelDistance: 80,
+ maxPixelDistance: 200,
+ formatter: (date: Date) => ({
+ primary: TimeUtils.format(date, 'HH:mm'),
+ secondary: this.shouldShowDate(date) ? TimeUtils.format(date, 'MM/DD') : undefined
+ })
+ },
+ {
+ level: TimeFormatLevel.FiveMinutes,
+ majorInterval: 5 * 60 * 1000, // 5分钟
+ minorTicks: 5, // 每分钟一个次刻度
+ minPixelDistance: 60,
+ maxPixelDistance: 150,
+ formatter: (date: Date) => ({
+ primary: TimeUtils.format(date, 'HH:mm'),
+ secondary: this.shouldShowDate(date) ? TimeUtils.format(date, 'MM/DD') : undefined
+ })
+ },
+ {
+ level: TimeFormatLevel.TenMinutes,
+ majorInterval: 10 * 60 * 1000, // 10分钟
+ minorTicks: 2, // 每5分钟一个次刻度
+ minPixelDistance: 50,
+ maxPixelDistance: 120,
+ formatter: (date: Date) => ({
+ primary: TimeUtils.format(date, 'HH:mm'),
+ secondary: this.shouldShowDate(date) ? TimeUtils.format(date, 'MM/DD') : undefined
+ })
+ },
+ {
+ level: TimeFormatLevel.HalfHour,
+ majorInterval: 30 * 60 * 1000, // 30分钟
+ minorTicks: 3, // 每10分钟一个次刻度
+ minPixelDistance: 50,
+ maxPixelDistance: 120,
+ formatter: (date: Date) => ({
+ primary: TimeUtils.format(date, 'HH:mm'),
+ secondary: this.shouldShowDate(date) ?
+ `${TimeUtils.getMonthName(date.getMonth())} ${date.getDate()}` : undefined
+ })
+ },
+ {
+ level: TimeFormatLevel.Hour,
+ majorInterval: 60 * 60 * 1000, // 1小时
+ minorTicks: 2, // 每30分钟一个次刻度
+ minPixelDistance: 50,
+ maxPixelDistance: 120,
+ formatter: (date: Date) => ({
+ primary: TimeUtils.format(date, 'HH:00'),
+ secondary: date.getHours() === 0 ?
+ `${TimeUtils.getMonthName(date.getMonth())} ${date.getDate()}` : undefined
+ })
+ },
+ {
+ level: TimeFormatLevel.ThreeHours,
+ majorInterval: 3 * 60 * 60 * 1000, // 3小时
+ minorTicks: 3, // 每小时一个次刻度
+ minPixelDistance: 50,
+ maxPixelDistance: 120,
+ formatter: (date: Date) => ({
+ primary: TimeUtils.format(date, 'HH:00'),
+ secondary: date.getHours() === 0 ?
+ `${TimeUtils.getWeekdayName(date.getDay())}, ${TimeUtils.getMonthName(date.getMonth())} ${date.getDate()}` : undefined
+ })
+ },
+ {
+ level: TimeFormatLevel.SixHours,
+ majorInterval: 6 * 60 * 60 * 1000, // 6小时
+ minorTicks: 3, // 每2小时一个次刻度
+ minPixelDistance: 60,
+ maxPixelDistance: 150,
+ formatter: (date: Date) => ({
+ primary: date.getHours() === 0 ? '00:00' : `${TimeUtils.format(date, 'HH:00')}`,
+ secondary: date.getHours() === 0 ?
+ `${TimeUtils.getWeekdayName(date.getDay())}, ${TimeUtils.getMonthName(date.getMonth())} ${date.getDate()}` :
+ (date.getHours() === 12 ? TimeUtils.format(date, 'MM/DD') : undefined)
+ })
+ },
+ {
+ level: TimeFormatLevel.Day,
+ majorInterval: 24 * 60 * 60 * 1000, // 1天
+ minorTicks: 4, // 每6小时一个次刻度
+ minPixelDistance: 80,
+ maxPixelDistance: 200,
+ formatter: (date: Date) => ({
+ primary: `${TimeUtils.getMonthName(date.getMonth())} ${date.getDate()}`,
+ secondary: `${TimeUtils.getWeekdayName(date.getDay())}, ${date.getFullYear()}`
+ })
+ }
+ ];
+ }
+
+ /** 判断是否应该显示日期 */
+ private shouldShowDate(date: Date): boolean {
+ // 在午夜时显示日期,或者是每天的第一个刻度
+ return date.getHours() === 0 && date.getMinutes() === 0;
+ }
+
+ /** 根据缩放级别选择合适的刻度级别 */
+ selectLevel(viewport: Viewport): ScaleLevel {
+ const zoomLevel = viewport.getZoomLevel();
+
+ for (const level of this.scaleLevels) {
+ const pixelDistance = level.majorInterval * zoomLevel;
+ if (pixelDistance >= level.minPixelDistance && pixelDistance <= level.maxPixelDistance) {
+ this.currentLevel = level;
+ return level;
+ }
+ }
+
+ // 如果没有找到合适的级别,选择最接近的
+ const pixelDistances = this.scaleLevels.map(level => level.majorInterval * zoomLevel);
+ const targetDistance = 100; // 目标像素距离
+ const closestIndex = pixelDistances.reduce((bestIdx, dist, idx) => {
+ const bestDist = pixelDistances[bestIdx];
+ return Math.abs(dist - targetDistance) < Math.abs(bestDist - targetDistance) ? idx : bestIdx;
+ }, 0);
+
+ this.currentLevel = this.scaleLevels[closestIndex];
+ return this.currentLevel;
+ }
+
+ /** 计算可见范围内的刻度 */
+ calculateTicks(viewport: Viewport): Tick[] {
+ const ticks: Tick[] = [];
+ const [startTime, endTime] = viewport.getVisibleRange();
+ const level = this.selectLevel(viewport);
+
+ // 计算第一个主刻度位置
+ const firstMajor = TimeUtils.getNearestTick(startTime, level.majorInterval);
+
+ // 生成主刻度
+ let currentTime = firstMajor;
+ while (currentTime <= endTime + level.majorInterval) {
+ const position = viewport.timeToScreen(currentTime);
+
+ if (position >= -50 && position <= viewport.getWidth() + 50) {
+ const date = new Date(currentTime);
+ const labels = level.formatter(date);
+
+ ticks.push({
+ timestamp: currentTime,
+ position,
+ isMajor: true,
+ primaryLabel: labels.primary,
+ secondaryLabel: labels.secondary
+ });
+
+ // 生成次刻度
+ if (level.minorTicks > 0) {
+ const minorInterval = level.majorInterval / (level.minorTicks + 1);
+ for (let i = 1; i <= level.minorTicks; i++) {
+ const minorTime = currentTime + minorInterval * i;
+ if (minorTime <= endTime) {
+ const minorPosition = viewport.timeToScreen(minorTime);
+ if (minorPosition >= 0 && minorPosition <= viewport.getWidth()) {
+ ticks.push({
+ timestamp: minorTime,
+ position: minorPosition,
+ isMajor: false
+ });
+ }
+ }
+ }
+ }
+ }
+
+ currentTime += level.majorInterval;
+ }
+
+ return ticks;
+ }
+
+ /** 获取当前刻度级别 */
+ getCurrentLevel(): ScaleLevel | null {
+ return this.currentLevel;
+ }
+}
+
+/** 交互处理器 */
+class InteractionHandler {
+ private isDragging: boolean = false;
+ private lastMousePos: { x: number; y: number } | null = null;
+ private startMousePos: { x: number; y: number } | null = null;
+ private zoomSensitivity: number;
+ private zoomMode: ZoomMode;
+ private readonly CLICK_THRESHOLD = 10; // 点击阈值,像素
+
+ constructor(zoomMode: ZoomMode = ZoomMode.MousePosition, sensitivity: number = 0.001) {
+ this.zoomMode = zoomMode;
+ this.zoomSensitivity = sensitivity;
+ }
+
+ /** 计算人体工学优化的缩放因子 */
+ calculateZoomFactor(scrollDelta: number): number {
+ const baseFactor = 1.15;
+ if (scrollDelta > 0) {
+ return Math.pow(baseFactor, scrollDelta * this.zoomSensitivity * 100);
+ } else {
+ return 1 / Math.pow(baseFactor, -scrollDelta * this.zoomSensitivity * 100);
+ }
+ }
+
+ /** 开始拖拽 */
+ startDrag(x: number, y: number): void {
+ this.isDragging = true;
+ this.lastMousePos = { x, y };
+ this.startMousePos = { x, y };
+ }
+
+ /** 结束拖拽 */
+ endDrag(): void {
+ this.isDragging = false;
+
+ }
+
+ /** 判断是否为点击 */
+ isClick(): boolean {
+ if (!this.startMousePos || !this.lastMousePos) return false;
+
+ const deltaX = Math.abs(this.lastMousePos.x - this.startMousePos.x);
+ const deltaY = Math.abs(this.lastMousePos.y - this.startMousePos.y);
+
+ return deltaX <= this.CLICK_THRESHOLD;
+ }
+
+ /** 处理鼠标移动 */
+ handleMouseMove(x: number, y: number, viewport: Viewport): void {
+ if (this.isDragging && this.lastMousePos) {
+ const deltaX = x - this.lastMousePos.x;
+ viewport.pan(deltaX);
+ this.lastMousePos = { x, y };
+ }
+ }
+
+ /** 处理滚轮事件 */
+ handleWheel(deltaY: number, mouseX: number, viewport: Viewport, markX?: number): void {
+ const zoomFactor = this.calculateZoomFactor(-deltaY);
+
+ if (this.zoomMode === ZoomMode.MarkMode && markX) {
+ viewport.zoomAt(zoomFactor, markX);
+ return;
+ } else if (this.zoomMode === ZoomMode.MarkMode) {
+ viewport.zoomAt(zoomFactor, mouseX);
+ return;
+ }
+
+ if (this.zoomMode === ZoomMode.MousePosition) {
+ viewport.zoomAt(zoomFactor, mouseX);
+ } else {
+ viewport.zoom(zoomFactor);
+ }
+ }
+
+
+
+ // Getters & Setters
+ getZoomMode(): ZoomMode { return this.zoomMode; }
+ setZoomMode(mode: ZoomMode): void { this.zoomMode = mode; }
+ setZoomSensitivity(sensitivity: number): void {
+ this.zoomSensitivity = Math.max(0.0001, Math.min(0.01, sensitivity));
+ }
+ getIsDragging(): boolean { return this.isDragging; }
+}
+
+/** 实时时间轴主类 */
+class RealTimeTimeline {
+ private canvas: HTMLCanvasElement;
+ private ctx: CanvasRenderingContext2D;
+ private viewport: Viewport;
+ private scaleManager: ScaleManager;
+ private interaction: InteractionHandler;
+ private config: Required;
+ private animationFrameId: number | null = null;
+ private currentTimeUpdateInterval: number | null = null;
+
+ constructor(canvas: HTMLCanvasElement, config?: TimelineConfig) {
+ this.canvas = canvas;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) {
+ throw new Error('Failed to get 2D context');
+ }
+ this.ctx = ctx;
+
+ // 应用配置
+ this.config = this.mergeConfig(config);
+
+ // 初始化组件
+ this.viewport = new Viewport(canvas.width, canvas.height);
+ this.viewport.goToTime(this.config.initialCenterTime);
+ if (this.config.initialTimeRange) {
+ this.viewport.setTimeRange(this.config.initialTimeRange);
+ }
+
+ this.scaleManager = new ScaleManager();
+ this.interaction = new InteractionHandler(
+ this.config.zoomMode,
+ this.config.zoomSensitivity
+ );
+
+ // 设置画布事件监听
+ this.setupEventListeners();
+
+ // 开始渲染
+ this.render();
+
+ // 如果显示当前时间线,定期更新
+ if (this.config.showCurrentTime) {
+ this.startCurrentTimeUpdate();
+ }
+ }
+
+ /** 合并配置 */
+ private mergeConfig(config?: TimelineConfig): Required {
+ const defaultConfig: Required = {
+ initialCenterTime: Date.now(),
+ initialTimeRange: 60 * 60 * 1000, // 1小时
+ zoomMode: ZoomMode.MousePosition,
+ zoomSensitivity: 0.001,
+ timezoneOffset: new Date().getTimezoneOffset(),
+ colors: {
+ background: '#1a1a1a',
+ grid: '#2a2a2a',
+ majorTick: '#d0d0d0',
+ minorTick: '#606060',
+ primaryLabel: '#e0e0e0',
+ secondaryLabel: '#a0a0a0',
+ currentTime: '#ff4444',
+ weekend: 'rgba(100, 100, 255, 0.05)',
+ timeMark: '#ff6b6b',
+ timeMarkLabel: '#ffffff'
+ },
+ sizes: {
+ majorTickHeight: 12,
+ minorTickHeight: 6,
+ labelOffset: 8,
+ primaryFontSize: 13,
+ secondaryFontSize: 11,
+ timeMarkHeight: 16,
+ timeMarkWidth: 1
+ },
+ showCurrentTime: true,
+ highlightWeekends: true,
+ timeMarks: []
+ };
+
+ if (!config) return defaultConfig;
+
+ return {
+ initialCenterTime: config.initialCenterTime ?? defaultConfig.initialCenterTime,
+ initialTimeRange: config.initialTimeRange ?? defaultConfig.initialTimeRange,
+ zoomMode: config.zoomMode ?? defaultConfig.zoomMode,
+ zoomSensitivity: config.zoomSensitivity ?? defaultConfig.zoomSensitivity,
+ timezoneOffset: config.timezoneOffset ?? defaultConfig.timezoneOffset,
+ colors: { ...defaultConfig.colors, ...config.colors },
+ sizes: { ...defaultConfig.sizes, ...config.sizes },
+ showCurrentTime: config.showCurrentTime ?? defaultConfig.showCurrentTime,
+ highlightWeekends: config.highlightWeekends ?? defaultConfig.highlightWeekends,
+ timeMarks: config.timeMarks ?? defaultConfig.timeMarks
+ };
+ }
+
+ /** 设置事件监听器 */
+ private setupEventListeners(): void {
+ // 鼠标按下
+ this.canvas.addEventListener('mousedown', (e) => {
+ if (e.button === 0) {
+ const rect = this.canvas.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+ this.interaction.startDrag(x, y);
+ this.canvas.style.cursor = 'grabbing';
+ } else if (e.button === 2) {
+ e.preventDefault();
+ }
+
+ });
+
+ // 鼠标移动
+ this.canvas.addEventListener('mousemove', (e) => {
+ if (e.button === 0) {
+ const rect = this.canvas.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+ this.interaction.handleMouseMove(x, y, this.viewport);
+
+ if (this.interaction.getIsDragging()) {
+ this.render();
+ }
+ }
+
+ });
+
+ // 鼠标释放
+ this.canvas.addEventListener('mouseup', (e) => {
+ if (e.button === 0) {
+ this.interaction.endDrag();
+ this.canvas.style.cursor = 'crosshair';
+ } else if (e.button === 2) {
+ e.preventDefault();
+ }
+ });
+
+ // 鼠标离开画布
+ this.canvas.addEventListener('mouseleave', () => {
+ this.interaction.endDrag();
+ this.canvas.style.cursor = 'crosshair';
+ });
+
+ this.canvas.addEventListener('mouseup', (e) => {
+ // 只有在点击时才添加时间标记
+ if (this.interaction.isClick()) {
+ const rect = this.canvas.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ // 获取当前刻度信息用于吸附
+ const ticks = this.scaleManager.calculateTicks(this.viewport);
+ const date = this.viewport.screenToTime(x, true, ticks);
+ this.interaction.setZoomMode(ZoomMode.MarkMode);
+ this.replaceTimeMark({
+ timestamp: date,
+ color: '#ff6b6b',
+ label: format(date, 'HH:mm:ss'),
+ type: 'custom'
+ });
+ }
+ });
+
+ // 滚轮事件
+ this.canvas.addEventListener('wheel', (e) => {
+ e.preventDefault();
+ const rect = this.canvas.getBoundingClientRect();
+ const mouseX = e.clientX - rect.left;
+
+ const markTimestamp = this.config.timeMarks?.find(mark => mark.type === 'custom')?.timestamp;
+
+ if (markTimestamp) {
+ const markX = this.viewport.timeToScreen(markTimestamp);
+ if (markX >= 0 && markX <= this.viewport.getWidth()) {
+ this.interaction.handleWheel(e.deltaY, mouseX, this.viewport, markX);
+ this.render();
+ return
+ }
+ }
+
+ this.interaction.handleWheel(e.deltaY, mouseX, this.viewport);
+ this.render();
+ });
+
+ // 处理画布大小变化
+ const resizeObserver = new ResizeObserver(() => {
+ this.handleResize();
+ });
+ resizeObserver.observe(this.canvas);
+
+ // 设置初始光标
+ this.canvas.style.cursor = 'crosshair';
+ }
+
+ /** 处理画布大小变化 */
+ private handleResize(): void {
+ const rect = this.canvas.getBoundingClientRect();
+ this.canvas.width = rect.width * window.devicePixelRatio;
+ this.canvas.height = rect.height * window.devicePixelRatio;
+ this.ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
+ this.viewport.updateSize(rect.width, rect.height);
+ this.render();
+ }
+
+ /** 开始当前时间更新 */
+ private startCurrentTimeUpdate(): void {
+ this.currentTimeUpdateInterval = window.setInterval(() => {
+ this.render();
+ }, 1000); // 每秒更新一次
+ }
+
+ /** 渲染时间轴 */
+ render(): void {
+ const width = this.viewport.getWidth();
+ const height = this.viewport.getHeight();
+
+ // 清空画布
+ if (this.config.colors.background === 'transparent') {
+ // 对于透明背景,使用 clearRect 来清空画布
+ this.ctx.clearRect(0, 0, width, height);
+ } else {
+ // 对于有颜色的背景,使用 fillRect 来填充
+ this.ctx.fillStyle = this.config.colors.background ?? '#1a1a1a';
+ this.ctx.fillRect(0, 0, width, height);
+ }
+
+ // 高亮周末(如果启用)
+ if (this.config.highlightWeekends) {
+ this.drawWeekends();
+ }
+
+ // 计算并绘制刻度
+ const ticks = this.scaleManager.calculateTicks(this.viewport);
+ this.drawTicks(ticks);
+
+ // 绘制当前时间线(如果启用)
+ if (this.config.showCurrentTime) {
+ this.drawCurrentTime();
+ }
+
+ // 绘制时间标记
+ this.drawTimeMarks();
+ }
+
+ /** 绘制周末高亮 */
+ private drawWeekends(): void {
+ const [startTime, endTime] = this.viewport.getVisibleRange();
+ const height = this.viewport.getHeight();
+
+ // 找到第一个周六或周日
+ let currentTime = TimeUtils.getDayStart(startTime);
+
+ while (currentTime <= endTime) {
+ const date = new Date(currentTime);
+ if (TimeUtils.isWeekend(currentTime)) {
+ const dayStart = currentTime;
+ const dayEnd = dayStart + 24 * 60 * 60 * 1000;
+
+ const x1 = this.viewport.timeToScreen(dayStart);
+ const x2 = this.viewport.timeToScreen(dayEnd);
+
+ if (x2 > 0 && x1 < this.viewport.getWidth()) {
+ this.ctx.fillStyle = this.config.colors.weekend ?? 'rgba(100, 100, 255, 0.05)';
+ this.ctx.fillRect(
+ Math.max(0, x1),
+ 0,
+ Math.min(x2, this.viewport.getWidth()) - Math.max(0, x1),
+ height
+ );
+ }
+ }
+ currentTime += 24 * 60 * 60 * 1000; // 下一天
+ }
+ }
+
+ /** 绘制刻度 */
+ private drawTicks(ticks: Tick[]): void {
+ const height = this.viewport.getHeight();
+
+ // 先绘制网格线
+ ticks.filter(t => t.isMajor).forEach(tick => {
+ this.ctx.strokeStyle = this.config.colors.grid ?? '#2a2a2a';
+ this.ctx.lineWidth = 0.5;
+ this.ctx.beginPath();
+ this.ctx.moveTo(tick.position, 0);
+ this.ctx.lineTo(tick.position, height);
+ this.ctx.stroke();
+ });
+
+ // 绘制刻度线和标签
+ ticks.forEach(tick => {
+ const x = tick.position;
+
+ // 绘制刻度线
+ const tickHeight = tick.isMajor ?
+ (this.config.sizes.majorTickHeight ?? 12) :
+ (this.config.sizes.minorTickHeight ?? 6);
+
+ this.ctx.strokeStyle = tick.isMajor ?
+ (this.config.colors.majorTick ?? '#d0d0d0') :
+ (this.config.colors.minorTick ?? '#606060');
+ this.ctx.lineWidth = tick.isMajor ? 1.5 : 1;
+ this.ctx.beginPath();
+ this.ctx.moveTo(x, height - tickHeight);
+ this.ctx.lineTo(x, height);
+ this.ctx.stroke();
+
+ // 绘制标签(仅主刻度)
+ if (tick.primaryLabel) {
+ // 主标签
+ this.ctx.fillStyle = this.config.colors.primaryLabel ?? '#e0e0e0';
+ this.ctx.font = `${this.config.sizes.primaryFontSize ?? 13}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
+ this.ctx.textAlign = 'center';
+ this.ctx.textBaseline = 'bottom';
+ this.ctx.fillText(
+ tick.primaryLabel,
+ x,
+ height - tickHeight - (this.config.sizes.labelOffset ?? 8)
+ );
+
+ // 次标签(如日期)
+ if (tick.secondaryLabel) {
+ this.ctx.fillStyle = this.config.colors.secondaryLabel ?? '#a0a0a0';
+ this.ctx.font = `${this.config.sizes.secondaryFontSize ?? 11}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
+ this.ctx.fillText(
+ tick.secondaryLabel,
+ x,
+ height - tickHeight - (this.config.sizes.labelOffset ?? 8) - 12
+ );
+ }
+ }
+ });
+ }
+
+ /** 绘制当前时间线 */
+ private drawCurrentTime(): void {
+ const now = Date.now();
+ const x = this.viewport.timeToScreen(now);
+
+ if (x >= 0 && x <= this.viewport.getWidth()) {
+ const height = this.viewport.getHeight();
+
+ // 绘制时间线
+ this.ctx.strokeStyle = this.config.colors.currentTime ?? '#ff4444';
+ this.ctx.lineWidth = 1;
+ this.ctx.setLineDash([5, 5]);
+ this.ctx.beginPath();
+ this.ctx.moveTo(x, 0);
+ this.ctx.lineTo(x, height);
+ this.ctx.stroke();
+ this.ctx.setLineDash([]);
+
+ // 绘制时间标签背景
+ const timeStr = TimeUtils.format(new Date(now), 'HH:mm:ss');
+ this.ctx.font = `bold ${this.config.sizes.primaryFontSize ?? 13}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
+ const metrics = this.ctx.measureText(timeStr);
+ const labelWidth = metrics.width + 10;
+ const labelHeight = 20;
+
+ this.ctx.fillStyle = this.config.colors.currentTime ?? '#ff4444';
+ // this.ctx.fillRect(x - labelWidth / 2, 0, labelWidth, labelHeight);
+
+ // 绘制时间文本
+ this.ctx.fillStyle = '#ffffff';
+ this.ctx.textAlign = 'center';
+ this.ctx.textBaseline = 'middle';
+ // this.ctx.fillText(timeStr, x, labelHeight / 2);
+ }
+ }
+
+ /** 绘制时间标记 */
+ private drawTimeMarks(): void {
+ if (!this.config.timeMarks || this.config.timeMarks.length === 0) {
+ return;
+ }
+
+ const height = this.viewport.getHeight();
+ const markHeight = this.config.sizes.timeMarkHeight ?? 16;
+ const markWidth = this.config.sizes.timeMarkWidth ?? 4;
+
+ this.config.timeMarks.forEach(mark => {
+ const x = this.viewport.timeToScreen(mark.timestamp);
+
+ // 只绘制可见范围内的标记
+ if (x >= -markWidth && x <= this.viewport.getWidth() + markWidth) {
+ // 绘制标记线
+ this.ctx.strokeStyle = mark.color || (this.config.colors.timeMark ?? '#ff6b6b');
+ this.ctx.lineWidth = markWidth;
+ this.ctx.beginPath();
+ this.ctx.moveTo(x, 0);
+ this.ctx.lineTo(x, height);
+ this.ctx.stroke();
+
+ // 绘制标记标签(如果有)
+ if (mark.label) {
+ const labelHeight = 16;
+ const labelWidth = 50;
+ const labelY = Math.max(5, height - labelHeight - 5);
+
+ // 绘制标签背景
+ this.ctx.fillStyle = mark.color || (this.config.colors.timeMark ?? '#ff6b6b');
+ // this.ctx.fillRect(x - 25, labelY, 50, labelHeight);
+ this.ctx.fillRect(x - labelWidth / 2, 0, labelWidth, labelHeight)
+
+ // 绘制标签文本
+ this.ctx.fillStyle = this.config.colors.timeMarkLabel ?? '#ffffff';
+ this.ctx.font = `${this.config.sizes.secondaryFontSize ?? 11}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
+ this.ctx.textAlign = 'center';
+ this.ctx.textBaseline = 'middle';
+ // this.ctx.fillText(mark.label, x, labelY + labelHeight / 2);
+ this.ctx.fillText(mark.label, x, labelHeight / 2);
+ }
+ }
+ });
+ }
+
+
+
+ /** 获取当前视口信息 */
+ getViewportInfo(): {
+ centerTime: number;
+ timeRange: number;
+ visibleRange: [number, number];
+ currentLevel: TimeFormatLevel | null;
+ } {
+ const currentLevel = this.scaleManager.getCurrentLevel();
+ return {
+ centerTime: this.viewport.getCenterTime(),
+ timeRange: this.viewport.getTimeRange(),
+ visibleRange: this.viewport.getVisibleRange(),
+ currentLevel: currentLevel?.level || null
+ };
+ }
+
+ /** 跳转到指定时间 */
+ goToTime(timestamp: number): void {
+ this.viewport.goToTime(timestamp);
+ this.render();
+ }
+
+ /** 跳转到当前时间 */
+ goToNow(): void {
+ this.goToTime(Date.now());
+ }
+
+ /** 设置时间范围 */
+ setTimeRange(range: number): void {
+ this.viewport.setTimeRange(range);
+ this.render();
+ }
+
+ /** 设置缩放模式 */
+ setZoomMode(mode: ZoomMode): void {
+ this.interaction.setZoomMode(mode);
+ }
+
+ /** 添加时间标记 */
+ addTimeMark(mark: TimeMark): void {
+ this.config.timeMarks.push(mark);
+ this.render();
+ }
+
+ replaceTimeMark(mark: TimeMark): void {
+ this.config.timeMarks.splice(0);
+ this.config.timeMarks.push(mark);
+ this.render();
+ }
+
+ forwardTimeMark(delta: number, timestamp?: number): void {
+ if (!timestamp) {
+ timestamp = this.config.timeMarks[0].timestamp;
+ }
+ const index = this.config.timeMarks.findIndex(mark => mark.timestamp === timestamp);
+ if (index !== -1) {
+ this.config.timeMarks.splice(index, 1);
+ this.replaceTimeMark({
+ timestamp: timestamp + delta,
+ color: '#ff6b6b',
+ label: format(timestamp + delta, 'HH:mm:ss'),
+ type: 'custom'
+ });
+ this.render();
+ }
+ }
+
+ backwardTimeMark(delta: number, timestamp?: number): void {
+ if (!timestamp) {
+ timestamp = this.config.timeMarks[0].timestamp;
+ }
+ const index = this.config.timeMarks.findIndex(mark => mark.timestamp === timestamp);
+ if (index !== -1) {
+ this.config.timeMarks.splice(index, 1);
+ this.replaceTimeMark({
+ timestamp: timestamp - delta,
+ color: '#ff6b6b',
+ label: format(timestamp - delta, 'HH:mm:ss'),
+ type: 'custom'
+ });
+ this.render();
+ }
+ }
+
+ /** 确保 Mark 处于当前视野内 */
+ ensureMarkInView(timestamp?: number): void {
+ if (!timestamp) {
+ const mark = this.config.timeMarks.find(mark => mark.type === 'custom');
+ if (!mark) return;
+ timestamp = mark.timestamp;
+ }
+
+ const [startTime, endTime] = this.viewport.getVisibleRange();
+ const markX = this.viewport.timeToScreen(timestamp);
+
+ // 如果 Mark 不在视野内,调整视口
+ if (markX < 0 || markX > this.viewport.getWidth()) {
+ // 计算 Mark 应该位于视口左侧 1/3 位置(符合人体工学)
+ const leftOffset = this.viewport.getWidth() / 3;
+ const timeOffset = (leftOffset / this.viewport.getWidth()) * this.viewport.getTimeRange();
+ const targetCenterTime = timestamp + timeOffset;
+ this.viewport.goToTime(targetCenterTime);
+ this.render();
+ }
+ }
+
+ /** 播放翻页后确保 Mark 在视野内 */
+ playAndEnsureMarkInView(delta: number, timestamp?: number): void {
+ // 先执行时间标记的前进
+ this.forwardTimeMark(delta, timestamp);
+
+ // 然后确保 Mark 在视野内
+ this.ensureMarkInView();
+ }
+
+ /** 后退翻页后确保 Mark 在视野内 */
+ playBackwardAndEnsureMarkInView(delta: number, timestamp?: number): void {
+ // 先执行时间标记的后退
+ this.backwardTimeMark(delta, timestamp);
+
+ // 然后确保 Mark 在视野内
+ this.ensureMarkInView();
+ }
+
+ /** 移除时间标记 */
+ removeTimeMark(timestamp: number): void {
+ const index = this.config.timeMarks.findIndex(mark => mark.timestamp === timestamp);
+ if (index !== -1) {
+ this.config.timeMarks.splice(index, 1);
+ this.render();
+ }
+ }
+
+ /** 清除所有时间标记 */
+ clearTimeMarks(): void {
+ this.config.timeMarks = [];
+ this.render();
+ }
+
+ /** 获取所有时间标记 */
+ getTimeMarks(): TimeMark[] {
+ return [...this.config.timeMarks];
+ }
+
+ /** 快速设置显示范围的辅助方法 */
+ showLastMinutes(minutes: number): void {
+ this.setTimeRange(minutes * 60 * 1000);
+ this.goToNow();
+ }
+
+ showLastHours(hours: number): void {
+ this.setTimeRange(hours * 60 * 60 * 1000);
+ this.goToNow();
+ }
+
+ showLastDays(days: number): void {
+ this.setTimeRange(days * 24 * 60 * 60 * 1000);
+ this.goToNow();
+ }
+
+ showToday(): void {
+ const now = new Date();
+ const dayStart = TimeUtils.getDayStart(now.getTime());
+ const dayEnd = dayStart + 24 * 60 * 60 * 1000;
+
+ this.viewport.goToTime((dayStart + dayEnd) / 2);
+ this.setTimeRange(24 * 60 * 60 * 1000);
+ }
+
+ showThisWeek(): void {
+ const now = new Date();
+ const day = now.getDay();
+ const weekStart = TimeUtils.getDayStart(now.getTime() - day * 24 * 60 * 60 * 1000);
+ const weekEnd = weekStart + 7 * 24 * 60 * 60 * 1000;
+
+ this.viewport.goToTime((weekStart + weekEnd) / 2);
+ this.setTimeRange(7 * 24 * 60 * 60 * 1000);
+ }
+
+ showThisMonth(): void {
+ const now = new Date();
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
+ const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0).getTime();
+
+ this.viewport.goToTime((monthStart + monthEnd) / 2);
+ this.setTimeRange(monthEnd - monthStart);
+ }
+
+ /** 导出当前视图为图片 */
+ exportAsImage(filename: string = 'timeline.png'): void {
+ this.canvas.toBlob((blob) => {
+ if (blob) {
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+ }
+ });
+ }
+
+ /** 销毁时间轴 */
+ destroy(): void {
+ if (this.animationFrameId) {
+ cancelAnimationFrame(this.animationFrameId);
+ }
+ if (this.currentTimeUpdateInterval) {
+ clearInterval(this.currentTimeUpdateInterval);
+ }
+ }
+}
+
+// 导出
+export {
+ RealTimeTimeline as Timeline,
+ ZoomMode,
+ TimeFormatLevel,
+ TimeUtils
+};
+export type { TimelineConfig, Tick, ScaleLevel, TimeMark };
\ No newline at end of file