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 实例数据
|
vesicaData?: VesicaDataPoint[]; // vesica 实例数据
|
||||||
dateFormat?: (timestamp: number) => string; // 自定义时间格式化函数
|
dateFormat?: (timestamp: number) => string; // 自定义时间格式化函数
|
||||||
timelineConfig?: TimelineConfig; // 时间轴配置
|
timelineConfig?: TimelineConfig; // 时间轴配置
|
||||||
|
imageUrlGenerator?: (timestamp: number) => string; // 图像URL生成函数
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Timeline: React.FC<Props> = React.memo(({
|
export const Timeline: React.FC<Props> = React.memo(({
|
||||||
@ -82,6 +83,7 @@ export const Timeline: React.FC<Props> = React.memo(({
|
|||||||
vesicaData,
|
vesicaData,
|
||||||
dateFormat,
|
dateFormat,
|
||||||
timelineConfig,
|
timelineConfig,
|
||||||
|
imageUrlGenerator,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { isPlaying, togglePlay, setTime, setTimelineTime, setWsStatus } = useTimeline({})
|
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 [time, setDateTime] = useState(new Date())
|
||||||
const [speed, setSpeed] = useState(1)
|
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 { data } = useWS()
|
||||||
const isMobile = useIsMobile(1024) // Use lg breakpoint (1024px)
|
const isMobile = useIsMobile(1024) // Use lg breakpoint (1024px)
|
||||||
|
|
||||||
@ -121,6 +129,72 @@ export const Timeline: React.FC<Props> = React.memo(({
|
|||||||
}, [data, lock])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
let intervalId: NodeJS.Timeout | null = null;
|
let intervalId: NodeJS.Timeout | null = null;
|
||||||
@ -130,8 +204,9 @@ export const Timeline: React.FC<Props> = React.memo(({
|
|||||||
// 执行时间前进操作
|
// 执行时间前进操作
|
||||||
if (timelineEngineRef.current) {
|
if (timelineEngineRef.current) {
|
||||||
timelineEngineRef.current.playAndEnsureMarkInView(timeStep)
|
timelineEngineRef.current.playAndEnsureMarkInView(timeStep)
|
||||||
|
|
||||||
}
|
}
|
||||||
}, 600 / speed); // 每秒执行一次,你可以根据需要调整这个间隔
|
}, 600 / speed);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@ -139,7 +214,7 @@ export const Timeline: React.FC<Props> = React.memo(({
|
|||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [isPlaying, timeStep, speed]);
|
}, [isPlaying, timeStep, speed, imageUrlGenerator, updateTextureWithFrame]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ticksCanvasRef.current) return;
|
if (!ticksCanvasRef.current) return;
|
||||||
@ -186,8 +261,6 @@ export const Timeline: React.FC<Props> = React.memo(({
|
|||||||
onDateChange: async (date: Date) => {
|
onDateChange: async (date: Date) => {
|
||||||
const datestr = formatInTimeZone(date, 'UTC', 'yyyyMMddHHmmss')
|
const datestr = formatInTimeZone(date, 'UTC', 'yyyyMMddHHmmss')
|
||||||
const url_base = process.env.NEXT_PUBLIC_GRAPHQL_BACKEND_URL?.replace('/graphql', '') || 'http://localhost:3050'
|
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`)
|
const response = await fetch(`${url_base}/api/v1/data/nearest?datetime=${datestr}&area=cn`)
|
||||||
|
|
||||||
setTimelineTime(date)
|
setTimelineTime(date)
|
||||||
@ -228,7 +301,7 @@ export const Timeline: React.FC<Props> = React.memo(({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize timeline engine:', error);
|
console.error('Failed to initialize timeline engine:', error);
|
||||||
}
|
}
|
||||||
}, [startDate, endDate, currentDate, initialZoom, timelineConfig, onDateChange]);
|
}, [startDate, endDate, currentDate, initialZoom, timelineConfig, onDateChange, imageUrlGenerator, timeStep]);
|
||||||
|
|
||||||
// 处理画布大小变化
|
// 处理画布大小变化
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -253,13 +326,13 @@ export const Timeline: React.FC<Props> = React.memo(({
|
|||||||
if (timelineEngineRef.current) {
|
if (timelineEngineRef.current) {
|
||||||
timelineEngineRef.current.playBackwardAndEnsureMarkInView(timeStep)
|
timelineEngineRef.current.playBackwardAndEnsureMarkInView(timeStep)
|
||||||
}
|
}
|
||||||
}, [timeStep]);
|
}, [timeStep, imageUrlGenerator, updateTextureWithFrame]);
|
||||||
|
|
||||||
const handleNext = useCallback(() => {
|
const handleNext = useCallback(() => {
|
||||||
if (timelineEngineRef.current) {
|
if (timelineEngineRef.current) {
|
||||||
timelineEngineRef.current.playAndEnsureMarkInView(timeStep)
|
timelineEngineRef.current.playAndEnsureMarkInView(timeStep)
|
||||||
}
|
}
|
||||||
}, [timeStep]);
|
}, [timeStep, imageUrlGenerator, updateTextureWithFrame]);
|
||||||
|
|
||||||
const handleRefresh = useCallback(async () => {
|
const handleRefresh = useCallback(async () => {
|
||||||
if (timelineEngineRef.current) {
|
if (timelineEngineRef.current) {
|
||||||
@ -458,6 +531,7 @@ export const Timeline: React.FC<Props> = React.memo(({
|
|||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto overflow-hidden p-0" align="end">
|
<PopoverContent className="w-auto overflow-hidden p-0" align="end">
|
||||||
|
<div className="relative">
|
||||||
<Calendar
|
<Calendar
|
||||||
mode="single"
|
mode="single"
|
||||||
captionLayout="dropdown"
|
captionLayout="dropdown"
|
||||||
@ -465,6 +539,10 @@ export const Timeline: React.FC<Props> = React.memo(({
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div className="absolute inset-0 bg-white/20 backdrop-blur-sm flex items-center justify-center">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React, { useRef, useEffect, useState } from 'react'
|
import React, { useRef, useEffect, useState } from 'react'
|
||||||
import { createColorMap, ColorMapType } from '@/lib/color-maps'
|
import { createColorMap, ColorMapType } from '@/lib/color-maps'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface ColorbarProps {
|
interface ColorbarProps {
|
||||||
colorMapType: ColorMapType
|
colorMapType: ColorMapType
|
||||||
@ -16,7 +17,7 @@ interface ColorbarProps {
|
|||||||
|
|
||||||
export function Colorbar({
|
export function Colorbar({
|
||||||
colorMapType,
|
colorMapType,
|
||||||
width = 200,
|
width = 20,
|
||||||
height = 20,
|
height = 20,
|
||||||
showLabels = true,
|
showLabels = true,
|
||||||
minValue = 0,
|
minValue = 0,
|
||||||
@ -44,9 +45,11 @@ export function Colorbar({
|
|||||||
|
|
||||||
// 绘制色标
|
// 绘制色标
|
||||||
const imageData = ctx.createImageData(width, height)
|
const imageData = ctx.createImageData(width, height)
|
||||||
|
const colorBarThickness = orientation === 'horizontal' ? Math.min(height, 4) : Math.min(width, 4) // 限制颜色带厚度
|
||||||
|
|
||||||
if (orientation === 'horizontal') {
|
if (orientation === 'horizontal') {
|
||||||
// 水平方向:从左到右
|
// 水平方向:从左到右
|
||||||
|
const yStart = Math.floor((height - colorBarThickness) / 2)
|
||||||
for (let x = 0; x < width; x++) {
|
for (let x = 0; x < width; x++) {
|
||||||
const t = x / (width - 1) // 归一化到 0-1
|
const t = x / (width - 1) // 归一化到 0-1
|
||||||
const colorIndex = Math.floor(t * 255) * 4
|
const colorIndex = Math.floor(t * 255) * 4
|
||||||
@ -56,7 +59,7 @@ export function Colorbar({
|
|||||||
const b = colorMap[colorIndex + 2]
|
const b = colorMap[colorIndex + 2]
|
||||||
const a = colorMap[colorIndex + 3]
|
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
|
const pixelIndex = (y * width + x) * 4
|
||||||
imageData.data[pixelIndex] = r
|
imageData.data[pixelIndex] = r
|
||||||
imageData.data[pixelIndex + 1] = g
|
imageData.data[pixelIndex + 1] = g
|
||||||
@ -66,6 +69,7 @@ export function Colorbar({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 垂直方向:从下到上
|
// 垂直方向:从下到上
|
||||||
|
const xStart = Math.floor((width - colorBarThickness) / 2)
|
||||||
for (let y = 0; y < height; y++) {
|
for (let y = 0; y < height; y++) {
|
||||||
const t = (height - 1 - y) / (height - 1) // 归一化到 0-1,颠倒方向
|
const t = (height - 1 - y) / (height - 1) // 归一化到 0-1,颠倒方向
|
||||||
const colorIndex = Math.floor(t * 255) * 4
|
const colorIndex = Math.floor(t * 255) * 4
|
||||||
@ -75,7 +79,7 @@ export function Colorbar({
|
|||||||
const b = colorMap[colorIndex + 2]
|
const b = colorMap[colorIndex + 2]
|
||||||
const a = colorMap[colorIndex + 3]
|
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
|
const pixelIndex = (y * width + x) * 4
|
||||||
imageData.data[pixelIndex] = r
|
imageData.data[pixelIndex] = r
|
||||||
imageData.data[pixelIndex + 1] = g
|
imageData.data[pixelIndex + 1] = g
|
||||||
@ -88,9 +92,9 @@ export function Colorbar({
|
|||||||
ctx.putImageData(imageData, 0, 0)
|
ctx.putImageData(imageData, 0, 0)
|
||||||
|
|
||||||
// 添加边框
|
// 添加边框
|
||||||
ctx.strokeStyle = '#666'
|
// ctx.strokeStyle = '#666'
|
||||||
ctx.lineWidth = 1
|
// ctx.lineWidth = 1
|
||||||
ctx.strokeRect(0, 0, width, height)
|
// ctx.strokeRect(0, 0, width, height)
|
||||||
|
|
||||||
}, [colorMapType, width, height, orientation])
|
}, [colorMapType, width, height, orientation])
|
||||||
|
|
||||||
@ -132,7 +136,12 @@ export function Colorbar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="cursor-crosshair"
|
className="cursor-crosshair"
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
'use client'
|
'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 maplibregl, { CustomLayerInterface, CustomRenderMethodInput, LogoControl, } from 'maplibre-gl'
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import { useMap } from '@/app/map-context'
|
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 fragmentSource from '@/app/glsl/radar/frag.glsl'
|
||||||
// import * as Sentry from '@sentry/nextjs'
|
// import * as Sentry from '@sentry/nextjs'
|
||||||
import { logger, logWebGLError, logMapEvent, logPerformanceMetric } from '@/lib/logger'
|
import { logger, logWebGLError, logMapEvent, logPerformanceMetric } from '@/lib/logger'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
interface MapComponentProps {
|
interface MapComponentProps {
|
||||||
style?: string
|
style?: string
|
||||||
@ -34,7 +35,21 @@ export function MapComponent({
|
|||||||
onColorMapChange
|
onColorMapChange
|
||||||
}: MapComponentProps) {
|
}: 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 mapContainer = useRef<HTMLDivElement>(null)
|
||||||
const { setMap, mapRef, currentDatetime, isMapReady } = useMap()
|
const { setMap, mapRef, currentDatetime, isMapReady } = useMap()
|
||||||
const { location } = useMapLocation()
|
const { location } = useMapLocation()
|
||||||
@ -44,6 +59,11 @@ export function MapComponent({
|
|||||||
const customLayerRef = useRef<CustomGlLayer | null>(null)
|
const customLayerRef = useRef<CustomGlLayer | null>(null)
|
||||||
const [isReady, setIsReady] = useState<boolean>(false)
|
const [isReady, setIsReady] = useState<boolean>(false)
|
||||||
const [currentColorMapType, setCurrentColorMapType] = useState<ColorMapType>(colorMapType)
|
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 }) // 从右边和下边的距离
|
const [colorbarPosition, setColorbarPosition] = useState({ x: 16, y: 36 }) // 从右边和下边的距离
|
||||||
@ -57,14 +77,30 @@ export function MapComponent({
|
|||||||
startPositionY: 0
|
startPositionY: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMapReady || !currentDatetime) return;
|
if (!isMapReady || !currentDatetime) return;
|
||||||
const utc_time_str = formatInTimeZone(currentDatetime, 'UTC', 'yyyyMMddHHmmss')
|
switchToTime(currentDatetime.getTime())
|
||||||
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)
|
|
||||||
}, [currentDatetime, isMapReady])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!mapContainer.current) return
|
if (!mapContainer.current) return
|
||||||
|
|
||||||
@ -463,7 +499,9 @@ export function MapComponent({
|
|||||||
|
|
||||||
// 清理函数:当组件卸载或重新初始化时清理资源
|
// 清理函数:当组件卸载或重新初始化时清理资源
|
||||||
return () => {
|
return () => {
|
||||||
// console.log('Cleaning up map resources...');
|
logger.info('Cleaning up map and radar tile resources', {
|
||||||
|
component: 'MapComponent'
|
||||||
|
});
|
||||||
|
|
||||||
// 清理自定义图层引用
|
// 清理自定义图层引用
|
||||||
customLayerRef.current = null;
|
customLayerRef.current = null;
|
||||||
@ -475,6 +513,7 @@ export function MapComponent({
|
|||||||
|
|
||||||
// 重置状态
|
// 重置状态
|
||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
|
setCacheStats({ totalFrames: 0, loadedFrames: 0, cacheHitRate: 0 })
|
||||||
|
|
||||||
// 移除地图实例
|
// 移除地图实例
|
||||||
if (map) {
|
if (map) {
|
||||||
@ -488,24 +527,39 @@ export function MapComponent({
|
|||||||
}, [mapContainer])
|
}, [mapContainer])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (imgBitmap && texRef.current) {
|
if (currentBitmap && texRef.current) {
|
||||||
const gl = glRef.current
|
const gl = glRef.current
|
||||||
if (!gl) return;
|
if (!gl) return;
|
||||||
|
|
||||||
// console.log('Updating texture with imgBitmap:', imgBitmap);
|
|
||||||
|
|
||||||
gl.bindTexture(gl.TEXTURE_2D, texRef.current)
|
gl.bindTexture(gl.TEXTURE_2D, texRef.current)
|
||||||
|
|
||||||
// 针对灰度图优化:使用单通道RED格式,减少内存使用和提高性能
|
// 针对灰度图优化:使用单通道RED格式,减少内存使用和提高性能
|
||||||
// 虽然ImageBitmap仍是RGBA格式,但WebGL会自动将灰度值映射到RED通道
|
// 虽然ImageBitmap仍是RGBA格式,但WebGL会自动将灰度值映射到RED通道
|
||||||
|
|
||||||
|
if (!isImageBitmapLoaded.current) {
|
||||||
gl.texImage2D(
|
gl.texImage2D(
|
||||||
gl.TEXTURE_2D,
|
gl.TEXTURE_2D,
|
||||||
0,
|
0,
|
||||||
gl.RGBA, // 内部格式:使用RGBA,兼容性更好
|
gl.RGBA, // 内部格式:使用RGBA,兼容性更好
|
||||||
gl.RGBA, // 数据格式:ImageBitmap总是RGBA
|
gl.RGBA, // 数据格式:ImageBitmap总是RGBA
|
||||||
gl.UNSIGNED_BYTE,
|
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);
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||||
@ -520,7 +574,7 @@ export function MapComponent({
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}, [imgBitmap, isReady])
|
}, [currentBitmap, isReady])
|
||||||
|
|
||||||
// 监听色标类型变化,更新LUT纹理
|
// 监听色标类型变化,更新LUT纹理
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -633,7 +687,28 @@ export function MapComponent({
|
|||||||
style={{ minHeight: '400px' }}
|
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
|
<div
|
||||||
data-colorbar
|
data-colorbar
|
||||||
className={`absolute select-none ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
|
className={`absolute select-none ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
|
||||||
|
|||||||
@ -304,7 +304,6 @@ export function useAdminPanel(options: UseAdminPanelOptions): UseAdminPanelRetur
|
|||||||
}, [fields, config.onValidate, form]);
|
}, [fields, config.onValidate, form]);
|
||||||
|
|
||||||
const save = useCallback(async () => {
|
const save = useCallback(async () => {
|
||||||
debugger
|
|
||||||
if (!onSubmit) return;
|
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 { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
import { addDays, subDays } from 'date-fns'
|
import { useImageFrameLoader } from './use-image-frame-loader'
|
||||||
|
|
||||||
interface UseRadarTileOptions {
|
interface UseRadarTileOptions {
|
||||||
|
maxBufferSize?: number;
|
||||||
|
targetPrefetch?: number;
|
||||||
|
preCacheCount?: number; // 前后各预缓存N个帧
|
||||||
|
generateUrl?: (timestamp: number) => string; // 根据时间戳生成URL的函数
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RadarTileStatus {
|
interface RadarTileFrame {
|
||||||
needRefresh: boolean;
|
timestamp: number;
|
||||||
isLoading: boolean;
|
frameIndex: number;
|
||||||
isError: boolean;
|
url: string;
|
||||||
url: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RadarTile {
|
|
||||||
needRefresh: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
isError: boolean;
|
|
||||||
url: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRadarTile({
|
export function useRadarTile({
|
||||||
|
maxBufferSize = 12,
|
||||||
|
targetPrefetch = 4,
|
||||||
|
preCacheCount = 3,
|
||||||
|
generateUrl
|
||||||
}: UseRadarTileOptions = {}) {
|
}: 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,
|
const timestampToFrameMap = useRef<Map<number, number>>(new Map())
|
||||||
isLoading: false,
|
// 订阅取消函数
|
||||||
isError: false,
|
const unsubscribeRef = useRef<(() => void) | null>(null)
|
||||||
url: 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
|
const frameIndex = Math.abs(timestamp.toString().split('').reduce((a, b) => {
|
||||||
radarTileRef.current.isError = false
|
a = ((a << 5) - a) + parseInt(b) || 0
|
||||||
|
return a & a
|
||||||
|
}, 0))
|
||||||
|
|
||||||
fetch(url).then(async (resp) => {
|
timestampToFrameMap.current.set(timestamp, frameIndex)
|
||||||
radarTileRef.current.url = url
|
return frameIndex
|
||||||
const blob = await resp.blob()
|
}, [])
|
||||||
const newImgBitmap = await createImageBitmap(blob)
|
|
||||||
// console.log('Created new ImageBitmap:', newImgBitmap);
|
// 基础功能:切换到指定时间的帧
|
||||||
setImgBitmap(newImgBitmap) // 使用 setState 更新状态
|
const switchToTime = useCallback(async (timestamp: number) => {
|
||||||
}).catch((err) => {
|
if (!generateUrl) {
|
||||||
radarTileRef.current.isError = true
|
setError('generateUrl function is required')
|
||||||
console.error(err)
|
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 {
|
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-request": "^7.2.0",
|
||||||
"graphql-ws": "^6.0.6",
|
"graphql-ws": "^6.0.6",
|
||||||
"lodash.throttle": "^4.1.1",
|
"lodash.throttle": "^4.1.1",
|
||||||
|
"lru-cache": "^11.1.0",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"maplibre-gl": "^5.6.1",
|
"maplibre-gl": "^5.6.1",
|
||||||
"next": "15.4.1",
|
"next": "15.4.1",
|
||||||
@ -306,6 +307,15 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@babel/helper-compilation-targets/node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
@ -315,6 +325,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"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": {
|
"node_modules/@babel/helper-globals": {
|
||||||
"version": "7.28.0",
|
"version": "7.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||||
@ -10526,20 +10542,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz",
|
||||||
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
"integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"engines": {
|
||||||
"yallist": "^3.0.2"
|
"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": {
|
"node_modules/lucide-react": {
|
||||||
"version": "0.525.0",
|
"version": "0.525.0",
|
||||||
"resolved": "http://mirrors.cloud.tencent.com/npm/lucide-react/-/lucide-react-0.525.0.tgz",
|
"resolved": "http://mirrors.cloud.tencent.com/npm/lucide-react/-/lucide-react-0.525.0.tgz",
|
||||||
@ -15333,10 +15343,23 @@
|
|||||||
"semver": "^6.3.1"
|
"semver": "^6.3.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": {
|
"semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="
|
"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": {
|
"lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz",
|
||||||
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
"integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A=="
|
||||||
"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=="
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"lucide-react": {
|
"lucide-react": {
|
||||||
"version": "0.525.0",
|
"version": "0.525.0",
|
||||||
|
|||||||
@ -63,6 +63,7 @@
|
|||||||
"graphql-request": "^7.2.0",
|
"graphql-request": "^7.2.0",
|
||||||
"graphql-ws": "^6.0.6",
|
"graphql-ws": "^6.0.6",
|
||||||
"lodash.throttle": "^4.1.1",
|
"lodash.throttle": "^4.1.1",
|
||||||
|
"lru-cache": "^11.1.0",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"maplibre-gl": "^5.6.1",
|
"maplibre-gl": "^5.6.1",
|
||||||
"next": "15.4.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