@mytec: iter3 start

This commit is contained in:
2026-01-30 09:47:00 +02:00
parent af0fb2154a
commit d3fb1801a8

View File

@@ -0,0 +1,473 @@
# 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! 🚀