"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 { 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 = 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(null); const ticksCanvasRef = useRef(null); const timelineEngineRef = useRef(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(null) const textureRef = useRef(null) const [textureSize, setTextureSize] = useState<[number, number] | null>(null) const lastFrameRef = useRef(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 (
{/* Controls row - always visible, responsive layout */}
{/* Primary controls - always visible */}
{isMobile && ( 播放控制 setSpeed(1)}> 播放速度: 1x {speed === 1 && "✓"} setSpeed(2)}> 播放速度: 2x {speed === 2 && "✓"} setSpeed(3)}> 播放速度: 3x {speed === 3 && "✓"} 时间间隔 setTimeStep(60000)}> 1分钟 {timeStep === 60000 && "✓"} setTimeStep(360000)}> 6分钟 {timeStep === 360000 && "✓"} setTimeStep(3600000)}> 1小时 {timeStep === 3600000 && "✓"} setLock(!lock)}> {lock ? : } {lock ? "解锁时间" : "锁定时间"} 刷新数据 setOpen(true)}> 设置时间 )}
{!isMobile && (
)} {!isMobile && (
{ setOpen(false) }} />
)}
); });