add worker

This commit is contained in:
tsuki 2025-08-26 11:42:24 +08:00
parent 0e3640c0ce
commit 17743458e4
9 changed files with 810 additions and 111 deletions

View File

@ -66,6 +66,7 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
vesicaData?: VesicaDataPoint[]; // vesica 实例数据
dateFormat?: (timestamp: number) => string; // 自定义时间格式化函数
timelineConfig?: TimelineConfig; // 时间轴配置
imageUrlGenerator?: (timestamp: number) => string; // 图像URL生成函数
}
export const Timeline: React.FC<Props> = React.memo(({
@ -82,6 +83,7 @@ export const Timeline: React.FC<Props> = React.memo(({
vesicaData,
dateFormat,
timelineConfig,
imageUrlGenerator,
...props
}) => {
const { isPlaying, togglePlay, setTime, setTimelineTime, setWsStatus } = useTimeline({})
@ -100,6 +102,12 @@ export const Timeline: React.FC<Props> = React.memo(({
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)
@ -121,6 +129,72 @@ export const Timeline: React.FC<Props> = React.memo(({
}, [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;
@ -130,8 +204,9 @@ export const Timeline: React.FC<Props> = React.memo(({
// 执行时间前进操作
if (timelineEngineRef.current) {
timelineEngineRef.current.playAndEnsureMarkInView(timeStep)
}
}, 600 / speed); // 每秒执行一次,你可以根据需要调整这个间隔
}, 600 / speed);
}
return () => {
@ -139,7 +214,7 @@ export const Timeline: React.FC<Props> = React.memo(({
clearInterval(intervalId);
}
};
}, [isPlaying, timeStep, speed]);
}, [isPlaying, timeStep, speed, imageUrlGenerator, updateTextureWithFrame]);
useEffect(() => {
if (!ticksCanvasRef.current) return;
@ -186,8 +261,6 @@ export const Timeline: React.FC<Props> = React.memo(({
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)
@ -228,7 +301,7 @@ export const Timeline: React.FC<Props> = React.memo(({
} catch (error) {
console.error('Failed to initialize timeline engine:', error);
}
}, [startDate, endDate, currentDate, initialZoom, timelineConfig, onDateChange]);
}, [startDate, endDate, currentDate, initialZoom, timelineConfig, onDateChange, imageUrlGenerator, timeStep]);
// 处理画布大小变化
useEffect(() => {
@ -253,13 +326,13 @@ export const Timeline: React.FC<Props> = React.memo(({
if (timelineEngineRef.current) {
timelineEngineRef.current.playBackwardAndEnsureMarkInView(timeStep)
}
}, [timeStep]);
}, [timeStep, imageUrlGenerator, updateTextureWithFrame]);
const handleNext = useCallback(() => {
if (timelineEngineRef.current) {
timelineEngineRef.current.playAndEnsureMarkInView(timeStep)
}
}, [timeStep]);
}, [timeStep, imageUrlGenerator, updateTextureWithFrame]);
const handleRefresh = useCallback(async () => {
if (timelineEngineRef.current) {
@ -458,6 +531,7 @@ export const Timeline: React.FC<Props> = React.memo(({
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="end">
<div className="relative">
<Calendar
mode="single"
captionLayout="dropdown"
@ -465,6 +539,10 @@ export const Timeline: React.FC<Props> = React.memo(({
setOpen(false)
}}
/>
<div className="absolute inset-0 bg-white/20 backdrop-blur-sm flex items-center justify-center">
</div>
</div>
</PopoverContent>
</Popover>
</div>

View File

@ -2,6 +2,7 @@
import React, { useRef, useEffect, useState } from 'react'
import { createColorMap, ColorMapType } from '@/lib/color-maps'
import { cn } from '@/lib/utils'
interface ColorbarProps {
colorMapType: ColorMapType
@ -16,7 +17,7 @@ interface ColorbarProps {
export function Colorbar({
colorMapType,
width = 200,
width = 20,
height = 20,
showLabels = true,
minValue = 0,
@ -44,9 +45,11 @@ export function Colorbar({
// 绘制色标
const imageData = ctx.createImageData(width, height)
const colorBarThickness = orientation === 'horizontal' ? Math.min(height, 4) : Math.min(width, 4) // 限制颜色带厚度
if (orientation === 'horizontal') {
// 水平方向:从左到右
const yStart = Math.floor((height - colorBarThickness) / 2)
for (let x = 0; x < width; x++) {
const t = x / (width - 1) // 归一化到 0-1
const colorIndex = Math.floor(t * 255) * 4
@ -56,7 +59,7 @@ export function Colorbar({
const b = colorMap[colorIndex + 2]
const a = colorMap[colorIndex + 3]
for (let y = 0; y < height; y++) {
for (let y = yStart; y < yStart + colorBarThickness; y++) {
const pixelIndex = (y * width + x) * 4
imageData.data[pixelIndex] = r
imageData.data[pixelIndex + 1] = g
@ -66,6 +69,7 @@ export function Colorbar({
}
} else {
// 垂直方向:从下到上
const xStart = Math.floor((width - colorBarThickness) / 2)
for (let y = 0; y < height; y++) {
const t = (height - 1 - y) / (height - 1) // 归一化到 0-1颠倒方向
const colorIndex = Math.floor(t * 255) * 4
@ -75,7 +79,7 @@ export function Colorbar({
const b = colorMap[colorIndex + 2]
const a = colorMap[colorIndex + 3]
for (let x = 0; x < width; x++) {
for (let x = xStart; x < xStart + colorBarThickness; x++) {
const pixelIndex = (y * width + x) * 4
imageData.data[pixelIndex] = r
imageData.data[pixelIndex + 1] = g
@ -88,9 +92,9 @@ export function Colorbar({
ctx.putImageData(imageData, 0, 0)
// 添加边框
ctx.strokeStyle = '#666'
ctx.lineWidth = 1
ctx.strokeRect(0, 0, width, height)
// ctx.strokeStyle = '#666'
// ctx.lineWidth = 1
// ctx.strokeRect(0, 0, width, height)
}, [colorMapType, width, height, orientation])
@ -132,7 +136,12 @@ export function Colorbar({
}
return (
<div className={orientation === 'horizontal' ? 'relative' : 'flex items-center'}>
<div className={
cn(
"bg-black/20 backdrop-blur-xl m-3 border border-white/10 rounded-xl shadow-2xl overflow-hidden",
orientation === 'horizontal' ? 'relative' : 'flex items-center'
)
}>
<canvas
ref={canvasRef}
className="cursor-crosshair"

View File

@ -1,5 +1,5 @@
'use client'
import React, { useEffect, useRef, useState } from 'react'
import React, { useEffect, useRef, useState, useCallback } from 'react'
import maplibregl, { CustomLayerInterface, CustomRenderMethodInput, LogoControl, } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useMap } from '@/app/map-context'
@ -13,6 +13,7 @@ import vertexSource from '@/app/glsl/radar/verx.glsl'
import fragmentSource from '@/app/glsl/radar/frag.glsl'
// import * as Sentry from '@sentry/nextjs'
import { logger, logWebGLError, logMapEvent, logPerformanceMetric } from '@/lib/logger'
import { Loader2 } from 'lucide-react'
interface MapComponentProps {
style?: string
@ -34,7 +35,21 @@ export function MapComponent({
onColorMapChange
}: MapComponentProps) {
const { fetchRadarTile, imgBitmap } = useRadarTile();
// 使用完整的 useRadarTile 功能,包括缓存和预取
const {
isLoading,
switchToTime,
currentBitmap,
isWorkerSupported
} = useRadarTile({
maxBufferSize: 16, // 增大缓存以支持时间序列
preCacheCount: 3,
generateUrl: (timestamp: number) => {
const utc_time_str = formatInTimeZone(timestamp, 'UTC', 'yyyyMMddHHmmss')
const new_url_prefix = process.env.NEXT_PUBLIC_GRAPHQL_BACKEND_URL?.replace('/graphql', '') || 'http://localhost:3050'
return `${new_url_prefix}/api/v1/data?datetime=${utc_time_str}&area=cn`
}
});
const mapContainer = useRef<HTMLDivElement>(null)
const { setMap, mapRef, currentDatetime, isMapReady } = useMap()
const { location } = useMapLocation()
@ -44,6 +59,11 @@ export function MapComponent({
const customLayerRef = useRef<CustomGlLayer | null>(null)
const [isReady, setIsReady] = useState<boolean>(false)
const [currentColorMapType, setCurrentColorMapType] = useState<ColorMapType>(colorMapType)
const isImageBitmapLoaded = useRef<boolean>(false)
// 缓存统计状态
const [cacheStats, setCacheStats] = useState({ totalFrames: 0, loadedFrames: 0, cacheHitRate: 0 })
const [showCacheStats, setShowCacheStats] = useState(false)
// 拖动状态
const [colorbarPosition, setColorbarPosition] = useState({ x: 16, y: 36 }) // 从右边和下边的距离
@ -57,14 +77,30 @@ export function MapComponent({
startPositionY: 0
})
useEffect(() => {
if (!isMapReady || !currentDatetime) return;
const utc_time_str = formatInTimeZone(currentDatetime, 'UTC', 'yyyyMMddHHmmss')
const new_url_prefix = process.env.NEXT_PUBLIC_GRAPHQL_BACKEND_URL?.replace('/graphql', '') || 'http://localhost:3050'
const new_url = `${new_url_prefix}/api/v1/data?datetime=${utc_time_str}&area=cn`
fetchRadarTile(new_url)
switchToTime(currentDatetime.getTime())
}, [currentDatetime, isMapReady])
// 键盘快捷键切换缓存统计显示
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
// Ctrl/Cmd + Shift + C 切换缓存统计显示
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'C') {
e.preventDefault()
setShowCacheStats(prev => !prev)
logger.debug(`Cache stats display ${!showCacheStats ? 'enabled' : 'disabled'}`, {
component: 'MapComponent'
});
}
}
document.addEventListener('keydown', handleKeyPress)
return () => document.removeEventListener('keydown', handleKeyPress)
}, [showCacheStats])
useEffect(() => {
if (!mapContainer.current) return
@ -463,7 +499,9 @@ export function MapComponent({
// 清理函数:当组件卸载或重新初始化时清理资源
return () => {
// console.log('Cleaning up map resources...');
logger.info('Cleaning up map and radar tile resources', {
component: 'MapComponent'
});
// 清理自定义图层引用
customLayerRef.current = null;
@ -475,6 +513,7 @@ export function MapComponent({
// 重置状态
setIsReady(false);
setCacheStats({ totalFrames: 0, loadedFrames: 0, cacheHitRate: 0 })
// 移除地图实例
if (map) {
@ -488,24 +527,39 @@ export function MapComponent({
}, [mapContainer])
useEffect(() => {
if (imgBitmap && texRef.current) {
if (currentBitmap && texRef.current) {
const gl = glRef.current
if (!gl) return;
// console.log('Updating texture with imgBitmap:', imgBitmap);
gl.bindTexture(gl.TEXTURE_2D, texRef.current)
// 针对灰度图优化使用单通道RED格式减少内存使用和提高性能
// 虽然ImageBitmap仍是RGBA格式但WebGL会自动将灰度值映射到RED通道
if (!isImageBitmapLoaded.current) {
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA, // 内部格式使用RGBA兼容性更好
gl.RGBA, // 数据格式ImageBitmap总是RGBA
gl.UNSIGNED_BYTE,
imgBitmap
currentBitmap
)
isImageBitmapLoaded.current = true
} else {
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
currentBitmap.width,
currentBitmap.height,
gl.RGBA,
gl.UNSIGNED_BYTE,
currentBitmap
)
}
// 设置纹理参数(如果还没有设置)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
@ -520,7 +574,7 @@ export function MapComponent({
}
}, [imgBitmap, isReady])
}, [currentBitmap, isReady])
// 监听色标类型变化更新LUT纹理
useEffect(() => {
@ -633,7 +687,28 @@ export function MapComponent({
style={{ minHeight: '400px' }}
/>
{/* 可拖动的 Colorbar */}
{/* 缓存统计信息显示 */}
{showCacheStats && (
<div className="absolute top-4 left-4 bg-black/80 text-white text-xs p-3 rounded-lg font-mono select-none">
<div className="mb-2 font-semibold text-green-400">Radar Cache Stats</div>
<div>Total Frames: {cacheStats.totalFrames}</div>
<div>Loaded: {cacheStats.loadedFrames}</div>
<div>Hit Rate: {Math.round(cacheStats.cacheHitRate * 100)}%</div>
<div className="mt-2 text-gray-400">
Worker: {isWorkerSupported ? '✓' : '✗'}
</div>
<div className="mt-1 text-gray-400 text-[10px]">
Ctrl+Shift+C to toggle
</div>
</div>
)}
{isLoading && (
<div className="absolute top-1/2 left-1/2 bg-black/20 backdrop-blur-xl rounded-xl shadow-2xl overflow-hidden flex items-center space-x-2 w-20 h-20 -translate-x-1/2 -translate-y-1/2 flex justify-center items-center">
<Loader2 className="w-8 h-8 animate-spin text-white/50" />
</div>
)}
<div
data-colorbar
className={`absolute select-none ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}

View File

@ -304,7 +304,6 @@ export function useAdminPanel(options: UseAdminPanelOptions): UseAdminPanelRetur
}, [fields, config.onValidate, form]);
const save = useCallback(async () => {
debugger
if (!onSubmit) return;
// 验证(如果需要)

View File

@ -0,0 +1,185 @@
import { useRef, useCallback, useState, useMemo } from 'react'
import { LRUCache } from 'lru-cache'
interface UseImageFrameLoaderOptions {
maxBufferSize?: number
targetPrefetch?: number
}
interface FrameLoadedCallback {
(frameIndex: number, imageBitmap: ImageBitmap): void
}
export function useImageFrameLoader(options: UseImageFrameLoaderOptions = {}) {
const frameCache = useRef<LRUCache<number, ImageBitmap>>(new LRUCache({
max: options.maxBufferSize || 12,
dispose: (bitmap) => {
bitmap.close() // 释放 ImageBitmap 内存
}
}))
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// 订阅者管理
const frameSubscribers = useRef<Map<number, Set<FrameLoadedCallback>>>(new Map())
// 初始化 Worker
const worker = useMemo(() => {
if (typeof window !== 'undefined' && window.Worker) {
try {
const worker = new Worker(
new URL('../workers/image-frame-loader.ts', import.meta.url),
{ type: 'module' }
)
// 监听 Worker 消息
worker.addEventListener('message', (event) => {
const { frameIndex, error, imageBitmap, priority } = event.data
if (error) {
setError(error)
} else if (imageBitmap) {
debugger
frameCache.current.set(frameIndex, imageBitmap)
// 如果是高优先级请求,通知订阅者
if (priority === 'high') {
const subscribers = frameSubscribers.current.get(frameIndex)
if (subscribers) {
subscribers.forEach(callback => {
try {
callback(frameIndex, imageBitmap)
} catch (err) {
console.error('Frame subscriber callback error:', err)
}
})
// 清理订阅者
frameSubscribers.current.delete(frameIndex)
}
}
}
setIsLoading(false)
})
// 设置预取配置
worker.postMessage({
type: 'setBuffer',
data: {
targetPrefetch: options.targetPrefetch || 4
}
})
return worker
} catch (err) {
console.error('Failed to initialize image frame loader worker:', err)
setError('Worker initialization failed')
return null
}
} else {
setError('Web Workers not supported')
return null
}
}, [options.maxBufferSize, options.targetPrefetch]) // eslint-disable-line react-hooks/exhaustive-deps
// 加载单个帧
const loadFrame = useCallback((url: string, frameIndex: number, priority: 'high' | 'normal' | 'low' = 'high') => {
if (!worker) {
setError('Worker not available')
return
}
// 如果主线程缓存中已存在,直接返回
if (frameCache.current.has(frameIndex)) {
return
}
setIsLoading(true)
setError(null)
worker.postMessage({
type: 'load',
data: { url, frameIndex, priority }
})
}, [worker])
// 订阅帧加载完成通知
const subscribeToFrame = useCallback((frameIndex: number, callback: FrameLoadedCallback) => {
if (!frameSubscribers.current.has(frameIndex)) {
frameSubscribers.current.set(frameIndex, new Set())
}
frameSubscribers.current.get(frameIndex)!.add(callback)
// 返回取消订阅函数
return () => {
const subscribers = frameSubscribers.current.get(frameIndex)
if (subscribers) {
subscribers.delete(callback)
if (subscribers.size === 0) {
frameSubscribers.current.delete(frameIndex)
}
}
}
}, [])
// 预取多个帧
const prefetchFrames = useCallback((urls: string[], startIndex: number) => {
if (!worker) {
setError('Worker not available')
return
}
worker.postMessage({
type: 'prefetch',
data: { urls, startIndex }
})
}, [])
// 清理缓存
const clearCache = useCallback(() => {
if (!worker) return
// LRU 缓存会自动调用 dispose 方法释放 ImageBitmap
frameCache.current.clear()
worker.postMessage({ type: 'clear' })
}, [worker])
// 获取帧 - 直接从 LRU 缓存获取
const getFrame = useCallback((frameIndex: number): ImageBitmap | null => {
console.log(Array.from(frameCache.current.keys()))
return frameCache.current.get(frameIndex) || null
}, [])
// 检查帧是否已缓存
const isFrameCached = useCallback((frameIndex: number): boolean => {
return frameCache.current.has(frameIndex)
}, [])
// 获取缓存统计信息
const getCacheStats = useCallback(() => {
const cache = frameCache.current
const totalFrames = cache.size
const maxFrames = cache.max
return {
totalFrames,
maxFrames,
cacheUtilization: maxFrames > 0 ? totalFrames / maxFrames : 0
}
}, [])
return {
loadFrame,
prefetchFrames,
clearCache,
getFrame,
isFrameCached,
getCacheStats,
subscribeToFrame,
isLoading,
error,
isWorkerSupported: typeof window !== 'undefined' && !!window.Worker
}
}

View File

@ -1,54 +1,245 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { addDays, subDays } from 'date-fns'
import { useState, useCallback, useRef, useEffect } from 'react'
import { useImageFrameLoader } from './use-image-frame-loader'
interface UseRadarTileOptions {
maxBufferSize?: number;
targetPrefetch?: number;
preCacheCount?: number; // 前后各预缓存N个帧
generateUrl?: (timestamp: number) => string; // 根据时间戳生成URL的函数
}
interface RadarTileStatus {
needRefresh: boolean;
isLoading: boolean;
isError: boolean;
url: string | null;
}
interface RadarTile {
needRefresh: boolean;
isLoading: boolean;
isError: boolean;
url: string | null;
interface RadarTileFrame {
timestamp: number;
frameIndex: number;
url: string;
}
export function useRadarTile({
maxBufferSize = 12,
targetPrefetch = 4,
preCacheCount = 3,
generateUrl
}: UseRadarTileOptions = {}) {
const [imgBitmap, setImgBitmap] = useState<ImageBitmap | null>(null)
const [currentFrame, setCurrentFrame] = useState<RadarTileFrame | null>(null)
const [currentBitmap, setCurrentBitmap] = useState<ImageBitmap | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const radarTileRef = useRef<RadarTile>({
needRefresh: false,
isLoading: false,
isError: false,
url: null,
// 时间戳到帧索引的映射缓存
const timestampToFrameMap = useRef<Map<number, number>>(new Map())
// 订阅取消函数
const unsubscribeRef = useRef<(() => void) | null>(null)
const {
loadFrame,
clearCache,
getFrame,
isFrameCached,
getCacheStats,
subscribeToFrame,
isLoading: frameLoaderIsLoading,
error: frameLoaderError,
isWorkerSupported
} = useImageFrameLoader({
maxBufferSize,
targetPrefetch
})
const fetchRadarTile = async (url: string) => {
// 生成稳定的帧索引(基于时间戳)
const getFrameIndex = useCallback((timestamp: number): number => {
if (timestampToFrameMap.current.has(timestamp)) {
return timestampToFrameMap.current.get(timestamp)!
}
radarTileRef.current.needRefresh = true
radarTileRef.current.isError = false
const frameIndex = Math.abs(timestamp.toString().split('').reduce((a, b) => {
a = ((a << 5) - a) + parseInt(b) || 0
return a & a
}, 0))
fetch(url).then(async (resp) => {
radarTileRef.current.url = url
const blob = await resp.blob()
const newImgBitmap = await createImageBitmap(blob)
// console.log('Created new ImageBitmap:', newImgBitmap);
setImgBitmap(newImgBitmap) // 使用 setState 更新状态
}).catch((err) => {
radarTileRef.current.isError = true
console.error(err)
timestampToFrameMap.current.set(timestamp, frameIndex)
return frameIndex
}, [])
// 基础功能:切换到指定时间的帧
const switchToTime = useCallback(async (timestamp: number) => {
if (!generateUrl) {
setError('generateUrl function is required')
return null
}
const url = generateUrl(timestamp)
const frameIndex = getFrameIndex(timestamp)
const frame: RadarTileFrame = {
timestamp,
frameIndex,
url
}
setCurrentFrame(frame)
setError(null)
setIsLoading(true)
// 检查是否已缓存
const cachedBitmap = getFrame(frameIndex)
if (cachedBitmap) {
setCurrentBitmap(cachedBitmap)
setIsLoading(false)
return cachedBitmap
}
try {
// 清理之前的订阅
if (unsubscribeRef.current) {
unsubscribeRef.current()
}
// 订阅帧加载完成通知
unsubscribeRef.current = subscribeToFrame(frameIndex, (loadedFrameIndex, imageBitmap) => {
if (loadedFrameIndex === frameIndex) {
setCurrentBitmap(imageBitmap)
setIsLoading(false)
unsubscribeRef.current = null
}
})
// 使用高优先级加载当前帧
loadFrame(url, frameIndex, 'high')
// 触发预缓存
await preCacheAroundTime(timestamp)
return null // 实际的bitmap会通过订阅通知获取
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
setError(errorMessage)
setIsLoading(false)
if (unsubscribeRef.current) {
unsubscribeRef.current()
unsubscribeRef.current = null
}
return null
}
}, [generateUrl, getFrameIndex, getFrame, loadFrame, subscribeToFrame])
// 在指定时间前后预缓存N个帧
const preCacheAroundTime = useCallback(async (centerTimestamp: number, timeInterval: number = 360000) => { // 默认5分钟间隔
if (!generateUrl) return
const urlsWithFrameIndices: Array<{url: string, frameIndex: number}> = []
// 生成前后各N个时间点的URL和对应的frameIndex
for (let i = -preCacheCount; i <= preCacheCount; i++) {
if (i === 0) continue // 跳过当前帧
const timestamp = centerTimestamp + (i * timeInterval)
const url = generateUrl(timestamp)
const frameIndex = getFrameIndex(timestamp)
urlsWithFrameIndices.push({ url, frameIndex })
}
// 分别加载每个帧使用正确的frameIndex
if (urlsWithFrameIndices.length > 0) {
urlsWithFrameIndices.forEach(({ url, frameIndex }) => {
loadFrame(url, frameIndex, 'normal')
})
}
}, [generateUrl, getFrameIndex, preCacheCount, loadFrame])
// 清理订阅当组件卸载时
useEffect(() => {
return () => {
if (unsubscribeRef.current) {
unsubscribeRef.current()
}
}
}, [])
// 同步错误和加载状态
useEffect(() => {
if (frameLoaderError && !error) {
setError(frameLoaderError)
setIsLoading(false)
}
}, [frameLoaderError, error])
useEffect(() => {
setIsLoading(frameLoaderIsLoading)
}, [frameLoaderIsLoading])
// 高级功能:清空缓存
const clearAllCache = useCallback(() => {
if (unsubscribeRef.current) {
unsubscribeRef.current()
unsubscribeRef.current = null
}
clearCache()
timestampToFrameMap.current.clear()
setCurrentBitmap(null)
setCurrentFrame(null)
setError(null)
setIsLoading(false)
}, [clearCache])
// 高级功能:预缓存指定时间范围
const preCacheTimeRange = useCallback(async (startTime: number, endTime: number, timeInterval: number = 300000) => {
if (!generateUrl) return
const urlsWithFrameIndices: Array<{url: string, frameIndex: number}> = []
for (let timestamp = startTime; timestamp <= endTime; timestamp += timeInterval) {
const url = generateUrl(timestamp)
const frameIndex = getFrameIndex(timestamp)
urlsWithFrameIndices.push({ url, frameIndex })
}
// 分别加载每个帧使用正确的frameIndex
if (urlsWithFrameIndices.length > 0) {
urlsWithFrameIndices.forEach(({ url, frameIndex }) => {
loadFrame(url, frameIndex, 'normal')
})
}
}, [generateUrl, getFrameIndex, loadFrame])
// 高级功能:检查时间是否已缓存
const isTimeCached = useCallback((timestamp: number): boolean => {
const frameIndex = getFrameIndex(timestamp)
return isFrameCached(frameIndex)
}, [getFrameIndex, isFrameCached])
// 高级功能:获取指定时间的帧(不触发加载)
const getFrameAtTime = useCallback((timestamp: number): ImageBitmap | null => {
const frameIndex = getFrameIndex(timestamp)
return getFrame(frameIndex)
}, [getFrameIndex, getFrame])
return {
imgBitmap,
fetchRadarTile,
// 核心功能:基于时间切换帧
switchToTime,
// 当前状态
currentFrame,
currentBitmap,
currentTimestamp: currentFrame?.timestamp || null,
// 加载状态
isLoading,
error,
// 高级功能
preCacheAroundTime,
preCacheTimeRange,
clearAllCache,
getCacheStats,
// 实用工具
isTimeCached,
getFrameAtTime,
getFrameIndex,
// 底层信息
isWorkerSupported
}
}

61
package-lock.json generated
View File

@ -62,6 +62,7 @@
"graphql-request": "^7.2.0",
"graphql-ws": "^6.0.6",
"lodash.throttle": "^4.1.1",
"lru-cache": "^11.1.0",
"lucide-react": "^0.525.0",
"maplibre-gl": "^5.6.1",
"next": "15.4.1",
@ -306,6 +307,15 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"license": "ISC",
"dependencies": {
"yallist": "^3.0.2"
}
},
"node_modules/@babel/helper-compilation-targets/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@ -315,6 +325,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/@babel/helper-compilation-targets/node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"license": "ISC"
},
"node_modules/@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
@ -10526,20 +10542,14 @@
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz",
"integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==",
"license": "ISC",
"dependencies": {
"yallist": "^3.0.2"
"engines": {
"node": "20 || >=22"
}
},
"node_modules/lru-cache/node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"license": "ISC"
},
"node_modules/lucide-react": {
"version": "0.525.0",
"resolved": "http://mirrors.cloud.tencent.com/npm/lucide-react/-/lucide-react-0.525.0.tgz",
@ -15333,10 +15343,23 @@
"semver": "^6.3.1"
},
"dependencies": {
"lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"requires": {
"yallist": "^3.0.2"
}
},
"semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="
},
"yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
}
}
},
@ -21529,19 +21552,9 @@
}
},
"lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"requires": {
"yallist": "^3.0.2"
},
"dependencies": {
"yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
}
}
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz",
"integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A=="
},
"lucide-react": {
"version": "0.525.0",

View File

@ -63,6 +63,7 @@
"graphql-request": "^7.2.0",
"graphql-ws": "^6.0.6",
"lodash.throttle": "^4.1.1",
"lru-cache": "^11.1.0",
"lucide-react": "^0.525.0",
"maplibre-gl": "^5.6.1",
"next": "15.4.1",

View File

@ -0,0 +1,148 @@
// Web Worker for concurrent image frame downloading and decoding
interface FrameRequest {
url: string
frameIndex: number
priority: 'high' | 'normal' | 'low'
}
interface FrameResponse {
frameIndex: number
url: string
error?: string
priority?: 'high' | 'normal' | 'low'
}
interface WorkerMessage {
type: 'load' | 'prefetch' | 'clear' | 'setBuffer'
data?: any
}
class ImageFrameLoader {
private loadingQueue = new Set<number>()
private maxConcurrent = 4 // 最大并发下载数
private targetPrefetch = 4 // 预取帧数
private currentLoading = 0
constructor() {
this.setupMessageHandler()
}
private setupMessageHandler() {
self.addEventListener('message', async (event: MessageEvent<WorkerMessage>) => {
const { type, data } = event.data
switch (type) {
case 'load':
await this.loadFrame(data?.url, data?.frameIndex, data?.priority || 'high')
break
case 'prefetch':
await this.prefetchFrames(data?.urls, data?.startIndex)
break
case 'clear':
this.clearCache()
break
case 'setBuffer':
this.targetPrefetch = data?.targetPrefetch || 4
break
}
})
}
private async loadFrame(url: string, frameIndex: number, priority: 'high' | 'normal' | 'low') {
// 如果正在加载,跳过
if (this.loadingQueue.has(frameIndex)) {
return
}
// 检查并发限制
if (this.currentLoading >= this.maxConcurrent && priority !== 'high') {
// 对于非高优先级请求,延迟处理
setTimeout(() => this.loadFrame(url, frameIndex, priority), 50)
return
}
this.loadingQueue.add(frameIndex)
this.currentLoading++
try {
const imageBitmap = await this.fetchAndDecodeImage(url)
// 直接发送到主线程,不在 worker 中缓存
this.postFrameResponse(frameIndex, imageBitmap, url, priority)
} catch (error) {
// 区分优先级处理错误
if (priority === 'high') {
// 高优先级请求(当前帧)应该报告错误
this.postError(frameIndex, url, (error as Error).message)
} else {
console.debug(`Prefetch frame ${frameIndex} not available (${url}) - this is normal for future timestamps`)
}
} finally {
this.loadingQueue.delete(frameIndex)
this.currentLoading--
}
}
private async prefetchFrames(urls: string[], startIndex: number) {
const prefetchPromises: Promise<void>[] = []
// 并发预取接下来的 targetPrefetch 帧
for (let i = 0; i < this.targetPrefetch && i < urls.length; i++) {
const frameIndex = startIndex + i
const url = urls[i]
if (!this.loadingQueue.has(frameIndex)) {
prefetchPromises.push(this.loadFrame(url, frameIndex, 'normal'))
}
}
await Promise.allSettled(prefetchPromises)
}
private async fetchAndDecodeImage(url: string): Promise<ImageBitmap> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const blob = await response.blob()
// 使用 createImageBitmap 进行优化解码
const imageBitmap = await createImageBitmap(blob, {
colorSpaceConversion: 'none', // 不进行颜色空间转换
premultiplyAlpha: 'none', // 不预乘 alpha
resizeQuality: 'high' // 高质量缩放(如果需要)
})
return imageBitmap
}
private postFrameResponse(frameIndex: number, imageBitmap: ImageBitmap, url: string, priority?: 'high' | 'normal' | 'low') {
postMessage({
frameIndex,
imageBitmap,
url,
priority
}, { transfer: [imageBitmap] })
}
private postError(frameIndex: number, url: string, error: string) {
postMessage({
frameIndex,
url,
error
})
}
private clearCache() {
// 清理加载队列
this.loadingQueue.clear()
}
}
// 初始化 worker
new ImageFrameLoader()
export type { FrameRequest, FrameResponse, WorkerMessage }