add worker
This commit is contained in:
parent
0e3640c0ce
commit
17743458e4
92
app/tl.tsx
92
app/tl.tsx
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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'}`}
|
||||
|
||||
@ -304,7 +304,6 @@ export function useAdminPanel(options: UseAdminPanelOptions): UseAdminPanelRetur
|
||||
}, [fields, config.onValidate, form]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
debugger
|
||||
if (!onSubmit) return;
|
||||
|
||||
// 验证(如果需要)
|
||||
|
||||
185
hooks/use-image-frame-loader.ts
Normal file
185
hooks/use-image-frame-loader.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
61
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
148
workers/image-frame-loader.ts
Normal file
148
workers/image-frame-loader.ts
Normal 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 }
|
||||
Loading…
Reference in New Issue
Block a user