@mytec: iter7.2 start
This commit is contained in:
258
RFCP-Iteration7.2-Proper-Gradient.md
Normal file
258
RFCP-Iteration7.2-Proper-Gradient.md
Normal file
@@ -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 (
|
||||||
|
<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!
|
||||||
Reference in New Issue
Block a user