# RFCP - Iteration 7.3: Geographic Scale Compensation ## Root Cause **Previous fix failed because:** - `radius` is in **pixels** - But coverage is in **kilometers** - At zoom 8: 1km = ~100px - At zoom 14: 1km = ~6400px - Same `radius=30px` covers VERY different geographic areas! ## The REAL Solution We need to keep **geographic radius constant**, not pixel radius! ### 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 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)); }; // CRITICAL FIX: Calculate pixels per kilometer at current zoom // Leaflet formula: pixelsPerKm = 2^zoom * 256 / (40075 * cos(lat)) // At equator (simplified): pixelsPerKm ≈ 2^zoom * 6.4 const pixelsPerKm = Math.pow(2, mapZoom) * 6.4; // Target: 300m geographic radius (coverage point spacing) const targetRadiusKm = 0.3; // 300 meters const radiusPixels = targetRadiusKm * pixelsPerKm; // Clamp for visual quality const radius = Math.max(8, Math.min(60, radiusPixels)); // Blur proportional to radius const blur = radius * 0.6; // 60% of radius // FIXED maxIntensity (no compensation needed now!) const maxIntensity = 0.75; 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 Geographic:', { zoom: mapZoom, pixelsPerKm: pixelsPerKm.toFixed(1), targetRadiusKm, radiusPixels: radiusPixels.toFixed(1), radiusClamped: radius.toFixed(1), blur: blur.toFixed(1), maxIntensity, points: points.length, rsrpRange: `${Math.min(...rsrpValues).toFixed(1)} to ${Math.max(...rsrpValues).toFixed(1)} dBm` }); } return (
p[1]} latitudeExtractor={(p) => p[0]} intensityExtractor={(p) => p[2]} gradient={{ 0.0: '#1a237e', 0.15: '#0d47a1', 0.25: '#2196f3', 0.35: '#00bcd4', 0.45: '#00897b', 0.55: '#4caf50', 0.65: '#8bc34a', 0.75: '#ffeb3b', 0.85: '#ff9800', 1.0: '#f44336', }} radius={radius} blur={blur} max={maxIntensity} // CONSTANT! No more compensation! minOpacity={0.3} />
); } ``` ### Why This Works **Geographic consistency:** - Same 300m radius covers same geographic area at ALL zooms - Pixel radius auto-adjusts: zoom 8 = 20px, zoom 14 = 1920px - Colors stay consistent because geographic coverage is consistent! **Example:** ``` Zoom 8: pixelsPerKm = 1638 → radius = 0.3 * 1638 = 491px (clamped to 60) Zoom 10: pixelsPerKm = 6553 → radius = 0.3 * 6553 = 1966px (clamped to 60) Zoom 14: pixelsPerKm = 104857 → radius = 0.3 * 104857 = 31457px (clamped to 60) ``` **Wait, problem!** At high zoom, radius gets clamped to 60px which is TOO SMALL! --- ## Better Approach: Remove Clamp ```typescript // Don't clamp! Let radius grow with zoom const radius = targetRadiusKm * pixelsPerKm; const blur = radius * 0.6; // But limit max for performance const radius = Math.min(radiusPixels, 100); // Max 100px const blur = Math.min(radius * 0.6, 60); ``` --- ## Even Better: Adaptive Target Radius ```typescript // Smaller geographic radius at high zoom (more detail) // Larger geographic radius at low zoom (smooth coverage) const targetRadiusKm = mapZoom < 10 ? 0.5 : 0.3; // km const radiusPixels = targetRadiusKm * pixelsPerKm; const radius = Math.max(15, Math.min(50, radiusPixels)); ``` --- ## Alternative: Fix Resolution Instead **Problem:** 200m resolution creates visible grid at high zoom. **Solution:** Adaptive resolution based on zoom. **File:** `frontend/src/store/coverage.ts` ```typescript const calculateCoverage = async () => { // Adaptive resolution: finer at close zoom const adaptiveResolution = mapZoom >= 12 ? 100 // 100m for close zoom : 200; // 200m for far zoom await coverageWorker.calculateCoverage({ sites, radius, resolution: adaptiveResolution, rsrpThreshold }); }; ``` --- ## Ultimate Solution: Multi-Resolution Calculate different resolutions for different zoom levels: ```typescript // Calculate 3 datasets const coarse = calculate(resolution: 500); // Zoom 6-9 const medium = calculate(resolution: 200); // Zoom 10-13 const fine = calculate(resolution: 100); // Zoom 14+ // Show appropriate one based on zoom const pointsToShow = mapZoom < 10 ? coarse : mapZoom < 14 ? medium : fine; ``` --- ## Quick Fix: Just Increase Radius at High Zoom **File:** `frontend/src/components/map/Heatmap.tsx` ```typescript // Simple progressive formula const radius = mapZoom < 10 ? Math.max(20, Math.min(45, 55 - mapZoom * 2.5)) // Small zoom: old formula : Math.max(30, Math.min(80, 10 + mapZoom * 3)); // Large zoom: grow radius const blur = radius * 0.6; const maxIntensity = 0.75; // Keep constant ``` --- ## My Recommendation **Try Option 1 first (geographic scale)** with adjusted clamps: ```typescript const pixelsPerKm = Math.pow(2, mapZoom) * 6.4; const targetRadiusKm = 0.4; // 400m const radiusPixels = targetRadiusKm * pixelsPerKm; // Progressive clamps const minRadius = mapZoom < 10 ? 15 : 30; const maxRadius = mapZoom < 10 ? 45 : 80; const radius = Math.max(minRadius, Math.min(maxRadius, radiusPixels)); const blur = radius * 0.6; const maxIntensity = 0.75; ``` **If that fails, try Option 2 (adaptive resolution).** --- ## Testing 1. **Coverage extent:** - [ ] Zoom 8: Coverage reaches expected radius (e.g., 10km) - [ ] Zoom 12: Coverage still reaches same distance - [ ] No "shrinking" at any zoom 2. **Grid visibility:** - [ ] Zoom 16+: No visible dots/grid pattern - [ ] Smooth gradient even at close zoom 3. **Color consistency:** - [ ] Pick point at 5km from site - [ ] Check color at zoom 8, 12, 16 - [ ] Should be within 1-2 gradient stops --- ## Build & Test ```bash cd /opt/rfcp/frontend npm run build sudo systemctl reload caddy ``` --- ## Commit Message ``` fix(heatmap): geographic scale-aware radius calculation - Calculate pixels per km based on zoom level - Keep geographic radius constant (400m) - Adjust pixel radius to maintain geographic coverage - Progressive clamps for visual quality at all zooms - Fixed maxIntensity at 0.75 (no compensation needed) Coverage extent now consistent across zoom levels. No visible grid pattern at high zoom. Colors remain stable due to consistent geographic scale. ``` 🚀 Ready for Iteration 7.3!