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