@mytec: iteration2 start
This commit is contained in:
625
RFCP-Iteration2-Task.md
Normal file
625
RFCP-Iteration2-Task.md
Normal file
@@ -0,0 +1,625 @@
|
||||
# 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! 🚀
|
||||
Reference in New Issue
Block a user