# 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!