mosaicmap/components/map-component.tsx
2025-08-19 12:23:02 +08:00

712 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

'use client'
import React, { useEffect, useRef, useState } from 'react'
import maplibregl, { CustomLayerInterface, CustomRenderMethodInput, LogoControl, } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useMap } from '@/app/map-context'
import { useMapLocation } from '@/hooks/use-map-location'
import { getSubdivisionRecommendation, detectPerformanceLevel, RegionMeshPresets } from '@/lib/tile-mesh'
import { createColorMap, ColorMapType, } from '@/lib/color-maps'
import { Colorbar } from './colorbar'
import { useRadarTile } from '@/hooks/use-radartile'
import { format, formatInTimeZone } from 'date-fns-tz'
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'
interface MapComponentProps {
style?: string
center?: [number, number]
zoom?: number
imgBitmap?: ImageBitmap | null
colorMapType?: ColorMapType
onColorMapChange?: (type: ColorMapType) => void
}
export function MapComponent({
style = 'https://api.maptiler.com/maps/019817f1-82a8-7f37-901d-4bedf68b27fb/style.json?key=hj3fxRdwF9KjEsBq8sYI',
// style = 'https://api.maptiler.com/maps/landscape/style.json?key=hj3fxRdwF9KjEsBq8sYI',
// style = 'https://api.maptiler.com/tiles/land-gradient-dark/tiles.json?key=hj3fxRdwF9KjEsBq8sYI',
// center = [103.851959, 1.290270],
// zoom = 11
imgBitmap: propImgBitmap,
colorMapType = 'meteorological',
onColorMapChange
}: MapComponentProps) {
const { fetchRadarTile, imgBitmap } = useRadarTile();
const mapContainer = useRef<HTMLDivElement>(null)
const { setMap, mapRef, currentDatetime, isMapReady } = useMap()
const { location } = useMapLocation()
const texRef = useRef<WebGLTexture | null>(null)
const lutTexRef = useRef<WebGLTexture | null>(null)
const glRef = useRef<WebGL2RenderingContext | null>(null)
const customLayerRef = useRef<CustomGlLayer | null>(null)
const [isReady, setIsReady] = useState<boolean>(false)
const [currentColorMapType, setCurrentColorMapType] = useState<ColorMapType>(colorMapType)
// 拖动状态
const [colorbarPosition, setColorbarPosition] = useState({ x: 16, y: 36 }) // 从右边和下边的距离
const [isDragging, setIsDragging] = useState(false)
// 使用ref来避免频繁的状态更新
const dragRef = useRef({
startX: 0,
startY: 0,
startPositionX: 0,
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)
}, [currentDatetime, isMapReady])
useEffect(() => {
if (!mapContainer.current) return
const span = Sentry.startInactiveSpan({
op: "ui.component.load",
name: "Map Component Initialization",
});
span.setAttribute("map.style", style);
span.setAttribute("map.center", `${location.center[0]},${location.center[1]}`);
span.setAttribute("map.zoom", location.zoom);
const map = new maplibregl.Map({
container: mapContainer.current,
style: style,
center: location.center,
zoom: location.zoom,
attributionControl: false, // 禁用默认的版权控制
canvasContextAttributes: {
contextType: 'webgl2', // 请求 WebGL2
antialias: true // 打开多重采样抗锯齿
},
})
map.on('style.load', () => {
logMapEvent('style.load', {
style: style,
center: location.center,
zoom: location.zoom
});
logger.info('Map style loaded successfully', {
component: 'MapComponent',
style: style
});
map.setProjection({
type: 'globe'
})
const customGlLayer: CustomGlLayer = {
id: 'player',
type: 'custom',
lastZoom: -1, // 添加缓存的缩放级别
uniformLocations: {} as Record<string, WebGLUniformLocation | null>, // 缓存uniform位置
prerender(gl: WebGLRenderingContext | WebGL2RenderingContext, { shaderData }: CustomRenderMethodInput) {
if (!this.program) {
glRef.current = gl as WebGL2RenderingContext;
if (!(gl instanceof WebGL2RenderingContext)) {
return;
}
// Helper function to compile shader
const compileShader = (source: string, type: number): WebGLShader | null => {
const shader = gl.createShader(type);
if (!shader) {
const error = new Error('Failed to create WebGL shader');
Sentry.captureException(error);
logWebGLError('shader_creation', 'Failed to create WebGL shader', { shaderType: type === gl.VERTEX_SHADER ? 'vertex' : 'fragment' });
return null;
}
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const errorLog = gl.getShaderInfoLog(shader) || 'Unknown shader compilation error';
const error = new Error(`Shader compilation failed: ${errorLog}`);
Sentry.captureException(error, {
tags: { component: 'MapComponent', operation: 'shader_compilation' },
extra: { shaderType: type === gl.VERTEX_SHADER ? 'vertex' : 'fragment', source }
});
logWebGLError('shader_compilation', errorLog, { shaderType: type === gl.VERTEX_SHADER ? 'vertex' : 'fragment' });
gl.deleteShader(shader);
return null;
}
return shader;
}
// Compile shaders
const vertexShader = compileShader(vertexSource, gl.VERTEX_SHADER);
const fragmentShader = compileShader(fragmentSource, gl.FRAGMENT_SHADER);
if (!vertexShader || !fragmentShader) return;
// Create and link program
const program = gl.createProgram();
if (!program) return;
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const errorLog = gl.getProgramInfoLog(program) || 'Unknown program linking error';
const error = new Error(`WebGL program linking failed: ${errorLog}`);
Sentry.captureException(error, {
tags: { component: 'MapComponent', operation: 'program_linking' },
extra: { programLog: errorLog }
});
logWebGLError('program_linking', errorLog);
return;
}
// Clean up shaders (they're now part of the program)
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
this.program = program;
const tex = gl.createTexture()
if (!tex) {
const error = new Error('Failed to create WebGL texture');
Sentry.captureException(error, {
tags: { component: 'MapComponent', operation: 'texture_creation' }
});
logWebGLError('texture_creation', 'Failed to create WebGL texture');
return;
}
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
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);
// 初始化时不更新纹理,等待 useEffect 中的更新
gl.bindTexture(gl.TEXTURE_2D, null);
this.tex = tex;
texRef.current = tex;
// 创建 LUT 纹理
const lutTex = createLutTexture(gl, currentColorMapType);
if (!lutTex) return;
this.lutTex = lutTex;
lutTexRef.current = lutTex;
// 缓存uniform位置
this.uniformLocations = {
'u_projection_fallback_matrix': gl.getUniformLocation(program, 'u_projection_fallback_matrix'),
'u_projection_matrix': gl.getUniformLocation(program, 'u_projection_matrix'),
'u_projection_tile_mercator_coords': gl.getUniformLocation(program, 'u_projection_tile_mercator_coords'),
'u_projection_clipping_plane': gl.getUniformLocation(program, 'u_projection_clipping_plane'),
'u_projection_transition': gl.getUniformLocation(program, 'u_projection_transition'),
'u_tex': gl.getUniformLocation(program, 'u_tex'),
'u_lut': gl.getUniformLocation(program, 'u_lut')
};
// 创建并绑定顶点缓冲区
const vertexBuffer = gl.createBuffer();
if (!vertexBuffer) {
console.error('Failed to create vertex buffer');
return;
}
// 创建并绑定索引缓冲区
const indexBuffer = gl.createBuffer();
if (!indexBuffer) {
console.error('Failed to create index buffer');
return;
}
// Create vertex array object (WebGL2 feature)
const vao = gl.createVertexArray();
if (!vao) {
console.error('Failed to create VAO');
return;
}
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
// 设置位置属性 (location = 0)
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(
0, // attribute location
2, // size (x, y)
gl.FLOAT, // type
false, // normalized
16, // stride (4 floats * 4 bytes = 16 bytes per vertex)
0 // offset (位置在开始)
);
gl.vertexAttribDivisor(0, 0);
// 设置纹理坐标属性 (location = 1)
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(
1, // attribute location
2, // size (u, v)
gl.FLOAT, // type
false, // normalized
16, // stride (4 floats * 4 bytes = 16 bytes per vertex)
8 // offset (纹理坐标在位置之后2 floats * 4 bytes = 8 bytes)
);
gl.vertexAttribDivisor(1, 0);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
// Unbind VAO
gl.bindVertexArray(null);
this.vao = vao;
this.vertexBuffer = vertexBuffer;
this.indexBuffer = indexBuffer;
setIsReady(true)
}
// 只在缩放级别变化时更新网格数据
const currentZoom = Math.floor(map.getZoom());
if (currentZoom !== this.lastZoom) {
logMapEvent('zoom_change', {
previousZoom: this.lastZoom,
currentZoom: currentZoom
});
// 智能计算最佳细分数量
const performanceLevel = detectPerformanceLevel();
const canvas = map.getCanvas();
const viewportSize = canvas ? {
width: canvas.width,
height: canvas.height
} : undefined;
// 获取细分建议信息
const recommendation = getSubdivisionRecommendation(currentZoom, performanceLevel);
logger.debug(logger.fmt`Zoom level: ${currentZoom}, Performance level: ${performanceLevel}`, {
currentZoom,
performanceLevel,
component: 'MapComponent'
});
logger.debug(logger.fmt`Subdivision recommendation: ${recommendation.subdivisions} (${recommendation.description})`, {
subdivisions: recommendation.subdivisions,
description: recommendation.description,
triangleCount: recommendation.triangleCount,
estimatedMemoryMB: recommendation.estimatedMemoryMB
});
logPerformanceMetric('triangles', recommendation.triangleCount, 'count');
logPerformanceMetric('memory_estimate', recommendation.estimatedMemoryMB, 'MB');
const meshData = RegionMeshPresets.china(currentZoom, 32);
if (gl instanceof WebGL2RenderingContext && this.vertexBuffer && this.indexBuffer) {
// 更新顶点缓冲区
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, meshData.vertices, gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
// 更新索引缓冲区
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, meshData.indices, gl.STATIC_DRAW);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
this.meshData = meshData;
this.lastZoom = currentZoom;
}
}
// 移除这里的纹理更新,避免循环更新
},
onAdd: function (map: maplibregl.Map, gl: WebGL2RenderingContext) {
logger.info('WebGL custom layer added successfully', {
component: 'MapComponent',
layer: 'custom-gl-layer'
});
customLayerRef.current = this;
},
onRemove: function (map: maplibregl.Map, gl: WebGL2RenderingContext) {
// 清理WebGL资源
if (this.program) {
if (gl) {
// 禁用顶点属性
gl.disableVertexAttribArray(0); // 位置属性
gl.disableVertexAttribArray(1); // 纹理坐标属性
gl.deleteProgram(this.program);
if (this.vertexBuffer) gl.deleteBuffer(this.vertexBuffer);
if (this.indexBuffer) gl.deleteBuffer(this.indexBuffer);
if (this.vao) gl.deleteVertexArray(this.vao);
if (this.tex) gl.deleteTexture(this.tex);
if (this.lutTex) gl.deleteTexture(this.lutTex);
}
}
logger.info('WebGL custom layer resources cleaned up successfully', {
component: 'MapComponent',
layer: 'custom-gl-layer'
});
},
render(gl: WebGL2RenderingContext | WebGLRenderingContext, { defaultProjectionData }: CustomRenderMethodInput) {
if (!(gl instanceof WebGL2RenderingContext) || !this.program || !this.meshData || !this.vao) {
return;
}
// 保存当前状态
const currentProgram = gl.getParameter(gl.CURRENT_PROGRAM);
const currentVAO = gl.getParameter(gl.VERTEX_ARRAY_BINDING);
const blendEnabled = gl.isEnabled(gl.BLEND);
const currentBlendSrc = gl.getParameter(gl.BLEND_SRC_ALPHA);
const currentBlendDst = gl.getParameter(gl.BLEND_DST_ALPHA);
gl.useProgram(this.program);
// 使用缓存的uniform位置
const locations = this.uniformLocations!;
if (locations['u_projection_fallback_matrix']) {
gl.uniformMatrix4fv(
locations['u_projection_fallback_matrix'],
false,
defaultProjectionData.fallbackMatrix
);
}
if (locations['u_projection_matrix']) {
gl.uniformMatrix4fv(
locations['u_projection_matrix'],
false,
defaultProjectionData.mainMatrix
);
}
if (locations['u_projection_tile_mercator_coords']) {
gl.uniform4f(
locations['u_projection_tile_mercator_coords'],
...defaultProjectionData.tileMercatorCoords
);
}
if (locations['u_projection_clipping_plane']) {
gl.uniform4f(
locations['u_projection_clipping_plane'],
...defaultProjectionData.clippingPlane
);
}
if (locations['u_projection_transition']) {
gl.uniform1f(
locations['u_projection_transition'],
defaultProjectionData.projectionTransition
);
}
// 绑定纹理
if (this.tex && locations['u_tex']) {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.tex);
gl.uniform1i(locations['u_tex'], 0);
}
if (this.lutTex && locations['u_lut']) {
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.lutTex);
gl.uniform1i(locations['u_lut'], 1);
}
gl.bindVertexArray(this.vao);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
// 使用索引绘制三角形
const indexType = this.meshData.uses32bitIndices ? gl.UNSIGNED_INT : gl.UNSIGNED_SHORT;
const indexCount = this.meshData.indices.length;
gl.drawElements(gl.TRIANGLES, indexCount, indexType, 0);
// 恢复状态
gl.bindVertexArray(currentVAO);
gl.useProgram(currentProgram);
if (!blendEnabled) gl.disable(gl.BLEND);
if (blendEnabled) gl.blendFunc(currentBlendSrc, currentBlendDst);
}
};
map.addLayer(customGlLayer);
})
setMap(map, [])
// 清理函数:当组件卸载或重新初始化时清理资源
return () => {
console.log('Cleaning up map resources...');
// 清理自定义图层引用
customLayerRef.current = null;
// 清理 WebGL 引用
glRef.current = null;
texRef.current = null;
lutTexRef.current = null;
// 重置状态
setIsReady(false);
// 移除地图实例
if (map) {
map.remove();
}
// 结束Sentry span
span.end();
}
}, [mapContainer])
useEffect(() => {
if (imgBitmap && 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
)
// 设置纹理参数(如果还没有设置)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
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);
gl.bindTexture(gl.TEXTURE_2D, null);
// Redraw the map
mapRef.current?.triggerRepaint()
}
}, [imgBitmap, isReady])
// 监听色标类型变化更新LUT纹理
useEffect(() => {
if (currentColorMapType !== colorMapType) {
setCurrentColorMapType(colorMapType);
}
}, [colorMapType, currentColorMapType])
// 当色标类型改变时重新创建LUT纹理
useEffect(() => {
if (isReady && lutTexRef.current && glRef.current) {
const gl = glRef.current;
const newLutTex = createLutTexture(gl, currentColorMapType);
if (newLutTex) {
// 删除旧的纹理
gl.deleteTexture(lutTexRef.current);
lutTexRef.current = newLutTex;
// 通知自定义图层更新LUT纹理
if (customLayerRef.current) {
customLayerRef.current.lutTex = newLutTex;
}
}
}
}, [currentColorMapType, isReady])
// 拖动事件处理函数
const handleMouseDown = (e: React.MouseEvent) => {
Sentry.startSpan(
{
op: "ui.interaction",
name: "Colorbar Drag Start",
},
(span) => {
span.setAttribute("colorbar.position.x", colorbarPosition.x);
span.setAttribute("colorbar.position.y", colorbarPosition.y);
e.preventDefault()
setIsDragging(true)
// 记录拖动开始时的鼠标位置和colorbar位置
dragRef.current = {
startX: e.clientX,
startY: e.clientY,
startPositionX: colorbarPosition.x,
startPositionY: colorbarPosition.y
}
}
);
}
// 全局鼠标事件监听
useEffect(() => {
if (!isDragging) return
let animationFrameId: number
const handleGlobalMouseMove = (e: MouseEvent) => {
// 使用requestAnimationFrame来优化性能
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
}
animationFrameId = requestAnimationFrame(() => {
const mapContainer = document.querySelector('.h-full.w-full.relative') as HTMLElement
if (!mapContainer) return
const containerRect = mapContainer.getBoundingClientRect()
// 计算鼠标移动的总距离
const deltaX = dragRef.current.startX - e.clientX
const deltaY = dragRef.current.startY - e.clientY
// 计算新位置(相对于容器右下角的距离)
const newX = dragRef.current.startPositionX + deltaX
const newY = dragRef.current.startPositionY + deltaY
// 限制在容器范围内
const clampedX = Math.max(0, Math.min(newX, containerRect.width - 50)) // 预留colorbar宽度
const clampedY = Math.max(0, Math.min(newY, containerRect.height - 220)) // 预留colorbar高度
setColorbarPosition({ x: clampedX, y: clampedY })
})
}
const handleGlobalMouseUp = () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
}
setIsDragging(false)
}
document.addEventListener('mousemove', handleGlobalMouseMove)
document.addEventListener('mouseup', handleGlobalMouseUp)
return () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
}
document.removeEventListener('mousemove', handleGlobalMouseMove)
document.removeEventListener('mouseup', handleGlobalMouseUp)
}
}, [isDragging]) // 只依赖isDragging避免频繁重新创建事件监听器
return (
<div className="h-full w-full relative">
<div
ref={mapContainer}
className="w-full h-full"
style={{ minHeight: '400px' }}
/>
{/* 可拖动的 Colorbar */}
<div
data-colorbar
className={`absolute select-none ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
style={{
right: `${colorbarPosition.x}px`,
bottom: `${colorbarPosition.y}px`,
transform: isDragging ? 'scale(1.02)' : 'scale(1)',
transition: isDragging ? 'none' : 'transform 0.1s ease-out',
willChange: isDragging ? 'transform' : 'auto'
}}
onMouseDown={handleMouseDown}
>
<Colorbar
colorMapType={currentColorMapType}
width={16}
height={220}
minValue={0}
maxValue={75}
unit="dBZ"
orientation="vertical"
/>
</div>
</div>
)
}
interface CustomGlLayer extends CustomLayerInterface {
program?: WebGLProgram;
aPos?: number;
buffer?: WebGLBuffer | null;
vao?: WebGLVertexArrayObject | null;
meshData?: { vertices: Float32Array; indices: Uint16Array | Uint32Array; uses32bitIndices: boolean; vertexCount: number; triangleCount: number; };
vertexBuffer?: WebGLBuffer | null;
indexBuffer?: WebGLBuffer | null;
lastZoom?: number; // 缓存的缩放级别
uniformLocations?: Record<string, WebGLUniformLocation | null>; // 缓存uniform位置
tex?: WebGLTexture | null;
lutTex?: WebGLTexture | null;
}
function createLutTexture(gl: WebGL2RenderingContext, colorMapType: ColorMapType = 'radar') {
// 使用统一的色标创建函数
const lut = createColorMap(colorMapType);
const tex = gl.createTexture()
if (!tex) {
console.error('Failed to create texture');
return;
}
gl.bindTexture(gl.TEXTURE_2D, tex)
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
256,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
lut
)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
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);
gl.bindTexture(gl.TEXTURE_2D, null);
return tex;
}