mosaicmap/app/tl.tsx
2025-08-19 22:49:28 +08:00

486 lines
20 KiB
TypeScript

"use client"
import React, { useRef, useEffect, useState, useCallback } from "react";
import { Calendar } from "@/components/ui/calendar"
import {
Popover,
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, 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"
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";
import { Label } from "@/components/ui/label";
interface Uniforms {
startTimestamp: number; // Unix 时间戳开始
endTimestamp: number; // Unix 时间戳结束
currentTimestamp: number; // 当前时间戳
radius: number;
d: number;
timelineStartX: number; // 时间轴在屏幕上的开始X坐标
timelineEndX: number; // 时间轴在屏幕上的结束X坐标
viewportSize: [number, number];
zoomLevel: number; // 当前缩放级别
panOffset: number; // 当前平移偏移
}
interface Instants {
position: Float32Array;
color: Float32Array;
}
interface VesicaDataPoint {
timestamp: number; // Unix 时间戳
color?: [number, number, number, number]; // RGBA 颜色,默认为白色
}
interface Props extends React.HTMLAttributes<HTMLDivElement> {
boxSize?: [number, number];
startDate?: Date;
endDate?: Date;
currentDate?: Date;
onDateChange?: (date: Date) => void;
onPlay?: () => void;
onPause?: () => void;
minZoom?: number; // 最小缩放级别
maxZoom?: number; // 最大缩放级别
initialZoom?: number; // 初始缩放级别
vesicaData?: VesicaDataPoint[]; // vesica 实例数据
dateFormat?: (timestamp: number) => string; // 自定义时间格式化函数
timelineConfig?: TimelineConfig; // 时间轴配置
}
export const Timeline: React.FC<Props> = React.memo(({
startDate,
endDate,
currentDate,
onDateChange,
onPlay,
onPause,
boxSize = [4, 8],
minZoom = 0.5,
maxZoom = 8,
initialZoom = 1,
vesicaData,
dateFormat,
timelineConfig,
...props
}) => {
const { isPlaying, togglePlay, setTime, setTimelineTime, setWsStatus } = useTimeline({})
const [lock, setLock] = useState(false)
const canvasRef = useRef<HTMLCanvasElement>(null);
const ticksCanvasRef = useRef<HTMLCanvasElement>(null);
const timelineEngineRef = useRef<TimelineEngine | null>(null);
const [timeStep, setTimeStep] = useState(360000);
const [viewportInfo, setViewportInfo] = useState({
centerTime: 0,
timeRange: 0,
visibleRange: [0, 0] as [number, number],
currentLevel: null as any
});
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) {
if (data.statusUpdates) {
if (!lock && data.statusUpdates.newestDt) {
const newDt = parse(data.statusUpdates.newestDt + 'Z', 'yyyyMMddHHmmssX', new Date())
setTime(newDt)
setTimelineTime(newDt)
if (timelineEngineRef.current) {
timelineEngineRef.current.replaceTimeMarkByTimestamp(newDt.getTime())
}
}
} else {
}
}
}, [data, lock])
// 定时器效果 - 当播放时每隔指定时间执行操作
useEffect(() => {
let intervalId: NodeJS.Timeout | null = null;
if (isPlaying && speed > 0) {
intervalId = setInterval(() => {
// 执行时间前进操作
if (timelineEngineRef.current) {
timelineEngineRef.current.playAndEnsureMarkInView(timeStep)
}
}, 600 / speed); // 每秒执行一次,你可以根据需要调整这个间隔
}
return () => {
if (intervalId) {
clearInterval(intervalId);
}
};
}, [isPlaying, timeStep, speed]);
useEffect(() => {
if (!ticksCanvasRef.current) return;
const canvas = ticksCanvasRef.current;
// 计算初始时间范围
const now = Date.now();
const defaultStartTime = startDate ? startDate.getTime() : now - 24 * 60 * 60 * 1000; // 默认24小时前
const defaultEndTime = endDate ? endDate.getTime() : now + 24 * 60 * 60 * 1000; // 默认24小时后
const defaultCenterTime = currentDate ? currentDate.getTime() : now;
// 合并配置
const config: TimelineConfig = {
initialCenterTime: defaultCenterTime,
highlightWeekends: false,
zoomMode: ZoomMode.MousePosition,
zoomSensitivity: 0.001,
colors: {
background: 'transparent', // 使用透明背景,让父容器背景显示
grid: '#333333',
majorTick: '#cccccc',
minorTick: '#cccccc',
primaryLabel: '#b4b4b4',
secondaryLabel: '#b4b4b4',
currentTime: '#ff0000'
},
sizes: {
majorTickHeight: 8,
minorTickHeight: 4,
labelOffset: 3,
primaryFontSize: 10,
secondaryFontSize: 10
},
discreteZoomLevels: [
604123.3009862272,
383869.5109421124,
167314.88060646105,
64314.3439546868,
24625.670894409748,
4964.7447283082365,
],
initialZoomLevel: 4964.7447283082365,
onDateChange: async (date: Date) => {
const datestr = formatInTimeZone(date, 'UTC', 'yyyyMMddHHmmss')
const url_base = process.env.NEXT_PUBLIC_GRAPHQL_BACKEND_URL?.replace('/graphql', '') || 'http://localhost:3050'
// const url_base = "http://45.152.65.37:3050"
const response = await fetch(`${url_base}/api/v1/data/nearest?datetime=${datestr}&area=cn`)
setTimelineTime(date)
if (response.ok) {
const data = await response.json()
const nearestDatetime = data.nearest_data_time
const nearestDate = new Date(Date.parse(nearestDatetime))
setTime(nearestDate)
} else {
console.error('Failed to fetch data:', response.status)
}
},
...timelineConfig
};
try {
timelineEngineRef.current = new TimelineEngine(canvas, config);
const updateViewportInfo = () => {
if (timelineEngineRef.current) {
const info = timelineEngineRef.current.getViewportInfo();
setViewportInfo(info);
if (onDateChange) {
onDateChange(new Date(info.centerTime));
}
}
};
const interval = setInterval(updateViewportInfo, 100);
return () => {
clearInterval(interval);
if (timelineEngineRef.current) {
timelineEngineRef.current.destroy();
timelineEngineRef.current = null;
}
};
} catch (error) {
console.error('Failed to initialize timeline engine:', error);
}
}, [startDate, endDate, currentDate, initialZoom, timelineConfig, onDateChange]);
// 处理画布大小变化
useEffect(() => {
const handleResize = () => {
if (timelineEngineRef.current && ticksCanvasRef.current) {
timelineEngineRef.current.render();
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const handleHome = useCallback(() => {
if (timelineEngineRef.current) {
timelineEngineRef.current.goToTime(new Date().getTime())
}
}, []);
// 控制按钮功能
const handlePrevious = useCallback(() => {
if (timelineEngineRef.current) {
timelineEngineRef.current.playBackwardAndEnsureMarkInView(timeStep)
}
}, [timeStep]);
const handleNext = useCallback(() => {
if (timelineEngineRef.current) {
timelineEngineRef.current.playAndEnsureMarkInView(timeStep)
}
}, [timeStep]);
const handleRefresh = useCallback(async () => {
if (timelineEngineRef.current) {
const date = new Date()
const datestr = formatInTimeZone(date, 'UTC', 'yyyyMMddHHmmss')
const url_base = process.env.NEXT_PUBLIC_GRAPHQL_BACKEND_URL || 'http://localhost:3050'
// const url_base = "http://45.152.65.37:3050"
const response = await fetch(`${url_base}/api/v1/data/nearest?datetime=${datestr}&area=cn`)
if (response.ok) {
const data = await response.json()
const nearestDatetime = data.nearest_data_time
const nearestDate = new Date(Date.parse(nearestDatetime))
setTime(nearestDate)
timelineEngineRef.current.replaceTimeMarkByTimestamp(nearestDate.getTime())
timelineEngineRef.current.ensureMarkInView()
} else {
console.error('Failed to fetch data:', response.status)
}
}
}, []);
return (
<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"
className="size-5"
onClick={handlePrevious}
title="上一个时间段"
>
<ChevronLeft size={10} />
</Button>
<Button
variant={isPlaying ? "default" : "secondary"}
size="icon"
className="size-5"
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>
{!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" />
<select
defaultValue="360000"
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));
}}
style={{ fontSize: '10px' }}
>
<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>
<Button
variant="secondary"
size="icon"
className="size-5"
onClick={() => {
handleRefresh()
}}
title="刷新"
>
<RefreshCwIcon size={10} />
</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 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}
className="w-full h-full absolute inset-0 select-none"
style={{ touchAction: 'manipulation' }}
/>
</div>
</div>
);
});