564 lines
22 KiB
TypeScript
564 lines
22 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; // 时间轴配置
|
||
imageUrlGenerator?: (timestamp: number) => string; // 图像URL生成函数
|
||
}
|
||
|
||
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,
|
||
imageUrlGenerator,
|
||
...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)
|
||
|
||
// WebGL纹理管理
|
||
const glRef = useRef<WebGL2RenderingContext | null>(null)
|
||
const textureRef = useRef<WebGLTexture | null>(null)
|
||
const [textureSize, setTextureSize] = useState<[number, number] | null>(null)
|
||
const lastFrameRef = useRef<ImageBitmap | null>(null)
|
||
|
||
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])
|
||
|
||
|
||
// 初始化WebGL纹理
|
||
useEffect(() => {
|
||
if (!canvasRef.current) return
|
||
|
||
const gl = canvasRef.current.getContext('webgl2')
|
||
if (!gl) {
|
||
console.warn('WebGL2 not supported')
|
||
return
|
||
}
|
||
|
||
glRef.current = gl
|
||
|
||
// 创建纹理
|
||
const texture = gl.createTexture()
|
||
if (!texture) {
|
||
console.error('Failed to create texture')
|
||
return
|
||
}
|
||
|
||
textureRef.current = texture
|
||
gl.bindTexture(gl.TEXTURE_2D, texture)
|
||
|
||
// 设置纹理参数
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||
|
||
return () => {
|
||
if (textureRef.current) {
|
||
gl.deleteTexture(textureRef.current)
|
||
textureRef.current = null
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
// 处理图像帧更新
|
||
const updateTextureWithFrame = useCallback((imageBitmap: ImageBitmap) => {
|
||
if (!glRef.current || !textureRef.current) return
|
||
|
||
const gl = glRef.current
|
||
gl.bindTexture(gl.TEXTURE_2D, textureRef.current)
|
||
|
||
// 首次分配纹理存储
|
||
if (!textureSize || textureSize[0] !== imageBitmap.width || textureSize[1] !== imageBitmap.height) {
|
||
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, imageBitmap.width, imageBitmap.height)
|
||
setTextureSize([imageBitmap.width, imageBitmap.height])
|
||
}
|
||
|
||
// 使用texSubImage2D更新纹理数据(零拷贝)
|
||
gl.texSubImage2D(
|
||
gl.TEXTURE_2D,
|
||
0, // level
|
||
0, 0, // xoffset, yoffset
|
||
gl.RGBA,
|
||
gl.UNSIGNED_BYTE,
|
||
imageBitmap
|
||
)
|
||
|
||
// 释放之前的帧
|
||
if (lastFrameRef.current) {
|
||
lastFrameRef.current.close()
|
||
}
|
||
lastFrameRef.current = imageBitmap
|
||
}, [textureSize])
|
||
|
||
// 定时器效果 - 当播放时每隔指定时间执行操作
|
||
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, imageUrlGenerator, updateTextureWithFrame]);
|
||
|
||
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 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, imageUrlGenerator, timeStep]);
|
||
|
||
// 处理画布大小变化
|
||
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, imageUrlGenerator, updateTextureWithFrame]);
|
||
|
||
const handleNext = useCallback(() => {
|
||
if (timelineEngineRef.current) {
|
||
timelineEngineRef.current.playAndEnsureMarkInView(timeStep)
|
||
}
|
||
}, [timeStep, imageUrlGenerator, updateTextureWithFrame]);
|
||
|
||
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">
|
||
<div className="relative">
|
||
<Calendar
|
||
mode="single"
|
||
captionLayout="dropdown"
|
||
onSelect={(date) => {
|
||
setOpen(false)
|
||
}}
|
||
/>
|
||
<div className="absolute inset-0 bg-white/20 backdrop-blur-sm flex items-center justify-center">
|
||
|
||
</div>
|
||
</div>
|
||
</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>
|
||
);
|
||
}); |