# 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 ( 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: updateCoverageSettings({ heatmapOpacity: value })} suffix="" help="Adjust heatmap transparency" /> // Pass to Heatmap component: // In Heatmap.tsx, wrap layer:
``` ### 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 (

Export Coverage

); } ``` ### 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; 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; saveProject: (project: Omit) => Promise; loadProject: (id: string) => Promise; deleteProject: (id: string) => Promise; } export const useProjectsStore = create((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; getElevationProfile(lat1: number, lon1: number, lat2: number, lon2: number, samples: number): Promise; } export class MockTerrainService implements TerrainService { async getElevation(lat: number, lon: number): Promise { // Return mock flat terrain for now return 0; } async getElevationProfile(lat1: number, lon1: number, lat2: number, lon2: number, samples: number): Promise { return Array(samples).fill(0); } } export class BackendTerrainService implements TerrainService { constructor(private apiUrl: string) {} async getElevation(lat: number, lon: number): Promise { 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 { 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! 🚀