/** * 实时时间轴组件 - 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; /** Initial Zoom Level */ initialZoomLevel?: number | null; /** 缩放模式 */ zoomMode?: ZoomMode; /** 缩放灵敏度 */ zoomSensitivity?: number; /** 预定义缩放级别(毫秒),用于离散缩放 */ discreteZoomLevels?: number[]; /** 启用平滑缩放动画 */ enableSmoothZoom?: boolean; /** 缩放动画持续时间(毫秒) */ zoomAnimationDuration?: 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[]; onDateChange?: (date: Date) => void; } /** 时间工具类 */ 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 = 1200 * 1000; private readonly MAX_RANGE = 10 * 24 * 60 * 60 * 1000; /** 动画相关状态 */ private animationState: { isAnimating: boolean; startTime: number; duration: number; startTimeRange: number; targetTimeRange: number; startCenterTime: number; targetCenterTime: number; animationId?: number; onComplete?: () => void; } | null = null; 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.timeRange / this.width; } /** 将时间转换为屏幕坐标 */ 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; console.log(this.getZoomLevel()) } /** 动画缩放到指定时间范围 */ animateToTimeRange( targetRange: number, duration: number = 300, targetCenterTime?: number, onComplete?: () => void ): void { // 如果已有动画正在进行,取消它 if (this.animationState?.animationId) { cancelAnimationFrame(this.animationState.animationId); } const startTime = Date.now(); const startTimeRange = this.timeRange; const startCenterTime = this.centerTime; // 限制目标范围 targetRange = Math.max(this.MIN_RANGE, Math.min(this.MAX_RANGE, targetRange)); this.animationState = { isAnimating: true, startTime, duration, startTimeRange, targetTimeRange: targetRange, startCenterTime, targetCenterTime: targetCenterTime ?? this.centerTime, onComplete }; const animate = () => { if (!this.animationState) return; const elapsed = Date.now() - this.animationState.startTime; const progress = Math.min(elapsed / this.animationState.duration, 1); // 使用缓动函数 (easeInOutCubic) const easedProgress = progress < 0.5 ? 4 * progress * progress * progress : 1 - Math.pow(-2 * progress + 2, 3) / 2; // 插值计算当前值 this.timeRange = this.animationState.startTimeRange + (this.animationState.targetTimeRange - this.animationState.startTimeRange) * easedProgress; this.centerTime = this.animationState.startCenterTime + (this.animationState.targetCenterTime - this.animationState.startCenterTime) * easedProgress; if (progress >= 1) { // 动画完成 this.timeRange = this.animationState.targetTimeRange; this.centerTime = this.animationState.targetCenterTime; const onComplete = this.animationState.onComplete; this.animationState = null; if (onComplete) onComplete(); } else { // 继续动画 this.animationState.animationId = requestAnimationFrame(animate); } }; this.animationState.animationId = requestAnimationFrame(animate); } /** 动画缩放到指定位置和时间范围 */ animateZoomAt( targetRange: number, screenX: number, duration: number = 300, onComplete?: () => void ): void { const timeAtMouse = this.screenToTime(screenX); // 计算缩放后的中心时间,保持鼠标位置下的时间不变 const currentRatio = (screenX - this.width / 2) / this.width; const targetCenterTime = timeAtMouse - currentRatio * targetRange; this.animateToTimeRange(targetRange, duration, targetCenterTime, onComplete); } /** 检查是否正在动画中 */ isAnimating(): boolean { return this.animationState?.isAnimating ?? false; } /** 停止当前动画 */ stopAnimation(): void { if (this.animationState?.animationId) { cancelAnimationFrame(this.animationState.animationId); this.animationState = null; } } /** 更新视口尺寸 */ 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: 6 * 60 * 1000, // 5分钟 minorTicks: 6, // 每分钟一个次刻度 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: 12 * 60 * 1000, // 12分钟 minorTicks: 2, // 每6分钟一个次刻度 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: 12, // 每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; // 点击阈值,像素 private discreteZoomLevels: number[]; private enableSmoothZoom: boolean; private zoomAnimationDuration: number; // 触摸相关状态 private lastTouchDistance: number | null = null; private lastTouchCenter: { x: number; y: number } | null = null; private isTouchZooming: boolean = false; constructor( zoomMode: ZoomMode = ZoomMode.MousePosition, sensitivity: number = 0.001, discreteZoomLevels: number[] = [], enableSmoothZoom: boolean = true, zoomAnimationDuration: number = 300 ) { this.zoomMode = zoomMode; this.zoomSensitivity = sensitivity; this.discreteZoomLevels = discreteZoomLevels; this.enableSmoothZoom = enableSmoothZoom; this.zoomAnimationDuration = zoomAnimationDuration; } /** 计算人体工学优化的缩放因子 */ 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 { // 如果正在动画中,忽略滚轮事件 if (viewport.isAnimating()) { return; } // 如果启用了离散缩放级别 if (this.discreteZoomLevels.length > 0) { this.handleDiscreteZoom(deltaY, mouseX, viewport, markX); return; } // 原有的连续缩放逻辑 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); } } /** 处理离散缩放 */ private handleDiscreteZoom(deltaY: number, mouseX: number, viewport: Viewport, markX?: number): void { const currentLevel = viewport.getZoomLevel(); // 找到当前最接近的缩放级别 let currentLevelIndex = 0; let minDiff = Math.abs(this.discreteZoomLevels[0] - currentLevel); for (let i = 1; i < this.discreteZoomLevels.length; i++) { const diff = Math.abs(this.discreteZoomLevels[i] - currentLevel); if (diff < minDiff) { minDiff = diff; currentLevelIndex = i; } } // 根据滚轮方向确定目标级别 let targetLevelIndex: number; if (deltaY < 0) { // 向上滚动,缩小(增加时间范围) targetLevelIndex = Math.min(currentLevelIndex + 1, this.discreteZoomLevels.length - 1); } else { // 向下滚动,放大(减少时间范围) targetLevelIndex = Math.max(currentLevelIndex - 1, 0); } // 如果级别没有变化,不执行动画 if (targetLevelIndex === currentLevelIndex) { return; } const targetRange = viewport.getWidth() * this.discreteZoomLevels[targetLevelIndex]; const zoomX = (this.zoomMode === ZoomMode.MarkMode && markX) ? markX : mouseX; if (this.enableSmoothZoom) { // 使用平滑动画 if (this.zoomMode === ZoomMode.MousePosition || this.zoomMode === ZoomMode.MarkMode) { viewport.animateZoomAt(targetRange, zoomX, this.zoomAnimationDuration); } else { viewport.animateToTimeRange(targetRange, this.zoomAnimationDuration); } } else { // 立即跳转 if (this.zoomMode === ZoomMode.MousePosition || this.zoomMode === ZoomMode.MarkMode) { const timeAtMouse = viewport.screenToTime(zoomX); viewport.setTimeRange(targetRange); const newTimeAtMouse = viewport.screenToTime(zoomX); const timeCorrection = timeAtMouse - newTimeAtMouse; viewport.goToTime(viewport.getCenterTime() + timeCorrection); } else { viewport.setTimeRange(targetRange); } } } /** 处理触摸开始事件(支持双指缩放) */ handleTouchStart(touches: TouchList): void { if (touches.length === 2) { // 双指触摸,准备缩放 this.isTouchZooming = true; this.isDragging = false; const touch1 = touches[0]; const touch2 = touches[1]; this.lastTouchDistance = Math.sqrt( Math.pow(touch2.clientX - touch1.clientX, 2) + Math.pow(touch2.clientY - touch1.clientY, 2) ); this.lastTouchCenter = { x: (touch1.clientX + touch2.clientX) / 2, y: (touch1.clientY + touch2.clientY) / 2 }; } else if (touches.length === 1 && !this.isTouchZooming) { // 单指触摸,准备拖拽 const touch = touches[0]; this.startDrag(touch.clientX, touch.clientY); } } /** 处理触摸移动事件 */ handleTouchMove(touches: TouchList, viewport: Viewport, canvas: HTMLCanvasElement): void { if (touches.length === 2 && this.isTouchZooming) { // 双指缩放 const touch1 = touches[0]; const touch2 = touches[1]; const currentDistance = Math.sqrt( Math.pow(touch2.clientX - touch1.clientX, 2) + Math.pow(touch2.clientY - touch1.clientY, 2) ); const currentCenter = { x: (touch1.clientX + touch2.clientX) / 2, y: (touch1.clientY + touch2.clientY) / 2 }; if (this.lastTouchDistance && this.lastTouchCenter) { const scaleChange = currentDistance / this.lastTouchDistance; // 转换为画布坐标 const canvasRect = canvas.getBoundingClientRect(); const canvasX = currentCenter.x - canvasRect.left; if (this.discreteZoomLevels.length > 0) { // 对于离散缩放,当缩放变化超过阈值时触发 const threshold = 1.1; // 10% 的变化阈值 if (scaleChange > threshold) { this.handleDiscreteZoom(-100, canvasX, viewport); // 放大 this.lastTouchDistance = currentDistance; // 重置基准距离 } else if (scaleChange < 1 / threshold) { this.handleDiscreteZoom(100, canvasX, viewport); // 缩小 this.lastTouchDistance = currentDistance; // 重置基准距离 } } else { // 连续缩放 viewport.zoomAt(scaleChange, canvasX); this.lastTouchDistance = currentDistance; } } this.lastTouchCenter = currentCenter; } else if (touches.length === 1 && !this.isTouchZooming) { // 单指拖拽 - 转换为画布坐标 const touch = touches[0]; const canvasRect = canvas.getBoundingClientRect(); const canvasX = touch.clientX - canvasRect.left; const canvasY = touch.clientY - canvasRect.top; this.handleMouseMove(canvasX, canvasY, viewport); } } /** 处理触摸结束事件 */ handleTouchEnd(): void { this.isTouchZooming = false; this.lastTouchDistance = null; this.lastTouchCenter = null; this.endDrag(); } // 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; private mousePosition: { x: number; y: number } | null = null; private showMouseIndicator: boolean = false; private pendingRender: boolean = false; 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.config.initialZoomLevel) { this.viewport.setTimeRange(this.config.initialTimeRange); } if (this.config.initialZoomLevel && !this.config.initialTimeRange) { this.viewport.setTimeRange(this.viewport.getWidth() * this.config.initialZoomLevel); } this.scaleManager = new ScaleManager(); this.interaction = new InteractionHandler( this.config.zoomMode, this.config.zoomSensitivity, this.config.discreteZoomLevels, this.config.enableSmoothZoom, this.config.zoomAnimationDuration ); // 设置画布事件监听 this.setupEventListeners(); // 开始渲染 this.render(); // 如果显示当前时间线,定期更新 if (this.config.showCurrentTime) { this.startCurrentTimeUpdate(); } } /** 合并配置 */ private mergeConfig(config?: TimelineConfig): Required { // 默认离散缩放级别:从1分钟到10天 const defaultDiscreteZoomLevels = [ 1 * 60 * 1000, // 1分钟 2 * 60 * 1000, // 2分钟 5 * 60 * 1000, // 5分钟 10 * 60 * 1000, // 10分钟 20 * 60 * 1000, // 20分钟 30 * 60 * 1000, // 30分钟 60 * 60 * 1000, // 1小时 2 * 60 * 60 * 1000, // 2小时 6 * 60 * 60 * 1000, // 6小时 12 * 60 * 60 * 1000, // 12小时 24 * 60 * 60 * 1000, // 1天 2 * 24 * 60 * 60 * 1000, // 2天 7 * 24 * 60 * 60 * 1000, // 1周 14 * 24 * 60 * 60 * 1000, // 2周 30 * 24 * 60 * 60 * 1000 // 1个月 ]; const defaultConfig: Required = { initialCenterTime: Date.now(), initialTimeRange: 60 * 60 * 1000, // 1小时 initialZoomLevel: null, zoomMode: ZoomMode.MousePosition, zoomSensitivity: 0.001, discreteZoomLevels: defaultDiscreteZoomLevels, enableSmoothZoom: true, zoomAnimationDuration: 300, 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: [], onDateChange: () => { } }; if (!config) return defaultConfig; return { initialCenterTime: config.initialCenterTime ?? defaultConfig.initialCenterTime, initialTimeRange: config.initialTimeRange ?? defaultConfig.initialTimeRange, initialZoomLevel: config.initialZoomLevel ?? defaultConfig.initialZoomLevel, zoomMode: config.zoomMode ?? defaultConfig.zoomMode, zoomSensitivity: config.zoomSensitivity ?? defaultConfig.zoomSensitivity, discreteZoomLevels: config.discreteZoomLevels ?? defaultConfig.discreteZoomLevels, enableSmoothZoom: config.enableSmoothZoom ?? defaultConfig.enableSmoothZoom, zoomAnimationDuration: config.zoomAnimationDuration ?? defaultConfig.zoomAnimationDuration, 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, onDateChange: config.onDateChange ?? defaultConfig.onDateChange }; } /** 设置事件监听器 */ private setupEventListeners(): void { // 获取统一的坐标处理函数 const getEventCoordinates = (e: MouseEvent | TouchEvent): { x: number; y: number } => { const rect = this.canvas.getBoundingClientRect(); if ('touches' in e && e.touches.length > 0) { // 触摸事件 return { x: e.touches[0].clientX - rect.left, y: e.touches[0].clientY - rect.top }; } else if ('changedTouches' in e && e.changedTouches.length > 0) { // 触摸结束事件 return { x: e.changedTouches[0].clientX - rect.left, y: e.changedTouches[0].clientY - rect.top }; } else { // 鼠标事件 const mouseEvent = e as MouseEvent; return { x: mouseEvent.clientX - rect.left, y: mouseEvent.clientY - rect.top }; } }; // 统一的开始拖拽处理(鼠标) const handleMouseStart = (e: MouseEvent) => { e.preventDefault(); const { x, y } = getEventCoordinates(e); this.interaction.startDrag(x, y); this.canvas.style.cursor = 'grabbing'; }; // 统一的移动处理(鼠标) const handleMouseMove = (e: MouseEvent) => { const { x, y } = getEventCoordinates(e); // 更新鼠标位置并显示指示线 this.mousePosition = { x, y }; this.showMouseIndicator = true; // 处理拖拽逻辑 this.interaction.handleMouseMove(x, y, this.viewport); // 使用节流渲染以优化性能 this.requestRender(); }; // 统一的结束拖拽处理(鼠标) const handleMouseEnd = (e: MouseEvent) => { const { x, y } = getEventCoordinates(e); // 检查是否为点击(而非拖拽) if (this.interaction.isClick()) { // 获取当前刻度信息用于吸附 const ticks = this.scaleManager.calculateTicks(this.viewport); const date = this.viewport.screenToTime(x, true, ticks); this.changeTime(new Date(date)); } this.interaction.endDrag(); this.canvas.style.cursor = 'crosshair'; }; // 触摸事件处理 const handleTouchStart = (e: TouchEvent) => { e.preventDefault(); this.interaction.handleTouchStart(e.touches); this.canvas.style.cursor = 'grabbing'; }; const handleTouchMove = (e: TouchEvent) => { e.preventDefault(); // 对于单指触摸,更新显示位置 if (e.touches.length === 1 && !this.interaction.getIsDragging()) { const { x, y } = getEventCoordinates(e); this.mousePosition = { x, y }; this.showMouseIndicator = true; } this.interaction.handleTouchMove(e.touches, this.viewport, this.canvas); this.requestRender(); }; const handleTouchEnd = (e: TouchEvent) => { // 检查是否为点击(单指轻触) if (e.changedTouches.length === 1 && this.interaction.isClick()) { const { x } = getEventCoordinates(e); const ticks = this.scaleManager.calculateTicks(this.viewport); const date = this.viewport.screenToTime(x, true, ticks); this.changeTime(new Date(date)); } this.interaction.handleTouchEnd(); this.canvas.style.cursor = 'crosshair'; }; // 鼠标事件 this.canvas.addEventListener('mousedown', (e) => { if (e.button === 0) { handleMouseStart(e); } else if (e.button === 2) { e.preventDefault(); } }); this.canvas.addEventListener('mousemove', handleMouseMove); this.canvas.addEventListener('mouseup', (e) => { if (e.button === 0) { handleMouseEnd(e); } else if (e.button === 2) { e.preventDefault(); } }); // 触摸事件 this.canvas.addEventListener('touchstart', handleTouchStart, { passive: false }); this.canvas.addEventListener('touchmove', handleTouchMove, { passive: false }); this.canvas.addEventListener('touchend', handleTouchEnd, { passive: false }); this.canvas.addEventListener('touchcancel', () => { this.interaction.handleTouchEnd(); this.canvas.style.cursor = 'crosshair'; this.showMouseIndicator = false; this.requestRender(); }); // 鼠标离开画布 this.canvas.addEventListener('mouseleave', () => { this.interaction.endDrag(); this.canvas.style.cursor = 'crosshair'; this.showMouseIndicator = false; this.requestRender(); }); // 滚轮事件 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); // 每秒更新一次 } /** 请求渲染(使用 requestAnimationFrame 节流) */ private requestRender(): void { if (!this.pendingRender) { this.pendingRender = true; requestAnimationFrame(() => { this.render(); this.pendingRender = false; }); } } /** 渲染时间轴 */ render(): void { const width = this.viewport.getWidth(); const height = this.viewport.getHeight(); // 如果正在动画中,继续请求下一帧 if (this.viewport.isAnimating()) { requestAnimationFrame(() => this.render()); } // 清空画布 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(); // 绘制鼠标位置指示线 this.drawMouseIndicator(); } private changeTime(date: Date): void { this.interaction.setZoomMode(ZoomMode.MarkMode); this.replaceTimeMark({ timestamp: date.getTime(), color: '#ff6b6b', label: format(date, 'HH:mm:ss'), type: 'custom' }); this.config.onDateChange?.(date) } /** 绘制周末高亮 */ 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(); const currentTimeColor = this.config.colors.currentTime ?? '#ff4444'; this.ctx.save(); // 绘制毛玻璃背景条带 const backgroundWidth = 14; const gradient = this.ctx.createLinearGradient(x - backgroundWidth / 2, 0, x + backgroundWidth / 2, 0); // 使用当前时间颜色的红色调 gradient.addColorStop(0, `rgba(255, 68, 68, 0.02)`); gradient.addColorStop(0.5, `rgba(255, 68, 68, 0.18)`); gradient.addColorStop(1, `rgba(255, 68, 68, 0.02)`); this.ctx.fillStyle = gradient; this.ctx.fillRect(x - backgroundWidth / 2, 0, backgroundWidth, height); // 绘制外层辉光效果 this.ctx.shadowColor = `rgba(255, 68, 68, 0.7)`; this.ctx.shadowBlur = 10; this.ctx.shadowOffsetX = 0; this.ctx.shadowOffsetY = 0; // 绘制主指示线(实线) this.ctx.strokeStyle = `rgba(255, 68, 68, 0.95)`; this.ctx.lineWidth = 2.5; this.ctx.beginPath(); this.ctx.moveTo(x, 0); this.ctx.lineTo(x, height); this.ctx.stroke(); // 重置阴影,绘制内层亮线增强辉光效果 this.ctx.shadowBlur = 0; this.ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)'; this.ctx.lineWidth = 1; this.ctx.beginPath(); this.ctx.moveTo(x, 0); this.ctx.lineTo(x, height); this.ctx.stroke(); this.ctx.restore(); // 绘制时间标签(可选,目前被注释掉) 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 = currentTimeColor; // 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); } } }); } /** 绘制鼠标位置指示线 */ private drawMouseIndicator(): void { if (!this.showMouseIndicator || !this.mousePosition) { return; } const height = this.viewport.getHeight(); const x = this.mousePosition.x; // 确保指示线在画布范围内 if (x >= 0 && x <= this.viewport.getWidth()) { this.ctx.save(); // 绘制毛玻璃背景条带 const backgroundWidth = 12; const gradient = this.ctx.createLinearGradient(x - backgroundWidth / 2, 0, x + backgroundWidth / 2, 0); gradient.addColorStop(0, 'rgba(135, 206, 235, 0.02)'); gradient.addColorStop(0.5, 'rgba(135, 206, 235, 0.15)'); gradient.addColorStop(1, 'rgba(135, 206, 235, 0.02)'); this.ctx.fillStyle = gradient; this.ctx.fillRect(x - backgroundWidth / 2, 0, backgroundWidth, height); // 绘制外层辉光效果 this.ctx.shadowColor = 'rgba(135, 206, 235, 0.6)'; this.ctx.shadowBlur = 8; this.ctx.shadowOffsetX = 0; this.ctx.shadowOffsetY = 0; // 绘制主指示线(实线) this.ctx.strokeStyle = 'rgba(135, 206, 235, 0.9)'; this.ctx.lineWidth = 2; this.ctx.beginPath(); this.ctx.moveTo(x, 0); this.ctx.lineTo(x, height); this.ctx.stroke(); // 重置阴影,绘制内层亮线增强辉光效果 this.ctx.shadowBlur = 0; this.ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)'; this.ctx.lineWidth = 1; this.ctx.beginPath(); this.ctx.moveTo(x, 0); this.ctx.lineTo(x, height); this.ctx.stroke(); this.ctx.restore(); } } currentLevel(): number { return this.viewport.getZoomLevel() } /** 获取当前视口信息 */ 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(); } replaceTimeMarkByTimestamp(timestamp: number): void { this.replaceTimeMark({ timestamp: timestamp, color: '#ff6b6b', label: format(timestamp, 'HH:mm:ss'), type: 'custom' }); this.interaction.setZoomMode(ZoomMode.MarkMode) } 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.changeTime(new Date(timestamp + delta)) 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.changeTime(new Date(timestamp - delta)) 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); } // 停止任何正在进行的动画 this.viewport.stopAnimation(); } } // 导出 export { RealTimeTimeline as Timeline, ZoomMode, TimeFormatLevel, TimeUtils }; export type { TimelineConfig, Tick, ScaleLevel, TimeMark };