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