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!