@mytec: stack done, rust next

This commit is contained in:
2026-02-07 12:56:25 +02:00
parent 1d8375af02
commit 833dead43c
15 changed files with 1609 additions and 141 deletions

View File

@@ -0,0 +1,463 @@
# RFCP — Iteration 3.10: Link Budget, Fresnel Zone & Interference Modeling
## Overview
Add three interconnected RF analysis features: link budget calculator panel, Fresnel zone visualization on terrain profiles, and basic interference (C/I) modeling for multi-site scenarios. These build on existing infrastructure — propagation models, terrain profiles, and multi-site coverage.
## Priority Order
1. Link Budget Calculator (simplest, standalone UI)
2. Fresnel Zone Visualization (extends terrain profile)
3. Interference Modeling (extends coverage engine)
---
## Feature 1: Link Budget Calculator
### Description
A panel/dialog that shows the complete RF link budget as a table — from transmitter to receiver. Uses existing propagation model values but presents them in the standard telecom link budget format.
### Implementation
**New component:** `frontend/src/components/panels/LinkBudgetPanel.tsx`
The panel should display a table with rows for each element in the link chain. It should use the currently selected site's parameters and a configurable receiver point (either clicked on map or manually entered coordinates).
**Link Budget Table Structure:**
```
TRANSMITTER
Tx Power (dBm) [from site config, e.g. 43 dBm]
Tx Antenna Gain (dBi) [from site config, e.g. 18 dBi]
Tx Cable/Connector Loss (dB) [new field, default 2 dB]
EIRP (dBm) = Tx Power + Gain - Cable Loss
PATH
Distance (km) [calculated from Tx to Rx point]
Free Space Path Loss (dB) [existing formula: 20log(d) + 20log(f) + 32.45]
Terrain Diffraction Loss (dB) [from terrain_los model if available]
Vegetation Loss (dB) [from vegetation model if available]
Atmospheric Loss (dB) [from atmospheric model if available]
Total Path Loss (dB) = sum of all path losses
RECEIVER
Rx Antenna Gain (dBi) [configurable, default 0 dBi for handset]
Rx Cable Loss (dB) [configurable, default 0 dB]
Rx Sensitivity (dBm) [configurable, default -100 dBm]
RESULT
Received Power (dBm) = EIRP - Total Path Loss + Rx Gain - Rx Cable
Link Margin (dB) = Received Power - Rx Sensitivity
Status = "OK" if margin > 0, "FAIL" if < 0
```
**Backend addition:** Add a new endpoint or extend existing coverage API.
**File:** `backend/app/api/routes/coverage.py` (or new `link_budget.py`)
```python
@router.post("/api/link-budget")
async def calculate_link_budget(request: dict):
"""Calculate point-to-point link budget.
Body: {
"site_id": "...", # or tx_lat/tx_lon/tx_params
"tx_lat": 48.46,
"tx_lon": 35.04,
"tx_power_dbm": 43,
"tx_gain_dbi": 18,
"tx_cable_loss_db": 2,
"tx_height_m": 30,
"rx_lat": 48.50,
"rx_lon": 35.10,
"rx_gain_dbi": 0,
"rx_cable_loss_db": 0,
"rx_sensitivity_dbm": -100,
"rx_height_m": 1.5,
"frequency_mhz": 1800
}
"""
from app.services.terrain_service import terrain_service
# Calculate distance
distance_m = terrain_service.haversine_distance(
request["tx_lat"], request["tx_lon"],
request["rx_lat"], request["rx_lon"]
)
distance_km = distance_m / 1000
# Get elevations
tx_elev = await terrain_service.get_elevation(request["tx_lat"], request["tx_lon"])
rx_elev = await terrain_service.get_elevation(request["rx_lat"], request["rx_lon"])
# EIRP
eirp_dbm = request["tx_power_dbm"] + request["tx_gain_dbi"] - request["tx_cable_loss_db"]
# Free space path loss
freq = request["frequency_mhz"]
fspl_db = 20 * math.log10(distance_km) + 20 * math.log10(freq) + 32.45 if distance_km > 0 else 0
# Terrain profile for LOS check
profile = await terrain_service.get_elevation_profile(
request["tx_lat"], request["tx_lon"],
request["rx_lat"], request["rx_lon"],
num_points=100
)
# Simple LOS check - does terrain block line of sight?
tx_total_height = tx_elev + request.get("tx_height_m", 30)
rx_total_height = rx_elev + request.get("rx_height_m", 1.5)
terrain_loss_db = 0
los_clear = True
for i, point in enumerate(profile):
if i == 0 or i == len(profile) - 1:
continue
# Linear interpolation of LOS line at this point
fraction = i / (len(profile) - 1)
los_height = tx_total_height + fraction * (rx_total_height - tx_total_height)
if point["elevation"] > los_height:
los_clear = False
# Simple knife-edge diffraction estimate
terrain_loss_db += 6 # ~6dB per obstruction (simplified)
total_path_loss = fspl_db + terrain_loss_db
# Received power
rx_power_dbm = eirp_dbm - total_path_loss + request["rx_gain_dbi"] - request["rx_cable_loss_db"]
# Link margin
margin_db = rx_power_dbm - request["rx_sensitivity_dbm"]
return {
"distance_km": round(distance_km, 2),
"distance_m": round(distance_m, 1),
"tx_elevation_m": round(tx_elev, 1),
"rx_elevation_m": round(rx_elev, 1),
"eirp_dbm": round(eirp_dbm, 1),
"fspl_db": round(fspl_db, 1),
"terrain_loss_db": round(terrain_loss_db, 1),
"total_path_loss_db": round(total_path_loss, 1),
"los_clear": los_clear,
"rx_power_dbm": round(rx_power_dbm, 1),
"margin_db": round(margin_db, 1),
"status": "OK" if margin_db >= 0 else "FAIL",
"profile": profile,
}
```
### UI Requirements
- New panel accessible from sidebar or toolbar button (calculator icon)
- Click on map to set Rx point (with crosshair cursor)
- Auto-populates Tx params from selected site
- Shows result table with color coding (green margin = OK, red = FAIL)
- Optionally draws line on map from Tx to Rx
---
## Feature 2: Fresnel Zone Visualization
### Description
Draw Fresnel zone ellipse overlay on the Terrain Profile chart, showing where terrain intrudes into the first Fresnel zone. This is critical for understanding if a radio link will actually work — even if terrain doesn't block direct LOS, Fresnel zone obstruction causes significant signal loss.
### Implementation
**Modify:** The existing Terrain Profile component/chart
**Fresnel Zone Radius Formula:**
```python
import math
def fresnel_radius(n: int, frequency_mhz: float, d1_m: float, d2_m: float) -> float:
"""Calculate nth Fresnel zone radius at a point along the path.
Args:
n: Fresnel zone number (1 = first zone, most important)
frequency_mhz: Frequency in MHz
d1_m: Distance from transmitter to this point (meters)
d2_m: Distance from this point to receiver (meters)
Returns:
Radius of nth Fresnel zone in meters
"""
wavelength = 300.0 / frequency_mhz # meters
d_total = d1_m + d2_m
if d_total == 0:
return 0
radius = math.sqrt((n * wavelength * d1_m * d2_m) / d_total)
return radius
```
**Backend endpoint:** `backend/app/api/routes/coverage.py`
```python
@router.post("/api/fresnel-profile")
async def fresnel_profile(request: dict):
"""Calculate terrain profile with Fresnel zone boundaries.
Body: {
"tx_lat": 48.46, "tx_lon": 35.04, "tx_height_m": 30,
"rx_lat": 48.50, "rx_lon": 35.10, "rx_height_m": 1.5,
"frequency_mhz": 1800,
"num_points": 100
}
"""
from app.services.terrain_service import terrain_service
tx_lat, tx_lon = request["tx_lat"], request["tx_lon"]
rx_lat, rx_lon = request["rx_lat"], request["rx_lon"]
tx_height = request.get("tx_height_m", 30)
rx_height = request.get("rx_height_m", 1.5)
freq = request.get("frequency_mhz", 1800)
num_points = request.get("num_points", 100)
# Get terrain profile
profile = await terrain_service.get_elevation_profile(
tx_lat, tx_lon, rx_lat, rx_lon, num_points
)
total_distance = profile[-1]["distance"] if profile else 0
# Get endpoint elevations
tx_elev = profile[0]["elevation"] if profile else 0
rx_elev = profile[-1]["elevation"] if profile else 0
tx_total = tx_elev + tx_height
rx_total = rx_elev + rx_height
wavelength = 300.0 / freq # meters
# Calculate Fresnel zone at each profile point
fresnel_data = []
los_blocked = False
fresnel_blocked = False
worst_clearance = float('inf')
for i, point in enumerate(profile):
d1 = point["distance"] # distance from tx
d2 = total_distance - d1 # distance to rx
# LOS height at this point (linear interpolation)
if total_distance > 0:
fraction = d1 / total_distance
else:
fraction = 0
los_height = tx_total + fraction * (rx_total - tx_total)
# First Fresnel zone radius
if d1 > 0 and d2 > 0 and total_distance > 0:
f1_radius = math.sqrt((1 * wavelength * d1 * d2) / total_distance)
else:
f1_radius = 0
# Fresnel zone boundaries (height above sea level)
fresnel_top = los_height + f1_radius
fresnel_bottom = los_height - f1_radius
# Clearance: how much space between terrain and Fresnel bottom
clearance = fresnel_bottom - point["elevation"]
if clearance < worst_clearance:
worst_clearance = clearance
if point["elevation"] > los_height:
los_blocked = True
if point["elevation"] > fresnel_bottom:
fresnel_blocked = True
fresnel_data.append({
"distance": point["distance"],
"lat": point["lat"],
"lon": point["lon"],
"terrain_elevation": point["elevation"],
"los_height": round(los_height, 1),
"fresnel_top": round(fresnel_top, 1),
"fresnel_bottom": round(fresnel_bottom, 1),
"f1_radius": round(f1_radius, 1),
"clearance": round(clearance, 1),
})
return {
"profile": fresnel_data,
"total_distance_m": round(total_distance, 1),
"tx_elevation": round(tx_elev, 1),
"rx_elevation": round(rx_elev, 1),
"frequency_mhz": freq,
"wavelength_m": round(wavelength, 4),
"los_clear": not los_blocked,
"fresnel_clear": not fresnel_blocked,
"worst_clearance_m": round(worst_clearance, 1),
"recommendation": (
"Clear — excellent link" if not fresnel_blocked
else "Fresnel zone partially blocked — expect 3-6 dB additional loss"
if not los_blocked
else "LOS blocked — significant diffraction loss expected"
),
}
```
### Frontend Visualization
On the existing Terrain Profile chart:
- Draw the LOS line (straight line from Tx to Rx) — this may already exist
- Draw first Fresnel zone as a **semi-transparent elliptical area** around the LOS line
- Upper boundary = `fresnel_top` series
- Lower boundary = `fresnel_bottom` series
- Color: light blue with ~20% opacity
- Where terrain intersects Fresnel zone, highlight in red/orange
- Show clearance info in the profile tooltip
- Add a summary badge: "LOS Clear ✓" / "Fresnel 60% Clear ⚠" / "LOS Blocked ✗"
---
## Feature 3: Interference Modeling (C/I Ratio)
### Description
Add carrier-to-interference ratio calculation to the coverage engine. For each grid point, calculate the C/I ratio: the signal from the serving cell vs the sum of signals from all other cells on the same frequency. Display as a separate heatmap layer.
### Implementation
**Backend changes:**
**File:** `backend/app/services/coverage_service.py` (or gpu_service.py)
Add C/I calculation after existing coverage computation:
```python
def calculate_interference(self, sites: list, coverage_results: dict) -> np.ndarray:
"""Calculate C/I ratio for each grid point.
For each point:
- C = signal strength from strongest (serving) cell
- I = sum of signal strengths from all other co-frequency cells
- C/I = C - 10*log10(sum of linear interference powers)
Returns array of C/I values in dB.
"""
# Get all RSRP grids (already calculated)
# For each point, find:
# 1. Best server (strongest signal) = C
# 2. Sum of all others on same frequency = I
# 3. C/I = C(dBm) - I(dBm)
# Group sites by frequency
freq_groups = {}
for site in sites:
freq = site.get("frequency_mhz", 1800)
if freq not in freq_groups:
freq_groups[freq] = []
freq_groups[freq].append(site)
# Only calculate interference for frequency groups with 2+ sites
# For single-site frequencies, C/I = infinity (no interference)
# The RSRP values are already in dBm, need to convert to linear for summing
# P_linear = 10^(P_dBm / 10)
# I_total_linear = sum(P_linear for all interferers)
# I_total_dBm = 10 * log10(I_total_linear)
# C/I = C_dBm - I_total_dBm
pass
```
**Key algorithm (for GPU pipeline in gpu_service.py):**
```python
# After computing RSRP for all sites at all grid points:
# rsrp_grid shape: (num_sites, num_points) in dBm
# Convert to linear (mW)
rsrp_linear = 10 ** (rsrp_grid / 10.0) # CuPy array
# For each point, best server
best_server_idx = cp.argmax(rsrp_grid, axis=0)
best_rsrp_linear = cp.take_along_axis(rsrp_linear, best_server_idx[cp.newaxis, :], axis=0)[0]
# Total power from all sites
total_power = cp.sum(rsrp_linear, axis=0)
# Interference = total - serving
interference_linear = total_power - best_rsrp_linear
# C/I ratio in dB
# Avoid log10(0) with small epsilon
epsilon = 1e-30
ci_ratio_db = 10 * cp.log10(best_rsrp_linear / (interference_linear + epsilon))
# Clip to reasonable range
ci_ratio_db = cp.clip(ci_ratio_db, -20, 50)
```
### Frontend Visualization
- Add a toggle in the coverage controls: "Show: Signal (RSRP) | Interference (C/I)"
- C/I heatmap uses different color scale:
- Dark red: < 0 dB (interference dominant — no service)
- Orange: 0-10 dB (marginal)
- Yellow: 10-20 dB (acceptable)
- Green: 20-30 dB (good)
- Blue: > 30 dB (excellent, minimal interference)
- The C/I map only makes sense with 2+ sites on same frequency
- Show warning if all sites are on different frequencies (no co-channel interference)
### API Response Extension
Add `ci_ratio` field to coverage calculation response alongside existing `rsrp` values.
---
## Testing Checklist
### Link Budget
- [ ] Panel opens from toolbar/sidebar
- [ ] Click on map sets Rx point
- [ ] Tx parameters auto-populate from selected site
- [ ] Link budget table shows all rows correctly
- [ ] Margin calculation is correct (manual verification)
- [ ] Color coding: green for positive margin, red for negative
- [ ] Line drawn on map from Tx to Rx
### Fresnel Zone
- [ ] Terrain profile shows Fresnel zone overlay
- [ ] Fresnel ellipse is widest at midpoint (correct shape)
- [ ] Red highlighting where terrain enters Fresnel zone
- [ ] Summary shows LOS/Fresnel status
- [ ] Works at different frequencies (zone size changes with frequency)
- [ ] Clearance values are reasonable (first Fresnel zone at 1800 MHz, 10km = ~22m radius at midpoint)
### Interference
- [ ] C/I toggle appears when 2+ sites exist
- [ ] C/I heatmap renders with correct color scale
- [ ] Single-site scenario shows "no interference" or infinite C/I
- [ ] Two sites on same frequency show interference zones between them
- [ ] C/I values are reasonable (> 20 dB near serving cell, < 10 dB at cell edge)
## Build & Deploy
```bash
cd D:\root\rfcp
# Backend — just restart uvicorn (Python, no build)
cd backend
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
# Frontend — rebuild if UI components changed
cd frontend
npm run build
# Full installer rebuild if needed
# (use existing build script)
```
## Commit Message
```
feat(rf): add link budget, Fresnel zone, and interference modeling
- Add /api/link-budget endpoint with full path analysis
- Add /api/fresnel-profile endpoint with zone clearance calculation
- Add C/I ratio computation to GPU coverage pipeline
- Add LinkBudgetPanel frontend component
- Add Fresnel zone overlay to terrain profile chart
- Add C/I heatmap toggle alongside RSRP display
- Group interference by frequency for co-channel analysis
```
## Success Criteria
1. Link budget shows correct margin for known test case (Dnipro, 10km, 1800MHz)
2. Fresnel zone visually shows ellipse on terrain profile
3. Two co-frequency sites show interference pattern between them
4. All three features work with existing terrain data (no new downloads needed)
5. GPU pipeline performance not significantly degraded by C/I calculation

