18 KiB
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:
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 layerfrontend/src/store/settings.ts- persist toggle state
Implementation:
A) Add to settings store:
// 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:
// 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:
// 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:
// 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 UIfrontend/src/components/panels/BatchEdit.tsx- new componentfrontend/src/store/sites.ts- batch update methods
Implementation:
A) Update sites store with batch operations:
// 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:
// 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:
// 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.
// 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/blurfrontend/src/components/map/Map.tsx- terrain overlay togglefrontend/src/components/panels/SiteList.tsx- checkboxes + batch UIfrontend/src/store/settings.ts- add showTerrainfrontend/src/store/sites.ts- batch operations methods
IMPLEMENTATION ORDER
- Heatmap zoom fix (5 min) - quick visual improvement
- Terrain overlay (10 min) - new feature, easy to add
- 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! 🚀