mosaicmap/lib/timeline.ts
2025-08-07 21:39:37 +08:00

1211 lines
42 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;
/** 缩放模式 */
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<TimelineConfig>;
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<TimelineConfig> {
const defaultConfig: Required<TimelineConfig> = {
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 };