View File

@@ -0,0 +1,210 @@
# RFCP — Iteration 3.10.1: UI/UX Bugfixes
## Overview
Four bugs found during 3.10 testing. All are frontend issues, no backend changes needed.
---
## Bug 1: Ruler places point when clicking Terrain Profile button
**Problem:** When Ruler mode is active and user clicks "Terrain Profile" button in the measurement overlay, it also places a ruler point on the map underneath. The click event propagates to the map.
**Fix:** Stop event propagation on the Terrain Profile button click handler. The Terrain Profile button (and any overlay UI elements) should call `e.stopPropagation()` to prevent the click from reaching the map layer.
Also review: any other UI overlays that sit on top of the map (Link Budget panel, coverage controls, etc.) should also stop propagation to prevent accidental ruler/site placement.
**Files to check:**
- MeasurementTool component (Terrain Profile button handler)
- Any overlay/popup components that sit on top of the Leaflet map
---
## Bug 2: Cursor should be default arrow, not hand; Ruler snap to site
**Problem A:** The map cursor shows as a grab/hand icon. Should be default arrow cursor for normal mode. Hand cursor should only appear when dragging the map.
**Fix A:** Set Leaflet map cursor styles:
```css
/* Default cursor */
.leaflet-container {
cursor: default !important;
}
/* Grabbing only when dragging */
.leaflet-container.leaflet-drag-target {
cursor: grabbing !important;
}
/* Crosshair for ruler mode */
.leaflet-container.ruler-mode {
cursor: crosshair !important;
}
/* Crosshair for RX point placement mode */
.leaflet-container.rx-placement-mode {
cursor: crosshair !important;
}
```
Apply CSS classes to the map container based on current mode. Remove Leaflet's default grab cursor.
**Problem B:** When using the ruler, it should be possible to snap the ruler start/end point exactly to a site (tower) location. Currently you have to eyeball it.
**Fix B:** When in ruler mode and clicking near a site marker (within ~20px), snap the ruler point to the exact site coordinates. This gives precise distance measurements from tower to any point.
```typescript
// In ruler click handler:
const SNAP_DISTANCE_PX = 20;
function findNearestSite(clickLatLng: L.LatLng, map: L.Map): Site | null {
const clickPoint = map.latLngToContainerPoint(clickLatLng);
let nearest: Site | null = null;
let minDist = Infinity;
for (const site of sites) {
const sitePoint = map.latLngToContainerPoint(L.latLng(site.lat, site.lon));
const dist = clickPoint.distanceTo(sitePoint);
if (dist < SNAP_DISTANCE_PX && dist < minDist) {
minDist = dist;
nearest = site;
}
}
return nearest;
}
// When placing ruler point:
const snappedSite = findNearestSite(clickLatLng, map);
if (snappedSite) {
// Use exact site coordinates
rulerPoint = L.latLng(snappedSite.lat, snappedSite.lon);
} else {
rulerPoint = clickLatLng;
}
```
---
## Bug 3: Link Budget Calculator text invisible + RX point not placed on map
**Problem A:** Text in Link Budget Calculator panel is black on dark background — invisible. The input fields and labels need light text color for dark theme.
**Fix A:** Ensure all text in LinkBudgetPanel uses light colors:
```css
/* All text in the panel should be light */
color: #e2e8f0; /* or whatever the app's light text color is */
/* Input fields */
input {
color: #e2e8f0;
background: #1e293b; /* dark input background */
border: 1px solid #475569;
}
/* Labels */
label {
color: #94a3b8; /* slightly muted for labels */
}
/* Values/results */
.result-value {
color: #f1f5f9; /* bright white for important values */
}
```
Check if the panel is using Tailwind classes — if so, ensure `text-slate-200` or similar is applied to the container. The panel likely inherits wrong text color or has hardcoded dark text.
**Problem B:** When clicking "Click on Map to Set RX Point" and then clicking on the map, the RX marker does not appear on the map. The coordinates might update in the fields but there's no visual indicator.
**Fix B:** When RX point is set:
1. Place a visible marker on the map at the RX location (use a different icon than the TX site — e.g., a small circle or pin in a different color like orange or blue)
2. Draw a dashed line from the TX site to the RX marker
3. The marker should be draggable to adjust position
4. When Link Budget panel is closed, remove the RX marker and line
```typescript
// RX marker icon (different from site markers)
const rxIcon = L.divIcon({
className: 'rx-marker',
html: '<div style="width: 12px; height: 12px; background: #f97316; border: 2px solid white; border-radius: 50%;"></div>',
iconSize: [12, 12],
iconAnchor: [6, 6],
});
// Place marker
const rxMarker = L.marker([rxLat, rxLon], { icon: rxIcon, draggable: true }).addTo(map);
// Dashed line from TX to RX
const linkLine = L.polyline([[txLat, txLon], [rxLat, rxLon]], {
color: '#f97316',
weight: 2,
dashArray: '8, 4',
opacity: 0.8,
}).addTo(map);
// Update on drag
rxMarker.on('drag', (e) => {
const pos = e.target.getLatLng();
linkLine.setLatLngs([[txLat, txLon], [pos.lat, pos.lng]]);
// Update Link Budget panel coordinates
updateRxCoordinates(pos.lat, pos.lng);
});
```
---
## Bug 4: Elevation color opacity not working
**Problem:** The opacity control for elevation/terrain colors on the map is not functioning. Adjusting the opacity slider has no effect on the terrain overlay visibility.
**Fix:** Check how the elevation overlay is rendered:
1. If it's a tile layer (Leaflet tile overlay), use `layer.setOpacity(value)`
2. If it's the topo map layer, the opacity needs to be applied to the correct layer reference
3. If it's the coverage heatmap opacity that's broken, check the canvas renderer opacity
The "Elev" button on the right toolbar likely toggles an elevation visualization. Find where this layer is created and ensure:
```typescript
// When opacity slider changes:
elevationLayer.setOpacity(opacityValue);
// Or if it's a canvas overlay:
const canvas = document.querySelector('.elevation-overlay');
if (canvas) {
canvas.style.opacity = String(opacityValue);
}
```
Also check: there might be TWO opacity controls that are confused:
- Coverage heatmap opacity (the RSRP colors)
- Terrain/elevation color overlay opacity (the topo colors)
Make sure each slider controls the correct layer.
---
## Testing Checklist
- [ ] Click Terrain Profile button with Ruler active — NO extra ruler point placed
- [ ] Default cursor is arrow, not hand
- [ ] Cursor changes to crosshair in Ruler mode
- [ ] Cursor changes to crosshair in RX placement mode
- [ ] Ruler snaps to site when clicking near tower marker
- [ ] Link Budget panel text is readable (light on dark)
- [ ] Clicking map in RX mode places visible orange marker
- [ ] Dashed line drawn from TX to RX
- [ ] RX marker removed when panel closes
- [ ] Elevation opacity slider actually changes overlay transparency
## Commit Message
```
fix(ui): resolve ruler propagation, cursor, link budget visibility, elevation opacity
- Stop click propagation on Terrain Profile button (prevents ruler point)
- Change default cursor to arrow, crosshair for tool modes
- Add ruler snap-to-site (20px threshold)
- Fix Link Budget panel text colors for dark theme
- Add RX marker and dashed line on map
- Fix elevation overlay opacity control binding
```

