Files
rfcp/RFCP-Iteration7-Elevation-UX.md
2026-01-30 12:53:06 +02:00

570 lines
14 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
<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`
```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:
<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:**
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<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)
```typescript
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:**
```typescript
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`
```typescript
{showElevationOverlay && (
<TileLayer
url="https://tiles.stadiamaps.com/tiles/stamen_terrain/{z}/{x}/{y}.png"
attribution='&copy; Stamen Design'
opacity={0.5}
zIndex={97}
/>
)}
```
**Add control:**
```typescript
<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)
```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 (
<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`
```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 (
<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 (&gt; -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 (&lt; -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)
```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 (
<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
```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!