# 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 (
    <div style={{ opacity }}>
      <HeatmapLayer
        points={heatmapPoints}
        longitudeExtractor={(p) => 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}
      />
    </div>
  );
}
```

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