389 lines
14 KiB
TypeScript
389 lines
14 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 { cn } from "@/lib/utils";
|
|
import { Button } from "@/components/ui/button";
|
|
import { CalendarIcon, ChevronLeft, ChevronRight, HomeIcon, LockIcon, Pause, Play, RefreshCwIcon, 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";
|
|
|
|
|
|
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 { data } = useWS()
|
|
|
|
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) {
|
|
intervalId = setInterval(() => {
|
|
// 执行时间前进操作
|
|
if (timelineEngineRef.current) {
|
|
timelineEngineRef.current.playAndEnsureMarkInView(timeStep)
|
|
}
|
|
}, 1000); // 每秒执行一次,你可以根据需要调整这个间隔
|
|
}
|
|
|
|
return () => {
|
|
if (intervalId) {
|
|
clearInterval(intervalId);
|
|
}
|
|
};
|
|
}, [isPlaying, timeStep]);
|
|
|
|
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,
|
|
initialTimeRange: (defaultEndTime - defaultStartTime) / 2, // 使用时间范围的一半作为初始范围
|
|
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
|
|
},
|
|
onDateChange: async (date: Date) => {
|
|
const datestr = formatInTimeZone(date, 'UTC', 'yyyyMMddHHmmss')
|
|
const url_base = process.env.GRAPHQL_BACKEND_URL?.replace('/graphql', '') || 'http://localhost: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.GRAPHQL_BACKEND_URL || 'http://localhost: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 h-10 flex flex-row")}>
|
|
<div className="h-full flex flex-row items-center px-3 gap-2 bg-black/30" >
|
|
<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>
|
|
|
|
<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"
|
|
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>
|
|
|
|
<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 className={cn("relative", "w-full h-full bg-gray-800/20")}>
|
|
<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 >
|
|
);
|
|
}); |