7.2 KiB
RFCP - Iteration 7.2: Proper Heatmap Gradient Fix
Root Cause Analysis
Why "nuclear fix" failed:
- Fixed
radius=25, blur=15works 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:
- Visual size of each point (pixels on screen)
- 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
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 (
<div style={{ opacity }}>
<HeatmapLayer
points={heatmapPoints}
longitudeExtractor={(p) => 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}
/>
</div>
);
}
Why This Works
Zoom Out (zoom 6-8):
radius = 55 - 6*2.5 = 40radiusScale = 40/30 = 1.33maxIntensity = 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.4maxIntensity = 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:
// 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:
// 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
- Pick reference point 5km from site
- Zoom 8: Note exact color (e.g., "yellow-green")
- Zoom 10: Should be SAME color
- Zoom 12: SAME
- Zoom 14: SAME
- Zoom 16: SAME
If colors shift more than 1-2 gradient stops, need to adjust:
baseMaxvalue (try 0.5, 0.6, 0.7)radiusScaleformula- Clamp range (min/max)
Coverage Area Fix
Why coverage looks smaller:
- Fixed
radius=25was too small at far zoom - Points didn't reach edge of coverage
Fix: Restored zoom-dependent radius (above solution)
Build & Test
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
- Calculate coverage at FIXED resolution (e.g., 100m)
- Store in state with EXACT lat/lon grid
- Render with SVG circles (fixed size, zoom-independent)
- Color each circle by RSRP bucket
This gives perfect color consistency but loses heatmap smoothness.
🚀 Ready for Iteration 7.2!