fix mobile theme

This commit is contained in:
tsuki 2025-08-18 15:04:40 +08:00
parent f4c4e8a3b5
commit 667b96b33e
3 changed files with 627 additions and 147 deletions

View File

@ -28,13 +28,17 @@ export async function generateMetadata(
export default function Page() { export default function Page() {
return ( return (
<div className="flex flex-row h-full"> <div className="flex flex-row h-full">
{/* Sidebar - hidden on screens smaller than lg (1024px) */}
<div className="hidden lg:block">
<AppSidebar /> <AppSidebar />
</div>
<WSProvider> <WSProvider>
<div className="flex-1 relative min-h-0"> <div className="flex-1 relative min-h-0">
<MapComponent /> <MapComponent />
<div className="absolute top-0 left-0 right-0 z-10"> <div className="absolute top-0 left-0 right-0 z-10">
<StatusBar /> <StatusBar />
</div> </div>
{/* Timeline with responsive layout - single row on desktop, double row on mobile */}
<div className="absolute bottom-0 left-0 right-0 z-10 bg-black/20 backdrop-blur-xl m-3 border border-white/10 rounded-xl shadow-2xl overflow-hidden"> <div className="absolute bottom-0 left-0 right-0 z-10 bg-black/20 backdrop-blur-xl m-3 border border-white/10 rounded-xl shadow-2xl overflow-hidden">
<Timeline /> <Timeline />
</div> </div>

View File

@ -6,9 +6,17 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover" } from "@/components/ui/popover"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; 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 { formatInTimeZone } from "date-fns-tz";
import { parse } from "date-fns" 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 { Timeline as TimelineEngine, ZoomMode, TimelineConfig } from "@/lib/timeline";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { useWS } from "./ws-context"; import { useWS } from "./ws-context";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { useIsMobile } from "@/hooks/use-mobile";
interface Uniforms { interface Uniforms {
@ -87,8 +97,10 @@ export const Timeline: React.FC<Props> = React.memo(({
}); });
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [time, setDateTime] = useState(new Date()) const [time, setDateTime] = useState(new Date())
const [speed, setSpeed] = useState(1)
const { data } = useWS() const { data } = useWS()
const isMobile = useIsMobile(1024) // Use lg breakpoint (1024px)
useEffect(() => { useEffect(() => {
if (data) { if (data) {
@ -112,13 +124,13 @@ export const Timeline: React.FC<Props> = React.memo(({
useEffect(() => { useEffect(() => {
let intervalId: NodeJS.Timeout | null = null; let intervalId: NodeJS.Timeout | null = null;
if (isPlaying) { if (isPlaying && speed > 0) {
intervalId = setInterval(() => { intervalId = setInterval(() => {
// 执行时间前进操作 // 执行时间前进操作
if (timelineEngineRef.current) { if (timelineEngineRef.current) {
timelineEngineRef.current.playAndEnsureMarkInView(timeStep) timelineEngineRef.current.playAndEnsureMarkInView(timeStep)
} }
}, 1000); // 每秒执行一次,你可以根据需要调整这个间隔 }, 600 / speed); // 每秒执行一次,你可以根据需要调整这个间隔
} }
return () => { return () => {
@ -126,7 +138,7 @@ export const Timeline: React.FC<Props> = React.memo(({
clearInterval(intervalId); clearInterval(intervalId);
} }
}; };
}, [isPlaying, timeStep]); }, [isPlaying, timeStep, speed]);
useEffect(() => { useEffect(() => {
if (!ticksCanvasRef.current) return; if (!ticksCanvasRef.current) return;
@ -263,8 +275,62 @@ export const Timeline: React.FC<Props> = React.memo(({
}, []); }, []);
return ( return (
<div className={cn(props.className, "w-full h-10 flex flex-row")}> <div className={
<div className="h-full flex flex-row items-center px-3 gap-2 bg-black/30" > cn(props.className, "w-full flex flex-col lg:flex-row lg:h-10")}>
{/* Controls row - always visible, responsive layout */}
<div className={cn("flex flex-row items-center px-3 gap-2 bg-black/30 h-10 lg:h-full min-w-0", isMobile ? "justify-center" : "")}>
{/* Primary controls - always visible */}
<div className="flex flex-row items-center gap-2 flex-shrink-0">
{isMobile && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="secondary"
size="icon"
className="size-5"
title="更多选项"
>
<MoreHorizontal size={10} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuItem onClick={() => setSpeed(1)}>
播放速度: 1x {speed === 1 && "✓"}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSpeed(2)}>
播放速度: 2x {speed === 2 && "✓"}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSpeed(3)}>
播放速度: 3x {speed === 3 && "✓"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuItem onClick={() => setTimeStep(60000)}>
1 {timeStep === 60000 && "✓"}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTimeStep(360000)}>
6 {timeStep === 360000 && "✓"}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTimeStep(3600000)}>
1 {timeStep === 3600000 && "✓"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setLock(!lock)}>
{lock ? <UnlockIcon size={14} className="mr-2" /> : <LockIcon size={14} className="mr-2" />}
{lock ? "解锁时间" : "锁定时间"}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleRefresh}>
<RefreshCwIcon size={14} className="mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setOpen(true)}>
<CalendarIcon size={14} className="mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<Button <Button
variant="secondary" variant="secondary"
size="icon" size="icon"
@ -303,17 +369,33 @@ export const Timeline: React.FC<Props> = React.memo(({
size="icon" size="icon"
className="size-5" className="size-5"
onClick={handleHome} onClick={handleHome}
title="上一个时间段" title="回到当前时间"
> >
<HomeIcon size={10} /> <HomeIcon size={10} />
</Button> </Button>
</div>
{!isMobile && (
<div className="hidden sm:flex flex-row items-center gap-2 ml-2">
<select
defaultValue="1"
className="w-12 h-6 text-xs bg-background border border-input rounded px-2 focus:outline-none focus:ring-2 focus:ring-ring"
onChange={(e) => {
const value = e.target.value;
setSpeed(parseInt(value));
}}
style={{ fontSize: '10px' }}
>
<option value="1">1x</option>
<option value="2">2x</option>
<option value="3">3x</option>
</select>
<Separator orientation="vertical" className="h-4" /> <Separator orientation="vertical" className="h-4" />
<div className="ml-2">
<select <select
defaultValue="360000" defaultValue="360000"
className="w-20 h-6 text-xs bg-background border border-input rounded px-2 focus:outline-none focus:ring-2 focus:ring-ring" className="w-16 md:w-20 h-6 text-xs bg-background border border-input rounded px-2 focus:outline-none focus:ring-2 focus:ring-ring"
onChange={(e) => { onChange={(e) => {
const value = e.target.value; const value = e.target.value;
setTimeStep(parseInt(value)); setTimeStep(parseInt(value));
@ -329,7 +411,10 @@ export const Timeline: React.FC<Props> = React.memo(({
<option value="86400000">1</option> <option value="86400000">1</option>
</select> </select>
</div> </div>
)}
{!isMobile && (
<div className="hidden md:flex flex-row items-center gap-2 ml-2">
<Button <Button
variant={lock ? "default" : "secondary"} variant={lock ? "default" : "secondary"}
size="icon" size="icon"
@ -372,14 +457,14 @@ export const Timeline: React.FC<Props> = React.memo(({
}} }}
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div>
)}
</div> </div>
<div className={cn("relative", "w-full h-full bg-gray-800/20")}> <div className={cn("relative bg-gray-800/20 h-10 lg:h-full lg:flex-1")}>
<canvas ref={canvasRef} className="w-full h-full absolute inset-0" /> <canvas ref={canvasRef} className="w-full h-full absolute inset-0" />
<canvas <canvas
ref={ticksCanvasRef} ref={ticksCanvasRef}
@ -387,6 +472,6 @@ export const Timeline: React.FC<Props> = React.memo(({
style={{ touchAction: 'manipulation' }} style={{ touchAction: 'manipulation' }}
/> />
</div> </div>
</div > </div>
); );
}); });

View File

@ -79,6 +79,12 @@ interface TimelineConfig {
zoomMode?: ZoomMode; zoomMode?: ZoomMode;
/** 缩放灵敏度 */ /** 缩放灵敏度 */
zoomSensitivity?: number; zoomSensitivity?: number;
/** 预定义缩放级别(毫秒),用于离散缩放 */
discreteZoomLevels?: number[];
/** 启用平滑缩放动画 */
enableSmoothZoom?: boolean;
/** 缩放动画持续时间(毫秒) */
zoomAnimationDuration?: number;
/** 时区偏移(分钟) */ /** 时区偏移(分钟) */
timezoneOffset?: number; timezoneOffset?: number;
/** 颜色配置 */ /** 颜色配置 */
@ -195,9 +201,22 @@ class Viewport {
private height: number; 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 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) { constructor(width: number = 800, height: number = 100) {
this.centerTime = Date.now(); this.centerTime = Date.now();
this.timeRange = 60 * 60 * 1000; // 默认显示1小时 this.timeRange = 60 * 60 * 1000; // 默认显示1小时
@ -274,6 +293,99 @@ class Viewport {
this.centerTime += timeCorrection; 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 { updateSize(width: number, height: number): void {
this.width = width; this.width = width;
@ -495,10 +607,27 @@ class InteractionHandler {
private zoomSensitivity: number; private zoomSensitivity: number;
private zoomMode: ZoomMode; private zoomMode: ZoomMode;
private readonly CLICK_THRESHOLD = 10; // 点击阈值,像素 private readonly CLICK_THRESHOLD = 10; // 点击阈值,像素
private discreteZoomLevels: number[];
private enableSmoothZoom: boolean;
private zoomAnimationDuration: number;
constructor(zoomMode: ZoomMode = ZoomMode.MousePosition, sensitivity: number = 0.001) { // 触摸相关状态
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.zoomMode = zoomMode;
this.zoomSensitivity = sensitivity; 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 { 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); const zoomFactor = this.calculateZoomFactor(-deltaY);
if (this.zoomMode === ZoomMode.MarkMode && markX) { 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 // Getters & Setters
getZoomMode(): ZoomMode { return this.zoomMode; } getZoomMode(): ZoomMode { return this.zoomMode; }
@ -608,7 +889,10 @@ class RealTimeTimeline {
this.scaleManager = new ScaleManager(); this.scaleManager = new ScaleManager();
this.interaction = new InteractionHandler( this.interaction = new InteractionHandler(
this.config.zoomMode, 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<TimelineConfig> { 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> = { const defaultConfig: Required<TimelineConfig> = {
initialCenterTime: Date.now(), initialCenterTime: Date.now(),
initialTimeRange: 60 * 60 * 1000, // 1小时 initialTimeRange: 60 * 60 * 1000, // 1小时
zoomMode: ZoomMode.MousePosition, zoomMode: ZoomMode.MousePosition,
zoomSensitivity: 0.001, zoomSensitivity: 0.001,
discreteZoomLevels: defaultDiscreteZoomLevels,
enableSmoothZoom: true,
zoomAnimationDuration: 300,
timezoneOffset: new Date().getTimezoneOffset(), timezoneOffset: new Date().getTimezoneOffset(),
colors: { colors: {
background: '#1a1a1a', background: '#1a1a1a',
@ -665,6 +971,9 @@ class RealTimeTimeline {
initialTimeRange: config.initialTimeRange ?? defaultConfig.initialTimeRange, initialTimeRange: config.initialTimeRange ?? defaultConfig.initialTimeRange,
zoomMode: config.zoomMode ?? defaultConfig.zoomMode, zoomMode: config.zoomMode ?? defaultConfig.zoomMode,
zoomSensitivity: config.zoomSensitivity ?? defaultConfig.zoomSensitivity, 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, timezoneOffset: config.timezoneOffset ?? defaultConfig.timezoneOffset,
colors: { ...defaultConfig.colors, ...config.colors }, colors: { ...defaultConfig.colors, ...config.colors },
sizes: { ...defaultConfig.sizes, ...config.sizes }, sizes: { ...defaultConfig.sizes, ...config.sizes },
@ -677,25 +986,42 @@ class RealTimeTimeline {
/** 设置事件监听器 */ /** 设置事件监听器 */
private setupEventListeners(): void { private setupEventListeners(): void {
// 鼠标按下 // 获取统一的坐标处理函数
this.canvas.addEventListener('mousedown', (e) => { const getEventCoordinates = (e: MouseEvent | TouchEvent): { x: number; y: number } => {
if (e.button === 0) {
const rect = this.canvas.getBoundingClientRect(); const rect = this.canvas.getBoundingClientRect();
const x = e.clientX - rect.left; if ('touches' in e && e.touches.length > 0) {
const y = e.clientY - rect.top; // 触摸事件
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.interaction.startDrag(x, y);
this.canvas.style.cursor = 'grabbing'; this.canvas.style.cursor = 'grabbing';
} else if (e.button === 2) { };
e.preventDefault();
}
}); // 统一的移动处理(鼠标)
const handleMouseMove = (e: MouseEvent) => {
// 鼠标移动 const { x, y } = getEventCoordinates(e);
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.mousePosition = { x, y };
@ -706,36 +1032,94 @@ class RealTimeTimeline {
// 使用节流渲染以优化性能 // 使用节流渲染以优化性能
this.requestRender(); 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.canvas.addEventListener('mouseup', (e) => {
if (e.button === 0) {
this.interaction.endDrag(); this.interaction.endDrag();
this.canvas.style.cursor = 'crosshair'; 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) { } else if (e.button === 2) {
e.preventDefault(); 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.canvas.addEventListener('mouseleave', () => {
this.interaction.endDrag(); this.interaction.endDrag();
this.canvas.style.cursor = 'crosshair'; this.canvas.style.cursor = 'crosshair';
this.showMouseIndicator = false; this.showMouseIndicator = false;
this.requestRender(); // 重新渲染以隐藏鼠标指示线 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))
}
}); });
// 滚轮事件 // 滚轮事件
@ -802,6 +1186,11 @@ class RealTimeTimeline {
const width = this.viewport.getWidth(); const width = this.viewport.getWidth();
const height = this.viewport.getHeight(); const height = this.viewport.getHeight();
// 如果正在动画中,继续请求下一帧
if (this.viewport.isAnimating()) {
requestAnimationFrame(() => this.render());
}
// 清空画布 // 清空画布
if (this.config.colors.background === 'transparent') { if (this.config.colors.background === 'transparent') {
// 对于透明背景,使用 clearRect 来清空画布 // 对于透明背景,使用 clearRect 来清空画布
@ -1306,6 +1695,8 @@ class RealTimeTimeline {
if (this.currentTimeUpdateInterval) { if (this.currentTimeUpdateInterval) {
clearInterval(this.currentTimeUpdateInterval); clearInterval(this.currentTimeUpdateInterval);
} }
// 停止任何正在进行的动画
this.viewport.stopAnimation();
} }
} }