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