Files
rfcp/RFCP-Iteration3-Comprehensive-Task.md
2026-01-30 09:47:00 +02:00

474 lines
12 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.
# 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! 🚀