517 lines
14 KiB
Markdown
517 lines
14 KiB
Markdown
# 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<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:**
|
|
```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 ? (
|
|
<WebGLCoverageLayer
|
|
points={coveragePoints}
|
|
opacity={heatmapOpacity}
|
|
minRsrp={-130}
|
|
maxRsrp={-50}
|
|
visible={showCoverage}
|
|
/>
|
|
) : (
|
|
<GeographicHeatmap ... /> // Existing canvas implementation
|
|
)}
|
|
```
|
|
|
|
**Add setting:** `frontend/src/components/panels/SettingsPanel.tsx`
|
|
|
|
```typescript
|
|
<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
|
|
|
|
```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!**
|
|
|