mosaicmap/app/tl.tsx
2025-08-27 22:24:26 +08:00

564 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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: 24625.670894409748,
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>
);
});