diff --git a/RFCP-Iteration7-Elevation-UX.md b/RFCP-Iteration7-Elevation-UX.md new file mode 100644 index 0000000..6fe231b --- /dev/null +++ b/RFCP-Iteration7-Elevation-UX.md @@ -0,0 +1,569 @@ +# RFCP - Iteration 7: Elevation Data + UX Polish + +## Issues from Iteration 6 + +1. ❌ **Omni circle visible** - orange circle shows for omni antennas, should be hidden +2. ❌ **Zoom still breaks gradient** - colors shift with zoom (maxIntensity not working?) +3. ❌ **No elevation visualization** - can't see hills/mountains on map +4. ❌ **Sector cloning creates 3 sectors** - should clone 1 at a time + +--- + +## QUICK FIX 1: Hide Omni Coverage Circle + +**Problem:** Orange circle shows for omni antennas (360° beamwidth). + +**Solution:** Only draw sector wedge for directional antennas (beamwidth < 360). + +**File:** `frontend/src/components/map/SiteMarker.tsx` + +```typescript +// Only show wedge for directional sectors +{site.sectors.map(sector => ( + sector.enabled && sector.beamwidth < 360 && ( // ← ADD THIS CHECK + + ) +))} +``` + +--- + +## QUICK FIX 2: Debug Heatmap Zoom Issue + +**Problem:** Colors still change with zoom despite maxIntensity=0.75 fix. + +**Debug first:** + +**File:** `frontend/src/components/map/Heatmap.tsx` + +```typescript +// Add detailed logging +useEffect(() => { + if (import.meta.env.DEV && points.length > 0) { + const rsrpValues = points.map(p => p.rsrp); + const normalizedSample = points.slice(0, 5).map(p => ({ + rsrp: p.rsrp, + normalized: normalizeRSRP(p.rsrp) + })); + + console.log('🔍 Heatmap Debug:', { + zoom: mapZoom, + totalPoints: points.length, + rsrpRange: `${Math.min(...rsrpValues).toFixed(1)} to ${Math.max(...rsrpValues).toFixed(1)} dBm`, + radius, + blur, + maxIntensity, // ← Should be 0.75 + sample: normalizedSample + }); + } +}, [points, mapZoom]); +``` + +**Possible issue:** If `maxIntensity` is still a formula instead of constant 0.75, replace it: + +```typescript +// MUST be constant! +const maxIntensity = 0.75; // NOT a formula! +``` + +--- + +## QUICK FIX 3: Clone Single Sector + +**Problem:** Tri-sector preset creates 3 sectors at once. User wants to clone one sector at a time. + +**Solution:** Add "Clone Sector" button per sector. + +**File:** `frontend/src/components/panels/SectorConfig.tsx` + +```typescript +const cloneSector = (sector: Sector) => { + const newSector: Sector = { + ...sector, + id: `s${Date.now()}`, // Unique ID + azimuth: (sector.azimuth + 30) % 360 // Offset by 30° + }; + onUpdate([...sectors, newSector]); +}; + +// In sector item UI: +
+ + {sectors.length > 1 && ( + + )} +
+``` + +--- + +## FEATURE 1: Elevation Visualization + +**What we want:** +1. See elevation (meters above sea level) when hovering cursor +2. Color-coded elevation overlay on map +3. Elevation profile along measurement line + +### A. Cursor Elevation Display + +**Option 1: Use Open-Elevation API (free, no auth)** + +**File:** `frontend/src/hooks/useElevation.ts` (new) + +```typescript +import { useState, useEffect } from 'react'; +import { useMap } from 'react-leaflet'; +import L from 'leaflet'; + +export function useElevation() { + const map = useMap(); + const [elevation, setElevation] = useState(null); + const [position, setPosition] = useState<{ lat: number; lon: number } | null>(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + let timeoutId: number; + let abortController: AbortController; + + const handleMouseMove = (e: L.LeafletMouseEvent) => { + setPosition({ lat: e.latlng.lat, lon: e.latlng.lng }); + + // Debounce API calls (300ms) + clearTimeout(timeoutId); + if (abortController) abortController.abort(); + + timeoutId = window.setTimeout(async () => { + setLoading(true); + abortController = new AbortController(); + + try { + const response = await fetch( + `https://api.open-elevation.com/api/v1/lookup?locations=${e.latlng.lat},${e.latlng.lng}`, + { signal: abortController.signal } + ); + const data = await response.json(); + setElevation(data.results[0].elevation); + } catch (error) { + if (error.name !== 'AbortError') { + console.error('Elevation fetch failed:', error); + setElevation(null); + } + } finally { + setLoading(false); + } + }, 300); + }; + + map.on('mousemove', handleMouseMove); + + return () => { + map.off('mousemove', handleMouseMove); + clearTimeout(timeoutId); + if (abortController) abortController.abort(); + }; + }, [map]); + + return { elevation, position, loading }; +} +``` + +**Display Component:** + +**File:** `frontend/src/components/map/ElevationDisplay.tsx` (new) + +```typescript +import { useElevation } from '@/hooks/useElevation'; + +export function ElevationDisplay() { + const { elevation, position, loading } = useElevation(); + + if (!position) return null; + + return ( +
+
📍 {position.lat.toFixed(5)}°, {position.lon.toFixed(5)}°
+
+ ⛰️ {loading ? 'Loading...' : elevation !== null ? `${elevation}m ASL` : 'N/A'} +
+
+ ); +} +``` + +**Usage in Map.tsx:** +```typescript +import { ElevationDisplay } from './ElevationDisplay'; + +{showElevationInfo && } +``` + +### B. Elevation Color Overlay + +**Option: Use Stamen Terrain (color-coded by elevation)** + +**File:** `frontend/src/components/map/Map.tsx` + +```typescript +{showElevationOverlay && ( + +)} +``` + +**Add control:** +```typescript + +``` + +--- + +## FEATURE 2: Site Templates & Quick Actions + +**What:** Preset site configurations for common deployments. + +**File:** `frontend/src/components/panels/SiteTemplates.tsx` (new) + +```typescript +const SITE_TEMPLATES = { + urban_macro: { + name: 'Urban Macro Site', + height: 30, + power: 43, + frequency: 1800, + sectors: [ + { azimuth: 0, beamwidth: 65, gain: 18 }, + { azimuth: 120, beamwidth: 65, gain: 18 }, + { azimuth: 240, beamwidth: 65, gain: 18 } + ] + }, + rural_tower: { + name: 'Rural Tower', + height: 50, + power: 46, + frequency: 800, + sectors: [ + { azimuth: 0, beamwidth: 360, gain: 8 } // Omni + ] + }, + small_cell: { + name: 'Small Cell', + height: 6, + power: 30, + frequency: 2600, + sectors: [ + { azimuth: 0, beamwidth: 90, gain: 12 } + ] + }, + indoor_das: { + name: 'Indoor DAS', + height: 3, + power: 23, + frequency: 2100, + sectors: [ + { azimuth: 0, beamwidth: 360, gain: 2 } + ] + } +}; + +export function SiteTemplates({ onApply }: { onApply: (template: any) => void }) { + return ( +
+

Quick Templates

+
+ {Object.entries(SITE_TEMPLATES).map(([key, template]) => ( + + ))} +
+
+ ); +} +``` + +--- + +## FEATURE 3: Coverage Analysis Tools + +### A. Best Server Map + +**What:** Show which site provides best signal at each point (Voronoi-like). + +**Implementation:** Color each point by `siteId` instead of RSRP. + +**File:** `frontend/src/store/coverage.ts` + +```typescript +interface CoverageState { + // ...existing + viewMode: 'signal' | 'best-server'; // NEW +} + +// In coverage calculation: +if (viewMode === 'best-server') { + // Color by siteId instead of RSRP + return { lat, lon, siteId, rsrp }; +} +``` + +### B. Coverage Statistics + +**What:** Show coverage area, population, percentages. + +**File:** `frontend/src/components/panels/CoverageStats.tsx` (new) + +```typescript +export function CoverageStats({ points, sites }: Props) { + const totalArea = calculateArea(points); // km² + const coverageByLevel = { + excellent: points.filter(p => p.rsrp > -70).length, + good: points.filter(p => p.rsrp > -85 && p.rsrp <= -70).length, + fair: points.filter(p => p.rsrp > -100 && p.rsrp <= -85).length, + weak: points.filter(p => p.rsrp <= -100).length + }; + + return ( +
+

Coverage Analysis

+ +
+ Total Coverage Area: + {totalArea.toFixed(1)} km² +
+ +
+ Excellent (> -70 dBm): + {(coverageByLevel.excellent / points.length * 100).toFixed(1)}% +
+ +
+ Good (-85 to -70 dBm): + {(coverageByLevel.good / points.length * 100).toFixed(1)}% +
+ +
+ Fair (-100 to -85 dBm): + {(coverageByLevel.fair / points.length * 100).toFixed(1)}% +
+ +
+ Weak (< -100 dBm): + {(coverageByLevel.weak / points.length * 100).toFixed(1)}% +
+
+ ); +} +``` + +--- + +## FEATURE 4: Import/Export Sites + +**What:** Save/load site configurations as JSON/CSV. + +**File:** `frontend/src/components/panels/SiteImportExport.tsx` (new) + +```typescript +export function SiteImportExport() { + const { sites, setSites } = useSitesStore(); + + const exportSites = () => { + const json = JSON.stringify(sites, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `rfcp-sites-${Date.now()}.json`; + a.click(); + toast.success('Sites exported'); + }; + + const importSites = async (file: File) => { + try { + const text = await file.text(); + const imported = JSON.parse(text); + setSites(imported); + toast.success(`Imported ${imported.length} sites`); + } catch (error) { + toast.error('Invalid file format'); + } + }; + + return ( +
+

Import/Export

+ + + + e.target.files?.[0] && importSites(e.target.files[0])} + /> +
+ ); +} +``` + +--- + +## FEATURE 5: 3D Terrain View (Future - Phase 4) + +**What:** Optional 3D view with MapLibre GL + terrain data. + +**Teaser for later:** +- MapLibre GL for 3D rendering +- Real terrain elevation from SRTM files +- Line-of-sight visualization +- 3D buildings in cities + +--- + +## TESTING CHECKLIST (Iteration 7) + +### Quick Fixes: +- [ ] Omni coverage circle hidden (only sectors show wedges) +- [ ] Zoom gradient: Check console - maxIntensity=0.75 always? +- [ ] Clone sector: Creates 1 sector (not 3) + +### Elevation: +- [ ] Hover cursor shows elevation (meters ASL) +- [ ] Elevation overlay shows color-coded terrain +- [ ] Elevation display updates smoothly (debounced) + +### Templates: +- [ ] Apply "Urban Macro" template → 3 sectors created +- [ ] Apply "Rural Tower" → 1 omni sector +- [ ] Apply "Small Cell" → appropriate settings + +### Coverage Stats: +- [ ] Shows total coverage area in km² +- [ ] Shows percentage by signal level +- [ ] Updates after recalculation + +### Import/Export: +- [ ] Export sites → downloads JSON file +- [ ] Import JSON → restores sites correctly +- [ ] Invalid file shows error + +--- + +## BUILD & DEPLOY + +```bash +cd /opt/rfcp/frontend +npm run build +sudo systemctl reload caddy +``` + +--- + +## COMMIT MESSAGE + +``` +fix(ui): hide omni coverage circle visualization + +- Only show sector wedges for beamwidth < 360 +- Omni antennas (360°) no longer show orange circle + +fix(heatmap): debug zoom-dependent gradient issue + +- Added detailed console logging +- Verify maxIntensity is constant 0.75 + +feat(ux): clone single sector instead of tri-sector + +- Added "Clone Sector" button per sector +- Creates duplicate with 30° azimuth offset +- Removed automatic tri-sector creation + +feat(elevation): cursor elevation display + +- Shows elevation (meters ASL) at cursor position +- Uses Open-Elevation API with debouncing +- Optional elevation color overlay (Stamen Terrain) + +feat(templates): site configuration templates + +- Urban Macro (3-sector, 30m, 1800 MHz) +- Rural Tower (omni, 50m, 800 MHz) +- Small Cell (single sector, 6m, 2600 MHz) +- Indoor DAS (omni, 3m, 2100 MHz) + +feat(analysis): coverage statistics panel + +- Total coverage area in km² +- Signal quality breakdown (excellent/good/fair/weak) +- Percentage distribution + +feat(io): import/export site configurations + +- Export sites as JSON +- Import sites from JSON file +- Preserves all site and sector settings +``` + +--- + +## Iteration 7 Summary + +**Quick Fixes:** +1. Hide omni circle +2. Debug/fix zoom gradient +3. Clone 1 sector (not 3) + +**New Features:** +4. Elevation display (cursor + overlay) +5. Site templates (4 presets) +6. Coverage statistics +7. Import/Export sites + +**Future (Iteration 8?):** +- 3D terrain view (MapLibre GL) +- Line-of-sight analysis +- Population coverage estimation +- Network capacity planning + +🚀 Ready for Iteration 7!