From c52716978f6d05971efc71ed02f2b95e1bd2bbd6 Mon Sep 17 00:00:00 2001 From: mytec Date: Fri, 30 Jan 2026 13:24:47 +0200 Subject: [PATCH] @mytec: iter7.2 start --- RFCP-Iteration7.2-Proper-Gradient.md | 258 +++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 RFCP-Iteration7.2-Proper-Gradient.md diff --git a/RFCP-Iteration7.2-Proper-Gradient.md b/RFCP-Iteration7.2-Proper-Gradient.md new file mode 100644 index 0000000..c16a623 --- /dev/null +++ b/RFCP-Iteration7.2-Proper-Gradient.md @@ -0,0 +1,258 @@ +# 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!