From 17743458e4ed329b65e9f1e92cb93b9d907e4e86 Mon Sep 17 00:00:00 2001 From: tsuki Date: Tue, 26 Aug 2025 11:42:24 +0800 Subject: [PATCH] add worker --- app/tl.tsx | 106 +++++++++++-- components/colorbar.tsx | 23 ++- components/map-component.tsx | 133 ++++++++++++---- hooks/use-admin-panel.ts | 1 - hooks/use-image-frame-loader.ts | 185 ++++++++++++++++++++++ hooks/use-radartile.ts | 263 +++++++++++++++++++++++++++----- package-lock.json | 61 +++++--- package.json | 1 + workers/image-frame-loader.ts | 148 ++++++++++++++++++ 9 files changed, 810 insertions(+), 111 deletions(-) create mode 100644 hooks/use-image-frame-loader.ts create mode 100644 workers/image-frame-loader.ts diff --git a/app/tl.tsx b/app/tl.tsx index 45f72df..acdd5d2 100644 --- a/app/tl.tsx +++ b/app/tl.tsx @@ -66,6 +66,7 @@ interface Props extends React.HTMLAttributes { vesicaData?: VesicaDataPoint[]; // vesica 实例数据 dateFormat?: (timestamp: number) => string; // 自定义时间格式化函数 timelineConfig?: TimelineConfig; // 时间轴配置 + imageUrlGenerator?: (timestamp: number) => string; // 图像URL生成函数 } export const Timeline: React.FC = React.memo(({ @@ -82,6 +83,7 @@ export const Timeline: React.FC = React.memo(({ vesicaData, dateFormat, timelineConfig, + imageUrlGenerator, ...props }) => { const { isPlaying, togglePlay, setTime, setTimelineTime, setWsStatus } = useTimeline({}) @@ -100,6 +102,12 @@ export const Timeline: React.FC = React.memo(({ 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) @@ -121,6 +129,72 @@ export const Timeline: React.FC = 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 = React.memo(({ // 执行时间前进操作 if (timelineEngineRef.current) { timelineEngineRef.current.playAndEnsureMarkInView(timeStep) + } - }, 600 / speed); // 每秒执行一次,你可以根据需要调整这个间隔 + }, 600 / speed); } return () => { @@ -139,7 +214,7 @@ export const Timeline: React.FC = 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 = 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 = 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 = 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,13 +531,18 @@ export const Timeline: React.FC = React.memo(({ - { - setOpen(false) - }} - /> +
+ { + setOpen(false) + }} + /> +
+ +
+
diff --git a/components/colorbar.tsx b/components/colorbar.tsx index 8e6aeef..2cd45dc 100644 --- a/components/colorbar.tsx +++ b/components/colorbar.tsx @@ -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 ( -
+
{ + 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(null) const { setMap, mapRef, currentDatetime, isMapReady } = useMap() const { location } = useMapLocation() @@ -44,6 +59,11 @@ export function MapComponent({ const customLayerRef = useRef(null) const [isReady, setIsReady] = useState(false) const [currentColorMapType, setCurrentColorMapType] = useState(colorMapType) + const isImageBitmapLoaded = useRef(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通道 - gl.texImage2D( - gl.TEXTURE_2D, - 0, - gl.RGBA, // 内部格式:使用RGBA,兼容性更好 - gl.RGBA, // 数据格式:ImageBitmap总是RGBA - gl.UNSIGNED_BYTE, - imgBitmap - ) + + if (!isImageBitmapLoaded.current) { + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, // 内部格式:使用RGBA,兼容性更好 + gl.RGBA, // 数据格式:ImageBitmap总是RGBA + gl.UNSIGNED_BYTE, + 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(() => { @@ -558,16 +612,16 @@ export function MapComponent({ // span.setAttribute("colorbar.position.x", colorbarPosition.x); // span.setAttribute("colorbar.position.y", colorbarPosition.y); - e.preventDefault() - setIsDragging(true) + e.preventDefault() + setIsDragging(true) - // 记录拖动开始时的鼠标位置和colorbar位置 - dragRef.current = { - startX: e.clientX, - startY: e.clientY, - startPositionX: colorbarPosition.x, - startPositionY: colorbarPosition.y - } + // 记录拖动开始时的鼠标位置和colorbar位置 + dragRef.current = { + startX: e.clientX, + startY: e.clientY, + startPositionX: colorbarPosition.x, + startPositionY: colorbarPosition.y + } // } // ); } @@ -633,7 +687,28 @@ export function MapComponent({ style={{ minHeight: '400px' }} /> - {/* 可拖动的 Colorbar */} + {/* 缓存统计信息显示 */} + {showCacheStats && ( +
+
Radar Cache Stats
+
Total Frames: {cacheStats.totalFrames}
+
Loaded: {cacheStats.loadedFrames}
+
Hit Rate: {Math.round(cacheStats.cacheHitRate * 100)}%
+
+ Worker: {isWorkerSupported ? '✓' : '✗'} +
+
+ Ctrl+Shift+C to toggle +
+
+ )} + + {isLoading && ( +
+ +
+ )} +
{ - debugger if (!onSubmit) return; // 验证(如果需要) diff --git a/hooks/use-image-frame-loader.ts b/hooks/use-image-frame-loader.ts new file mode 100644 index 0000000..ec32bd8 --- /dev/null +++ b/hooks/use-image-frame-loader.ts @@ -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>(new LRUCache({ + max: options.maxBufferSize || 12, + dispose: (bitmap) => { + bitmap.close() // 释放 ImageBitmap 内存 + } + })) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + // 订阅者管理 + const frameSubscribers = useRef>>(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 + } +} \ No newline at end of file diff --git a/hooks/use-radartile.ts b/hooks/use-radartile.ts index 51a7218..e84e635 100644 --- a/hooks/use-radartile.ts +++ b/hooks/use-radartile.ts @@ -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(null) + const [currentFrame, setCurrentFrame] = useState(null) + const [currentBitmap, setCurrentBitmap] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) - const radarTileRef = useRef({ - needRefresh: false, - isLoading: false, - isError: false, - url: null, + // 时间戳到帧索引的映射缓存 + const timestampToFrameMap = useRef>(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 } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2325ecc..5b73e6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e344b60..751b1b3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/workers/image-frame-loader.ts b/workers/image-frame-loader.ts new file mode 100644 index 0000000..99ec12b --- /dev/null +++ b/workers/image-frame-loader.ts @@ -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() + private maxConcurrent = 4 // 最大并发下载数 + private targetPrefetch = 4 // 预取帧数 + private currentLoading = 0 + + constructor() { + this.setupMessageHandler() + } + + private setupMessageHandler() { + self.addEventListener('message', async (event: MessageEvent) => { + 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[] = [] + + // 并发预取接下来的 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 { + 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 } \ No newline at end of file