474 lines
12 KiB
Markdown
474 lines
12 KiB
Markdown
# RFCP - Iteration 3: Heatmap Fix + Phase 4 Preparation
|
||
|
||
## Context
|
||
RFCP is successfully deployed on VPS (https://rfcp.eliah.one) with:
|
||
- ✅ Dark theme working
|
||
- ✅ Terrain overlay (Topo button) working
|
||
- ✅ Batch operations working
|
||
- ⚠️ Heatmap gradient issue: becomes solid yellow/orange at close zoom (12+)
|
||
|
||
Current deployment: VPS-A via Caddy reverse proxy + FastAPI backend on port 8888
|
||
|
||
---
|
||
|
||
## CRITICAL FIX: Heatmap Gradient at Close Zoom
|
||
|
||
**Problem:** When zooming close (level 12-16), the entire heatmap becomes solid yellow/orange instead of showing the blue→cyan→green→yellow→orange→red gradient.
|
||
|
||
**Root Cause:** The `max` parameter in leaflet.heat and RSRP normalization range cause saturation at close zoom.
|
||
|
||
**File:** `frontend/src/components/map/Heatmap.tsx`
|
||
|
||
### Fix Implementation:
|
||
|
||
```typescript
|
||
import { useEffect, useState } from 'react';
|
||
import { useMap } from 'react-leaflet';
|
||
import { HeatmapLayerFactory } from '@vgrid/react-leaflet-heatmap-layer';
|
||
|
||
const HeatmapLayer = HeatmapLayerFactory<[number, number, number]>();
|
||
|
||
interface HeatmapProps {
|
||
points: Array<{
|
||
lat: number;
|
||
lon: number;
|
||
rsrp: number;
|
||
siteId: string;
|
||
}>;
|
||
visible: boolean;
|
||
}
|
||
|
||
export function Heatmap({ points, visible }: HeatmapProps) {
|
||
const map = useMap();
|
||
const [mapZoom, setMapZoom] = useState(map.getZoom());
|
||
|
||
useEffect(() => {
|
||
const handleZoomEnd = () => {
|
||
setMapZoom(map.getZoom());
|
||
};
|
||
|
||
map.on('zoomend', handleZoomEnd);
|
||
return () => {
|
||
map.off('zoomend', handleZoomEnd);
|
||
};
|
||
}, [map]);
|
||
|
||
if (!visible || points.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
// CRITICAL FIX 1: Correct RSRP range
|
||
const normalizeRSRP = (rsrp: number): number => {
|
||
const minRSRP = -120;
|
||
const maxRSRP = -70; // ← CHANGED from -60 to -70 for full range
|
||
const normalized = (rsrp - minRSRP) / (maxRSRP - minRSRP);
|
||
return Math.max(0, Math.min(1, normalized));
|
||
};
|
||
|
||
// CRITICAL FIX 2: Dynamic max intensity based on zoom
|
||
const getHeatmapParams = (zoom: number) => {
|
||
const radius = Math.max(8, Math.min(40, 50 - zoom * 2.5));
|
||
const blur = Math.max(6, Math.min(20, 30 - zoom * 1.5));
|
||
|
||
// KEY FIX: Lower max at high zoom to prevent saturation
|
||
// zoom 6 (country): max=0.90 → smooth blend
|
||
// zoom 10 (region): max=0.70 → medium detail
|
||
// zoom 14 (city): max=0.50 → gradient visible
|
||
// zoom 18 (street): max=0.30 → tight detail
|
||
const maxIntensity = Math.max(0.3, Math.min(1.0, 1.2 - zoom * 0.05));
|
||
|
||
return { radius, blur, maxIntensity };
|
||
};
|
||
|
||
const { radius, blur, maxIntensity } = getHeatmapParams(mapZoom);
|
||
|
||
// Convert points to heatmap format
|
||
const heatmapPoints = points.map(point => [
|
||
point.lat,
|
||
point.lon,
|
||
normalizeRSRP(point.rsrp)
|
||
] as [number, number, number]);
|
||
|
||
return (
|
||
<HeatmapLayer
|
||
points={heatmapPoints}
|
||
longitudeExtractor={(p) => p[1]}
|
||
latitudeExtractor={(p) => p[0]}
|
||
intensityExtractor={(p) => p[2]}
|
||
gradient={{
|
||
0.0: '#0d47a1', // Dark Blue
|
||
0.2: '#00bcd4', // Cyan
|
||
0.4: '#4caf50', // Green
|
||
0.6: '#ffeb3b', // Yellow
|
||
0.8: '#ff9800', // Orange
|
||
1.0: '#f44336', // Red
|
||
}}
|
||
radius={radius}
|
||
blur={blur}
|
||
max={maxIntensity} // ← DYNAMIC based on zoom
|
||
minOpacity={0.3}
|
||
/>
|
||
);
|
||
}
|
||
```
|
||
|
||
### Why This Works:
|
||
|
||
**Problem:** At close zoom, many heatmap points overlap in small screen space. If `max=1.0` (default), they all saturate to the peak color (solid orange).
|
||
|
||
**Solution:** By lowering `max` at high zoom levels (e.g., `max=0.5` at zoom 14), we "stretch" the intensity scale. Now the densely packed points map to different parts of the 0.0-1.0 range, revealing the full gradient even at close zoom.
|
||
|
||
**RSRP Fix:** Changing `maxRSRP` from -60 to -70 dBm ensures points in the -70 to -60 range (excellent signal) map to intensity 1.0 (red), not stuck at 0.8 (orange).
|
||
|
||
---
|
||
|
||
## ADDITIONAL IMPROVEMENTS
|
||
|
||
### 1. Add Heatmap Opacity Slider
|
||
|
||
**File:** `frontend/src/components/panels/CoverageSettings.tsx` (or wherever settings panel is)
|
||
|
||
```typescript
|
||
import { Slider } from '@/components/ui/Slider';
|
||
|
||
// In coverage store:
|
||
interface CoverageSettings {
|
||
radius: number;
|
||
resolution: number;
|
||
rsrpThreshold: number;
|
||
heatmapOpacity: number; // NEW
|
||
}
|
||
|
||
// In UI:
|
||
<Slider
|
||
label="Heatmap Opacity"
|
||
min={0.3}
|
||
max={1.0}
|
||
step={0.1}
|
||
value={coverageSettings.heatmapOpacity}
|
||
onChange={(value) => updateCoverageSettings({ heatmapOpacity: value })}
|
||
suffix=""
|
||
help="Adjust heatmap transparency"
|
||
/>
|
||
|
||
// Pass to Heatmap component:
|
||
<Heatmap
|
||
points={points}
|
||
visible={visible}
|
||
opacity={coverageSettings.heatmapOpacity} // NEW
|
||
/>
|
||
|
||
// In Heatmap.tsx, wrap layer:
|
||
<div style={{ opacity: opacity }}>
|
||
<HeatmapLayer ... />
|
||
</div>
|
||
```
|
||
|
||
### 2. Export Coverage Data (CSV/GeoJSON)
|
||
|
||
**File:** `frontend/src/components/panels/ExportPanel.tsx` (new)
|
||
|
||
```typescript
|
||
import { Button } from '@/components/ui/Button';
|
||
import { useCoverageStore } from '@/store/coverage';
|
||
import { toast } from '@/components/ui/Toast';
|
||
|
||
export function ExportPanel() {
|
||
const { coveragePoints, sites } = useCoverageStore();
|
||
|
||
const exportCSV = () => {
|
||
if (coveragePoints.length === 0) {
|
||
toast.error('No coverage data to export');
|
||
return;
|
||
}
|
||
|
||
const csv = [
|
||
'lat,lon,rsrp,site_id',
|
||
...coveragePoints.map(p => `${p.lat},${p.lon},${p.rsrp},${p.siteId}`)
|
||
].join('\n');
|
||
|
||
const blob = new Blob([csv], { type: 'text/csv' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `coverage-${Date.now()}.csv`;
|
||
a.click();
|
||
|
||
toast.success('Exported coverage data');
|
||
};
|
||
|
||
const exportGeoJSON = () => {
|
||
if (coveragePoints.length === 0) {
|
||
toast.error('No coverage data to export');
|
||
return;
|
||
}
|
||
|
||
const geojson = {
|
||
type: 'FeatureCollection',
|
||
features: coveragePoints.map(p => ({
|
||
type: 'Feature',
|
||
geometry: {
|
||
type: 'Point',
|
||
coordinates: [p.lon, p.lat]
|
||
},
|
||
properties: {
|
||
rsrp: p.rsrp,
|
||
siteId: p.siteId
|
||
}
|
||
}))
|
||
};
|
||
|
||
const blob = new Blob([JSON.stringify(geojson, null, 2)], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `coverage-${Date.now()}.geojson`;
|
||
a.click();
|
||
|
||
toast.success('Exported GeoJSON');
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
<h3 className="font-semibold">Export Coverage</h3>
|
||
<div className="flex gap-2">
|
||
<Button onClick={exportCSV} size="sm">
|
||
📊 CSV
|
||
</Button>
|
||
<Button onClick={exportGeoJSON} size="sm">
|
||
🗺️ GeoJSON
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 3. Project Save/Load (IndexedDB)
|
||
|
||
**File:** `frontend/src/store/projects.ts` (new)
|
||
|
||
```typescript
|
||
import { create } from 'zustand';
|
||
import Dexie, { Table } from 'dexie';
|
||
|
||
interface Project {
|
||
id: string;
|
||
name: string;
|
||
description?: string;
|
||
sites: Site[];
|
||
coverageSettings: CoverageSettings;
|
||
createdAt: number;
|
||
updatedAt: number;
|
||
}
|
||
|
||
class ProjectDatabase extends Dexie {
|
||
projects!: Table<Project>;
|
||
|
||
constructor() {
|
||
super('rfcp-projects');
|
||
this.version(1).stores({
|
||
projects: 'id, name, updatedAt'
|
||
});
|
||
}
|
||
}
|
||
|
||
const db = new ProjectDatabase();
|
||
|
||
interface ProjectsState {
|
||
currentProject: Project | null;
|
||
projects: Project[];
|
||
|
||
loadProjects: () => Promise<void>;
|
||
saveProject: (project: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>) => Promise<void>;
|
||
loadProject: (id: string) => Promise<void>;
|
||
deleteProject: (id: string) => Promise<void>;
|
||
}
|
||
|
||
export const useProjectsStore = create<ProjectsState>((set, get) => ({
|
||
currentProject: null,
|
||
projects: [],
|
||
|
||
loadProjects: async () => {
|
||
const projects = await db.projects.orderBy('updatedAt').reverse().toArray();
|
||
set({ projects });
|
||
},
|
||
|
||
saveProject: async (projectData) => {
|
||
const id = crypto.randomUUID();
|
||
const now = Date.now();
|
||
|
||
const project: Project = {
|
||
...projectData,
|
||
id,
|
||
createdAt: now,
|
||
updatedAt: now
|
||
};
|
||
|
||
await db.projects.put(project);
|
||
await get().loadProjects();
|
||
|
||
toast.success(`Project "${project.name}" saved`);
|
||
},
|
||
|
||
loadProject: async (id) => {
|
||
const project = await db.projects.get(id);
|
||
if (!project) {
|
||
toast.error('Project not found');
|
||
return;
|
||
}
|
||
|
||
// Load sites and settings into their respective stores
|
||
useSitesStore.getState().setSites(project.sites);
|
||
useCoverageStore.getState().updateSettings(project.coverageSettings);
|
||
|
||
set({ currentProject: project });
|
||
toast.success(`Loaded project "${project.name}"`);
|
||
},
|
||
|
||
deleteProject: async (id) => {
|
||
await db.projects.delete(id);
|
||
await get().loadProjects();
|
||
toast.success('Project deleted');
|
||
}
|
||
}));
|
||
```
|
||
|
||
---
|
||
|
||
## TESTING CHECKLIST
|
||
|
||
After implementing fixes:
|
||
|
||
### Heatmap Gradient Test:
|
||
- [ ] Zoom out to level 6-8: Should see smooth gradient with large coverage area
|
||
- [ ] Zoom to level 10-12: Gradient still visible, colors distinct
|
||
- [ ] Zoom to level 14-16: NO solid yellow/orange, full blue→red range visible
|
||
- [ ] Zoom to level 18+: Individual points with gradient, not solid blobs
|
||
|
||
### Export Test:
|
||
- [ ] Calculate coverage for 1 site
|
||
- [ ] Export CSV: Should download with lat,lon,rsrp,site_id columns
|
||
- [ ] Export GeoJSON: Should be valid GeoJSON FeatureCollection
|
||
- [ ] Open in QGIS/online viewer: Points should render correctly
|
||
|
||
### Project Save/Load Test:
|
||
- [ ] Create 2-3 sites with custom settings
|
||
- [ ] Save project with name
|
||
- [ ] Clear all sites
|
||
- [ ] Load project: Sites and settings restored
|
||
- [ ] Delete project: Removed from list
|
||
|
||
---
|
||
|
||
## BUILD & DEPLOY
|
||
|
||
After making changes:
|
||
|
||
```bash
|
||
# Frontend
|
||
cd /opt/rfcp/frontend
|
||
npm run build
|
||
|
||
# Check dist/
|
||
ls -lah dist/
|
||
|
||
# Deploy
|
||
sudo systemctl reload caddy
|
||
|
||
# Test
|
||
curl https://rfcp.eliah.one/api/health
|
||
curl https://rfcp.eliah.one/ | head -20
|
||
|
||
# From Windows via WireGuard:
|
||
# Open: https://rfcp.eliah.one
|
||
```
|
||
|
||
---
|
||
|
||
## PHASE 4 PREPARATION (Future)
|
||
|
||
### Terrain Integration Stub
|
||
|
||
**File:** `frontend/src/services/terrain.ts` (new)
|
||
|
||
```typescript
|
||
export interface TerrainService {
|
||
getElevation(lat: number, lon: number): Promise<number>;
|
||
getElevationProfile(lat1: number, lon1: number, lat2: number, lon2: number, samples: number): Promise<number[]>;
|
||
}
|
||
|
||
export class MockTerrainService implements TerrainService {
|
||
async getElevation(lat: number, lon: number): Promise<number> {
|
||
// Return mock flat terrain for now
|
||
return 0;
|
||
}
|
||
|
||
async getElevationProfile(lat1: number, lon1: number, lat2: number, lon2: number, samples: number): Promise<number[]> {
|
||
return Array(samples).fill(0);
|
||
}
|
||
}
|
||
|
||
export class BackendTerrainService implements TerrainService {
|
||
constructor(private apiUrl: string) {}
|
||
|
||
async getElevation(lat: number, lon: number): Promise<number> {
|
||
const response = await fetch(`${this.apiUrl}/api/terrain/elevation?lat=${lat}&lon=${lon}`);
|
||
const data = await response.json();
|
||
return data.elevation;
|
||
}
|
||
|
||
async getElevationProfile(lat1: number, lon1: number, lat2: number, lon2: number, samples: number): Promise<number[]> {
|
||
const response = await fetch(
|
||
`${this.apiUrl}/api/terrain/profile?lat1=${lat1}&lon1=${lon1}&lat2=${lat2}&lon2=${lon2}&samples=${samples}`
|
||
);
|
||
const data = await response.json();
|
||
return data.profile;
|
||
}
|
||
}
|
||
|
||
// Default to mock for now
|
||
export const terrainService: TerrainService = new MockTerrainService();
|
||
```
|
||
|
||
---
|
||
|
||
## SUCCESS CRITERIA
|
||
|
||
✅ Heatmap shows full blue→cyan→green→yellow→orange→red gradient at all zoom levels
|
||
✅ No solid yellow/orange blobs at close zoom
|
||
✅ Opacity slider works smoothly
|
||
✅ CSV export produces valid data
|
||
✅ GeoJSON export renders in QGIS
|
||
✅ Projects can be saved and loaded
|
||
✅ All existing features still work
|
||
✅ Dark theme applies to new UI elements
|
||
✅ Mobile responsive
|
||
|
||
---
|
||
|
||
## COMMIT MESSAGE TEMPLATE
|
||
|
||
```
|
||
fix(heatmap): resolve gradient saturation at close zoom
|
||
|
||
- Changed maxRSRP from -60 to -70 dBm for full intensity range
|
||
- Added dynamic max intensity based on zoom level (0.3-1.0)
|
||
- Prevents solid yellow/orange at zoom 14+
|
||
- Now shows full blue→red gradient at all zoom levels
|
||
|
||
feat(export): add coverage data export (CSV/GeoJSON)
|
||
|
||
- Export calculated coverage points as CSV
|
||
- Export as GeoJSON FeatureCollection
|
||
- Compatible with QGIS and other GIS tools
|
||
|
||
feat(projects): add project save/load functionality
|
||
|
||
- Save sites + settings as named projects
|
||
- Load projects from IndexedDB
|
||
- Project management UI in sidebar
|
||
```
|
||
|
||
Good luck! 🚀
|