670 lines
21 KiB
TypeScript
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;
|
|
}
|