View File

@@ -0,0 +1,349 @@
# RFCP — Iteration 3.10.2: Tool Mode System & Click Fixes
## Root Cause
All click-related bugs share one root cause: multiple features compete for the same map click event. Ruler, RX point placement, site placement, and terrain profile all listen to map clicks simultaneously. There's no centralized "active tool" state that prevents conflicts.
## Solution: Active Tool Mode
Create a single source of truth for which tool is currently active. Only the active tool receives map click events.
### Tool Modes (mutually exclusive):
```typescript
type ActiveTool =
| 'none' // Default — pan/zoom only, no click actions
| 'ruler' // Distance measurement, click to add points
| 'rx-placement' // Link Budget RX point, single click
| 'site-placement' // Place new site on map
```
### Implementation
**1. Add to app store (Zustand):**
```typescript
// In the main store or a new toolStore:
interface ToolState {
activeTool: ActiveTool;
setActiveTool: (tool: ActiveTool) => void;
clearTool: () => void;
}
const useToolStore = create<ToolState>((set) => ({
activeTool: 'none',
setActiveTool: (tool) => set({ activeTool: tool }),
clearTool: () => set({ activeTool: 'none' }),
}));
```
**2. Map click handler — single entry point:**
Replace all individual map click listeners with ONE handler:
```typescript
// In the main Map component:
map.on('click', (e: L.LeafletMouseEvent) => {
const { activeTool } = useToolStore.getState();
switch (activeTool) {
case 'ruler':
handleRulerClick(e);
break;
case 'rx-placement':
handleRxPlacement(e);
break;
case 'site-placement':
handleSitePlacement(e);
break;
case 'none':
default:
// No action on map click — just pan/zoom
break;
}
});
```
**3. Cursor changes based on active tool:**
```typescript
useEffect(() => {
const container = map.getContainer();
// Remove all tool cursors
container.classList.remove('ruler-mode', 'rx-placement-mode', 'site-placement-mode');
switch (activeTool) {
case 'ruler':
container.classList.add('ruler-mode');
break;
case 'rx-placement':
container.classList.add('rx-placement-mode');
break;
case 'site-placement':
container.classList.add('site-placement-mode');
break;
default:
// Default cursor (arrow)
break;
}
}, [activeTool]);
```
**4. CSS for cursors:**
```css
.leaflet-container {
cursor: default !important;
}
.leaflet-container.leaflet-dragging {
cursor: grabbing !important;
}
.leaflet-container.ruler-mode {
cursor: crosshair !important;
}
.leaflet-container.rx-placement-mode {
cursor: crosshair !important;
}
.leaflet-container.site-placement-mode {
cursor: cell !important;
}
```
**5. UI buttons toggle tool mode:**
```typescript
// Ruler button:
const handleRulerToggle = () => {
if (activeTool === 'ruler') {
clearTool(); // Toggle off
} else {
setActiveTool('ruler'); // Activate ruler, deactivate others
}
};
// Link Budget "Click on Map to Set RX Point" button:
const handleRxModeToggle = () => {
if (activeTool === 'rx-placement') {
clearTool();
} else {
setActiveTool('rx-placement');
}
};
```
**6. Auto-deactivation:**
- RX placement: deactivate after single click (point is set)
- Ruler: stays active until toggled off or right-click finishes
- Site placement: deactivate after placing site
---
## Fix: Ruler Snap to Site
In the ruler click handler, check proximity to existing sites:
```typescript
function handleRulerClick(e: L.LeafletMouseEvent) {
const map = e.target;
const clickPoint = map.latLngToContainerPoint(e.latlng);
const SNAP_THRESHOLD_PX = 20;
// Check all site markers
let snappedLatLng = e.latlng;
let snapped = false;
for (const site of sites) {
const siteLatLng = L.latLng(site.lat, site.lon);
const sitePoint = map.latLngToContainerPoint(siteLatLng);
const pixelDist = clickPoint.distanceTo(sitePoint);
if (pixelDist < SNAP_THRESHOLD_PX) {
snappedLatLng = siteLatLng;
snapped = true;
break;
}
}
// Add ruler point at snapped or original location
addRulerPoint(snappedLatLng);
// Optional: visual feedback for snap
if (snapped) {
// Brief highlight on the site marker
}
}
```
---
## Fix: RX Point Placement + Visual Marker
When in 'rx-placement' mode and map is clicked:
```typescript
function handleRxPlacement(e: L.LeafletMouseEvent) {
const { lat, lng } = e.latlng;
// Update Link Budget panel coordinates
setRxCoordinates(lat, lng);
// Place visible marker on map
if (rxMarkerRef.current) {
rxMarkerRef.current.setLatLng([lat, lng]);
} else {
rxMarkerRef.current = L.marker([lat, lng], {
icon: L.divIcon({
className: 'rx-point-marker',
html: `<div style="
width: 14px; height: 14px;
background: #f97316;
border: 2px solid #fff;
border-radius: 50%;
box-shadow: 0 0 6px rgba(249,115,22,0.6);
"></div>`,
iconSize: [14, 14],
iconAnchor: [7, 7],
}),
draggable: true,
}).addTo(map);
// Update coords on drag
rxMarkerRef.current.on('drag', (ev) => {
const pos = ev.target.getLatLng();
setRxCoordinates(pos.lat, pos.lng);
});
}
// Draw dashed line from TX to RX
const selectedSite = getSelectedSite();
if (selectedSite && linkLineRef.current) {
linkLineRef.current.setLatLngs([[selectedSite.lat, selectedSite.lon], [lat, lng]]);
} else if (selectedSite) {
linkLineRef.current = L.polyline(
[[selectedSite.lat, selectedSite.lon], [lat, lng]],
{ color: '#f97316', weight: 2, dashArray: '8,4', opacity: 0.8 }
).addTo(map);
}
// Deactivate RX placement mode (single click action)
clearTool();
}
// Cleanup when Link Budget panel closes:
function cleanupRxMarker() {
if (rxMarkerRef.current) {
rxMarkerRef.current.remove();
rxMarkerRef.current = null;
}
if (linkLineRef.current) {
linkLineRef.current.remove();
linkLineRef.current = null;
}
}
```
---
## Fix: Terrain Profile Click-Through
The Terrain Profile popup and its "Terrain Profile" trigger button must stop event propagation:
```typescript
// On the Terrain Profile button in the measurement overlay:
<button
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
showTerrainProfile();
}}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
Terrain Profile
</button>
// On the Terrain Profile popup container:
<div
className="terrain-profile-popup"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{/* ... chart content ... */}
</div>
```
Also ensure the popup/panel has `pointer-events: auto` and is positioned with a high z-index above the map.
With the tool mode system in place, this becomes less critical since clicking terrain profile UI won't trigger ruler (ruler mode would be separate), but stopping propagation is still good practice.
---
## Fix: Default Cursor (Not Hand)
Override Leaflet's default grab cursor:
```css
/* Global override in the app's main CSS */
.leaflet-container {
cursor: default !important;
}
/* Only show grab when actually dragging */
.leaflet-container.leaflet-dragging,
.leaflet-container:active {
cursor: grabbing !important;
}
/* Remove grab cursor from interactive layers too */
.leaflet-interactive {
cursor: default !important;
}
/* Tool-specific cursors applied via JS class toggle */
.leaflet-container.tool-ruler {
cursor: crosshair !important;
}
.leaflet-container.tool-rx-placement {
cursor: crosshair !important;
}
.leaflet-container.tool-site-placement {
cursor: cell !important;
}
```
---
## Testing Checklist
- [ ] Only ONE tool can be active at a time
- [ ] Activating Ruler deactivates RX placement and vice versa
- [ ] Default cursor is arrow (not hand/grab)
- [ ] Cursor changes to crosshair when Ruler is active
- [ ] Cursor changes to crosshair when RX placement is active
- [ ] Cursor shows grabbing only when dragging map
- [ ] Clicking Terrain Profile button does NOT place ruler point
- [ ] Clicking any UI panel/popup does NOT place ruler point
- [ ] Ruler point snaps to site marker when clicking within 20px
- [ ] RX point click places orange marker on map
- [ ] Dashed orange line appears from TX site to RX marker
- [ ] RX marker is draggable (updates coordinates in panel)
- [ ] RX marker removed when Link Budget panel closes
- [ ] Right-click finishes ruler measurement
## Commit Message
```
fix(tools): implement active tool mode system, fix click conflicts
- Add ActiveTool state (none/ruler/rx-placement/site-placement)
- Single map click handler dispatches to active tool only
- Fix cursor: default arrow, crosshair for tools, grabbing for drag
- Add ruler snap-to-site (20px threshold)
- Add RX marker with draggable orange dot and dashed line
- Stop event propagation on all UI overlays above map
- Clean up markers when panels close
```

View File

@@ -0,0 +1,106 @@
# RFCP — Iteration 3.10.3: Calculator Shortcut & Ruler Limit
## Two small UX changes, no backend.
---
## 1. Link Budget Calculator — Quick Access Button
Move calculator access to a visible toolbar button, not buried in Map Tools panel.
**Location:** Top-left corner of the map, below the zoom controls (+/- buttons). Similar to how Fit, Reset, Topo, Grid, Ruler, Elev buttons are in the top-right.
**Implementation:**
Add a button to the left toolbar (or create a small floating button group):
```typescript
// Top-left button, below zoom controls
<button
className="map-tool-btn"
onClick={() => setShowLinkBudget(!showLinkBudget)}
title="Link Budget Calculator"
>
{/* Calculator icon — use an emoji or SVG */}
🔗 {/* or a small "LB" text label, or a calculator SVG icon */}
</button>
```
**Styling:** Same visual style as the right-side tool buttons (Fit, Reset, Topo, Grid, Ruler, Elev) — dark rounded rectangle with light text/icon.
**Position options (pick one):**
- **Option A:** Add to the RIGHT toolbar stack below "Elev" button — keeps all tools together
- **Option B:** Floating button top-left below zoom — separate but prominent
- **Option C:** Add to the measurement overlay bar (near the ruler distance display)
Recommend **Option A** — add "LB" or calculator icon button to the right toolbar stack, below Elev. Consistent with existing UI pattern.
Also: Remove the "Hide Link Budget Calculator" button from Map Tools panel (or keep it as secondary toggle — but primary access should be the toolbar button).
---
## 2. Ruler — Maximum 2 Points Only
**Problem:** Ruler currently allows unlimited points, creating a web of measurement lines. For RF point-to-point measurement, only 2 points make sense: start and end.
**Fix:** Limit ruler to exactly 2 points. When both points are placed, the measurement is complete. To start a new measurement, clicking again replaces the first point and clears the old measurement.
```typescript
// In the map click handler for ruler mode:
function handleRulerClick(e: L.LeafletMouseEvent) {
const currentPoints = rulerPoints;
if (currentPoints.length === 0) {
// First point
setRulerPoints([snappedLatLng]);
} else if (currentPoints.length === 1) {
// Second point — measurement complete
setRulerPoints([currentPoints[0], snappedLatLng]);
// Optionally: auto-deactivate ruler mode after 2nd point
// clearTool(); // uncomment if you want one-shot behavior
} else {
// Already 2 points — start new measurement
// Replace: clear old points, start fresh with new first point
setRulerPoints([snappedLatLng]);
}
}
```
**Behavior:**
1. Click 1: Place start point (show marker)
2. Click 2: Place end point (show marker + line + distance label + Terrain Profile button)
3. Click 3: Clear previous, start new measurement from this click
4. Right-click or Escape: Cancel/clear ruler entirely
**Remove:**
- Remove "Right-click to finish" instruction (no longer needed — measurement auto-completes at 2 points)
- Remove multi-point polyline rendering (only single line between 2 points)
**Visual:**
- Show a single straight line between 2 points (green dashed, as current)
- Distance label at midpoint
- Terrain Profile button appears after 2nd point is placed
- Small circle markers at both endpoints
---
## Testing Checklist
- [ ] Calculator button visible in toolbar (right side, below Elev)
- [ ] Click calculator button opens/closes Link Budget panel
- [ ] Ruler allows exactly 2 points, no more
- [ ] Third click starts new measurement (replaces old)
- [ ] Escape clears ruler
- [ ] Distance + Terrain Profile button appears after 2nd point
- [ ] No multi-point web/polygon possible
- [ ] Ruler still snaps to site markers
## Commit Message
```
fix(ux): add calculator toolbar button, limit ruler to 2 points
- Add Link Budget Calculator button to right toolbar
- Limit ruler to exactly 2 points (point-to-point only)
- Third click starts new measurement, clears previous
- Remove multi-point polyline behavior
```

View File

@@ -0,0 +1,136 @@
# RFCP — Iteration 3.10.4: Terrain Profile Click Fix & TX Height
## Two bugs remaining from previous iterations.
---
## Bug 1: Terrain Profile click still places ruler point
**Problem:** Clicking inside the Terrain Profile popup (chart area, close button, fresnel checkbox, anywhere in the popup) triggers the map click handler underneath, which places a ruler point or resets the measurement.
**Previous fix was incomplete** — stopPropagation was added to some elements but not the entire popup container and its backdrop.
**Fix:** The Terrain Profile popup needs a FULL click barrier. Every mouse event must be caught:
```typescript
// The OUTERMOST container of the Terrain Profile popup:
<div
className="terrain-profile-container"
onClick={(e) => { e.stopPropagation(); e.nativeEvent.stopImmediatePropagation(); }}
onMouseDown={(e) => { e.stopPropagation(); e.nativeEvent.stopImmediatePropagation(); }}
onMouseUp={(e) => { e.stopPropagation(); e.nativeEvent.stopImmediatePropagation(); }}
onPointerDown={(e) => { e.stopPropagation(); e.nativeEvent.stopImmediatePropagation(); }}
onPointerUp={(e) => { e.stopPropagation(); e.nativeEvent.stopImmediatePropagation(); }}
onDoubleClick={(e) => { e.stopPropagation(); e.nativeEvent.stopImmediatePropagation(); }}
>
{/* All terrain profile content */}
</div>
```
**IMPORTANT:** `stopPropagation()` alone may not be enough because Leaflet listens to DOM events directly, not React synthetic events. The fix MUST also call `e.nativeEvent.stopImmediatePropagation()` to prevent Leaflet's native DOM listener from firing.
**Alternative approach (more robust):** Add the popup OUTSIDE the Leaflet map container in the DOM tree. If the Terrain Profile div is a sibling or parent of the map div (not a child), Leaflet's event delegation won't catch clicks on it at all.
```tsx
// In the main layout:
<div className="app-layout">
<div id="map-container">
{/* Leaflet map renders here */}
</div>
{/* These are OUTSIDE the map container — Leaflet can't intercept */}
{showTerrainProfile && (
<TerrainProfile ... />
)}
{showLinkBudget && (
<LinkBudgetPanel ... />
)}
</div>
```
If moving outside the map container is too much refactoring, the stopImmediatePropagation approach should work. But check: is the TerrainProfile component rendered INSIDE a Leaflet pane or overlay? If so, moving it out is the correct fix.
**Also apply the same fix to:**
- Link Budget Calculator panel
- Any other floating panel/popup that sits over the map
---
## Bug 2: TX Height always shows 2m in Link Budget Calculator
**Problem:** The Link Budget Calculator TRANSMITTER section always shows `Height: 2m` regardless of the actual site configuration. It should read the height from the selected site's settings.
**Root cause:** The LinkBudgetPanel component likely reads `site.height` but the site object might store height in a different field name (e.g., `site.antennaHeight`, `site.towerHeight`, `site.params.height`, or per-sector height).
**Fix:** Find where site height is stored and pass the correct value:
```typescript
// In LinkBudgetPanel.tsx, find where TX height is set:
// WRONG (probably current):
const txHeight = site.height || 2; // Defaults to 2 if field is missing
// Check the actual site data structure. It might be:
const txHeight = site.antennaHeight
|| site.tower_height
|| site.params?.height
|| site.sectors?.[0]?.height // If height is per-sector
|| 30; // Default should be 30m for a typical cell tower, not 2m
// Or if height is stored in meters in a nested config:
const txHeight = selectedSite?.config?.height || selectedSite?.height || 30;
```
**Steps to debug:**
1. In the browser console (F12), find the selected site object
2. Check what field contains the height value
3. Update LinkBudgetPanel to read from the correct field
**Display fix:**
```typescript
// In the TRANSMITTER section of the panel:
<div className="param-row">
<span>Height:</span>
<span>{txHeight} m</span>
</div>
```
The height should also be EDITABLE in the link budget calculator (as an input field, not just display), since you might want to test "what if I put the antenna at 40m instead of 30m?" without changing the actual site config.
```typescript
// Make height an editable field with site value as default:
const [txHeightOverride, setTxHeightOverride] = useState<number | null>(null);
const txHeight = txHeightOverride ?? (site?.height || 30);
<div className="param-row">
<label>Height:</label>
<input
type="number"
value={txHeight}
onChange={(e) => setTxHeightOverride(parseFloat(e.target.value))}
/> m
</div>
```
---
## Testing Checklist
- [ ] Click ANYWHERE inside Terrain Profile popup — NO ruler point placed
- [ ] Click Terrain Profile close button (X) — popup closes, no ruler point
- [ ] Click Fresnel Zone checkbox — toggles, no ruler point
- [ ] Click chart area — no ruler point
- [ ] Drag/scroll inside chart — no map pan/zoom
- [ ] TX Height in Link Budget shows actual site height (not 2m)
- [ ] TX Height is editable for what-if scenarios
- [ ] Changing TX height recalculates link budget
## Commit Message
```
fix(ui): block all click propagation from terrain profile, fix TX height
- Add stopImmediatePropagation on terrain profile container
- Prevent all mouse/pointer events from reaching Leaflet map
- Fix TX height reading from site config (was defaulting to 2m)
- Make TX height editable in link budget calculator
```

View File

@@ -0,0 +1,246 @@
# RFCP — Iteration 3.9.1: Terra Tile Server Integration
## Overview
Connect terrain_service.py to our SRTM tile server (terra.eliah.one) as primary download source, add terrain status API endpoint, and create a bulk pre-download utility. The `data/terrain/` directory already exists.
## Context
- terra.eliah.one is live and serving tiles via Caddy file_server
- SRTM3 (90m): 187 tiles, 515 MB — full Ukraine coverage (N44-N51, E018-E041)
- SRTM1 (30m): 160 tiles, 3.9 GB — same coverage area
- terrain_service.py already has bilinear interpolation (3.9.0)
- Backend runs on Windows with RTX 4060, tiles stored locally in `data/terrain/`
- Server is download source, NOT used during realtime calculations
## Changes Required
### 1. Update SRTM_SOURCES in terrain_service.py
**File:** `backend/app/services/terrain_service.py`
Replace current SRTM_SOURCES (lines 22-25):
```python
SRTM_SOURCES = [
"https://elevation-tiles-prod.s3.amazonaws.com/skadi/{lat_dir}/{tile_name}.hgt.gz",
"https://s3.amazonaws.com/elevation-tiles-prod/skadi/{lat_dir}/{tile_name}.hgt.gz",
]
```
With prioritized source list:
```python
SRTM_SOURCES = [
# Our tile server — SRTM1 (30m) preferred, uncompressed
{
"url": "https://terra.eliah.one/srtm1/{tile_name}.hgt",
"compressed": False,
"resolution": "srtm1",
},
# Our tile server — SRTM3 (90m) fallback
{
"url": "https://terra.eliah.one/srtm3/{tile_name}.hgt",
"compressed": False,
"resolution": "srtm3",
},
# Public AWS mirror — SRTM1, gzip compressed
{
"url": "https://elevation-tiles-prod.s3.amazonaws.com/skadi/{lat_dir}/{tile_name}.hgt.gz",
"compressed": True,
"resolution": "srtm1",
},
]
```
Update `download_tile()` to handle the new source format:
```python
async def download_tile(self, tile_name: str) -> bool:
"""Download SRTM tile from configured sources, preferring highest resolution."""
tile_path = self.get_tile_path(tile_name)
if tile_path.exists():
return True
lat_dir = tile_name[:3] # e.g., "N48"
async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
for source in self.SRTM_SOURCES:
url = source["url"].format(lat_dir=lat_dir, tile_name=tile_name)
try:
response = await client.get(url)
if response.status_code == 200:
data = response.content
# Skip empty responses
if len(data) < 1000:
continue
if source["compressed"]:
if url.endswith('.gz'):
data = gzip.decompress(data)
elif url.endswith('.zip'):
with zipfile.ZipFile(io.BytesIO(data)) as zf:
for name in zf.namelist():
if name.endswith('.hgt'):
data = zf.read(name)
break
# Validate tile size
if len(data) not in (3601 * 3601 * 2, 1201 * 1201 * 2):
print(f"[Terrain] Invalid tile size {len(data)} from {url}")
continue
tile_path.write_bytes(data)
res = source["resolution"]
size_mb = len(data) / 1048576
print(f"[Terrain] Downloaded {tile_name} ({res}, {size_mb:.1f} MB)")
return True
except Exception as e:
print(f"[Terrain] Failed from {url}: {e}")
continue
print(f"[Terrain] Could not download {tile_name} from any source")
return False
```
### 2. Add Terrain Status API Endpoint
**File:** `backend/app/api/routes.py` (or wherever API routes are defined)
Add a new endpoint:
```python
@router.get("/api/terrain/status")
async def terrain_status():
"""Return terrain data availability info."""
from app.services.terrain_service import terrain_service
cached_tiles = terrain_service.get_cached_tiles()
cache_size = terrain_service.get_cache_size_mb()
# Categorize by resolution
srtm1_tiles = [t for t in cached_tiles
if (terrain_service.terrain_path / f"{t}.hgt").stat().st_size == 3601 * 3601 * 2]
srtm3_tiles = [t for t in cached_tiles if t not in srtm1_tiles]
return {
"total_tiles": len(cached_tiles),
"srtm1": {
"count": len(srtm1_tiles),
"resolution_m": 30,
"tiles": sorted(srtm1_tiles),
},
"srtm3": {
"count": len(srtm3_tiles),
"resolution_m": 90,
"tiles": sorted(srtm3_tiles),
},
"cache_size_mb": round(cache_size, 1),
"memory_cached": len(terrain_service._tile_cache),
"terra_server": "https://terra.eliah.one",
}
```
### 3. Add Bulk Pre-Download Endpoint
**File:** Same routes file
```python
@router.post("/api/terrain/download")
async def terrain_download(request: dict):
"""Pre-download tiles for a region.
Body: {"center_lat": 48.46, "center_lon": 35.04, "radius_km": 50}
Or: {"tiles": ["N48E034", "N48E035", "N47E034", "N47E035"]}
"""
from app.services.terrain_service import terrain_service
if "tiles" in request:
tile_list = request["tiles"]
else:
center_lat = request.get("center_lat", 48.46)
center_lon = request.get("center_lon", 35.04)
radius_km = request.get("radius_km", 50)
tile_list = terrain_service.get_required_tiles(center_lat, center_lon, radius_km)
missing = [t for t in tile_list if not terrain_service.get_tile_path(t).exists()]
if not missing:
return {"status": "ok", "message": "All tiles already cached", "count": len(tile_list)}
# Download missing tiles
downloaded = []
failed = []
for tile_name in missing:
success = await terrain_service.download_tile(tile_name)
if success:
downloaded.append(tile_name)
else:
failed.append(tile_name)
return {
"status": "ok",
"required": len(tile_list),
"already_cached": len(tile_list) - len(missing),
"downloaded": downloaded,
"failed": failed,
}
```
### 4. Add Tile Index Endpoint
**File:** Same routes file
```python
@router.get("/api/terrain/index")
async def terrain_index():
"""Fetch tile index from terra server."""
import httpx
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get("https://terra.eliah.one/api/index")
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return {"error": "Could not reach terra.eliah.one", "offline": True}
```
## Testing Checklist
- [ ] `GET /api/terrain/status` returns tile counts and sizes
- [ ] `POST /api/terrain/download {"center_lat": 48.46, "center_lon": 35.04, "radius_km": 10}` downloads missing tiles from terra.eliah.one
- [ ] Tiles downloaded from terra are valid HGT format (2,884,802 or 25,934,402 bytes)
- [ ] SRTM1 is preferred over SRTM3 when downloading
- [ ] Existing tiles are not re-downloaded
- [ ] Coverage calculation works with terrain data (test with Dnipro coordinates)
- [ ] `GET /api/terrain/index` returns terra server tile list
## Build & Deploy
```bash
cd D:\root\rfcp\backend
# No build needed — Python backend, just restart
# Kill existing uvicorn and restart:
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
```
## Commit Message
```
feat(terrain): integrate terra.eliah.one tile server
- Add terra.eliah.one as primary SRTM source (SRTM1 30m preferred)
- Keep AWS S3 as fallback source
- Add /api/terrain/status endpoint (tile inventory)
- Add /api/terrain/download endpoint (bulk pre-download)
- Add /api/terrain/index endpoint (terra server index)
- Validate tile size before saving
- Add follow_redirects=True to httpx client
```
## Success Criteria
1. terrain_service downloads from terra.eliah.one first
2. /api/terrain/status shows correct tile counts by resolution
3. /api/terrain/download fetches tiles for any Ukrainian coordinate
4. Offline mode works — no downloads attempted if tiles exist locally
5. Coverage calculation uses real elevation data instead of flat terrain

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!**

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

View File

@@ -0,0 +1,281 @@
# RFCP v3.10.5: WebGL Smooth Coverage Implementation
## Контекст проблеми
**Поточний стан:**
- Backend повертає grid точок з lat/lon/RSRP (50m = 6,675 pts, 200m = 1,975 pts)
- WebGL texture-based rendering: points → texture → GL_LINEAR → colormap
- **Проблема:** Видимі grid squares/pixelation, особливо при zoom in або sparse grids (200m)
**Причина:**
- `GL_LINEAR` дає тільки C0 continuity (значення співпадають на краях, але похідні — ні)
- Це створює видимі "шви" між клітинками
## Рішення з ресерчу
### Ключовий інсайт
**Catmull-Rom spline interpolation** дає C1 continuity (smooth derivatives) І проходить через exact data values (на відміну від B-spline який blurs peaks).
**9-tap Catmull-Rom** замість `texture2D()`:
- 9 texture fetches замість 1
- ~0.32ms vs ~0.30ms на GTX 980 при 1920×1080
- Для нашої ~80×85 текстури — практично безкоштовно
### Критичне правило
**Інтерполювати RAW RSRP values ПЕРЕД colormap!**
- ❌ Неправильно: texture → colormap → interpolate (muddy colors)
- ✅ Правильно: texture → interpolate → colormap (clean gradients)
---
## Етап 1: Quick Fix (30 хвилин)
### Smoothstep coordinate remapping
Найшвидший спосіб прибрати grid edges — одна зміна в shader:
```glsl
// ЗАМІСТЬ:
vec4 texColor = texture2D(u_texture, v_uv);
// ВИКОРИСТАТИ:
vec4 textureSmooth(sampler2D tex, vec2 uv, vec2 texSize) {
vec2 p = uv * texSize + 0.5;
vec2 i = floor(p);
vec2 f = p - i;
f = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); // quintic hermite
return texture2D(tex, (i + f - 0.5) / texSize);
}
// В main():
vec4 texColor = textureSmooth(u_texture, v_uv, u_textureSize);
```
**Що це дає:**
- C2 continuity з одним texture read
- Прибирає видимі grid edges
- Мінімальний positional bias
**Потрібно додати uniform:**
```javascript
const textureSizeLocation = gl.getUniformLocation(program, 'u_textureSize');
gl.uniform2f(textureSizeLocation, textureWidth, textureHeight);
```
---
## Етап 2: Production Implementation (1-2 години)
### 9-tap Catmull-Rom Shader
```glsl
precision highp float;
uniform sampler2D u_texture;
uniform vec2 u_textureSize;
uniform float u_opacity;
varying vec2 v_uv;
// Catmull-Rom 9-tap interpolation
// Source: TheRealMJP's gist (108 GitHub stars)
vec4 SampleTextureCatmullRom(sampler2D tex, vec2 uv, vec2 texSize) {
vec2 samplePos = uv * texSize;
vec2 texPos1 = floor(samplePos - 0.5) + 0.5;
vec2 f = samplePos - texPos1;
// Catmull-Rom weights
vec2 w0 = f * (-0.5 + f * (1.0 - 0.5 * f));
vec2 w1 = 1.0 + f * f * (-2.5 + 1.5 * f);
vec2 w2 = f * (0.5 + f * (2.0 - 1.5 * f));
vec2 w3 = f * f * (-0.5 + 0.5 * f);
// Combine weights for optimized sampling
vec2 w12 = w1 + w2;
vec2 offset12 = w2 / (w1 + w2);
// Compute texture coordinates
vec2 texPos0 = (texPos1 - 1.0) / texSize;
vec2 texPos3 = (texPos1 + 2.0) / texSize;
vec2 texPos12 = (texPos1 + offset12) / texSize;
// 9 texture fetches (optimized from 16)
vec4 result = vec4(0.0);
result += texture2D(tex, vec2(texPos0.x, texPos0.y)) * w0.x * w0.y;
result += texture2D(tex, vec2(texPos12.x, texPos0.y)) * w12.x * w0.y;
result += texture2D(tex, vec2(texPos3.x, texPos0.y)) * w3.x * w0.y;
result += texture2D(tex, vec2(texPos0.x, texPos12.y)) * w0.x * w12.y;
result += texture2D(tex, vec2(texPos12.x, texPos12.y)) * w12.x * w12.y;
result += texture2D(tex, vec2(texPos3.x, texPos12.y)) * w3.x * w12.y;
result += texture2D(tex, vec2(texPos0.x, texPos3.y)) * w0.x * w3.y;
result += texture2D(tex, vec2(texPos12.x, texPos3.y)) * w12.x * w3.y;
result += texture2D(tex, vec2(texPos3.x, texPos3.y)) * w3.x * w3.y;
return result;
}
// RSRP to color mapping (cyan -> green -> yellow -> orange -> red)
vec3 rsrpToColor(float rsrp) {
// rsrp: normalized 0.0 (weak, -110dBm) to 1.0 (strong, -50dBm)
// Color stops: red -> orange -> yellow -> green -> cyan
vec3 c0 = vec3(1.0, 0.0, 0.0); // red (weak)
vec3 c1 = vec3(1.0, 0.5, 0.0); // orange
vec3 c2 = vec3(1.0, 1.0, 0.0); // yellow
vec3 c3 = vec3(0.0, 1.0, 0.0); // green
vec3 c4 = vec3(0.0, 1.0, 1.0); // cyan (strong)
float t = clamp(rsrp, 0.0, 1.0);
if (t < 0.25) {
return mix(c0, c1, t / 0.25);
} else if (t < 0.5) {
return mix(c1, c2, (t - 0.25) / 0.25);
} else if (t < 0.75) {
return mix(c2, c3, (t - 0.5) / 0.25);
} else {
return mix(c3, c4, (t - 0.75) / 0.25);
}
}
void main() {
// 1. Sample with Catmull-Rom interpolation (RAW value)
vec4 texColor = SampleTextureCatmullRom(u_texture, v_uv, u_textureSize);
float rsrpNormalized = texColor.r;
// 2. Discard if no coverage (validity check)
if (rsrpNormalized < 0.01) {
discard;
}
// 3. Apply colormap AFTER interpolation
vec3 color = rsrpToColor(rsrpNormalized);
// 4. Smooth boundary fading (optional)
float boundaryAlpha = smoothstep(0.01, 0.05, rsrpNormalized);
gl_FragColor = vec4(color, boundaryAlpha * u_opacity);
}
```
### JavaScript зміни
```javascript
// 1. Vertex shader (без змін)
const vertexShaderSource = `
attribute vec2 a_position;
attribute vec2 a_texCoord;
varying vec2 v_uv;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
v_uv = a_texCoord;
}
`;
// 2. При створенні texture — зберегти розміри
const textureWidth = gridWidth;
const textureHeight = gridHeight;
// 3. Передати uniform
const textureSizeLocation = gl.getUniformLocation(program, 'u_textureSize');
if (textureSizeLocation) {
gl.uniform2f(textureSizeLocation, textureWidth, textureHeight);
} else {
console.error('[WebGL] u_textureSize uniform NOT FOUND!');
}
// 4. Texture filtering — можна залишити LINEAR для fallback
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);
```
---
## Етап 3: Texture Data Format
### Поточний формат (перевірити)
```javascript
// Normalized RSRP value (0-255 mapped to 0.0-1.0 in shader)
const normalized = (rsrp - minRsrp) / (maxRsrp - minRsrp);
const value = Math.round(normalized * 255);
// Store in R channel
textureData[idx] = value; // R = normalized RSRP
textureData[idx + 1] = value; // G (можна використати для validity mask)
textureData[idx + 2] = value; // B
textureData[idx + 3] = 255; // A = fully opaque
```
### Альтернатива: Float texture (краща точність)
```javascript
// Якщо браузер підтримує OES_texture_float
const ext = gl.getExtension('OES_texture_float');
if (ext) {
const floatData = new Float32Array(width * height);
for (const point of points) {
const normalized = (point.rsrp - minRsrp) / (maxRsrp - minRsrp);
floatData[gridY * width + gridX] = normalized;
}
gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, width, height, 0,
gl.LUMINANCE, gl.FLOAT, floatData);
}
```
---
## Чеклист імплементації
### Phase 1: Quick Test (Smoothstep)
- [ ] Додати `u_textureSize` uniform
- [ ] Замінити `texture2D()` на `textureSmooth()`
- [ ] Тест на 50m і 200m
- [ ] Тест zoom in/out
### Phase 2: Production (Catmull-Rom)
- [ ] Імплементувати `SampleTextureCatmullRom()`
- [ ] Оновити colormap function
- [ ] Додати boundary fading
- [ ] Тест edge cases (краї текстури)
- [ ] Performance benchmark
### Phase 3: Polish
- [ ] Видалити старі CSS blur workarounds
- [ ] Видалити cellSize multiplication (не потрібно з Catmull-Rom)
- [ ] Cleanup debug logs
- [ ] Update version to v3.10.5
---
## Очікуваний результат
**До (GL_LINEAR):**
```
┌───┬───┬───┐
│ A │ B │ C │ ← Видимі краї між клітинками
├───┼───┼───┤ C0 continuity
│ D │ E │ F │
└───┴───┴───┘
```
**Після (Catmull-Rom):**
```
╭───────────────╮
│ ░░░▒▒▓▓██ │ ← Smooth gradient
│ ░░░▒▒▓▓██▓▓ │ C1 continuity
│ ░░▒▒▓▓██ │ Exact values at grid points
╰───────────────╯
```
---
## Референси
1. [TheRealMJP's 9-tap Catmull-Rom HLSL](https://gist.github.com/TheRealMJP/c83b8c0f46b63f3a88a5986f4fa982b1)
2. [Inigo Quilez - Better Texture Filtering](https://iquilezles.org/articles/texture/)
3. [2D Catmull-Rom in 4 samples - Shadertoy](https://www.shadertoy.com/view/4tyGDD)
4. [mapbox-gl-interpolate-heatmap](https://github.com/vinayakkulkarni/mapbox-gl-interpolate-heatmap)
5. [NVIDIA GPU Gems 2 - Fast Third-Order Texture Filtering](https://developer.nvidia.com/gpugems/gpugems2/part-iii-high-quality-rendering/chapter-20-fast-third-order-texture-filtering)

View File

@@ -0,0 +1,260 @@
# RFCP Session 2026-02-04 — Complete Development Log
**Session:** February 4, 2026 (afternoon/evening)
**Duration:** ~6 hours active development
**Iterations completed:** 3.9.0 → 3.9.1 → 3.10.0 → 3.10.1 → 3.10.2 → 3.10.3 → 3.10.4 (pending)
---
## What Was Done This Session
### Infrastructure: terra.eliah.one Tile Server ✅
- **DNS:** terra.eliah.one → 2.56.207.143 (VPS A, Hayhost)
- **Caddy:** File server with browse at /opt/terra/tiles/
- **SRTM3 (90m):** 187 tiles, 514.5 MB — full Ukraine (N44-N51, E018-E041)
- **SRTM1 (30m):** 160 tiles, 3,957.3 MB — full Ukraine (N44-N51, E022-E041)
- **Sources:** viewfinderpanoramas.org (SRTM3, void-filled), AWS S3 elevation-tiles-prod (SRTM1)
- **Index:** /api/index → tile_index.json (version 2, dual dataset)
- **Public access verified:** https://terra.eliah.one/srtm1/ and /srtm3/
### Iteration 3.9.1: Terra Integration ✅
- terrain_service.py updated with prioritized SRTM sources:
1. terra.eliah.one/srtm1/ (30m, preferred)
2. terra.eliah.one/srtm3/ (90m, fallback)
3. AWS S3 skadi mirror (public fallback)
- New endpoints: /api/terrain/status, /api/terrain/download, /api/terrain/index
- Auto-downloads tiles on first use, cached permanently on disk
- 173 tiles loaded (4,278.6 MB) confirmed in Data Cache panel
### Iteration 3.10.0: Link Budget + Fresnel Zone + Interference ✅
- **Link Budget Calculator:** Full TX→RX path analysis panel
- EIRP calculation, FSPL, terrain loss, received power, link margin
- RX point placement on map (orange marker, dashed line)
- ✓ LINK OK / ✗ FAIL status with margin display
- **Fresnel Zone Visualization:** On Terrain Profile chart
- First Fresnel zone ellipse overlay (semi-transparent)
- Red highlighting where terrain intrudes zone
- Frequency-aware (zone size changes with MHz)
- Clearance calculation with recommendation text
- **Interference Modeling (C/I):** Backend ready
- Carrier-to-interference ratio per grid point
- Co-frequency site grouping
- GPU-accelerated (CuPy vectorized)
### Iteration 3.10.1: UI Bugfixes (partial) ✅
- Elevation opacity control
- Data Cache panel with region downloads
- Various dark theme text fixes
### Iteration 3.10.2: Tool Mode System ✅
- **ActiveTool state:** 'none' | 'ruler' | 'rx-placement' | 'site-placement'
- Single map click handler dispatches to active tool
- Cursor management (default/crosshair/cell per tool)
- Ruler snap-to-site (20px threshold)
- Event propagation fixes (partial — terrain profile still leaks)
### Iteration 3.10.3: Calculator Button + Ruler Limit ✅
- Calculator button added to right toolbar
- Ruler limited to 2 points max (point-to-point only)
- Third click starts new measurement
### Iteration 3.10.4: Pending Fixes 🔧
- Terrain Profile click-through (needs stopImmediatePropagation on native event)
- TX Height hardcoded to 2m in Link Budget (should read from site config)
---
## Current State — What Works
### Core Features ✅
- Multi-site RF coverage planning with multi-sector antennas
- GPU-accelerated coverage calculation (RTX 4060, CuPy/CUDA)
- 9 propagation models (Free-Space, terrain_los, buildings, materials, dominant_path, street_canyon, reflections, water_reflection, vegetation, atmospheric)
- Performance: 11.2s Full preset (17.4x speedup from v3.8.0)
- Geographic-scale heatmap with Leaflet tile rendering
### Terrain Integration ✅
- SRTM elevation data (30m and 90m resolution)
- Bilinear interpolation for sub-pixel accuracy
- Memory-mapped I/O with LRU cache (20 tiles)
- Auto-detection SRTM1 vs SRTM3 by file size
- Terrain-aware coverage calculation (Line of Sight, terrain loss)
- Terrain Profile viewer with elevation chart
### Analysis Tools ✅
- **Link Budget Calculator** — point-to-point path analysis
- **Fresnel Zone Visualization** — on terrain profile chart
- **Ruler/Distance Measurement** — 2-point with snap-to-site
- **Terrain Profile** — elevation cross-section between 2 points
- **Coverage Statistics** — Excellent/Good/Fair/Weak breakdown
- **Session History** — compare calculation runs
### Data Management ✅
- Export: CSV, GeoJSON coverage data
- Import/Export: Site configurations (JSON)
- Data Cache: Regional tile pre-download (Ukraine, Eastern Ukraine, Donbas, Central, Western, Kyiv)
- 173 terrain tiles (4.3 GB) cached locally
### Infrastructure ✅
- Frontend: React 18 + TypeScript + Vite + Leaflet
- Backend: Python FastAPI + CuPy GPU pipeline
- Tile Server: terra.eliah.one (Caddy file_server)
- Packaging: PyInstaller + Electron (Windows installer)
- Desktop app: RFCP - RF Coverage Planner (native window)
---
## Known Bugs (for 3.10.4+)
| # | Bug | Severity | Root Cause |
|---|-----|----------|------------|
| 1 | Terrain Profile click places ruler point | Medium | stopPropagation not blocking Leaflet's native DOM listener. Need `e.nativeEvent.stopImmediatePropagation()` or move popup outside Leaflet container |
| 2 | TX Height shows 2m in Link Budget | Low | Hardcoded default, not reading from site config field |
| 3 | Cursor still shows hand in some cases | Low | Leaflet default grab cursor not fully overridden |
| 4 | Elevation Colors opacity slider | Low | May need correct layer reference binding |
---
## Roadmap — Updated February 4, 2026
### ✅ COMPLETED (Iterations 1-3.10.3)
**Phase 1: Foundation** (Dec 2024)
- React + TypeScript + Vite + Leaflet setup
- Basic site management, coverage calculation
**Phase 2: Core Features** (Jan 2025, Iterations 1-10.1)
- Multi-site, multi-sector, geographic heatmap
- Coverage statistics, keyboard shortcuts
- Code audit, production polish
**Phase 3: GPU Acceleration** (Feb 2-3, 2026, Iterations 3.1-3.8)
- CuPy/CUDA pipeline: 195s → 11.2s (17.4x)
- PyInstaller build with CUDA bundling
- Windows native backend (no WSL2)
**Phase 4: Terrain Integration** (Feb 4, 2026, Iterations 3.9-3.10)
- SRTM tile server (terra.eliah.one)
- 347 tiles, 4.5 GB, full Ukraine coverage
- Terrain-aware propagation, terrain profiles
- Link budget calculator, Fresnel zones
- Tool mode system, interference modeling
### 🔧 REMAINING ON CURRENT STACK
**3.10.4: Final Bugfixes** (1-2 hours)
- Terrain Profile click propagation fix
- TX Height from site config
- Cursor cleanup
- Elevation opacity fix
**3.11: Polish & QA** (optional, 2-3 hours)
- Interference C/I heatmap toggle on frontend
- Coverage comparison mode (before/after)
- Keyboard shortcuts help modal (?)
- Settings persistence (localStorage)
- Input validation improvements
**3.12: Offline Package** (optional, 2-3 hours)
- SRTM3 tiles bundled in installer (~180 MB gzipped)
- SRTM1 as optional "HD Terrain Pack" download
- First-run extraction to data/terrain/
- Full offline operation without internet
### 🔮 FUTURE (New Stack — When Inspired)
**Stack Migration: Tauri + SvelteKit + Rust**
- Native performance without Electron overhead
- Rust backend replacing Python FastAPI
- GPU compute via wgpu or Vulkan
- Smaller installer (<100 MB vs current ~1.6 GB)
- Already tested Tauri for UMTC Wiki project
**Advanced RF Features:**
- 3D terrain visualization (Three.js or WebGPU)
- Drive test data import and comparison
- Multiple frequency band planning
- Custom propagation model editor
- Real-time collaboration (via Matrix?)
**Field Deployment:**
- Live USB with BitLocker encryption
- Offline-first with full Ukraine terrain
- Integration with UMTC tactical mesh
- LoRa/IoT device position planning
---
## Tech Specs Quick Reference
### Backend
```
Location: D:\root\rfcp\backend
Framework: FastAPI + Uvicorn
GPU: CuPy + CUDA (RTX 4060)
Python: 3.x with numpy, scipy, httpx
Build: PyInstaller ONEDIR (~1.6 GB with CUDA)
Start: python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
```
### Frontend
```
Location: D:\root\rfcp\frontend
Framework: React 18 + TypeScript + Vite
Map: Leaflet + custom geographic heatmap
State: Zustand
Build: npm run build → dist/
Bundle: 163KB gzipped
```
### Tile Server
```
Domain: terra.eliah.one
Server: VPS A (2.56.207.143), Caddy file_server
Path: /opt/terra/tiles/srtm1/ and /opt/terra/tiles/srtm3/
Index: /api/index → tile_index.json
Health: /health → "ok"
Tiles: 187 SRTM3 (515 MB) + 160 SRTM1 (3.9 GB)
```
### Key Files
```
terrain_service.py — SRTM tile loading, bilinear interpolation, elevation profiles
gpu_service.py — CuPy/CUDA coverage calculation pipeline
coverage_service.py — Propagation models, coverage orchestration
routes/terrain.py — /api/terrain/status, /download, /index
routes/coverage.py — /api/link-budget, /api/fresnel-profile
frontend/src/store/tools.ts — ActiveTool state management
frontend/src/components/panels/LinkBudgetPanel.tsx
frontend/src/components/map/TerrainProfile.tsx
frontend/src/components/map/MeasurementTool.tsx
```
---
## Performance Benchmarks
| Preset | Resolution | Points | Time | GPU |
|--------|-----------|--------|------|-----|
| Standard | 200m | 1,975 | 7.4s | ✅ |
| Full | 50m | 6,639-6,662 | 11.2-11.7s | ✅ |
| 50km radius | 200m | 4,966 | ~30s | ✅ |
**GPU:** NVIDIA RTX 4060 (CUDA)
**Speedup:** 17.4x vs CPU-only (v3.7.0 baseline)
---
## Session Notes
Продуктивна сесія. За ~6 годин:
- Підняли tile server з нуля (terra.eliah.one)
- 347 тайлів terrain data для всієї України
- Інтегрували terrain в backend (auto-download, status API)
- Додали Link Budget Calculator, Fresnel Zone, Interference modeling
- Впровадили Tool Mode System для вирішення click conflicts
- Виправили купу UX багів
Продукт близький до завершення на поточному стеку. Основна функціональність працює, залишились polish баги та optional фічі. Рефактор на Tauri+SvelteKit+Rust — коли буде натхнення, не терміново.
Half Sword скачаний і чекає. 🗡️

View 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