fix mobile theme
This commit is contained in:
parent
f4c4e8a3b5
commit
667b96b33e
@ -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">
|
||||||
<AppSidebar />
|
{/* Sidebar - hidden on screens smaller than lg (1024px) */}
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<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>
|
||||||
|
|||||||
299
app/tl.tsx
299
app/tl.tsx
@ -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,123 +275,196 @@ 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")}>
|
||||||
<Button
|
{/* Controls row - always visible, responsive layout */}
|
||||||
variant="secondary"
|
<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" : "")}>
|
||||||
size="icon"
|
{/* Primary controls - always visible */}
|
||||||
className="size-5"
|
<div className="flex flex-row items-center gap-2 flex-shrink-0">
|
||||||
onClick={handlePrevious}
|
{isMobile && (
|
||||||
title="上一个时间段"
|
<DropdownMenu>
|
||||||
>
|
<DropdownMenuTrigger asChild>
|
||||||
<ChevronLeft size={10} />
|
<Button
|
||||||
</Button>
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
<Button
|
className="size-5"
|
||||||
variant={isPlaying ? "default" : "secondary"}
|
title="更多选项"
|
||||||
size="icon"
|
>
|
||||||
className="size-5"
|
<MoreHorizontal size={10} />
|
||||||
onClick={() => {
|
</Button>
|
||||||
togglePlay()
|
</DropdownMenuTrigger>
|
||||||
setLock(true)
|
<DropdownMenuContent align="end">
|
||||||
}}
|
<DropdownMenuLabel>播放控制</DropdownMenuLabel>
|
||||||
title={isPlaying ? "暂停" : "播放"}
|
<DropdownMenuItem onClick={() => setSpeed(1)}>
|
||||||
>
|
播放速度: 1x {speed === 1 && "✓"}
|
||||||
{isPlaying ? <Pause size={10} /> : <Play size={10} />}
|
</DropdownMenuItem>
|
||||||
</Button>
|
<DropdownMenuItem onClick={() => setSpeed(2)}>
|
||||||
|
播放速度: 2x {speed === 2 && "✓"}
|
||||||
<Button
|
</DropdownMenuItem>
|
||||||
variant="secondary"
|
<DropdownMenuItem onClick={() => setSpeed(3)}>
|
||||||
size="icon"
|
播放速度: 3x {speed === 3 && "✓"}
|
||||||
className="size-5"
|
</DropdownMenuItem>
|
||||||
onClick={handleNext}
|
<DropdownMenuSeparator />
|
||||||
title="下一个时间段"
|
<DropdownMenuLabel>时间间隔</DropdownMenuLabel>
|
||||||
>
|
<DropdownMenuItem onClick={() => setTimeStep(60000)}>
|
||||||
<ChevronRight size={10} />
|
1分钟 {timeStep === 60000 && "✓"}
|
||||||
</Button>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTimeStep(360000)}>
|
||||||
<Button
|
6分钟 {timeStep === 360000 && "✓"}
|
||||||
variant="secondary"
|
</DropdownMenuItem>
|
||||||
size="icon"
|
<DropdownMenuItem onClick={() => setTimeStep(3600000)}>
|
||||||
className="size-5"
|
1小时 {timeStep === 3600000 && "✓"}
|
||||||
onClick={handleHome}
|
</DropdownMenuItem>
|
||||||
title="上一个时间段"
|
<DropdownMenuSeparator />
|
||||||
>
|
<DropdownMenuItem onClick={() => setLock(!lock)}>
|
||||||
<HomeIcon size={10} />
|
{lock ? <UnlockIcon size={14} className="mr-2" /> : <LockIcon size={14} className="mr-2" />}
|
||||||
</Button>
|
{lock ? "解锁时间" : "锁定时间"}
|
||||||
|
</DropdownMenuItem>
|
||||||
<Separator orientation="vertical" className="h-4" />
|
<DropdownMenuItem onClick={handleRefresh}>
|
||||||
|
<RefreshCwIcon size={14} className="mr-2" />
|
||||||
<div className="ml-2">
|
刷新数据
|
||||||
<select
|
</DropdownMenuItem>
|
||||||
defaultValue="360000"
|
<DropdownMenuItem onClick={() => setOpen(true)}>
|
||||||
className="w-20 h-6 text-xs bg-background border border-input rounded px-2 focus:outline-none focus:ring-2 focus:ring-ring"
|
<CalendarIcon size={14} className="mr-2" />
|
||||||
onChange={(e) => {
|
设置时间
|
||||||
const value = e.target.value;
|
</DropdownMenuItem>
|
||||||
setTimeStep(parseInt(value));
|
</DropdownMenuContent>
|
||||||
}}
|
</DropdownMenu>
|
||||||
style={{ fontSize: '10px' }}
|
)}
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="size-5"
|
||||||
|
onClick={handlePrevious}
|
||||||
|
title="上一个时间段"
|
||||||
>
|
>
|
||||||
<option value="60000">1分钟</option>
|
<ChevronLeft size={10} />
|
||||||
<option value="360000">6分钟</option>
|
</Button>
|
||||||
<option value="600000">10分钟</option>
|
|
||||||
<option value="1800000">30分钟</option>
|
<Button
|
||||||
<option value="3600000">1小时</option>
|
variant={isPlaying ? "default" : "secondary"}
|
||||||
<option value="7200000">2小时</option>
|
size="icon"
|
||||||
<option value="86400000">1天</option>
|
className="size-5"
|
||||||
</select>
|
onClick={() => {
|
||||||
|
togglePlay()
|
||||||
|
setLock(true)
|
||||||
|
}}
|
||||||
|
title={isPlaying ? "暂停" : "播放"}
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause size={10} /> : <Play size={10} />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="size-5"
|
||||||
|
onClick={handleNext}
|
||||||
|
title="下一个时间段"
|
||||||
|
>
|
||||||
|
<ChevronRight size={10} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="size-5"
|
||||||
|
onClick={handleHome}
|
||||||
|
title="回到当前时间"
|
||||||
|
>
|
||||||
|
<HomeIcon size={10} />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
{!isMobile && (
|
||||||
variant={lock ? "default" : "secondary"}
|
<div className="hidden sm:flex flex-row items-center gap-2 ml-2">
|
||||||
size="icon"
|
<select
|
||||||
className="size-5"
|
defaultValue="1"
|
||||||
onClick={() => setLock(!lock)}
|
className="w-12 h-6 text-xs bg-background border border-input rounded px-2 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
title="锁定时间"
|
onChange={(e) => {
|
||||||
>
|
const value = e.target.value;
|
||||||
{lock ? <LockIcon size={10} /> : <UnlockIcon size={10} />}
|
setSpeed(parseInt(value));
|
||||||
</Button>
|
}}
|
||||||
|
style={{ fontSize: '10px' }}
|
||||||
|
>
|
||||||
|
<option value="1">1x</option>
|
||||||
|
<option value="2">2x</option>
|
||||||
|
<option value="3">3x</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
<Button
|
<Separator orientation="vertical" className="h-4" />
|
||||||
variant="secondary"
|
|
||||||
size="icon"
|
<select
|
||||||
className="size-5"
|
defaultValue="360000"
|
||||||
onClick={() => {
|
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"
|
||||||
handleRefresh()
|
onChange={(e) => {
|
||||||
}}
|
const value = e.target.value;
|
||||||
title="刷新"
|
setTimeStep(parseInt(value));
|
||||||
>
|
}}
|
||||||
<RefreshCwIcon size={10} />
|
style={{ fontSize: '10px' }}
|
||||||
</Button>
|
>
|
||||||
|
<option value="60000">1分钟</option>
|
||||||
|
<option value="360000">6分钟</option>
|
||||||
|
<option value="600000">10分钟</option>
|
||||||
|
<option value="1800000">30分钟</option>
|
||||||
|
<option value="3600000">1小时</option>
|
||||||
|
<option value="7200000">2小时</option>
|
||||||
|
<option value="86400000">1天</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isMobile && (
|
||||||
|
<div className="hidden md:flex flex-row items-center gap-2 ml-2">
|
||||||
|
<Button
|
||||||
|
variant={lock ? "default" : "secondary"}
|
||||||
|
size="icon"
|
||||||
|
className="size-5"
|
||||||
|
onClick={() => setLock(!lock)}
|
||||||
|
title="锁定时间"
|
||||||
|
>
|
||||||
|
{lock ? <LockIcon size={10} /> : <UnlockIcon size={10} />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-5"
|
className="size-5"
|
||||||
title="设置时间"
|
onClick={() => {
|
||||||
>
|
handleRefresh()
|
||||||
<CalendarIcon size={10} />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto overflow-hidden p-0" align="end">
|
|
||||||
<Calendar
|
|
||||||
mode="single"
|
|
||||||
captionLayout="dropdown"
|
|
||||||
onSelect={(date) => {
|
|
||||||
setOpen(false)
|
|
||||||
}}
|
}}
|
||||||
/>
|
title="刷新"
|
||||||
</PopoverContent>
|
>
|
||||||
|
<RefreshCwIcon size={10} />
|
||||||
</Popover>
|
</Button>
|
||||||
|
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="size-5"
|
||||||
|
title="设置时间"
|
||||||
|
>
|
||||||
|
<CalendarIcon size={10} />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto overflow-hidden p-0" align="end">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
captionLayout="dropdown"
|
||||||
|
onSelect={(date) => {
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
469
lib/timeline.ts
469
lib/timeline.ts
@ -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 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 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.canvas.style.cursor = 'grabbing';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 统一的移动处理(鼠标)
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
const { x, y } = getEventCoordinates(e);
|
||||||
|
|
||||||
// 更新鼠标位置并显示指示线
|
// 更新鼠标位置并显示指示线
|
||||||
this.mousePosition = { x, y };
|
this.mousePosition = { x, y };
|
||||||
@ -706,36 +1032,94 @@ class RealTimeTimeline {
|
|||||||
|
|
||||||
// 使用节流渲染以优化性能
|
// 使用节流渲染以优化性能
|
||||||
this.requestRender();
|
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) {
|
if (e.button === 0) {
|
||||||
this.interaction.endDrag();
|
handleMouseStart(e);
|
||||||
this.canvas.style.cursor = 'crosshair';
|
|
||||||
} 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user