@mytec: WebGL works

This commit is contained in:
2026-02-06 22:17:24 +02:00
parent 81e078e92a
commit acfd9b8f7b
31 changed files with 4427 additions and 156 deletions

View File

@@ -0,0 +1,516 @@
# 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!**