/** * WebGL coverage layer using texture-based value interpolation. * * Simple approach (like CloudRF surface raster): * 1. Create texture where each pixel = one grid cell's RSRP value * 2. GPU's GL_LINEAR filtering interpolates between adjacent cells * 3. Fragment shader maps interpolated value to color gradient */ import { useEffect, useRef, useMemo, useCallback } from 'react'; import { useMap } from 'react-leaflet'; export interface CoveragePoint { lat: number; lon: number; rsrp: number; } interface WebGLCoverageLayerProps { points: CoveragePoint[]; opacity: number; minRsrp?: number; maxRsrp?: number; visible: boolean; onWebGLFailed?: () => void; } const VERTEX_SHADER = ` attribute vec2 a_position; varying vec2 v_uv; void main() { gl_Position = vec4(a_position, 0.0, 1.0); // Map position to UV, flip Y v_uv = vec2((a_position.x + 1.0) * 0.5, 1.0 - (a_position.y + 1.0) * 0.5); } `; // Fragment shader with smoothstep interpolation for C2 continuity // This removes visible grid edges with minimal performance cost const FRAGMENT_SHADER = ` precision mediump float; uniform sampler2D u_coverage; uniform vec2 u_textureSize; varying vec2 v_uv; // Quintic Hermite smoothstep - gives C2 continuity (smooth 2nd derivatives) // This removes visible "seams" between grid cells vec4 textureSmooth(sampler2D tex, vec2 uv, vec2 texSize) { vec2 p = uv * texSize + 0.5; vec2 i = floor(p); vec2 f = p - i; // Quintic hermite curve: f³(6f² - 15f + 10) f = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); return texture2D(tex, (i + f - 0.5) / texSize); } // RSRP to color gradient (red -> orange -> yellow -> green -> cyan) // Applied AFTER interpolation for clean gradients vec3 rsrpToColor(float t) { // t: 0 = weak (red), 1 = strong (cyan) if (t < 0.25) return mix(vec3(1.0, 0.0, 0.0), vec3(1.0, 0.5, 0.0), t / 0.25); if (t < 0.5) return mix(vec3(1.0, 0.5, 0.0), vec3(1.0, 1.0, 0.0), (t - 0.25) / 0.25); if (t < 0.75) return mix(vec3(1.0, 1.0, 0.0), vec3(0.0, 1.0, 0.0), (t - 0.5) / 0.25); return mix(vec3(0.0, 1.0, 0.0), vec3(0.0, 1.0, 1.0), (t - 0.75) / 0.25); } void main() { // 1. Sample with smoothstep interpolation (RAW RSRP value) vec4 texel = textureSmooth(u_coverage, v_uv, u_textureSize); // 2. Alpha channel indicates coverage presence if (texel.a < 0.1) discard; // 3. Apply colormap AFTER interpolation (critical for clean gradients) float rsrp = texel.r; vec3 color = rsrpToColor(rsrp); // 4. Smooth boundary fading float boundaryAlpha = smoothstep(0.01, 0.05, rsrp); gl_FragColor = vec4(color, boundaryAlpha * 0.85); } `; function compileShader(gl: WebGLRenderingContext, source: string, type: number): WebGLShader | null { const shader = gl.createShader(type); if (!shader) return null; gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { console.error('Shader error:', gl.getShaderInfoLog(shader)); gl.deleteShader(shader); return null; } return shader; } function createProgram(gl: WebGLRenderingContext): WebGLProgram | null { const vs = compileShader(gl, VERTEX_SHADER, gl.VERTEX_SHADER); const fs = compileShader(gl, FRAGMENT_SHADER, gl.FRAGMENT_SHADER); if (!vs || !fs) return null; const program = gl.createProgram(); if (!program) return null; gl.attachShader(program, vs); gl.attachShader(program, fs); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error('Program error:', gl.getProgramInfoLog(program)); return null; } return program; } interface GridInfo { width: number; height: number; minLat: number; maxLat: number; minLon: number; maxLon: number; latStep: number; lonStep: number; } function detectGrid(points: CoveragePoint[]): GridInfo | null { if (points.length < 4) return null; // Calculate bounds directly from points (no rounding) let minLat = Infinity, maxLat = -Infinity; let minLon = Infinity, maxLon = -Infinity; for (const p of points) { if (p.lat < minLat) minLat = p.lat; if (p.lat > maxLat) maxLat = p.lat; if (p.lon < minLon) minLon = p.lon; if (p.lon > maxLon) maxLon = p.lon; } // Find grid step by looking at sorted unique coordinates const lats = new Set(); const lons = new Set(); for (const p of points) { lats.add(Math.round(p.lat * 1000000) / 1000000); // 6 decimal places lons.add(Math.round(p.lon * 1000000) / 1000000); } const sortedLats = Array.from(lats).sort((a, b) => a - b); const sortedLons = Array.from(lons).sort((a, b) => a - b); // Calculate step from median difference between adjacent points const latDiffs: number[] = []; const lonDiffs: number[] = []; for (let i = 1; i < sortedLats.length; i++) { latDiffs.push(sortedLats[i] - sortedLats[i-1]); } for (let i = 1; i < sortedLons.length; i++) { lonDiffs.push(sortedLons[i] - sortedLons[i-1]); } latDiffs.sort((a, b) => a - b); lonDiffs.sort((a, b) => a - b); const latStep = latDiffs[Math.floor(latDiffs.length / 2)] || (maxLat - minLat) / 10; const lonStep = lonDiffs[Math.floor(lonDiffs.length / 2)] || (maxLon - minLon) / 10; // Calculate grid dimensions from actual extent and step const width = Math.max(2, Math.round((maxLon - minLon) / lonStep) + 1); const height = Math.max(2, Math.round((maxLat - minLat) / latStep) + 1); return { width, height, minLat, maxLat, minLon, maxLon, latStep, lonStep, }; } interface TextureResult { texture: WebGLTexture; width: number; height: number; } function createCoverageTexture( gl: WebGLRenderingContext, points: CoveragePoint[], grid: GridInfo, minRsrp: number, maxRsrp: number ): TextureResult | null { const { width, height, minLat, maxLat, minLon, maxLon } = grid; const latRange = maxLat - minLat; const lonRange = maxLon - minLon; const rsrpRange = maxRsrp - minRsrp; // Step 1: Create sparse grid with actual point positions // Store normalized RSRP value (0-1) at each grid cell that has data const sparseGrid = new Map(); // key = gy * width + gx, value = normalized RSRP for (const p of points) { const gx = Math.round((p.lon - minLon) / lonRange * (width - 1)); const gy = Math.round((p.lat - minLat) / latRange * (height - 1)); if (gx >= 0 && gx < width && gy >= 0 && gy < height) { const normalized = Math.max(0, Math.min(1, (p.rsrp - minRsrp) / rsrpRange)); const key = gy * width + gx; // Keep the stronger signal if multiple points map to same cell if (!sparseGrid.has(key) || sparseGrid.get(key)! < normalized) { sparseGrid.set(key, normalized); } } } // Step 2: For each empty cell, find nearest filled cell using expanding search // This fills the circular coverage area properly const data = new Uint8Array(width * height * 4); const maxSearchRadius = Math.max(width, height); // Max distance to search let filledCount = 0; for (let gy = 0; gy < height; gy++) { for (let gx = 0; gx < width; gx++) { const key = gy * width + gx; if (sparseGrid.has(key)) { // Cell has actual data const value = Math.round(sparseGrid.get(key)! * 255); const idx = key * 4; data[idx] = value; data[idx + 1] = 0; data[idx + 2] = 0; data[idx + 3] = 255; filledCount++; } else { // Find nearest cell with data using expanding square search let found = false; let nearestValue = 0; let nearestDistSq = Infinity; // Search in expanding radius for (let r = 1; r <= maxSearchRadius && !found; r++) { // Check cells at distance r (square perimeter) for (let dy = -r; dy <= r && !found; dy++) { for (let dx = -r; dx <= r; dx++) { // Only check perimeter cells (optimization) if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue; const nx = gx + dx; const ny = gy + dy; if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; const nkey = ny * width + nx; if (sparseGrid.has(nkey)) { const distSq = dx * dx + dy * dy; if (distSq < nearestDistSq) { nearestDistSq = distSq; nearestValue = sparseGrid.get(nkey)!; } } } } // If we found something at this radius, use it (nearest neighbor) if (nearestDistSq < Infinity) { found = true; } } if (found) { // Fill with nearest neighbor value // Apply distance-based alpha fade for smooth edges const dist = Math.sqrt(nearestDistSq); const maxDist = 3; // Fade out over 3 cells const alpha = dist <= maxDist ? 255 : Math.max(0, 255 - (dist - maxDist) * 50); const value = Math.round(nearestValue * 255); const idx = key * 4; data[idx] = value; data[idx + 1] = 0; data[idx + 2] = 0; data[idx + 3] = Math.round(alpha); filledCount++; } // If not found, leave as transparent (alpha = 0) } } } console.log('[WebGL] Texture created (nearest-neighbor filled):', { textureSize: `${width}x${height}`, originalPoints: sparseGrid.size, filledCells: filledCount, totalCells: width * height, fillPercent: (filledCount / (width * height) * 100).toFixed(1) + '%' }); const texture = gl.createTexture(); if (!texture) return null; gl.bindTexture(gl.TEXTURE_2D, texture); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, data); // LINEAR filtering for smooth interpolation between filled cells 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 { texture, width, height }; } export default function WebGLCoverageLayer({ points, opacity, minRsrp = -130, maxRsrp = -50, visible, onWebGLFailed, }: WebGLCoverageLayerProps) { const map = useMap(); // Refs for WebGL resources const canvasRef = useRef(null); const glRef = useRef(null); const programRef = useRef(null); const textureRef = useRef(null); const quadBufferRef = useRef(null); // Track what data the current texture was built from const lastPointsHashRef = useRef(''); const boundsRef = useRef<{ minLat: number; maxLat: number; minLon: number; maxLon: number } | null>(null); const textureSizeRef = useRef<{ width: number; height: number }>({ width: 1, height: 1 }); // Stable ref for callback to avoid re-initialization const onWebGLFailedRef = useRef(onWebGLFailed); onWebGLFailedRef.current = onWebGLFailed; // Track if initialized to prevent re-runs const initializedRef = useRef(false); // Compute stable hash for points data const pointsHash = useMemo(() => { if (points.length === 0) return ''; const first = points[0]; const last = points[points.length - 1]; return `${points.length}:${first.lat.toFixed(5)}:${last.lon.toFixed(5)}:${first.rsrp.toFixed(1)}`; }, [points]); // Render function - only draws, no resource creation const render = useCallback(() => { const canvas = canvasRef.current; const gl = glRef.current; const program = programRef.current; const texture = textureRef.current; const bounds = boundsRef.current; // DEBUG: Check what's missing if we can't render if (!canvas || !gl || !program || !texture || !bounds) { console.log('[WebGL] Render skipped - missing:', { canvas: !!canvas, gl: !!gl, program: !!program, texture: !!texture, bounds: !!bounds }); return; } // Position canvas over coverage area const nw = map.latLngToLayerPoint([bounds.maxLat, bounds.minLon]); const se = map.latLngToLayerPoint([bounds.minLat, bounds.maxLon]); const width = Math.abs(se.x - nw.x); const height = Math.abs(se.y - nw.y); if (width < 1 || height < 1) return; canvas.style.transform = `translate(${nw.x}px, ${nw.y}px)`; canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; // DEBUG: Log every reposition console.log('[WebGL] Canvas repositioned:', { transform: canvas.style.transform, width: canvas.style.width, height: canvas.style.height, zoom: map.getZoom() }); // Get texture size for shader uniform const texSize = textureSizeRef.current; // Set canvas resolution const dpr = Math.min(window.devicePixelRatio || 1, 2); const canvasW = Math.min(Math.round(width * dpr), 2048); const canvasH = Math.min(Math.round(height * dpr), 2048); if (canvas.width !== canvasW || canvas.height !== canvasH) { canvas.width = canvasW; canvas.height = canvasH; } // Render gl.viewport(0, 0, canvasW, canvasH); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); gl.useProgram(program); // Bind quad buffer gl.bindBuffer(gl.ARRAY_BUFFER, quadBufferRef.current); const posLoc = gl.getAttribLocation(program, 'a_position'); gl.enableVertexAttribArray(posLoc); gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0); // Bind texture gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texture); gl.uniform1i(gl.getUniformLocation(program, 'u_coverage'), 0); // Set texture size uniform (texSize already defined above for blur) const textureSizeLocation = gl.getUniformLocation(program, 'u_textureSize'); if (textureSizeLocation) { gl.uniform2f(textureSizeLocation, texSize.width, texSize.height); } // Draw gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); gl.disableVertexAttribArray(posLoc); }, [map]); // Effect 1: Initialize WebGL (canvas, context, program, quad buffer) - runs ONCE useEffect(() => { if (!visible) return; // Skip if already initialized if (initializedRef.current && canvasRef.current && glRef.current) { return; } const pane = map.getPane('overlayPane'); if (!pane) return; // Create canvas if needed if (!canvasRef.current) { // Remove any leftover canvas elements from previous sessions const existingCanvases = pane.querySelectorAll('canvas.webgl-coverage'); existingCanvases.forEach(c => c.remove()); console.log('[WebGL] Removed', existingCanvases.length, 'leftover canvas elements'); const canvas = document.createElement('canvas'); canvas.className = 'webgl-coverage'; // Add class for identification canvas.style.position = 'absolute'; canvas.style.pointerEvents = 'none'; canvas.style.transformOrigin = '0 0'; pane.appendChild(canvas); canvasRef.current = canvas; } const canvas = canvasRef.current; // Initialize WebGL if needed if (!glRef.current) { const gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: false }); if (!gl) { console.error('[WebGL] WebGL not available'); onWebGLFailedRef.current?.(); return; } glRef.current = gl; gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); } const gl = glRef.current; // Create program if needed if (!programRef.current) { const program = createProgram(gl); if (!program) { console.error('[WebGL] Failed to create program'); onWebGLFailedRef.current?.(); return; } programRef.current = program; } // Create quad buffer if needed if (!quadBufferRef.current) { const buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ -1, -1, 1, -1, -1, 1, 1, 1 ]), gl.STATIC_DRAW); quadBufferRef.current = buf; } initializedRef.current = true; console.log('[WebGL] Initialized (should appear ONCE)'); }, [visible, map]); // Removed onWebGLFailed - use ref instead // Effect 2: Create texture when points data changes useEffect(() => { if (!visible || points.length === 0 || !glRef.current) return; // Skip if same data if (pointsHash === lastPointsHashRef.current && textureRef.current) { return; } const gl = glRef.current; const grid = detectGrid(points); if (!grid) return; // Delete old texture if (textureRef.current) { gl.deleteTexture(textureRef.current); textureRef.current = null; } // Create new texture (returns texture + dimensions) const result = createCoverageTexture(gl, points, grid, minRsrp, maxRsrp); if (!result) { console.error('[WebGL] Failed to create texture'); return; } textureRef.current = result.texture; lastPointsHashRef.current = pointsHash; // Store texture size for shader uniform textureSizeRef.current = { width: result.width, height: result.height }; // Store bounds for rendering (with half-cell padding) const canvasBounds = { minLat: grid.minLat - grid.latStep / 2, maxLat: grid.maxLat + grid.latStep / 2, minLon: grid.minLon - grid.lonStep / 2, maxLon: grid.maxLon + grid.lonStep / 2, }; boundsRef.current = canvasBounds; // FULL DEBUG: Compare data extent vs canvas bounds const lats = points.map(p => p.lat); const lons = points.map(p => p.lon); const dataMinLat = Math.min(...lats); const dataMaxLat = Math.max(...lats); const dataMinLon = Math.min(...lons); const dataMaxLon = Math.max(...lons); console.log('[WebGL] FULL DEBUG:', { // Data extent (actual points) dataMinLat: dataMinLat.toFixed(6), dataMaxLat: dataMaxLat.toFixed(6), dataMinLon: dataMinLon.toFixed(6), dataMaxLon: dataMaxLon.toFixed(6), dataLatRange: (dataMaxLat - dataMinLat).toFixed(6), dataLonRange: (dataMaxLon - dataMinLon).toFixed(6), // Grid detection result gridWidth: grid.width, gridHeight: grid.height, gridMinLat: grid.minLat.toFixed(6), gridMaxLat: grid.maxLat.toFixed(6), gridMinLon: grid.minLon.toFixed(6), gridMaxLon: grid.maxLon.toFixed(6), gridLatStep: grid.latStep.toFixed(6), gridLonStep: grid.lonStep.toFixed(6), // Texture size textureWidth: result.width, textureHeight: result.height, // Canvas bounds (what we use for rendering) canvasMinLat: canvasBounds.minLat.toFixed(6), canvasMaxLat: canvasBounds.maxLat.toFixed(6), canvasMinLon: canvasBounds.minLon.toFixed(6), canvasMaxLon: canvasBounds.maxLon.toFixed(6), canvasLatRange: (canvasBounds.maxLat - canvasBounds.minLat).toFixed(6), canvasLonRange: (canvasBounds.maxLon - canvasBounds.minLon).toFixed(6), // Comparison latCoveragePercent: ((canvasBounds.maxLat - canvasBounds.minLat) / (dataMaxLat - dataMinLat) * 100).toFixed(1) + '%', lonCoveragePercent: ((canvasBounds.maxLon - canvasBounds.minLon) / (dataMaxLon - dataMinLon) * 100).toFixed(1) + '%', // Expected expectedRange: '~0.18 degrees for 20km radius', pointCount: points.length }); // Initial render render(); }, [visible, points, pointsHash, minRsrp, maxRsrp, render]); // Effect 3: Set up map event listeners for re-rendering on move/zoom // Note: Set up listeners even without texture - render() will check for texture useEffect(() => { if (!visible) return; let frameId = 0; let moveCount = 0; const onMapChange = () => { moveCount++; if (moveCount <= 3 || moveCount % 10 === 0) { console.log('[WebGL] Map event #' + moveCount + ', triggering render'); } cancelAnimationFrame(frameId); frameId = requestAnimationFrame(render); }; map.on('move', onMapChange); map.on('zoom', onMapChange); map.on('resize', onMapChange); console.log('[WebGL] Map listeners attached'); return () => { map.off('move', onMapChange); map.off('zoom', onMapChange); map.off('resize', onMapChange); cancelAnimationFrame(frameId); console.log('[WebGL] Map listeners detached'); }; }, [visible, map, render]); // Effect 4: Update opacity without recreating anything useEffect(() => { if (canvasRef.current) { canvasRef.current.style.opacity = String(opacity); } }, [opacity]); // Effect 5: Hide/show canvas based on visibility useEffect(() => { if (canvasRef.current) { canvasRef.current.style.display = visible ? 'block' : 'none'; } }, [visible]); // Cleanup on unmount useEffect(() => { return () => { const gl = glRef.current; if (gl) { if (textureRef.current) gl.deleteTexture(textureRef.current); if (quadBufferRef.current) gl.deleteBuffer(quadBufferRef.current); if (programRef.current) gl.deleteProgram(programRef.current); } if (canvasRef.current) { canvasRef.current.remove(); canvasRef.current = null; } glRef.current = null; programRef.current = null; textureRef.current = null; quadBufferRef.current = null; }; }, []); return null; }