@mytec: start iter7
This commit is contained in:
569
RFCP-Iteration7-Elevation-UX.md
Normal file
569
RFCP-Iteration7-Elevation-UX.md
Normal file
@@ -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
|
||||||
|
<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='© 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 (> -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)
|
||||||
|
|
||||||
|
```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!
|
||||||
Reference in New Issue
Block a user