Files
rfcp/docs/devlog/gpu_supp/RFCP-Iteration-3.10.5-WebGL-Smooth-Coverage.md
2026-02-07 12:56:25 +02:00

14 KiB

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

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:

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<HTMLCanvasElement | null>(null);
  const glRef = useRef<WebGLRenderingContext | null>(null);
  const programRef = useRef<WebGLProgram | null>(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:

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):

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:

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:

import WebGLCoverageLayer from './WebGLCoverageLayer';

// In MapView component:
const [useWebGL, setUseWebGL] = useState(true);

// In render:
{useWebGL ? (
  <WebGLCoverageLayer
    points={coveragePoints}
    opacity={heatmapOpacity}
    minRsrp={-130}
    maxRsrp={-50}
    visible={showCoverage}
  />
) : (
  <GeographicHeatmap ... /> // Existing canvas implementation
)}

Add setting: frontend/src/components/panels/SettingsPanel.tsx

<div className="flex items-center justify-between">
  <span>Smooth Coverage (WebGL)</span>
  <Toggle 
    checked={useWebGL} 
    onChange={setUseWebGL}
  />
</div>

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
// 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
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


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!