Files
rfcp/RFCP-Iteration2-Task.md
2026-01-30 08:26:08 +02:00

626 lines
18 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.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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='&copy; OpenStreetMap contributors'
/>
{/* Terrain overlay (when enabled) */}
{showTerrain && (
<TileLayer
url="https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png"
attribution='Map data: &copy; OpenStreetMap, SRTM | Style: &copy; 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! 🚀