14 KiB
RFCP - Iteration 7: Elevation Data + UX Polish
Issues from Iteration 6
- ❌ Omni circle visible - orange circle shows for omni antennas, should be hidden
- ❌ Zoom still breaks gradient - colors shift with zoom (maxIntensity not working?)
- ❌ No elevation visualization - can't see hills/mountains on map
- ❌ 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
// Only show wedge for directional sectors
{site.sectors.map(sector => (
sector.enabled && sector.beamwidth < 360 && ( // ← ADD THIS CHECK
<Polygon
key={sector.id}
positions={generateSectorWedge(site.lat, site.lon, sector)}
pathOptions={{
color: site.color,
weight: 2,
opacity: 0.6,
fillOpacity: 0.1,
dashArray: '5, 5'
}}
/>
)
))}
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
// 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:
// 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
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:
<div className="sector-actions">
<button onClick={() => cloneSector(sector)} title="Clone this sector">
📋 Clone
</button>
{sectors.length > 1 && (
<button onClick={() => removeSector(sector.id)}>
🗑️ Remove
</button>
)}
</div>
FEATURE 1: Elevation Visualization
What we want:
- See elevation (meters above sea level) when hovering cursor
- Color-coded elevation overlay on map
- 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)
import { useState, useEffect } from 'react';
import { useMap } from 'react-leaflet';
import L from 'leaflet';
export function useElevation() {
const map = useMap();
const [elevation, setElevation] = useState<number | null>(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)
import { useElevation } from '@/hooks/useElevation';
export function ElevationDisplay() {
const { elevation, position, loading } = useElevation();
if (!position) return null;
return (
<div className="elevation-display" style={{
position: 'absolute',
bottom: '40px',
left: '10px',
background: 'rgba(0,0,0,0.8)',
color: 'white',
padding: '8px 12px',
borderRadius: '4px',
fontSize: '12px',
zIndex: 1000,
pointerEvents: 'none'
}}>
<div>📍 {position.lat.toFixed(5)}°, {position.lon.toFixed(5)}°</div>
<div>
⛰️ {loading ? 'Loading...' : elevation !== null ? `${elevation}m ASL` : 'N/A'}
</div>
</div>
);
}
Usage in Map.tsx:
import { ElevationDisplay } from './ElevationDisplay';
{showElevationInfo && <ElevationDisplay />}
B. Elevation Color Overlay
Option: Use Stamen Terrain (color-coded by elevation)
File: frontend/src/components/map/Map.tsx
{showElevationOverlay && (
<TileLayer
url="https://tiles.stadiamaps.com/tiles/stamen_terrain/{z}/{x}/{y}.png"
attribution='© Stamen Design'
opacity={0.5}
zIndex={97}
/>
)}
Add control:
<label>
<input
type="checkbox"
checked={showElevationOverlay}
onChange={(e) => setShowElevationOverlay(e.target.checked)}
/>
Elevation Colors
</label>
FEATURE 2: Site Templates & Quick Actions
What: Preset site configurations for common deployments.
File: frontend/src/components/panels/SiteTemplates.tsx (new)
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 (
<div className="site-templates">
<h4>Quick Templates</h4>
<div className="template-grid">
{Object.entries(SITE_TEMPLATES).map(([key, template]) => (
<button
key={key}
onClick={() => onApply(template)}
className="template-btn"
>
{template.name}
</button>
))}
</div>
</div>
);
}
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
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)
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 (
<div className="coverage-stats">
<h4>Coverage Analysis</h4>
<div className="stat-item">
<span>Total Coverage Area:</span>
<strong>{totalArea.toFixed(1)} km²</strong>
</div>
<div className="stat-item">
<span>Excellent (> -70 dBm):</span>
<strong>{(coverageByLevel.excellent / points.length * 100).toFixed(1)}%</strong>
</div>
<div className="stat-item">
<span>Good (-85 to -70 dBm):</span>
<strong>{(coverageByLevel.good / points.length * 100).toFixed(1)}%</strong>
</div>
<div className="stat-item">
<span>Fair (-100 to -85 dBm):</span>
<strong>{(coverageByLevel.fair / points.length * 100).toFixed(1)}%</strong>
</div>
<div className="stat-item">
<span>Weak (< -100 dBm):</span>
<strong>{(coverageByLevel.weak / points.length * 100).toFixed(1)}%</strong>
</div>
</div>
);
}
FEATURE 4: Import/Export Sites
What: Save/load site configurations as JSON/CSV.
File: frontend/src/components/panels/SiteImportExport.tsx (new)
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 (
<div className="site-import-export">
<h4>Import/Export</h4>
<button onClick={exportSites}>
📥 Export Sites (JSON)
</button>
<input
type="file"
accept=".json"
onChange={(e) => e.target.files?.[0] && importSites(e.target.files[0])}
/>
</div>
);
}
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
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:
- Hide omni circle
- Debug/fix zoom gradient
- 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!