# RFCP — Iteration 3.10.5: WebGL Smooth Coverage Interpolation **Date:** February 6, 2026 **Priority:** P1 (Major Visual Improvement) **Estimated Time:** 3-4 hours **Author:** Claude (Opus 4.5) for Олег @ UMTC --- ## Overview Replace the current grid-based square coverage visualization with smooth WebGL-interpolated rendering. Currently coverage is displayed as discrete colored squares which looks "pixelated" and unrealistic. Professional RF tools like CloudRF use smooth gradients that interpolate between measurement points. **Current State:** Grid squares at 50m/200m resolution → blocky appearance **Target State:** Smooth bilinear/bicubic interpolation → professional gradient appearance --- ## Problem Description ### Current Implementation - Coverage points are rendered as discrete squares on a Leaflet canvas layer - Each grid point (lat, lon, rsrp) → one colored square - Resolution determines square size (50m = small squares, 200m = large squares) - Result: Looks like Minecraft, not like professional RF planning software ### Desired Outcome - Smooth color transitions between coverage points - GPU-accelerated rendering via WebGL - No visible grid artifacts - Performance maintained or improved (GPU does interpolation) - Same data, better visualization --- ## Technical Approach ### Option A: WebGL Fragment Shader (RECOMMENDED) Use a WebGL fragment shader that: 1. Receives coverage points as a texture or uniform array 2. For each screen pixel, finds nearest coverage points 3. Performs bilinear interpolation between them 4. Outputs smoothly interpolated color **Pros:** - Best visual quality - GPU-accelerated (fast) - Scales to any resolution - Industry standard approach **Cons:** - More complex implementation - Requires WebGL knowledge ### Option B: Canvas with Gaussian Blur Apply Gaussian blur to the existing canvas after rendering squares. **Pros:** - Simple to implement - Works with existing code **Cons:** - Blurs edges (coverage boundary becomes fuzzy) - Not true interpolation - Performance overhead ### Option C: Pre-interpolate on CPU Generate more points by interpolating between existing ones before rendering. **Pros:** - Simpler rendering - Works with existing canvas **Cons:** - Much slower (CPU-bound) - Memory intensive - Not scalable **DECISION: Implement Option A (WebGL Fragment Shader)** --- ## Implementation Plan ### Phase 1: WebGL Layer Setup **File:** `frontend/src/components/map/WebGLCoverageLayer.tsx` Create a new Leaflet layer that uses WebGL for rendering: ```typescript import { useEffect, useRef } from 'react'; import { useMap } from 'react-leaflet'; import L from 'leaflet'; interface CoveragePoint { lat: number; lon: number; rsrp: number; } interface WebGLCoverageLayerProps { points: CoveragePoint[]; opacity: number; minRsrp: number; maxRsrp: number; visible: boolean; } export default function WebGLCoverageLayer({ points, opacity, minRsrp, maxRsrp, visible }: WebGLCoverageLayerProps) { const map = useMap(); const canvasRef = useRef(null); const glRef = useRef(null); const programRef = useRef(null); useEffect(() => { if (!visible || points.length === 0) return; // Create canvas overlay const canvas = document.createElement('canvas'); const container = map.getContainer(); canvas.width = container.clientWidth; canvas.height = container.clientHeight; canvas.style.position = 'absolute'; canvas.style.top = '0'; canvas.style.left = '0'; canvas.style.pointerEvents = 'none'; canvas.style.zIndex = '400'; // Above tiles, below markers canvas.style.opacity = String(opacity); container.appendChild(canvas); canvasRef.current = canvas; // Initialize WebGL const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); if (!gl) { console.error('WebGL not supported, falling back to canvas'); return; } glRef.current = gl as WebGLRenderingContext; // Setup shaders and render initShaders(gl as WebGLRenderingContext); render(); // Handle map move/zoom const onMove = () => render(); map.on('move', onMove); map.on('zoom', onMove); map.on('resize', onResize); return () => { map.off('move', onMove); map.off('zoom', onMove); map.off('resize', onResize); canvas.remove(); }; }, [points, visible, opacity, minRsrp, maxRsrp, map]); // ... shader init and render functions } ``` ### Phase 2: WebGL Shaders **Vertex Shader:** ```glsl attribute vec2 a_position; varying vec2 v_texCoord; void main() { gl_Position = vec4(a_position, 0.0, 1.0); v_texCoord = (a_position + 1.0) / 2.0; } ``` **Fragment Shader (Bilinear Interpolation):** ```glsl precision mediump float; uniform sampler2D u_coverageTexture; uniform vec2 u_resolution; uniform vec4 u_bounds; // minLat, minLon, maxLat, maxLon uniform float u_minRsrp; uniform float u_maxRsrp; varying vec2 v_texCoord; // RSRP to color gradient (matches existing palette) vec3 rsrpToColor(float rsrp) { float t = clamp((rsrp - u_minRsrp) / (u_maxRsrp - u_minRsrp), 0.0, 1.0); // Color stops: red -> orange -> yellow -> green -> cyan -> blue // Reversed: strong signal = green/cyan, weak = red/orange if (t < 0.2) { return mix(vec3(0.5, 0.0, 0.0), vec3(1.0, 0.0, 0.0), t / 0.2); // maroon -> red } else if (t < 0.4) { return mix(vec3(1.0, 0.0, 0.0), vec3(1.0, 0.5, 0.0), (t - 0.2) / 0.2); // red -> orange } else if (t < 0.6) { return mix(vec3(1.0, 0.5, 0.0), vec3(1.0, 1.0, 0.0), (t - 0.4) / 0.2); // orange -> yellow } else if (t < 0.8) { return mix(vec3(1.0, 1.0, 0.0), vec3(0.0, 1.0, 0.0), (t - 0.6) / 0.2); // yellow -> green } else { return mix(vec3(0.0, 1.0, 0.0), vec3(0.0, 1.0, 1.0), (t - 0.8) / 0.2); // green -> cyan } } void main() { // Convert screen coords to geographic coords vec2 geoCoord = mix(u_bounds.xy, u_bounds.zw, v_texCoord); // Sample coverage texture (contains RSRP values encoded as colors) vec4 sample = texture2D(u_coverageTexture, v_texCoord); // Decode RSRP from texture (R channel = normalized RSRP) float rsrp = mix(u_minRsrp, u_maxRsrp, sample.r); // Skip if no coverage (alpha = 0) if (sample.a < 0.1) { discard; } vec3 color = rsrpToColor(rsrp); gl_FragColor = vec4(color, sample.a); } ``` ### Phase 3: Coverage Data → Texture Convert coverage points array to a WebGL texture for GPU sampling: ```typescript function createCoverageTexture( gl: WebGLRenderingContext, points: CoveragePoint[], bounds: L.LatLngBounds, textureSize: number = 512 ): WebGLTexture { // Create a grid texture from sparse points const data = new Uint8Array(textureSize * textureSize * 4); const minLat = bounds.getSouth(); const maxLat = bounds.getNorth(); const minLon = bounds.getWest(); const maxLon = bounds.getEast(); // For each texture pixel, find nearest coverage point and interpolate for (let y = 0; y < textureSize; y++) { for (let x = 0; x < textureSize; x++) { const lat = minLat + (maxLat - minLat) * (y / textureSize); const lon = minLon + (maxLon - minLon) * (x / textureSize); // Find nearest points and interpolate (IDW - Inverse Distance Weighting) const { value, weight } = interpolateIDW(points, lat, lon, 4); const idx = (y * textureSize + x) * 4; if (weight > 0) { // Encode normalized RSRP in R channel, weight in A channel const normalized = (value - minRsrp) / (maxRsrp - minRsrp); data[idx] = Math.floor(normalized * 255); // R = RSRP data[idx + 1] = 0; // G = unused data[idx + 2] = 0; // B = unused data[idx + 3] = Math.floor(Math.min(weight, 1) * 255); // A = coverage mask } else { data[idx + 3] = 0; // No coverage } } } const texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, textureSize, textureSize, 0, gl.RGBA, gl.UNSIGNED_BYTE, data); // Enable bilinear filtering for smooth interpolation 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!; } // Inverse Distance Weighting interpolation function interpolateIDW( points: CoveragePoint[], lat: number, lon: number, k: number = 4, power: number = 2 ): { value: number; weight: number } { // Find k nearest points const distances = points.map((p, i) => ({ index: i, dist: Math.sqrt(Math.pow(p.lat - lat, 2) + Math.pow(p.lon - lon, 2)) })); distances.sort((a, b) => a.dist - b.dist); const nearest = distances.slice(0, k); // If very close to a point, use its value directly if (nearest[0].dist < 0.0001) { return { value: points[nearest[0].index].rsrp, weight: 1 }; } // IDW formula: weighted average where weight = 1 / distance^power let sumWeights = 0; let sumValues = 0; for (const n of nearest) { const w = 1 / Math.pow(n.dist, power); sumWeights += w; sumValues += w * points[n.index].rsrp; } // Limit interpolation range (don't extrapolate too far from data) const maxDist = nearest[nearest.length - 1].dist; const coverage = maxDist < 0.01 ? 1 : Math.max(0, 1 - maxDist * 50); return { value: sumValues / sumWeights, weight: coverage }; } ``` ### Phase 4: Integration with Existing Code **Modify:** `frontend/src/components/map/MapView.tsx` Add toggle between old canvas layer and new WebGL layer: ```typescript import WebGLCoverageLayer from './WebGLCoverageLayer'; // In MapView component: const [useWebGL, setUseWebGL] = useState(true); // In render: {useWebGL ? ( ) : ( // Existing canvas implementation )} ``` **Add setting:** `frontend/src/components/panels/SettingsPanel.tsx` ```typescript
Smooth Coverage (WebGL)
``` ### Phase 5: Performance Optimizations 1. **Texture Caching:** Only regenerate texture when coverage data changes 2. **Resolution Scaling:** Use smaller texture on zoom out, larger on zoom in 3. **Frustum Culling:** Don't render points outside visible bounds 4. **Web Worker:** Move IDW interpolation to background thread ```typescript // Memoize texture generation const coverageTexture = useMemo(() => { if (!gl || points.length === 0) return null; return createCoverageTexture(gl, points, bounds, textureSize); }, [points, bounds, textureSize]); // Dynamic texture size based on zoom const textureSize = useMemo(() => { const zoom = map.getZoom(); if (zoom < 10) return 256; if (zoom < 14) return 512; return 1024; }, [map.getZoom()]); ``` --- ## Files to Create/Modify | File | Action | Description | |------|--------|-------------| | `frontend/src/components/map/WebGLCoverageLayer.tsx` | CREATE | New WebGL rendering component | | `frontend/src/components/map/shaders/coverage.vert` | CREATE | Vertex shader (optional, can inline) | | `frontend/src/components/map/shaders/coverage.frag` | CREATE | Fragment shader (optional, can inline) | | `frontend/src/components/map/MapView.tsx` | MODIFY | Add WebGL layer toggle | | `frontend/src/store/settings.ts` | MODIFY | Add useWebGL setting | | `frontend/src/components/panels/CoverageSettingsPanel.tsx` | MODIFY | Add WebGL toggle UI | --- ## Testing Checklist ### Visual Quality - [ ] No visible grid squares at any zoom level - [ ] Smooth color gradients between coverage points - [ ] Coverage boundary is smooth, not jagged - [ ] Colors match existing palette (weak = red, strong = cyan/green) - [ ] Opacity control works correctly ### Performance - [ ] 60 FPS during map pan/zoom - [ ] Initial render < 500ms for 6000 points - [ ] Memory usage reasonable (< 100MB for large coverage) - [ ] No GPU memory leaks on repeated calculations ### Compatibility - [ ] Works on systems without dedicated GPU (falls back gracefully) - [ ] Works in Chrome, Firefox, Edge - [ ] Works on both high-DPI and standard displays ### Integration - [ ] Toggle between WebGL and canvas modes works - [ ] Coverage data updates correctly after recalculation - [ ] Settings persist across sessions - [ ] No console errors or warnings --- ## Fallback Strategy If WebGL fails to initialize: 1. Log warning to console 2. Fall back to existing canvas implementation 3. Show toast notification to user ```typescript const gl = canvas.getContext('webgl'); if (!gl) { console.warn('WebGL not available, using canvas fallback'); setUseWebGL(false); toast.warning('WebGL not supported, using standard rendering'); return; } ``` --- ## Success Criteria 1. **Visual:** Coverage looks like CloudRF/professional tools — smooth gradients, no grid 2. **Performance:** Same or better than current canvas implementation 3. **Reliability:** Graceful fallback if WebGL unavailable 4. **UX:** User can toggle between modes in settings --- ## Additional Notes ### Color Gradient Reference Current RSRP color mapping (from `colorGradient.ts`): ``` -130 dBm → Maroon (no service) -110 dBm → Red (very weak) -100 dBm → Orange (weak) -85 dBm → Yellow (fair) -70 dBm → Green (good) -50 dBm → Cyan (excellent) ``` ### Coordinate Systems - **Geographic:** Latitude/Longitude (EPSG:4326) - **Screen:** Pixels from top-left - **WebGL:** Normalized device coordinates (-1 to 1) - **Texture:** UV coordinates (0 to 1) All conversions must account for Web Mercator projection distortion. --- ## References - WebGL Fundamentals: https://webglfundamentals.org/ - Leaflet Custom Layers: https://leafletjs.com/examples/extending/extending-2-layers.html - IDW Interpolation: https://en.wikipedia.org/wiki/Inverse_distance_weighting - CloudRF visualization: https://cloudrf.com (for visual reference) --- ## Commit Message ``` feat(coverage): WebGL smooth interpolation rendering - Add WebGLCoverageLayer with GPU-accelerated rendering - Implement IDW interpolation for smooth gradients - Add toggle between WebGL and canvas modes - Graceful fallback for systems without WebGL support Closes #coverage-interpolation ``` --- **Ready for Implementation!**