# RFCP - Iteration 7.2: Proper Heatmap Gradient Fix ## Root Cause Analysis **Why "nuclear fix" failed:** - Fixed `radius=25, blur=15` works at ONE zoom level only - At zoom 8: points too far apart → weak gradient - At zoom 14: points too close → everything saturated - Need: zoom-dependent VISUAL size, but color-independent INTENSITY ## The REAL Problem Leaflet Heatmap uses `radius` and `blur` to determine: 1. **Visual size** of each point (pixels on screen) 2. **Overlap intensity** (how points combine) When `radius` changes with zoom: - More overlap → higher intensity → different colors - **This is the bug!** ## The CORRECT Solution **Keep zoom-dependent radius/blur for visual quality** **BUT normalize intensity to compensate for overlap changes** ### Implementation **File:** `frontend/src/components/map/Heatmap.tsx` ```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; opacity?: number; } export function Heatmap({ points, visible, opacity = 0.7 }: 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; // RSRP normalization (wide range for full gradient) const normalizeRSRP = (rsrp: number): number => { const minRSRP = -130; const maxRSRP = -50; const normalized = (rsrp - minRSRP) / (maxRSRP - minRSRP); return Math.max(0, Math.min(1, normalized)); }; // Visual parameters (zoom-dependent for quality) const radius = Math.max(12, Math.min(45, 55 - mapZoom * 2.5)); const blur = Math.max(10, Math.min(25, 30 - mapZoom * 1.2)); // CRITICAL FIX: Scale maxIntensity to compensate for radius changes // When radius is larger (zoom out), points overlap more → need higher max // When radius is smaller (zoom in), less overlap → need lower max // This keeps COLORS consistent even though visual size changes! const baseMax = 0.6; const radiusScale = radius / 30; // Normalize to radius=30 baseline const maxIntensity = baseMax * radiusScale; // Clamp to reasonable range const clampedMax = Math.max(0.4, Math.min(0.9, maxIntensity)); const heatmapPoints = points.map(p => [ p.lat, p.lon, normalizeRSRP(p.rsrp) ] as [number, number, number]); // Debug if (import.meta.env.DEV && points.length > 0) { const rsrpValues = points.map(p => p.rsrp); console.log('🔍 Heatmap:', { zoom: mapZoom, points: points.length, rsrpRange: `${Math.min(...rsrpValues).toFixed(1)} to ${Math.max(...rsrpValues).toFixed(1)} dBm`, radius: radius.toFixed(1), blur: blur.toFixed(1), maxIntensity: clampedMax.toFixed(3), radiusScale: radiusScale.toFixed(3) }); } return (
p[1]} latitudeExtractor={(p) => p[0]} intensityExtractor={(p) => p[2]} gradient={{ 0.0: '#1a237e', // Deep blue 0.15: '#0d47a1', // Dark blue 0.25: '#2196f3', // Blue 0.35: '#00bcd4', // Cyan 0.45: '#00897b', // Teal 0.55: '#4caf50', // Green 0.65: '#8bc34a', // Light green 0.75: '#ffeb3b', // Yellow 0.85: '#ff9800', // Orange 1.0: '#f44336', // Red }} radius={radius} blur={blur} max={clampedMax} // ← Compensates for radius changes! minOpacity={0.3} />
); } ``` ### Why This Works **Zoom Out (zoom 6-8):** - `radius = 55 - 6*2.5 = 40` - `radiusScale = 40/30 = 1.33` - `maxIntensity = 0.6 * 1.33 = 0.8` - Points overlap more → higher max compensates → same colors **Zoom In (zoom 14-16):** - `radius = 55 - 14*2.5 = 20` (clamped to 12) - `radiusScale = 12/30 = 0.4` - `maxIntensity = 0.6 * 0.4 = 0.24` (clamped to 0.4) - Points overlap less → lower max compensates → same colors --- ## Alternative: Point Density Compensation If above doesn't work perfectly, try density-based scaling: ```typescript // Calculate point density (points per km²) const bounds = map.getBounds(); const area = calculateArea(bounds); // km² const density = points.length / area; // Scale maxIntensity by density const densityScale = Math.sqrt(density / 100); // Normalize to 100 pts/km² const maxIntensity = 0.6 * densityScale * radiusScale; ``` --- ## Alternative 2: Pre-normalize Intensities Instead of using raw normalized RSRP, pre-scale by expected overlap: ```typescript // Estimate overlap factor based on radius and point spacing const avgPointSpacing = 0.2; // km (200m resolution) const radiusKm = (radius / map.getZoom()) * 0.001; // Approx const overlapFactor = Math.pow(radiusKm / avgPointSpacing, 2); // Scale intensities DOWN when more overlap expected const heatmapPoints = points.map(p => [ p.lat, p.lon, normalizeRSRP(p.rsrp) / overlapFactor ] as [number, number, number]); // Keep maxIntensity constant const maxIntensity = 0.75; ``` --- ## Testing Strategy 1. **Pick reference point** 5km from site 2. **Zoom 8:** Note exact color (e.g., "yellow-green") 3. **Zoom 10:** Should be SAME color 4. **Zoom 12:** SAME 5. **Zoom 14:** SAME 6. **Zoom 16:** SAME If colors shift more than 1-2 gradient stops, need to adjust: - `baseMax` value (try 0.5, 0.6, 0.7) - `radiusScale` formula - Clamp range (min/max) --- ## Coverage Area Fix **Why coverage looks smaller:** - Fixed `radius=25` was too small at far zoom - Points didn't reach edge of coverage **Fix:** Restored zoom-dependent radius (above solution) --- ## Build & Test ```bash cd /opt/rfcp/frontend npm run build sudo systemctl reload cadry # Critical test: zoom 8 → 16 at same location ``` --- ## Expected Result ✅ Full gradient visible (blue → cyan → green → yellow → orange → red) ✅ Coverage area back to normal size ✅ Colors STAY consistent when zooming ✅ No visible grid pattern --- ## Commit Message ``` fix(heatmap): proper zoom-independent color scaling - Scale maxIntensity by radius to compensate for overlap changes - Formula: maxIntensity = baseMax * (radius / baselineRadius) - Visual size changes with zoom (radius/blur) for quality - Color intensity compensates for overlap → consistent colors - Restored zoom-dependent radius for proper coverage size Colors now remain consistent across all zoom levels while maintaining smooth visual appearance and full coverage extent. ``` --- ## If This Still Doesn't Work... **Ultimate fallback: Separate visual and data layers** 1. Calculate coverage at FIXED resolution (e.g., 100m) 2. Store in state with EXACT lat/lon grid 3. Render with SVG circles (fixed size, zoom-independent) 4. Color each circle by RSRP bucket This gives **perfect** color consistency but loses heatmap smoothness. 🚀 Ready for Iteration 7.2!