@mytec: 10km grad works
This commit is contained in:
345
RFCP-WebGL-Radial-Gradients-Task.md
Normal file
345
RFCP-WebGL-Radial-Gradients-Task.md
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
# RFCP: WebGL Radial Gradients Coverage Layer
|
||||||
|
|
||||||
|
## Мета
|
||||||
|
|
||||||
|
Переробити WebGL coverage layer з texture-based підходу на **radial gradients** — як працює Canvas GeographicHeatmap, але на GPU.
|
||||||
|
|
||||||
|
## Чому radial gradients краще для візуалізації
|
||||||
|
|
||||||
|
**Texture-based (поточний):**
|
||||||
|
- Кожна точка = 1 pixel в grid
|
||||||
|
- Nearest neighbor fill → blocky квадрати
|
||||||
|
- Навіть з smoothstep — видно grid структуру
|
||||||
|
- ✅ Добре для: terrain detail, точні значення
|
||||||
|
- ❌ Погано для: красива візуалізація
|
||||||
|
|
||||||
|
**Radial gradients (Canvas heatmap):**
|
||||||
|
- Кожна точка = круг з radial falloff
|
||||||
|
- Smooth blending між точками
|
||||||
|
- Природній вигляд coverage
|
||||||
|
- ✅ Добре для: красива візуалізація, презентації
|
||||||
|
- ❌ Погано для: точні значення (blending спотворює)
|
||||||
|
|
||||||
|
## Архітектура WebGL Radial Gradients
|
||||||
|
|
||||||
|
### Підхід: Multi-pass additive blending
|
||||||
|
|
||||||
|
```
|
||||||
|
Pass 1-N: Для кожної точки (або batch точок)
|
||||||
|
├── Малюємо full-screen quad
|
||||||
|
├── Fragment shader: radial falloff від центру точки
|
||||||
|
├── Output: (weight * value, weight, 0, 1)
|
||||||
|
└── Blending: GL_ONE, GL_ONE (additive)
|
||||||
|
|
||||||
|
Final Pass:
|
||||||
|
├── Читаємо accumulated texture
|
||||||
|
├── Normalize: value = R / G (weighted average)
|
||||||
|
└── Apply colormap
|
||||||
|
```
|
||||||
|
|
||||||
|
### Альтернатива: Single-pass з texture atlas
|
||||||
|
|
||||||
|
Замість N проходів, закодувати всі точки в texture і в одному fragment shader пройтись по всіх:
|
||||||
|
|
||||||
|
```glsl
|
||||||
|
// Fragment shader
|
||||||
|
uniform sampler2D u_points; // texture з точками: (lat, lon, rsrp, radius)
|
||||||
|
uniform int u_pointCount;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec2 worldPos = getWorldPosition(v_uv);
|
||||||
|
|
||||||
|
float totalWeight = 0.0;
|
||||||
|
float totalValue = 0.0;
|
||||||
|
|
||||||
|
for (int i = 0; i < MAX_POINTS; i++) {
|
||||||
|
if (i >= u_pointCount) break;
|
||||||
|
|
||||||
|
vec4 point = texelFetch(u_points, ivec2(i, 0), 0);
|
||||||
|
vec2 pointPos = point.xy;
|
||||||
|
float rsrp = point.z;
|
||||||
|
float radius = point.w;
|
||||||
|
|
||||||
|
float dist = distance(worldPos, pointPos);
|
||||||
|
float weight = smoothstep(radius, 0.0, dist);
|
||||||
|
|
||||||
|
totalWeight += weight;
|
||||||
|
totalValue += weight * rsrp;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalWeight < 0.001) discard;
|
||||||
|
|
||||||
|
float avgRsrp = totalValue / totalWeight;
|
||||||
|
vec3 color = rsrpToColor(avgRsrp);
|
||||||
|
|
||||||
|
gl_FragColor = vec4(color, smoothstep(0.0, 0.1, totalWeight));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Проблема:** Loop по 6,675 точках в кожному fragment = дуже повільно.
|
||||||
|
|
||||||
|
### Рекомендований підхід: Batched additive blending
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Створити offscreen framebuffer (float texture)
|
||||||
|
2. Для кожної точки (або batch по 100-500):
|
||||||
|
- Малювати quad розміром з radius точки
|
||||||
|
- Additive blend: (weight * rsrp, weight)
|
||||||
|
3. Final pass: normalize + colormap
|
||||||
|
```
|
||||||
|
|
||||||
|
Це як Mapbox heatmap працює.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Імплементація
|
||||||
|
|
||||||
|
### Крок 1: Створити offscreen framebuffer
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Accumulation texture (RG float for weighted sum)
|
||||||
|
const accumTexture = gl.createTexture();
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, accumTexture);
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RG32F, width, height, 0, gl.RG, gl.FLOAT, null);
|
||||||
|
|
||||||
|
const framebuffer = gl.createFramebuffer();
|
||||||
|
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
|
||||||
|
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, accumTexture, 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Примітка:** Потрібен `EXT_color_buffer_float` extension для float framebuffer.
|
||||||
|
|
||||||
|
### Крок 2: Point rendering shader
|
||||||
|
|
||||||
|
**Vertex shader:**
|
||||||
|
```glsl
|
||||||
|
attribute vec2 a_position; // quad vertices
|
||||||
|
attribute vec2 a_pointCenter; // point lat/lon (instanced)
|
||||||
|
attribute float a_pointRsrp; // point RSRP (instanced)
|
||||||
|
attribute float a_pointRadius; // point radius in pixels (instanced)
|
||||||
|
|
||||||
|
uniform mat4 u_matrix; // world to clip transform
|
||||||
|
|
||||||
|
varying vec2 v_localPos; // position relative to point center
|
||||||
|
varying float v_rsrp;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// Expand quad around point center
|
||||||
|
vec2 worldPos = a_pointCenter + a_position * a_pointRadius;
|
||||||
|
gl_Position = u_matrix * vec4(worldPos, 0.0, 1.0);
|
||||||
|
|
||||||
|
v_localPos = a_position; // -1 to 1
|
||||||
|
v_rsrp = a_pointRsrp;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fragment shader:**
|
||||||
|
```glsl
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
varying vec2 v_localPos;
|
||||||
|
varying float v_rsrp;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// Radial distance from center (0 at center, 1 at edge)
|
||||||
|
float dist = length(v_localPos);
|
||||||
|
|
||||||
|
// Discard outside circle
|
||||||
|
if (dist > 1.0) discard;
|
||||||
|
|
||||||
|
// Radial falloff (smooth at edges)
|
||||||
|
float weight = 1.0 - smoothstep(0.0, 1.0, dist);
|
||||||
|
// Or gaussian: weight = exp(-dist * dist * 2.0);
|
||||||
|
|
||||||
|
// Output: (weight * normalized_rsrp, weight)
|
||||||
|
float normalizedRsrp = (v_rsrp + 130.0) / 80.0; // -130 to -50 → 0 to 1
|
||||||
|
gl_FragColor = vec4(weight * normalizedRsrp, weight, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Крок 3: Final compositing shader
|
||||||
|
|
||||||
|
```glsl
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
uniform sampler2D u_accumTexture;
|
||||||
|
varying vec2 v_uv;
|
||||||
|
|
||||||
|
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() {
|
||||||
|
vec2 accum = texture2D(u_accumTexture, v_uv).rg;
|
||||||
|
|
||||||
|
float totalValue = accum.r;
|
||||||
|
float totalWeight = accum.g;
|
||||||
|
|
||||||
|
// No coverage
|
||||||
|
if (totalWeight < 0.001) discard;
|
||||||
|
|
||||||
|
// Weighted average RSRP
|
||||||
|
float avgRsrp = totalValue / totalWeight;
|
||||||
|
|
||||||
|
// Color mapping
|
||||||
|
vec3 color = rsrpToColor(avgRsrp);
|
||||||
|
|
||||||
|
// Alpha based on weight (fade at edges)
|
||||||
|
float alpha = smoothstep(0.0, 0.1, totalWeight) * 0.85;
|
||||||
|
|
||||||
|
gl_FragColor = vec4(color, alpha);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Крок 4: Rendering loop
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function render() {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const gl = glRef.current;
|
||||||
|
|
||||||
|
// 1. Position canvas over map
|
||||||
|
const nw = map.latLngToLayerPoint([bounds.maxLat, bounds.minLon]);
|
||||||
|
const se = map.latLngToLayerPoint([bounds.minLat, bounds.maxLon]);
|
||||||
|
canvas.style.transform = `translate(${nw.x}px, ${nw.y}px)`;
|
||||||
|
canvas.style.width = `${se.x - nw.x}px`;
|
||||||
|
canvas.style.height = `${se.y - nw.y}px`;
|
||||||
|
|
||||||
|
// 2. Clear accumulation buffer
|
||||||
|
gl.bindFramebuffer(gl.FRAMEBUFFER, accumFramebuffer);
|
||||||
|
gl.clearColor(0, 0, 0, 0);
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
|
// 3. Render points with additive blending
|
||||||
|
gl.useProgram(pointProgram);
|
||||||
|
gl.enable(gl.BLEND);
|
||||||
|
gl.blendFunc(gl.ONE, gl.ONE); // Additive
|
||||||
|
|
||||||
|
// Set uniforms (matrix, etc.)
|
||||||
|
const matrix = calculateWorldToClipMatrix(bounds, canvas.width, canvas.height);
|
||||||
|
gl.uniformMatrix4fv(u_matrix, false, matrix);
|
||||||
|
|
||||||
|
// Draw all points (instanced if supported, or batched)
|
||||||
|
drawPoints(gl, points);
|
||||||
|
|
||||||
|
// 4. Final composite pass
|
||||||
|
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||||
|
gl.useProgram(compositeProgram);
|
||||||
|
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); // Normal blend
|
||||||
|
|
||||||
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, accumTexture);
|
||||||
|
|
||||||
|
drawFullscreenQuad(gl);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Оптимізації
|
||||||
|
|
||||||
|
### 1. Instanced rendering (якщо підтримується)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const ext = gl.getExtension('ANGLE_instanced_arrays');
|
||||||
|
if (ext) {
|
||||||
|
// Use instanced rendering - draw all points in one call
|
||||||
|
ext.drawArraysInstancedANGLE(gl.TRIANGLE_STRIP, 0, 4, points.length);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Spatial culling
|
||||||
|
|
||||||
|
Малювати тільки точки що потрапляють у viewport:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const visiblePoints = points.filter(p => {
|
||||||
|
const screenPos = map.latLngToContainerPoint([p.lat, p.lon]);
|
||||||
|
return screenPos.x > -radius && screenPos.x < canvas.width + radius &&
|
||||||
|
screenPos.y > -radius && screenPos.y < canvas.height + radius;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Dynamic radius based on zoom
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const zoom = map.getZoom();
|
||||||
|
const metersPerPixel = 40075016.686 * Math.cos(centerLat * Math.PI / 180) / Math.pow(2, zoom + 8);
|
||||||
|
const radiusPixels = (settings.resolution * 1.5) / metersPerPixel;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Resolution scaling
|
||||||
|
|
||||||
|
На низьких zoom рівнях, рендерити в менший framebuffer і upscale:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const scale = zoom < 10 ? 0.5 : zoom < 12 ? 0.75 : 1.0;
|
||||||
|
const fbWidth = Math.round(canvas.width * scale);
|
||||||
|
const fbHeight = Math.round(canvas.height * scale);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Порівняння з поточним texture-based
|
||||||
|
|
||||||
|
| Аспект | Texture-based | Radial gradients |
|
||||||
|
|--------|---------------|------------------|
|
||||||
|
| Візуалізація | Blocky | Smooth |
|
||||||
|
| Terrain detail | Добре | Менш точно |
|
||||||
|
| Performance | Швидко (1 draw call) | Повільніше (N points) |
|
||||||
|
| Memory | Texture size | Framebuffer + points |
|
||||||
|
| Код складність | Середня | Висока |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Чеклист імплементації
|
||||||
|
|
||||||
|
### Phase 1: Basic setup
|
||||||
|
- [ ] Створити новий файл `WebGLRadialCoverageLayer.tsx`
|
||||||
|
- [ ] Setup WebGL context з float extensions
|
||||||
|
- [ ] Створити accumulation framebuffer
|
||||||
|
- [ ] Базовий vertex/fragment shader для точок
|
||||||
|
|
||||||
|
### Phase 2: Point rendering
|
||||||
|
- [ ] Implement point quad rendering
|
||||||
|
- [ ] Radial falloff function
|
||||||
|
- [ ] Additive blending
|
||||||
|
- [ ] Test з кількома точками
|
||||||
|
|
||||||
|
### Phase 3: Compositing
|
||||||
|
- [ ] Final pass shader
|
||||||
|
- [ ] Weighted average calculation
|
||||||
|
- [ ] Color mapping
|
||||||
|
- [ ] Alpha/transparency
|
||||||
|
|
||||||
|
### Phase 4: Integration
|
||||||
|
- [ ] Map positioning (як в поточному WebGL layer)
|
||||||
|
- [ ] Map event listeners (move/zoom)
|
||||||
|
- [ ] Opacity control
|
||||||
|
- [ ] Toggle в UI
|
||||||
|
|
||||||
|
### Phase 5: Optimization
|
||||||
|
- [ ] Instanced rendering
|
||||||
|
- [ ] Spatial culling
|
||||||
|
- [ ] Dynamic radius
|
||||||
|
- [ ] Resolution scaling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fallback
|
||||||
|
|
||||||
|
Якщо WebGL radial не працює (older GPU, missing extensions):
|
||||||
|
- Fallback до Canvas GeographicHeatmap
|
||||||
|
- Або до поточного texture-based WebGL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Референси
|
||||||
|
|
||||||
|
1. [Mapbox GL Heatmap implementation](https://github.com/mapbox/mapbox-gl-js/blob/main/src/render/draw_heatmap.js)
|
||||||
|
2. [deck.gl HeatmapLayer](https://deck.gl/docs/api-reference/aggregation-layers/heatmap-layer)
|
||||||
|
3. [WebGL additive blending](https://webglfundamentals.org/webgl/lessons/webgl-text-texture.html)
|
||||||
193
SESSION-2026-02-06-WebGL-Radial-Summary.md
Normal file
193
SESSION-2026-02-06-WebGL-Radial-Summary.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# RFCP v3.10.5 Session Summary - 2026-02-06
|
||||||
|
|
||||||
|
## Що зробили сьогодні
|
||||||
|
|
||||||
|
### 1. WebGL Texture-Based Coverage (ЗАВЕРШЕНО ✅)
|
||||||
|
|
||||||
|
**Проблема:** Canvas heatmap був blocky, хотіли smooth interpolation.
|
||||||
|
|
||||||
|
**Рішення:** Texture-based WebGL з smoothstep shader + nearest neighbor fill.
|
||||||
|
|
||||||
|
**Файл:** `frontend/src/components/map/WebGLCoverageLayer.tsx`
|
||||||
|
|
||||||
|
**Як працює:**
|
||||||
|
1. Створюємо texture де кожен pixel = RSRP value
|
||||||
|
2. Nearest neighbor fill для заповнення gaps (circular coverage → rectangular texture)
|
||||||
|
3. Smoothstep shader для C2 continuity interpolation
|
||||||
|
4. Colormap applied AFTER interpolation
|
||||||
|
|
||||||
|
**Статус:** Працює, але все ще blocky на zoom in через nearest neighbor fill.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. WebGL Radial Gradients Coverage (В ПРОЦЕСІ 🔄)
|
||||||
|
|
||||||
|
**Мета:** Красиві smooth gradients як Canvas heatmap, але GPU-accelerated.
|
||||||
|
|
||||||
|
**Файл:** `frontend/src/components/map/WebGLRadialCoverageLayer.tsx`
|
||||||
|
|
||||||
|
**Як працює:**
|
||||||
|
1. Кожна точка = quad з Gaussian radial falloff
|
||||||
|
2. Additive blending в float framebuffer: (weight × rsrp, weight)
|
||||||
|
3. Final composite pass: normalize (R/G) + colormap
|
||||||
|
|
||||||
|
**Поточний статус:**
|
||||||
|
- ✅ Framebuffer створюється правильно
|
||||||
|
- ✅ Points рендеряться (framebuffer має дані)
|
||||||
|
- ✅ Composite pass працює (final pixel має колір)
|
||||||
|
- ✅ 50m показує beautiful smooth gradients!
|
||||||
|
- ✅ 200m тепер теж показує (після radius fix)
|
||||||
|
- ⚠️ Coverage radius не повний (обрізається раніше ніж 10km)
|
||||||
|
- ⚠️ Темне коло на периферії (falloff занадто різкий?)
|
||||||
|
- ⚠️ Selector dropdown сірий на білому (CSS issue)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Coverage Renderer Selector (ЗАВЕРШЕНО ✅)
|
||||||
|
|
||||||
|
**Файл:** `frontend/src/store/settings.ts`
|
||||||
|
|
||||||
|
**Додано:** `coverageRenderer: 'radial' | 'texture' | 'canvas'`
|
||||||
|
|
||||||
|
**UI:** Dropdown в Coverage Settings panel
|
||||||
|
|
||||||
|
**Fallback chain:**
|
||||||
|
- Radial fails → Texture
|
||||||
|
- Texture fails → Canvas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Залишилось зробити (Next Session)
|
||||||
|
|
||||||
|
### Priority 1: Fix Radial Coverage Radius
|
||||||
|
|
||||||
|
**Симптом:** Coverage не покриває повні 10km, обрізається раніше.
|
||||||
|
|
||||||
|
**Можливі причини:**
|
||||||
|
1. Canvas bounds не включають padding для point radius
|
||||||
|
2. Points на краю мають gradient що виходить за canvas
|
||||||
|
3. Normalized coordinates calculation wrong at edges
|
||||||
|
|
||||||
|
**Debug:**
|
||||||
|
```javascript
|
||||||
|
// Перевірити bounds vs actual coverage extent
|
||||||
|
console.log('Canvas bounds:', bounds);
|
||||||
|
console.log('Points extent:', {
|
||||||
|
minLat: Math.min(...points.map(p => p.lat)),
|
||||||
|
maxLat: Math.max(...points.map(p => p.lat)),
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix approach:**
|
||||||
|
1. Додати padding до canvas bounds = point radius
|
||||||
|
2. Або clip points що виходять за межі
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Priority 2: Fix Dark Ring on Periphery
|
||||||
|
|
||||||
|
**Симптом:** Темне коло на краю coverage area.
|
||||||
|
|
||||||
|
**Причина:** Точки на периферії мають менше сусідів → менший total weight → темніший колір після normalization.
|
||||||
|
|
||||||
|
**Fix options:**
|
||||||
|
1. Збільшити radius multiplier (3.0× замість 2.5×)
|
||||||
|
2. Або додати edge detection і boost alpha там
|
||||||
|
3. Або використати min weight threshold перед normalization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Priority 3: Fix Selector Dropdown Styling
|
||||||
|
|
||||||
|
**Симптом:** Сірий текст на білому фоні (погано видно).
|
||||||
|
|
||||||
|
**Fix:** Update CSS classes в App.tsx для dropdown.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Priority 4: Performance Testing
|
||||||
|
|
||||||
|
Протестувати з великою кількістю точок:
|
||||||
|
- 10,000+ points
|
||||||
|
- 50,000+ points
|
||||||
|
- Measure frame time
|
||||||
|
|
||||||
|
Якщо повільно — implement instanced rendering.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Changed Today
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/components/map/
|
||||||
|
├── WebGLCoverageLayer.tsx # Texture-based (updated with NN fill)
|
||||||
|
├── WebGLRadialCoverageLayer.tsx # NEW - Radial gradients
|
||||||
|
└── GeographicHeatmap.tsx # Canvas fallback (unchanged)
|
||||||
|
|
||||||
|
frontend/src/store/
|
||||||
|
└── settings.ts # Added coverageRenderer option
|
||||||
|
|
||||||
|
frontend/src/
|
||||||
|
└── App.tsx # Integrated renderer selector
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Console Debug Commands
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Check which renderer is active
|
||||||
|
document.querySelectorAll('canvas').forEach(c =>
|
||||||
|
console.log(c.className, c.width, c.height)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check WebGL errors
|
||||||
|
const canvas = document.querySelector('.webgl-radial-coverage');
|
||||||
|
const gl = canvas?.getContext('webgl');
|
||||||
|
console.log('WebGL error:', gl?.getError());
|
||||||
|
|
||||||
|
// Read center pixel
|
||||||
|
gl?.readPixels(canvas.width/2, canvas.height/2, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(4));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Insights Learned
|
||||||
|
|
||||||
|
1. **Texture-based vs Radial:** Texture good for terrain detail accuracy, Radial good for beautiful visualization.
|
||||||
|
|
||||||
|
2. **Float framebuffer:** Need `EXT_color_buffer_float` extension. Fallback: use RGBA8 with encoding.
|
||||||
|
|
||||||
|
3. **Additive blending:** `gl.blendFunc(gl.ONE, gl.ONE)` for accumulation, then `gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)` for final composite.
|
||||||
|
|
||||||
|
4. **Weighted average in shader:** Store (weight × value, weight), then normalize: value = R / G.
|
||||||
|
|
||||||
|
5. **Radius scaling:** Higher resolution = more points = smaller radius. Lower resolution = fewer points = larger radius to compensate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Git Status
|
||||||
|
|
||||||
|
- ✅ Pushed working WebGL texture-based coverage
|
||||||
|
- 🔄 WebGL radial in progress (functional but incomplete)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Session Start Point
|
||||||
|
|
||||||
|
1. Відкрити RFCP project
|
||||||
|
2. `npm run dev` в frontend
|
||||||
|
3. Test radial coverage з 50m і 200m
|
||||||
|
4. Fix radius issue (Priority 1)
|
||||||
|
5. Fix dark ring (Priority 2)
|
||||||
|
6. Polish UI (Priority 3)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session Stats
|
||||||
|
|
||||||
|
- **Duration:** ~6 hours
|
||||||
|
- **Iterations:** 15+ fix attempts
|
||||||
|
- **Final result:** Working radial gradients renderer (90% complete)
|
||||||
|
- **Key breakthrough:** Discovering framebuffer had data but composite pass wasn't reading it
|
||||||
@@ -15,6 +15,7 @@ import { db } from '@/db/schema.ts';
|
|||||||
import MapView from '@/components/map/Map.tsx';
|
import MapView from '@/components/map/Map.tsx';
|
||||||
import GeographicHeatmap from '@/components/map/GeographicHeatmap.tsx';
|
import GeographicHeatmap from '@/components/map/GeographicHeatmap.tsx';
|
||||||
import WebGLCoverageLayer from '@/components/map/WebGLCoverageLayer.tsx';
|
import WebGLCoverageLayer from '@/components/map/WebGLCoverageLayer.tsx';
|
||||||
|
import WebGLRadialCoverageLayer from '@/components/map/WebGLRadialCoverageLayer.tsx';
|
||||||
import CoverageBoundary from '@/components/map/CoverageBoundary.tsx';
|
import CoverageBoundary from '@/components/map/CoverageBoundary.tsx';
|
||||||
import HeatmapLegend from '@/components/map/HeatmapLegend.tsx';
|
import HeatmapLegend from '@/components/map/HeatmapLegend.tsx';
|
||||||
import SiteList from '@/components/panels/SiteList.tsx';
|
import SiteList from '@/components/panels/SiteList.tsx';
|
||||||
@@ -126,8 +127,8 @@ export default function App() {
|
|||||||
const setShowElevationOverlay = useSettingsStore((s) => s.setShowElevationOverlay);
|
const setShowElevationOverlay = useSettingsStore((s) => s.setShowElevationOverlay);
|
||||||
const elevationOpacity = useSettingsStore((s) => s.elevationOpacity);
|
const elevationOpacity = useSettingsStore((s) => s.elevationOpacity);
|
||||||
const setElevationOpacity = useSettingsStore((s) => s.setElevationOpacity);
|
const setElevationOpacity = useSettingsStore((s) => s.setElevationOpacity);
|
||||||
const useWebGLCoverage = useSettingsStore((s) => s.useWebGLCoverage);
|
const coverageRenderer = useSettingsStore((s) => s.coverageRenderer);
|
||||||
const setUseWebGLCoverage = useSettingsStore((s) => s.setUseWebGLCoverage);
|
const setCoverageRenderer = useSettingsStore((s) => s.setCoverageRenderer);
|
||||||
|
|
||||||
// History (undo/redo)
|
// History (undo/redo)
|
||||||
const canUndo = useHistoryStore((s) => s.canUndo);
|
const canUndo = useHistoryStore((s) => s.canUndo);
|
||||||
@@ -698,8 +699,20 @@ export default function App() {
|
|||||||
{/* Show partial results during tiled calculation, or final result */}
|
{/* Show partial results during tiled calculation, or final result */}
|
||||||
{(coverageResult || (isCalculating && partialPoints.length > 0)) && (
|
{(coverageResult || (isCalculating && partialPoints.length > 0)) && (
|
||||||
<>
|
<>
|
||||||
{/* Only render ONE layer - WebGL or Canvas, never both */}
|
{/* Render coverage layer based on selected renderer */}
|
||||||
{useWebGLCoverage && (
|
{coverageRenderer === 'webgl-radial' && (
|
||||||
|
<WebGLRadialCoverageLayer
|
||||||
|
key="webgl-radial-coverage"
|
||||||
|
points={isCalculating && partialPoints.length > 0 ? partialPoints : (coverageResult?.points ?? [])}
|
||||||
|
visible={heatmapVisible}
|
||||||
|
opacity={settings.heatmapOpacity}
|
||||||
|
minRsrp={-130}
|
||||||
|
maxRsrp={-50}
|
||||||
|
radiusMeters={settings.heatmapRadius}
|
||||||
|
onWebGLFailed={() => setCoverageRenderer('webgl-texture')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{coverageRenderer === 'webgl-texture' && (
|
||||||
<WebGLCoverageLayer
|
<WebGLCoverageLayer
|
||||||
key="webgl-coverage"
|
key="webgl-coverage"
|
||||||
points={isCalculating && partialPoints.length > 0 ? partialPoints : (coverageResult?.points ?? [])}
|
points={isCalculating && partialPoints.length > 0 ? partialPoints : (coverageResult?.points ?? [])}
|
||||||
@@ -707,10 +720,10 @@ export default function App() {
|
|||||||
opacity={settings.heatmapOpacity}
|
opacity={settings.heatmapOpacity}
|
||||||
minRsrp={-130}
|
minRsrp={-130}
|
||||||
maxRsrp={-50}
|
maxRsrp={-50}
|
||||||
onWebGLFailed={() => setUseWebGLCoverage(false)}
|
onWebGLFailed={() => setCoverageRenderer('canvas')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!useWebGLCoverage && (
|
{coverageRenderer === 'canvas' && (
|
||||||
<GeographicHeatmap
|
<GeographicHeatmap
|
||||||
key="canvas-coverage"
|
key="canvas-coverage"
|
||||||
points={isCalculating && partialPoints.length > 0 ? partialPoints : (coverageResult?.points ?? [])}
|
points={isCalculating && partialPoints.length > 0 ? partialPoints : (coverageResult?.points ?? [])}
|
||||||
@@ -866,29 +879,24 @@ export default function App() {
|
|||||||
unit="%"
|
unit="%"
|
||||||
hint="Transparency of the RF coverage overlay"
|
hint="Transparency of the RF coverage overlay"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-gray-700 dark:text-dark-text">
|
<label className="text-sm font-medium text-gray-700 dark:text-dark-text">
|
||||||
Smooth Rendering
|
Coverage Renderer
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-gray-400 dark:text-dark-muted">
|
<p className="text-xs text-gray-400 dark:text-dark-muted mb-1">
|
||||||
WebGL interpolation for smooth gradients
|
Visualization style for coverage overlay
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<select
|
||||||
<button
|
value={coverageRenderer}
|
||||||
onClick={() => setUseWebGLCoverage(!useWebGLCoverage)}
|
onChange={(e) => setCoverageRenderer(e.target.value as 'webgl-radial' | 'webgl-texture' | 'canvas')}
|
||||||
className={`relative w-11 h-6 rounded-full transition-colors ${
|
className="w-full px-3 py-2 border border-gray-300 dark:border-dark-border rounded-lg bg-white dark:bg-dark-card text-sm dark:text-dark-text"
|
||||||
useWebGLCoverage ? 'bg-blue-500' : 'bg-gray-300 dark:bg-dark-border'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<span
|
<option value="webgl-radial">WebGL Radial (smooth)</option>
|
||||||
className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${
|
<option value="webgl-texture">WebGL Texture (fast)</option>
|
||||||
useWebGLCoverage ? 'translate-x-5' : ''
|
<option value="canvas">Canvas (fallback)</option>
|
||||||
}`}
|
</select>
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{!useWebGLCoverage && (
|
{coverageRenderer === 'canvas' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-gray-700 dark:text-dark-text">
|
<label className="text-sm font-medium text-gray-700 dark:text-dark-text">
|
||||||
Heatmap Quality
|
Heatmap Quality
|
||||||
|
|||||||
559
frontend/src/components/map/WebGLRadialCoverageLayer.tsx
Normal file
559
frontend/src/components/map/WebGLRadialCoverageLayer.tsx
Normal file
@@ -0,0 +1,559 @@
|
|||||||
|
/**
|
||||||
|
* WebGL Radial Gradients Coverage Layer
|
||||||
|
*
|
||||||
|
* Uses multi-pass additive blending to render smooth radial gradients
|
||||||
|
* around each coverage point, similar to Canvas GeographicHeatmap but GPU-accelerated.
|
||||||
|
*
|
||||||
|
* Approach:
|
||||||
|
* 1. Render each point as a quad with radial falloff (only when data changes)
|
||||||
|
* 2. Use additive blending to accumulate (weight * rsrp, weight)
|
||||||
|
* 3. Final pass: normalize and apply colormap (on every frame)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useMemo, useCallback } from 'react';
|
||||||
|
import { useMap } from 'react-leaflet';
|
||||||
|
|
||||||
|
// Logging: 0=off, 1=errors, 2=info, 3=debug
|
||||||
|
const LOG_LEVEL = 2;
|
||||||
|
const log = (level: number, ...args: unknown[]) => {
|
||||||
|
if (level <= LOG_LEVEL) console.log('[WebGL Radial]', ...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CoveragePoint {
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
rsrp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebGLRadialCoverageLayerProps {
|
||||||
|
points: CoveragePoint[];
|
||||||
|
opacity: number;
|
||||||
|
minRsrp?: number;
|
||||||
|
maxRsrp?: number;
|
||||||
|
visible: boolean;
|
||||||
|
radiusMeters?: number;
|
||||||
|
onWebGLFailed?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Point accumulation vertex shader
|
||||||
|
const POINT_VERTEX_SHADER = `
|
||||||
|
attribute vec2 a_position; // quad vertices (-1 to 1)
|
||||||
|
attribute vec2 a_pointPos; // point position in normalized coords
|
||||||
|
attribute float a_pointRsrp; // normalized RSRP (0-1)
|
||||||
|
attribute float a_pointRadius; // radius in normalized coords
|
||||||
|
|
||||||
|
varying vec2 v_localPos;
|
||||||
|
varying float v_rsrp;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// Expand quad around point center
|
||||||
|
vec2 pos = a_pointPos + a_position * a_pointRadius;
|
||||||
|
gl_Position = vec4(pos * 2.0 - 1.0, 0.0, 1.0); // Map 0-1 to clip space -1 to 1
|
||||||
|
|
||||||
|
v_localPos = a_position; // -1 to 1 within the quad
|
||||||
|
v_rsrp = a_pointRsrp;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Point accumulation fragment shader
|
||||||
|
const POINT_FRAGMENT_SHADER = `
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
varying vec2 v_localPos;
|
||||||
|
varying float v_rsrp;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// Radial distance from center (0 at center, 1 at edge)
|
||||||
|
float dist = length(v_localPos);
|
||||||
|
|
||||||
|
// Discard outside circle
|
||||||
|
if (dist > 1.0) discard;
|
||||||
|
|
||||||
|
// Radial falloff - softer gaussian for better edge coverage
|
||||||
|
// exp(-2) = 0.135 at edge vs exp(-3) = 0.05, giving more contribution from edge points
|
||||||
|
float weight = exp(-dist * dist * 2.0);
|
||||||
|
|
||||||
|
// Output: (weight * rsrp, weight, 0, 0)
|
||||||
|
// Using RG channels for accumulation
|
||||||
|
gl_FragColor = vec4(weight * v_rsrp, weight, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Final compositing vertex shader
|
||||||
|
const COMPOSITE_VERTEX_SHADER = `
|
||||||
|
attribute vec2 a_position;
|
||||||
|
varying vec2 v_uv;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
gl_Position = vec4(a_position, 0.0, 1.0);
|
||||||
|
v_uv = (a_position + 1.0) * 0.5;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Final compositing fragment shader
|
||||||
|
const COMPOSITE_FRAGMENT_SHADER = `
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
uniform sampler2D u_accumTexture;
|
||||||
|
uniform float u_opacity;
|
||||||
|
|
||||||
|
varying vec2 v_uv;
|
||||||
|
|
||||||
|
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() {
|
||||||
|
vec4 accum = texture2D(u_accumTexture, v_uv);
|
||||||
|
|
||||||
|
float totalValue = accum.r;
|
||||||
|
float totalWeight = accum.g;
|
||||||
|
|
||||||
|
// No coverage - discard if weight is truly zero
|
||||||
|
if (totalWeight < 0.0001) discard;
|
||||||
|
|
||||||
|
// Weighted average RSRP
|
||||||
|
float avgRsrp = clamp(totalValue / totalWeight, 0.0, 1.0);
|
||||||
|
|
||||||
|
// Color mapping
|
||||||
|
vec3 color = rsrpToColor(avgRsrp);
|
||||||
|
|
||||||
|
// Alpha based on weight (fade at edges)
|
||||||
|
float alpha = min(1.0, totalWeight * 0.1) * u_opacity;
|
||||||
|
|
||||||
|
gl_FragColor = vec4(color, alpha);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
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('[WebGL Radial] Shader error:', gl.getShaderInfoLog(shader));
|
||||||
|
gl.deleteShader(shader);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return shader;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProgram(gl: WebGLRenderingContext, vsSource: string, fsSource: string): WebGLProgram | null {
|
||||||
|
const vs = compileShader(gl, vsSource, gl.VERTEX_SHADER);
|
||||||
|
const fs = compileShader(gl, fsSource, 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('[WebGL Radial] Program error:', gl.getProgramInfoLog(program));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up shaders after linking
|
||||||
|
gl.deleteShader(vs);
|
||||||
|
gl.deleteShader(fs);
|
||||||
|
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Bounds {
|
||||||
|
minLat: number;
|
||||||
|
maxLat: number;
|
||||||
|
minLon: number;
|
||||||
|
maxLon: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WebGLRadialCoverageLayer({
|
||||||
|
points,
|
||||||
|
opacity,
|
||||||
|
minRsrp = -130,
|
||||||
|
maxRsrp = -50,
|
||||||
|
visible,
|
||||||
|
radiusMeters = 400,
|
||||||
|
onWebGLFailed,
|
||||||
|
}: WebGLRadialCoverageLayerProps) {
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
// Refs for WebGL resources
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const glRef = useRef<WebGLRenderingContext | null>(null);
|
||||||
|
const pointProgramRef = useRef<WebGLProgram | null>(null);
|
||||||
|
const compositeProgramRef = useRef<WebGLProgram | null>(null);
|
||||||
|
const accumTextureRef = useRef<WebGLTexture | null>(null);
|
||||||
|
const framebufferRef = useRef<WebGLFramebuffer | null>(null);
|
||||||
|
const quadBufferRef = useRef<WebGLBuffer | null>(null);
|
||||||
|
const pointBufferRef = useRef<WebGLBuffer | null>(null);
|
||||||
|
const boundsRef = useRef<Bounds | null>(null);
|
||||||
|
const initializedRef = useRef(false);
|
||||||
|
const lastPointsHashRef = useRef<string>('');
|
||||||
|
|
||||||
|
// Track if points need to be re-rendered (expensive pass)
|
||||||
|
const needsPointRenderRef = useRef(true);
|
||||||
|
|
||||||
|
// Stable ref for callback
|
||||||
|
const onWebGLFailedRef = useRef(onWebGLFailed);
|
||||||
|
onWebGLFailedRef.current = onWebGLFailed;
|
||||||
|
|
||||||
|
// Track framebuffer size
|
||||||
|
const fbSizeRef = useRef<{ width: number; height: number }>({ width: 0, height: 0 });
|
||||||
|
|
||||||
|
// Compute points hash for change detection
|
||||||
|
const pointsHash = useMemo(() => {
|
||||||
|
if (points.length === 0) return 'empty';
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// Calculate bounds from points
|
||||||
|
const calculateBounds = useCallback((pts: CoveragePoint[]): Bounds | null => {
|
||||||
|
if (pts.length === 0) return null;
|
||||||
|
|
||||||
|
let minLat = Infinity, maxLat = -Infinity;
|
||||||
|
let minLon = Infinity, maxLon = -Infinity;
|
||||||
|
|
||||||
|
for (const p of pts) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Padding needs to accommodate the radial gradient of edge points
|
||||||
|
// Each point's gradient extends beyond its center, use 12% of range as padding
|
||||||
|
const latRangeRaw = maxLat - minLat;
|
||||||
|
const lonRangeRaw = maxLon - minLon;
|
||||||
|
const latPaddingGradient = latRangeRaw * 0.12;
|
||||||
|
const lonPaddingGradient = lonRangeRaw * 0.12;
|
||||||
|
const latPaddingRadius = radiusMeters / 111000;
|
||||||
|
const lonPaddingRadius = radiusMeters / (111000 * Math.cos((minLat + maxLat) / 2 * Math.PI / 180));
|
||||||
|
|
||||||
|
const latPadding = Math.max(latPaddingGradient, latPaddingRadius);
|
||||||
|
const lonPadding = Math.max(lonPaddingGradient, lonPaddingRadius);
|
||||||
|
|
||||||
|
log(2, 'Bounds padding:', { latPadding: latPadding.toFixed(5), lonPadding: lonPadding.toFixed(5) });
|
||||||
|
|
||||||
|
return {
|
||||||
|
minLat: minLat - latPadding,
|
||||||
|
maxLat: maxLat + latPadding,
|
||||||
|
minLon: minLon - lonPadding,
|
||||||
|
maxLon: maxLon + lonPadding,
|
||||||
|
};
|
||||||
|
}, [radiusMeters]);
|
||||||
|
|
||||||
|
// Render function - split into point accumulation (expensive) and composite (cheap)
|
||||||
|
const render = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const gl = glRef.current;
|
||||||
|
const pointProgram = pointProgramRef.current;
|
||||||
|
const compositeProgram = compositeProgramRef.current;
|
||||||
|
const framebuffer = framebufferRef.current;
|
||||||
|
const accumTexture = accumTextureRef.current;
|
||||||
|
const quadBuffer = quadBufferRef.current;
|
||||||
|
const bounds = boundsRef.current;
|
||||||
|
|
||||||
|
if (!canvas || !gl || !pointProgram || !compositeProgram || !framebuffer ||
|
||||||
|
!accumTexture || !quadBuffer || !bounds) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(3, 'render() points:', points.length, 'needsPointRender:', needsPointRenderRef.current);
|
||||||
|
|
||||||
|
// 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`;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Resize canvas and framebuffer if needed (with tolerance to avoid subpixel jitter)
|
||||||
|
const needsResize = Math.abs(canvas.width - canvasW) > 2 || Math.abs(canvas.height - canvasH) > 2;
|
||||||
|
if (needsResize) {
|
||||||
|
canvas.width = canvasW;
|
||||||
|
canvas.height = canvasH;
|
||||||
|
|
||||||
|
// Resize accumulation texture
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, accumTexture);
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, canvasW, canvasH, 0, gl.RGBA, gl.FLOAT, null);
|
||||||
|
|
||||||
|
fbSizeRef.current = { width: canvasW, height: canvasH };
|
||||||
|
needsPointRenderRef.current = true; // Must re-render points after resize
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Pass 1: Accumulate points into framebuffer (only when needed) ===
|
||||||
|
if (needsPointRenderRef.current) {
|
||||||
|
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
|
||||||
|
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||||
|
gl.clearColor(0, 0, 0, 0);
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
|
gl.useProgram(pointProgram);
|
||||||
|
gl.enable(gl.BLEND);
|
||||||
|
gl.blendFunc(gl.ONE, gl.ONE); // Additive blending
|
||||||
|
|
||||||
|
// Bind quad buffer for point rendering
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
|
||||||
|
const posLoc = gl.getAttribLocation(pointProgram, 'a_position');
|
||||||
|
gl.enableVertexAttribArray(posLoc);
|
||||||
|
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
// Get attribute locations for point data
|
||||||
|
const pointPosLoc = gl.getAttribLocation(pointProgram, 'a_pointPos');
|
||||||
|
const pointRsrpLoc = gl.getAttribLocation(pointProgram, 'a_pointRsrp');
|
||||||
|
const pointRadiusLoc = gl.getAttribLocation(pointProgram, 'a_pointRadius');
|
||||||
|
|
||||||
|
// Calculate radius in normalized coords
|
||||||
|
const latRange = bounds.maxLat - bounds.minLat;
|
||||||
|
const lonRange = bounds.maxLon - bounds.minLon;
|
||||||
|
|
||||||
|
// Calculate radius: ensure smooth overlap between adjacent points
|
||||||
|
const gridDim = Math.sqrt(points.length);
|
||||||
|
const avgCellLat = latRange / gridDim;
|
||||||
|
const avgCellLon = lonRange / gridDim;
|
||||||
|
|
||||||
|
// For smooth coverage we need each point's gradient to reach ~2 cells in every direction
|
||||||
|
// Denser grids (more points) need relatively larger multiplier because edge effects matter more
|
||||||
|
const baseMultiplier = 3.5;
|
||||||
|
const densityBoost = Math.max(1.0, gridDim / 50); // 1.0 at 50pts, 1.6 at 80pts
|
||||||
|
const radiusMultiplier = baseMultiplier * densityBoost;
|
||||||
|
|
||||||
|
const normalizedRadiusLat = (avgCellLat * radiusMultiplier) / latRange;
|
||||||
|
const normalizedRadiusLon = (avgCellLon * radiusMultiplier) / lonRange;
|
||||||
|
const normalizedRadius = Math.max(normalizedRadiusLat, normalizedRadiusLon);
|
||||||
|
|
||||||
|
log(2, 'Grid estimate:', { points: points.length, gridDim: gridDim.toFixed(1), densityBoost: densityBoost.toFixed(2), radiusMultiplier: radiusMultiplier.toFixed(1), normalizedRadius: normalizedRadius.toFixed(4) });
|
||||||
|
|
||||||
|
// Draw each point as a quad
|
||||||
|
const rsrpRange = maxRsrp - minRsrp;
|
||||||
|
for (const p of points) {
|
||||||
|
const normX = (p.lon - bounds.minLon) / lonRange;
|
||||||
|
const normY = (p.lat - bounds.minLat) / latRange;
|
||||||
|
const normRsrp = Math.max(0, Math.min(1, (p.rsrp - minRsrp) / rsrpRange));
|
||||||
|
|
||||||
|
gl.vertexAttrib2f(pointPosLoc, normX, normY);
|
||||||
|
gl.vertexAttrib1f(pointRsrpLoc, normRsrp);
|
||||||
|
gl.vertexAttrib1f(pointRadiusLoc, normalizedRadius);
|
||||||
|
|
||||||
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.disableVertexAttribArray(posLoc);
|
||||||
|
needsPointRenderRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Pass 2: Composite to screen (always runs) ===
|
||||||
|
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||||
|
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||||
|
gl.clearColor(0, 0, 0, 0);
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
|
gl.useProgram(compositeProgram);
|
||||||
|
gl.enable(gl.BLEND);
|
||||||
|
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); // Normal blending
|
||||||
|
|
||||||
|
// Bind quad buffer
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
|
||||||
|
const compositePos = gl.getAttribLocation(compositeProgram, 'a_position');
|
||||||
|
gl.enableVertexAttribArray(compositePos);
|
||||||
|
gl.vertexAttribPointer(compositePos, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
// Bind accumulation texture
|
||||||
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, accumTexture);
|
||||||
|
gl.uniform1i(gl.getUniformLocation(compositeProgram, 'u_accumTexture'), 0);
|
||||||
|
gl.uniform1f(gl.getUniformLocation(compositeProgram, 'u_opacity'), opacity);
|
||||||
|
|
||||||
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||||
|
gl.disableVertexAttribArray(compositePos);
|
||||||
|
|
||||||
|
}, [map, points, minRsrp, maxRsrp, opacity]);
|
||||||
|
|
||||||
|
// Effect 1: Initialize WebGL
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
if (initializedRef.current && canvasRef.current && glRef.current) return;
|
||||||
|
|
||||||
|
const pane = map.getPane('overlayPane');
|
||||||
|
if (!pane) return;
|
||||||
|
|
||||||
|
// Remove any leftover canvas
|
||||||
|
const existing = pane.querySelectorAll('canvas.webgl-radial-coverage');
|
||||||
|
existing.forEach(c => c.remove());
|
||||||
|
|
||||||
|
// Create canvas
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.className = 'webgl-radial-coverage';
|
||||||
|
canvas.style.position = 'absolute';
|
||||||
|
canvas.style.pointerEvents = 'none';
|
||||||
|
canvas.style.transformOrigin = '0 0';
|
||||||
|
pane.appendChild(canvas);
|
||||||
|
canvasRef.current = canvas;
|
||||||
|
|
||||||
|
// Initialize WebGL
|
||||||
|
const gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: false });
|
||||||
|
if (!gl) {
|
||||||
|
console.error('[WebGL Radial] WebGL not available');
|
||||||
|
onWebGLFailedRef.current?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
glRef.current = gl;
|
||||||
|
|
||||||
|
// Check for float texture support
|
||||||
|
const floatExt = gl.getExtension('OES_texture_float');
|
||||||
|
gl.getExtension('OES_texture_float_linear'); // Enable if available
|
||||||
|
if (!floatExt) {
|
||||||
|
console.error('[WebGL Radial] OES_texture_float not supported');
|
||||||
|
onWebGLFailedRef.current?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.enable(gl.BLEND);
|
||||||
|
|
||||||
|
// Create point program
|
||||||
|
const pointProgram = createProgram(gl, POINT_VERTEX_SHADER, POINT_FRAGMENT_SHADER);
|
||||||
|
if (!pointProgram) {
|
||||||
|
console.error('[WebGL Radial] Failed to create point program');
|
||||||
|
onWebGLFailedRef.current?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pointProgramRef.current = pointProgram;
|
||||||
|
|
||||||
|
// Create composite program
|
||||||
|
const compositeProgram = createProgram(gl, COMPOSITE_VERTEX_SHADER, COMPOSITE_FRAGMENT_SHADER);
|
||||||
|
if (!compositeProgram) {
|
||||||
|
console.error('[WebGL Radial] Failed to create composite program');
|
||||||
|
onWebGLFailedRef.current?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
compositeProgramRef.current = compositeProgram;
|
||||||
|
|
||||||
|
// Create quad buffer (fullscreen quad)
|
||||||
|
const quadBuffer = gl.createBuffer();
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
||||||
|
-1, -1,
|
||||||
|
1, -1,
|
||||||
|
-1, 1,
|
||||||
|
1, 1,
|
||||||
|
]), gl.STATIC_DRAW);
|
||||||
|
quadBufferRef.current = quadBuffer;
|
||||||
|
|
||||||
|
// Create point buffer (will be filled per-point for now, TODO: instancing)
|
||||||
|
const pointBuffer = gl.createBuffer();
|
||||||
|
pointBufferRef.current = pointBuffer;
|
||||||
|
|
||||||
|
// Create accumulation texture (float RGBA)
|
||||||
|
// Use NEAREST filtering - float textures require OES_texture_float_linear for LINEAR
|
||||||
|
const accumTexture = gl.createTexture();
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, accumTexture);
|
||||||
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.FLOAT, null);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||||
|
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);
|
||||||
|
accumTextureRef.current = accumTexture;
|
||||||
|
|
||||||
|
// Create framebuffer
|
||||||
|
const framebuffer = gl.createFramebuffer();
|
||||||
|
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
|
||||||
|
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, accumTexture, 0);
|
||||||
|
framebufferRef.current = framebuffer;
|
||||||
|
|
||||||
|
// Check framebuffer status
|
||||||
|
const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
|
||||||
|
if (status !== gl.FRAMEBUFFER_COMPLETE) {
|
||||||
|
console.error('[WebGL Radial] Framebuffer not complete:', status);
|
||||||
|
onWebGLFailedRef.current?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||||
|
|
||||||
|
initializedRef.current = true;
|
||||||
|
|
||||||
|
}, [visible, map]);
|
||||||
|
|
||||||
|
// Effect 2: Update bounds when points change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || points.length === 0) return;
|
||||||
|
if (pointsHash === lastPointsHashRef.current) return;
|
||||||
|
|
||||||
|
const bounds = calculateBounds(points);
|
||||||
|
if (!bounds) return;
|
||||||
|
|
||||||
|
boundsRef.current = bounds;
|
||||||
|
lastPointsHashRef.current = pointsHash;
|
||||||
|
needsPointRenderRef.current = true; // Mark for point re-render
|
||||||
|
|
||||||
|
render();
|
||||||
|
}, [visible, points, pointsHash, calculateBounds, render]);
|
||||||
|
|
||||||
|
// Effect 3: Map event listeners
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
|
||||||
|
let frameId = 0;
|
||||||
|
const onMapChange = () => {
|
||||||
|
cancelAnimationFrame(frameId);
|
||||||
|
frameId = requestAnimationFrame(render);
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on('move', onMapChange);
|
||||||
|
map.on('zoom', onMapChange);
|
||||||
|
map.on('resize', onMapChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off('move', onMapChange);
|
||||||
|
map.off('zoom', onMapChange);
|
||||||
|
map.off('resize', onMapChange);
|
||||||
|
cancelAnimationFrame(frameId);
|
||||||
|
};
|
||||||
|
}, [visible, map, render]);
|
||||||
|
|
||||||
|
// Effect 4: Visibility toggle
|
||||||
|
useEffect(() => {
|
||||||
|
if (canvasRef.current) {
|
||||||
|
canvasRef.current.style.display = visible ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
const gl = glRef.current;
|
||||||
|
if (gl) {
|
||||||
|
if (accumTextureRef.current) gl.deleteTexture(accumTextureRef.current);
|
||||||
|
if (framebufferRef.current) gl.deleteFramebuffer(framebufferRef.current);
|
||||||
|
if (quadBufferRef.current) gl.deleteBuffer(quadBufferRef.current);
|
||||||
|
if (pointBufferRef.current) gl.deleteBuffer(pointBufferRef.current);
|
||||||
|
if (pointProgramRef.current) gl.deleteProgram(pointProgramRef.current);
|
||||||
|
if (compositeProgramRef.current) gl.deleteProgram(compositeProgramRef.current);
|
||||||
|
}
|
||||||
|
if (canvasRef.current) {
|
||||||
|
canvasRef.current.remove();
|
||||||
|
canvasRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { persist } from 'zustand/middleware';
|
|||||||
|
|
||||||
type Theme = 'light' | 'dark' | 'system';
|
type Theme = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
type CoverageRenderer = 'webgl-texture' | 'webgl-radial' | 'canvas';
|
||||||
|
|
||||||
interface SettingsState {
|
interface SettingsState {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
showTerrain: boolean;
|
showTerrain: boolean;
|
||||||
@@ -14,6 +16,7 @@ interface SettingsState {
|
|||||||
showElevationOverlay: boolean;
|
showElevationOverlay: boolean;
|
||||||
elevationOpacity: number;
|
elevationOpacity: number;
|
||||||
useWebGLCoverage: boolean;
|
useWebGLCoverage: boolean;
|
||||||
|
coverageRenderer: CoverageRenderer;
|
||||||
setTheme: (theme: Theme) => void;
|
setTheme: (theme: Theme) => void;
|
||||||
setShowBoundary: (show: boolean) => void;
|
setShowBoundary: (show: boolean) => void;
|
||||||
setShowTerrain: (show: boolean) => void;
|
setShowTerrain: (show: boolean) => void;
|
||||||
@@ -24,6 +27,7 @@ interface SettingsState {
|
|||||||
setShowElevationOverlay: (show: boolean) => void;
|
setShowElevationOverlay: (show: boolean) => void;
|
||||||
setElevationOpacity: (opacity: number) => void;
|
setElevationOpacity: (opacity: number) => void;
|
||||||
setUseWebGLCoverage: (use: boolean) => void;
|
setUseWebGLCoverage: (use: boolean) => void;
|
||||||
|
setCoverageRenderer: (renderer: CoverageRenderer) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTheme(theme: Theme) {
|
function applyTheme(theme: Theme) {
|
||||||
@@ -50,6 +54,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
showElevationOverlay: false,
|
showElevationOverlay: false,
|
||||||
elevationOpacity: 0.5,
|
elevationOpacity: 0.5,
|
||||||
useWebGLCoverage: true, // Default to WebGL smooth rendering
|
useWebGLCoverage: true, // Default to WebGL smooth rendering
|
||||||
|
coverageRenderer: 'webgl-radial' as CoverageRenderer, // Default to radial gradients
|
||||||
setTheme: (theme: Theme) => {
|
setTheme: (theme: Theme) => {
|
||||||
set({ theme });
|
set({ theme });
|
||||||
applyTheme(theme);
|
applyTheme(theme);
|
||||||
@@ -63,16 +68,21 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
setShowElevationOverlay: (show: boolean) => set({ showElevationOverlay: show }),
|
setShowElevationOverlay: (show: boolean) => set({ showElevationOverlay: show }),
|
||||||
setElevationOpacity: (opacity: number) => set({ elevationOpacity: opacity }),
|
setElevationOpacity: (opacity: number) => set({ elevationOpacity: opacity }),
|
||||||
setUseWebGLCoverage: (use: boolean) => set({ useWebGLCoverage: use }),
|
setUseWebGLCoverage: (use: boolean) => set({ useWebGLCoverage: use }),
|
||||||
|
setCoverageRenderer: (renderer: CoverageRenderer) => set({ coverageRenderer: renderer }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'rfcp-settings',
|
name: 'rfcp-settings',
|
||||||
version: 2, // Bump version to reset useWebGLCoverage to true
|
version: 3, // v3: Add coverageRenderer setting
|
||||||
migrate: (persistedState: unknown, version: number) => {
|
migrate: (persistedState: unknown, version: number) => {
|
||||||
const state = persistedState as Partial<SettingsState>;
|
const state = persistedState as Partial<SettingsState>;
|
||||||
if (version < 2) {
|
if (version < 2) {
|
||||||
// v2: Reset useWebGLCoverage to true (was stuck on false from early WebGL failures)
|
// v2: Reset useWebGLCoverage to true (was stuck on false from early WebGL failures)
|
||||||
state.useWebGLCoverage = true;
|
state.useWebGLCoverage = true;
|
||||||
}
|
}
|
||||||
|
if (version < 3) {
|
||||||
|
// v3: Add coverageRenderer, default to radial
|
||||||
|
state.coverageRenderer = 'webgl-radial';
|
||||||
|
}
|
||||||
return state as SettingsState;
|
return state as SettingsState;
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user