@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