Files
rfcp/frontend/src/components/map/WebGLCoverageLayer.tsx
2026-02-06 22:17:24 +02:00

670 lines
21 KiB
TypeScript

/**
* 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<number>();
const lons = new Set<number>();
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<number, number>(); // 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<HTMLCanvasElement | null>(null);
const glRef = useRef<WebGLRenderingContext | null>(null);
const programRef = useRef<WebGLProgram | null>(null);
const textureRef = useRef<WebGLTexture | null>(null);
const quadBufferRef = useRef<WebGLBuffer | null>(null);
// Track what data the current texture was built from
const lastPointsHashRef = useRef<string>('');
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;
}