626 lines
18 KiB
Markdown
626 lines
18 KiB
Markdown
# RFCP - Iteration 2: Terrain Overlay + Heatmap Fixes + Batch Operations
|
||
|
||
## Context
|
||
Iteration 1 completed successfully (dark theme, new colors, radius 100km, shortcuts).
|
||
Now addressing: heatmap pixelation at close zoom, terrain elevation overlay, batch height operations.
|
||
|
||
---
|
||
|
||
## CRITICAL FIXES
|
||
|
||
### 1. Dynamic Heatmap Radius Based on Zoom Level
|
||
**Problem:** At close zoom (12-15), heatmap becomes blocky/pixelated with square artifacts
|
||
**Cause:** Fixed radius (25px) doesn't scale with zoom
|
||
|
||
**File:** `frontend/src/components/map/Heatmap.tsx`
|
||
|
||
**Solution:**
|
||
```typescript
|
||
import { useEffect, useState } from 'react';
|
||
import { useMap } from 'react-leaflet';
|
||
|
||
export function Heatmap({ points }: HeatmapProps) {
|
||
const map = useMap();
|
||
const [mapZoom, setMapZoom] = useState(map.getZoom());
|
||
|
||
// Track zoom changes
|
||
useEffect(() => {
|
||
const handleZoomEnd = () => {
|
||
setMapZoom(map.getZoom());
|
||
};
|
||
|
||
map.on('zoomend', handleZoomEnd);
|
||
return () => {
|
||
map.off('zoomend', handleZoomEnd);
|
||
};
|
||
}, [map]);
|
||
|
||
// Calculate adaptive radius and blur based on zoom
|
||
// Lower zoom (zoomed out) = larger radius
|
||
// Higher zoom (zoomed in) = smaller radius
|
||
const getHeatmapParams = (zoom: number) => {
|
||
// Zoom 6 (country view): radius=40, blur=20
|
||
// Zoom 10 (regional): radius=28, blur=14
|
||
// Zoom 14 (city): radius=16, blur=10
|
||
// Zoom 18 (street): radius=8, blur=6
|
||
|
||
const radius = Math.max(8, Math.min(40, 50 - zoom * 2.5));
|
||
const blur = Math.max(6, Math.min(20, 30 - zoom * 1.5));
|
||
|
||
return { radius, blur };
|
||
};
|
||
|
||
const { radius, blur } = getHeatmapParams(mapZoom);
|
||
|
||
return (
|
||
<HeatmapLayer
|
||
points={heatmapPoints}
|
||
longitudeExtractor={(p: any) => p[1]}
|
||
latitudeExtractor={(p: any) => p[0]}
|
||
intensityExtractor={(p: any) => p[2]}
|
||
gradient={{
|
||
0.0: '#0d47a1',
|
||
0.2: '#00bcd4',
|
||
0.4: '#4caf50',
|
||
0.6: '#ffeb3b',
|
||
0.8: '#ff9800',
|
||
1.0: '#f44336',
|
||
}}
|
||
radius={radius} // ← Dynamic
|
||
blur={blur} // ← Dynamic
|
||
max={1.0}
|
||
minOpacity={0.3}
|
||
/>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Test:** Zoom in close to a site, heatmap should remain smooth, not blocky
|
||
|
||
---
|
||
|
||
### 2. Terrain Elevation Overlay 🏔️
|
||
**Feature:** Toggle terrain/topography layer to see elevation while planning
|
||
|
||
**Files:**
|
||
- `frontend/src/components/map/Map.tsx` - add layer
|
||
- `frontend/src/store/settings.ts` - persist toggle state
|
||
|
||
**Implementation:**
|
||
|
||
**A) Add to settings store:**
|
||
|
||
```typescript
|
||
// src/store/settings.ts
|
||
interface SettingsState {
|
||
theme: 'light' | 'dark' | 'system';
|
||
showTerrain: boolean; // ← New
|
||
setTheme: (theme: 'light' | 'dark' | 'system') => void;
|
||
setShowTerrain: (show: boolean) => void; // ← New
|
||
}
|
||
|
||
export const useSettingsStore = create<SettingsState>()(
|
||
persist(
|
||
(set) => ({
|
||
theme: 'system',
|
||
showTerrain: false, // ← Default off
|
||
setTheme: (theme) => {
|
||
set({ theme });
|
||
applyTheme(theme);
|
||
},
|
||
setShowTerrain: (show) => set({ showTerrain: show }),
|
||
}),
|
||
{
|
||
name: 'rfcp-settings',
|
||
}
|
||
)
|
||
);
|
||
```
|
||
|
||
**B) Add terrain layer to Map:**
|
||
|
||
```typescript
|
||
// src/components/map/Map.tsx
|
||
import { useSettingsStore } from '@/store/settings';
|
||
import { TileLayer } from 'react-leaflet';
|
||
|
||
function Map() {
|
||
const showTerrain = useSettingsStore((s) => s.showTerrain);
|
||
|
||
return (
|
||
<MapContainer /* ... */>
|
||
{/* Base OSM layer */}
|
||
<TileLayer
|
||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||
attribution='© OpenStreetMap contributors'
|
||
/>
|
||
|
||
{/* Terrain overlay (when enabled) */}
|
||
{showTerrain && (
|
||
<TileLayer
|
||
url="https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png"
|
||
attribution='Map data: © OpenStreetMap, SRTM | Style: © OpenTopoMap'
|
||
opacity={0.6} // Semi-transparent so base map shows through
|
||
zIndex={1}
|
||
/>
|
||
)}
|
||
|
||
{/* Rest of map content */}
|
||
</MapContainer>
|
||
);
|
||
}
|
||
```
|
||
|
||
**C) Add toggle button:**
|
||
|
||
```typescript
|
||
// In Map controls section:
|
||
<div className="absolute top-4 right-4 z-[1000] flex flex-col gap-2">
|
||
{/* Existing buttons (Fit, Reset) */}
|
||
|
||
{/* Terrain toggle */}
|
||
<button
|
||
onClick={() => setShowTerrain(!showTerrain)}
|
||
className={`
|
||
bg-white dark:bg-dark-surface shadow-lg rounded px-3 py-2
|
||
hover:bg-gray-50 dark:hover:bg-dark-border
|
||
transition-colors
|
||
${showTerrain ? 'ring-2 ring-blue-500' : ''}
|
||
`}
|
||
title={showTerrain ? 'Hide terrain' : 'Show terrain elevation'}
|
||
>
|
||
{showTerrain ? '🗺️' : '🏔️'}
|
||
</button>
|
||
</div>
|
||
```
|
||
|
||
**Alternative terrain sources:**
|
||
```typescript
|
||
// Option 1: OpenTopoMap (best for Europe, shows contour lines)
|
||
url="https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png"
|
||
|
||
// Option 2: USGS Topo (USA focused, detailed)
|
||
url="https://basemap.nationalmap.gov/arcgis/rest/services/USGSTopo/MapServer/tile/{z}/{y}/{x}"
|
||
|
||
// Option 3: Thunderforest Landscape (requires API key but beautiful)
|
||
url="https://{s}.tile.thunderforest.com/landscape/{z}/{x}/{y}.png?apikey=YOUR_KEY"
|
||
|
||
// Option 4: Stamen Terrain (classic look)
|
||
url="https://stamen-tiles.a.ssl.fastly.net/terrain/{z}/{x}/{y}.jpg"
|
||
```
|
||
|
||
**Recommendation:** Start with OpenTopoMap (no API key needed, good for Ukraine)
|
||
|
||
---
|
||
|
||
### 3. Batch Operations for Site Height 📊
|
||
**Feature:** Select multiple sites and adjust height together
|
||
|
||
**Files:**
|
||
- `frontend/src/components/panels/SiteList.tsx` - selection UI
|
||
- `frontend/src/components/panels/BatchEdit.tsx` - new component
|
||
- `frontend/src/store/sites.ts` - batch update methods
|
||
|
||
**Implementation:**
|
||
|
||
**A) Update sites store with batch operations:**
|
||
|
||
```typescript
|
||
// src/store/sites.ts
|
||
interface SitesState {
|
||
sites: Site[];
|
||
selectedSite: Site | null;
|
||
selectedSiteIds: string[]; // ← New for batch selection
|
||
placementMode: boolean;
|
||
|
||
// Existing methods...
|
||
|
||
// New batch methods:
|
||
toggleSiteSelection: (siteId: string) => void;
|
||
selectAllSites: () => void;
|
||
clearSelection: () => void;
|
||
batchUpdateHeight: (adjustment: number) => void;
|
||
batchSetHeight: (height: number) => void;
|
||
}
|
||
|
||
export const useSitesStore = create<SitesState>((set, get) => ({
|
||
sites: [],
|
||
selectedSite: null,
|
||
selectedSiteIds: [],
|
||
placementMode: false,
|
||
|
||
// ... existing methods ...
|
||
|
||
toggleSiteSelection: (siteId) => {
|
||
set((state) => {
|
||
const isSelected = state.selectedSiteIds.includes(siteId);
|
||
return {
|
||
selectedSiteIds: isSelected
|
||
? state.selectedSiteIds.filter(id => id !== siteId)
|
||
: [...state.selectedSiteIds, siteId]
|
||
};
|
||
});
|
||
},
|
||
|
||
selectAllSites: () => {
|
||
set((state) => ({
|
||
selectedSiteIds: state.sites.map(s => s.id)
|
||
}));
|
||
},
|
||
|
||
clearSelection: () => {
|
||
set({ selectedSiteIds: [] });
|
||
},
|
||
|
||
batchUpdateHeight: (adjustment) => {
|
||
set((state) => {
|
||
const selectedIds = new Set(state.selectedSiteIds);
|
||
return {
|
||
sites: state.sites.map(site =>
|
||
selectedIds.has(site.id)
|
||
? { ...site, height: Math.max(1, Math.min(100, site.height + adjustment)) }
|
||
: site
|
||
)
|
||
};
|
||
});
|
||
},
|
||
|
||
batchSetHeight: (height) => {
|
||
set((state) => {
|
||
const selectedIds = new Set(state.selectedSiteIds);
|
||
return {
|
||
sites: state.sites.map(site =>
|
||
selectedIds.has(site.id)
|
||
? { ...site, height: Math.max(1, Math.min(100, height)) }
|
||
: site
|
||
)
|
||
};
|
||
});
|
||
},
|
||
}));
|
||
```
|
||
|
||
**B) Create BatchEdit component:**
|
||
|
||
```typescript
|
||
// src/components/panels/BatchEdit.tsx
|
||
import { useState } from 'react';
|
||
import { useSitesStore } from '@/store/sites';
|
||
import { Button } from '@/components/ui/Button';
|
||
import { Input } from '@/components/ui/Input';
|
||
import { toast } from '@/components/ui/Toast';
|
||
|
||
export function BatchEdit() {
|
||
const {
|
||
selectedSiteIds,
|
||
batchUpdateHeight,
|
||
batchSetHeight,
|
||
clearSelection
|
||
} = useSitesStore();
|
||
|
||
const [customHeight, setCustomHeight] = useState('');
|
||
|
||
if (selectedSiteIds.length === 0) return null;
|
||
|
||
const handleAdjustHeight = (delta: number) => {
|
||
batchUpdateHeight(delta);
|
||
toast.success(`Adjusted ${selectedSiteIds.length} site(s) by ${delta > 0 ? '+' : ''}${delta}m`);
|
||
};
|
||
|
||
const handleSetHeight = () => {
|
||
const height = parseInt(customHeight);
|
||
if (isNaN(height) || height < 1 || height > 100) {
|
||
toast.error('Height must be between 1-100m');
|
||
return;
|
||
}
|
||
batchSetHeight(height);
|
||
toast.success(`Set ${selectedSiteIds.length} site(s) to ${height}m`);
|
||
setCustomHeight('');
|
||
};
|
||
|
||
return (
|
||
<div className="
|
||
bg-blue-50 dark:bg-blue-900/20
|
||
border border-blue-200 dark:border-blue-800
|
||
rounded-lg p-4 space-y-3
|
||
">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="font-semibold text-blue-900 dark:text-blue-100">
|
||
📊 Batch Edit ({selectedSiteIds.length} selected)
|
||
</h3>
|
||
<Button
|
||
onClick={clearSelection}
|
||
size="sm"
|
||
variant="ghost"
|
||
>
|
||
Clear
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Quick adjustments */}
|
||
<div>
|
||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 block">
|
||
Adjust Height:
|
||
</label>
|
||
<div className="flex gap-2">
|
||
<Button onClick={() => handleAdjustHeight(10)} size="sm">
|
||
+10m
|
||
</Button>
|
||
<Button onClick={() => handleAdjustHeight(5)} size="sm">
|
||
+5m
|
||
</Button>
|
||
<Button onClick={() => handleAdjustHeight(-5)} size="sm">
|
||
-5m
|
||
</Button>
|
||
<Button onClick={() => handleAdjustHeight(-10)} size="sm">
|
||
-10m
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Set exact height */}
|
||
<div>
|
||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 block">
|
||
Set Height:
|
||
</label>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
max={100}
|
||
value={customHeight}
|
||
onChange={(e) => setCustomHeight(e.target.value)}
|
||
placeholder="meters"
|
||
className="flex-1"
|
||
/>
|
||
<Button
|
||
onClick={handleSetHeight}
|
||
disabled={!customHeight}
|
||
>
|
||
Apply
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
**C) Update SiteList with checkboxes:**
|
||
|
||
```typescript
|
||
// src/components/panels/SiteList.tsx
|
||
import { useSitesStore } from '@/store/sites';
|
||
import { BatchEdit } from './BatchEdit';
|
||
|
||
export function SiteList() {
|
||
const {
|
||
sites,
|
||
selectedSiteIds,
|
||
toggleSiteSelection,
|
||
selectAllSites,
|
||
clearSelection
|
||
} = useSitesStore();
|
||
|
||
const allSelected = sites.length > 0 && selectedSiteIds.length === sites.length;
|
||
const someSelected = selectedSiteIds.length > 0 && !allSelected;
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{/* Header with select all */}
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="font-semibold">Sites ({sites.length})</h3>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => allSelected ? clearSelection() : selectAllSites()}
|
||
className="text-sm text-blue-600 hover:text-blue-700"
|
||
>
|
||
{allSelected ? 'Deselect All' : 'Select All'}
|
||
</button>
|
||
<Button onClick={onAddSite} size="sm">
|
||
+ Place on Map
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Batch edit panel (shown when items selected) */}
|
||
<BatchEdit />
|
||
|
||
{/* Sites list */}
|
||
<div className="space-y-2">
|
||
{sites.map((site) => {
|
||
const isSelected = selectedSiteIds.includes(site.id);
|
||
|
||
return (
|
||
<div
|
||
key={site.id}
|
||
className={`
|
||
p-3 rounded-lg border transition-all
|
||
${isSelected
|
||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||
: 'border-gray-200 dark:border-dark-border'
|
||
}
|
||
hover:shadow-md
|
||
`}
|
||
>
|
||
<div className="flex items-start gap-3">
|
||
{/* Checkbox */}
|
||
<input
|
||
type="checkbox"
|
||
checked={isSelected}
|
||
onChange={() => toggleSiteSelection(site.id)}
|
||
className="mt-1 w-4 h-4 rounded border-gray-300"
|
||
/>
|
||
|
||
{/* Color indicator */}
|
||
<div
|
||
className="w-4 h-4 rounded-full border-2 border-white shadow mt-0.5"
|
||
style={{ backgroundColor: site.color }}
|
||
/>
|
||
|
||
{/* Site info */}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="font-medium text-gray-900 dark:text-dark-text">
|
||
{site.name}
|
||
</div>
|
||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||
📻 {site.frequency} MHz •
|
||
⚡ {site.power} dBm •
|
||
📏 {site.height}m •
|
||
📡 {site.antennaType === 'omni' ? 'Omni' : `Sector ${site.azimuth}°`}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
<div className="flex gap-1">
|
||
<Button
|
||
onClick={() => onEdit(site.id)}
|
||
size="sm"
|
||
variant="ghost"
|
||
>
|
||
Edit
|
||
</Button>
|
||
<Button
|
||
onClick={() => onDelete(site.id)}
|
||
size="sm"
|
||
variant="ghost"
|
||
>
|
||
×
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## ADDITIONAL IMPROVEMENTS
|
||
|
||
### 4. Show Current Elevation on Map Hover (Future)
|
||
**Feature:** When hovering mouse, show elevation at cursor position
|
||
|
||
*Note: Requires terrain data loaded (Phase 4). Mark as TODO for now.*
|
||
|
||
```typescript
|
||
// TODO Phase 4: Add cursor elevation display
|
||
// When terrain manager is available:
|
||
const [cursorElevation, setCursorElevation] = useState<number | null>(null);
|
||
|
||
useEffect(() => {
|
||
const handleMouseMove = async (e: L.LeafletMouseEvent) => {
|
||
const { lat, lng } = e.latlng;
|
||
const elevation = await terrainManager.getElevation(lat, lng);
|
||
setCursorElevation(elevation);
|
||
};
|
||
|
||
map.on('mousemove', handleMouseMove);
|
||
return () => map.off('mousemove', handleMouseMove);
|
||
}, [map]);
|
||
|
||
// Display in corner:
|
||
{cursorElevation !== null && (
|
||
<div className="absolute bottom-4 left-4 bg-white dark:bg-dark-surface px-3 py-2 rounded shadow">
|
||
🏔️ Elevation: {cursorElevation}m
|
||
</div>
|
||
)}
|
||
```
|
||
|
||
---
|
||
|
||
### 5. Persist Batch Selection Across Panel Close
|
||
**Enhancement:** Remember which sites were selected even if user closes/reopens panel
|
||
|
||
Already handled by zustand store persistence!
|
||
|
||
---
|
||
|
||
## TESTING CHECKLIST
|
||
|
||
### Heatmap Zoom Test:
|
||
- [ ] Zoom out (level 6-8): heatmap should be smooth, large radius
|
||
- [ ] Zoom in (level 12-14): heatmap should remain smooth, smaller radius
|
||
- [ ] No blocky/square artifacts at any zoom level
|
||
|
||
### Terrain Overlay Test:
|
||
- [ ] Toggle terrain on → contour lines visible
|
||
- [ ] Toggle terrain off → back to normal OSM
|
||
- [ ] Works in both light and dark theme
|
||
- [ ] Terrain opacity allows base map to show through
|
||
- [ ] Toggle state persists after page refresh
|
||
|
||
### Batch Operations Test:
|
||
- [ ] Select individual sites with checkboxes
|
||
- [ ] "Select All" selects all sites
|
||
- [ ] BatchEdit panel appears when sites selected
|
||
- [ ] +10m button increases height of selected sites
|
||
- [ ] -10m button decreases height (min 1m)
|
||
- [ ] Custom height input sets exact height (1-100m validation)
|
||
- [ ] Toast notifications show number of sites affected
|
||
- [ ] Clear selection removes batch panel
|
||
- [ ] Height changes reflected in site list immediately
|
||
- [ ] Can still edit individual sites while others selected
|
||
|
||
---
|
||
|
||
## FILES TO CREATE/MODIFY
|
||
|
||
### New Files:
|
||
- `frontend/src/components/panels/BatchEdit.tsx` - batch operations UI
|
||
|
||
### Modified Files:
|
||
- `frontend/src/components/map/Heatmap.tsx` - dynamic radius/blur
|
||
- `frontend/src/components/map/Map.tsx` - terrain overlay toggle
|
||
- `frontend/src/components/panels/SiteList.tsx` - checkboxes + batch UI
|
||
- `frontend/src/store/settings.ts` - add showTerrain
|
||
- `frontend/src/store/sites.ts` - batch operations methods
|
||
|
||
---
|
||
|
||
## IMPLEMENTATION ORDER
|
||
|
||
1. **Heatmap zoom fix** (5 min) - quick visual improvement
|
||
2. **Terrain overlay** (10 min) - new feature, easy to add
|
||
3. **Batch operations** (20 min) - more complex, needs store + UI
|
||
|
||
**Total time:** ~35 minutes
|
||
|
||
---
|
||
|
||
## SUCCESS CRITERIA
|
||
|
||
✅ Heatmap stays smooth at all zoom levels
|
||
✅ Terrain overlay toggle works and persists
|
||
✅ Can select multiple sites and batch-adjust height
|
||
✅ BatchEdit panel is intuitive and responsive
|
||
✅ All operations work in dark mode
|
||
✅ No TypeScript errors
|
||
✅ Toast feedback for all batch operations
|
||
|
||
---
|
||
|
||
## NOTES
|
||
|
||
**About antenna height in calculations:**
|
||
Currently height is stored but not used in FSPL calculations (correct behavior).
|
||
Height will matter in Phase 4 when terrain loss is added:
|
||
- `terrainLoss = f(txHeight, rxHeight, elevation profile)`
|
||
- Higher antenna = better line-of-sight = less terrain loss
|
||
|
||
For now, height is cosmetic but will be critical in Phase 4.
|
||
|
||
---
|
||
|
||
**About terrain overlay vs terrain data:**
|
||
- **Terrain overlay** (this iteration): Visual layer showing topography
|
||
- **Terrain data** (Phase 4): 30m SRTM elevation data for calculations
|
||
|
||
They're different! Overlay is for user visualization, data is for RF calculations.
|
||
|
||
Good luck! 🚀
|