mosaicmap/lib/timeline.ts
2025-08-27 22:24:26 +08:00

1724 lines
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 实时时间轴组件 - 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 | null;
/** 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: 4, // 每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<TimelineConfig>;
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<TimelineConfig> {
// 默认离散缩放级别从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<TimelineConfig> = {
initialCenterTime: Date.now(),
initialTimeRange: null, // 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 };