diff --git a/app/page.tsx b/app/page.tsx index 799a7e4..2364663 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -28,13 +28,17 @@ export async function generateMetadata( export default function Page() { return (
- + {/* Sidebar - hidden on screens smaller than lg (1024px) */} +
+ +
+ {/* Timeline with responsive layout - single row on desktop, double row on mobile */}
diff --git a/app/tl.tsx b/app/tl.tsx index 47b95d5..41182bf 100644 --- a/app/tl.tsx +++ b/app/tl.tsx @@ -6,9 +6,17 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; -import { CalendarIcon, ChevronLeft, ChevronRight, HomeIcon, LockIcon, Pause, Play, RefreshCwIcon, UnlockIcon } from "lucide-react"; +import { CalendarIcon, ChevronLeft, ChevronRight, Clock, Dog, HomeIcon, LockIcon, MoreHorizontal, Pause, Play, Rabbit, RefreshCwIcon, Turtle, UnlockIcon } from "lucide-react"; import { formatInTimeZone } from "date-fns-tz"; import { parse } from "date-fns" @@ -16,6 +24,8 @@ import { useTimeline } from "@/hooks/use-timeline"; import { Timeline as TimelineEngine, ZoomMode, TimelineConfig } from "@/lib/timeline"; import { Separator } from "@/components/ui/separator"; import { useWS } from "./ws-context"; +import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"; +import { useIsMobile } from "@/hooks/use-mobile"; interface Uniforms { @@ -87,8 +97,10 @@ export const Timeline: React.FC = React.memo(({ }); const [open, setOpen] = useState(false) const [time, setDateTime] = useState(new Date()) + const [speed, setSpeed] = useState(1) const { data } = useWS() + const isMobile = useIsMobile(1024) // Use lg breakpoint (1024px) useEffect(() => { if (data) { @@ -112,13 +124,13 @@ export const Timeline: React.FC = React.memo(({ useEffect(() => { let intervalId: NodeJS.Timeout | null = null; - if (isPlaying) { + if (isPlaying && speed > 0) { intervalId = setInterval(() => { // 执行时间前进操作 if (timelineEngineRef.current) { timelineEngineRef.current.playAndEnsureMarkInView(timeStep) } - }, 1000); // 每秒执行一次,你可以根据需要调整这个间隔 + }, 600 / speed); // 每秒执行一次,你可以根据需要调整这个间隔 } return () => { @@ -126,7 +138,7 @@ export const Timeline: React.FC = React.memo(({ clearInterval(intervalId); } }; - }, [isPlaying, timeStep]); + }, [isPlaying, timeStep, speed]); useEffect(() => { if (!ticksCanvasRef.current) return; @@ -263,123 +275,196 @@ export const Timeline: React.FC = React.memo(({ }, []); return ( -
-
- - - - - - - - - - -
- + + + + + + + +
- + {!isMobile && ( +
+ - + + + +
+ )} + + {!isMobile && ( +
+ - - - - - { - setOpen(false) + onClick={() => { + handleRefresh() }} - /> - - - + title="刷新" + > + + + + + + + + { + setOpen(false) + }} + /> + + +
+ )}
-
+
= React.memo(({ style={{ touchAction: 'manipulation' }} />
-
+
); }); \ No newline at end of file diff --git a/lib/timeline.ts b/lib/timeline.ts index 4be23f8..f166a64 100644 --- a/lib/timeline.ts +++ b/lib/timeline.ts @@ -79,6 +79,12 @@ interface TimelineConfig { zoomMode?: ZoomMode; /** 缩放灵敏度 */ zoomSensitivity?: number; + /** 预定义缩放级别(毫秒),用于离散缩放 */ + discreteZoomLevels?: number[]; + /** 启用平滑缩放动画 */ + enableSmoothZoom?: boolean; + /** 缩放动画持续时间(毫秒) */ + zoomAnimationDuration?: number; /** 时区偏移(分钟) */ timezoneOffset?: number; /** 颜色配置 */ @@ -195,9 +201,22 @@ class Viewport { private height: number; /** 时间范围的最小和最大限制 */ - private readonly MIN_RANGE = 240 * 1000; + 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小时 @@ -274,6 +293,99 @@ class Viewport { this.centerTime += timeCorrection; } + /** 动画缩放到指定时间范围 */ + 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; @@ -495,10 +607,27 @@ class InteractionHandler { 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) { + 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; } /** 计算人体工学优化的缩放因子 */ @@ -545,6 +674,18 @@ class InteractionHandler { /** 处理滚轮事件 */ 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) { @@ -562,7 +703,147 @@ class InteractionHandler { } } + /** 处理离散缩放 */ + private handleDiscreteZoom(deltaY: number, mouseX: number, viewport: Viewport, markX?: number): void { + const currentRange = viewport.getTimeRange(); + + // 找到当前最接近的缩放级别 + let currentLevelIndex = 0; + let minDiff = Math.abs(this.discreteZoomLevels[0] - currentRange); + + for (let i = 1; i < this.discreteZoomLevels.length; i++) { + const diff = Math.abs(this.discreteZoomLevels[i] - currentRange); + 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 = 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; } @@ -608,7 +889,10 @@ class RealTimeTimeline { this.scaleManager = new ScaleManager(); this.interaction = new InteractionHandler( this.config.zoomMode, - this.config.zoomSensitivity + this.config.zoomSensitivity, + this.config.discreteZoomLevels, + this.config.enableSmoothZoom, + this.config.zoomAnimationDuration ); // 设置画布事件监听 @@ -625,11 +909,33 @@ class RealTimeTimeline { /** 合并配置 */ private mergeConfig(config?: TimelineConfig): Required { + // 默认离散缩放级别:从1分钟到10天 + const defaultDiscreteZoomLevels = [ + 1 * 60 * 1000, // 1分钟 + 2 * 60 * 1000, // 2分钟 + 5 * 60 * 1000, // 5分钟 + 10 * 60 * 1000, // 10分钟 + 20 * 60 * 1000, // 20分钟 + 30 * 60 * 1000, // 30分钟 + 60 * 60 * 1000, // 1小时 + 2 * 60 * 60 * 1000, // 2小时 + 6 * 60 * 60 * 1000, // 6小时 + 12 * 60 * 60 * 1000, // 12小时 + 24 * 60 * 60 * 1000, // 1天 + 2 * 24 * 60 * 60 * 1000, // 2天 + 7 * 24 * 60 * 60 * 1000, // 1周 + 14 * 24 * 60 * 60 * 1000, // 2周 + 30 * 24 * 60 * 60 * 1000 // 1个月 + ]; + const defaultConfig: Required = { initialCenterTime: Date.now(), initialTimeRange: 60 * 60 * 1000, // 1小时 zoomMode: ZoomMode.MousePosition, zoomSensitivity: 0.001, + discreteZoomLevels: defaultDiscreteZoomLevels, + enableSmoothZoom: true, + zoomAnimationDuration: 300, timezoneOffset: new Date().getTimezoneOffset(), colors: { background: '#1a1a1a', @@ -665,6 +971,9 @@ class RealTimeTimeline { initialTimeRange: config.initialTimeRange ?? defaultConfig.initialTimeRange, 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 }, @@ -677,25 +986,42 @@ class RealTimeTimeline { /** 设置事件监听器 */ 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 getEventCoordinates = (e: MouseEvent | TouchEvent): { x: number; y: number } => { const rect = this.canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; + 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 }; @@ -706,36 +1032,94 @@ class RealTimeTimeline { // 使用节流渲染以优化性能 this.requestRender(); - }); + }; - // 鼠标释放 - this.canvas.addEventListener('mouseup', (e) => { + // 统一的结束拖拽处理(鼠标) + 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) { - this.interaction.endDrag(); - this.canvas.style.cursor = 'crosshair'; + 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('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.requestRender(); }); // 滚轮事件 @@ -802,6 +1186,11 @@ class RealTimeTimeline { const width = this.viewport.getWidth(); const height = this.viewport.getHeight(); + // 如果正在动画中,继续请求下一帧 + if (this.viewport.isAnimating()) { + requestAnimationFrame(() => this.render()); + } + // 清空画布 if (this.config.colors.background === 'transparent') { // 对于透明背景,使用 clearRect 来清空画布 @@ -1306,6 +1695,8 @@ class RealTimeTimeline { if (this.currentTimeUpdateInterval) { clearInterval(this.currentTimeUpdateInterval); } + // 停止任何正在进行的动画 + this.viewport.stopAnimation(); } }