fix mobile theme
This commit is contained in:
parent
f4c4e8a3b5
commit
667b96b33e
@ -28,13 +28,17 @@ export async function generateMetadata(
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex flex-row h-full">
|
||||
{/* Sidebar - hidden on screens smaller than lg (1024px) */}
|
||||
<div className="hidden lg:block">
|
||||
<AppSidebar />
|
||||
</div>
|
||||
<WSProvider>
|
||||
<div className="flex-1 relative min-h-0">
|
||||
<MapComponent />
|
||||
<div className="absolute top-0 left-0 right-0 z-10">
|
||||
<StatusBar />
|
||||
</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">
|
||||
<Timeline />
|
||||
</div>
|
||||
|
||||
109
app/tl.tsx
109
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<Props> = 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<Props> = 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<Props> = React.memo(({
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
};
|
||||
}, [isPlaying, timeStep]);
|
||||
}, [isPlaying, timeStep, speed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ticksCanvasRef.current) return;
|
||||
@ -263,8 +275,62 @@ export const Timeline: React.FC<Props> = React.memo(({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn(props.className, "w-full h-10 flex flex-row")}>
|
||||
<div className="h-full flex flex-row items-center px-3 gap-2 bg-black/30" >
|
||||
<div className={
|
||||
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
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
@ -303,17 +369,33 @@ export const Timeline: React.FC<Props> = React.memo(({
|
||||
size="icon"
|
||||
className="size-5"
|
||||
onClick={handleHome}
|
||||
title="上一个时间段"
|
||||
title="回到当前时间"
|
||||
>
|
||||
<HomeIcon size={10} />
|
||||
</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" />
|
||||
|
||||
<div className="ml-2">
|
||||
<select
|
||||
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) => {
|
||||
const value = e.target.value;
|
||||
setTimeStep(parseInt(value));
|
||||
@ -329,7 +411,10 @@ export const Timeline: React.FC<Props> = React.memo(({
|
||||
<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"
|
||||
@ -372,14 +457,14 @@ export const Timeline: React.FC<Props> = React.memo(({
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
|
||||
</Popover>
|
||||
|
||||
</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={ticksCanvasRef}
|
||||
|
||||
461
lib/timeline.ts
461
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;
|
||||
|
||||
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.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<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> = {
|
||||
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 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';
|
||||
} 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;
|
||||
// 统一的移动处理(鼠标)
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const { x, y } = getEventCoordinates(e);
|
||||
|
||||
// 更新鼠标位置并显示指示线
|
||||
this.mousePosition = { x, y };
|
||||
@ -706,36 +1032,94 @@ class RealTimeTimeline {
|
||||
|
||||
// 使用节流渲染以优化性能
|
||||
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.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) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user