1319 lines
46 KiB
TypeScript
1319 lines
46 KiB
TypeScript
/**
|
||
* 实时时间轴组件 - 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[];
|
||
|
||
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 = 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;
|
||
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.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: [],
|
||
onDateChange: () => { }
|
||
};
|
||
|
||
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,
|
||
onDateChange: config.onDateChange ?? defaultConfig.onDateChange
|
||
};
|
||
}
|
||
|
||
/** 设置事件监听器 */
|
||
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) => {
|
||
const rect = this.canvas.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
const y = e.clientY - rect.top;
|
||
|
||
// 更新鼠标位置并显示指示线
|
||
this.mousePosition = { x, y };
|
||
this.showMouseIndicator = true;
|
||
|
||
// 处理拖拽逻辑
|
||
this.interaction.handleMouseMove(x, y, this.viewport);
|
||
|
||
// 使用节流渲染以优化性能
|
||
this.requestRender();
|
||
});
|
||
|
||
// 鼠标释放
|
||
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.showMouseIndicator = false;
|
||
this.requestRender(); // 重新渲染以隐藏鼠标指示线
|
||
});
|
||
|
||
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.changeTime(new Date(date))
|
||
}
|
||
});
|
||
|
||
// 滚轮事件
|
||
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.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();
|
||
}
|
||
}
|
||
|
||
|
||
|
||
/** 获取当前视口信息 */
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 导出
|
||
export {
|
||
RealTimeTimeline as Timeline,
|
||
ZoomMode,
|
||
TimeFormatLevel,
|
||
TimeUtils
|
||
};
|
||
export type { TimelineConfig, Tick, ScaleLevel, TimeMark }; |