Files
rfcp/frontend/src/components/map/Heatmap.tsx
2026-01-30 13:20:31 +02:00

120 lines
3.5 KiB
TypeScript

import { useEffect } from 'react';
import { useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet.heat';
import type { CoveragePoint } from '@/types/index.ts';
// Extend L with heat layer type
declare module 'leaflet' {
function heatLayer(
latlngs: Array<[number, number, number]>,
options?: Record<string, unknown>
): L.Layer;
}
interface HeatmapProps {
points: CoveragePoint[];
visible: boolean;
opacity?: number;
}
/**
* Normalize RSRP to 0-1 intensity for heatmap.
*
* Range: -140 to -40 dBm (wide range prevents color shifting)
*
* -140 dBm → 0.0 (deep blue, no service)
* -90 dBm → 0.5 (green, fair)
* -40 dBm → 1.0 (red, very strong)
*/
function rsrpToIntensity(rsrp: number): number {
const minRSRP = -140;
const maxRSRP = -40;
return Math.max(0, Math.min(1, (rsrp - minRSRP) / (maxRSRP - minRSRP)));
}
/**
* ALL heatmap parameters are FIXED constants.
* NO zoom-dependent logic — this is the only way to guarantee
* that the same RSRP → same color at any zoom level.
*
* radius/blur/max are constant so overlapping point contributions
* don't change as the user zooms, which was the root cause of
* the color-shifting bug.
*/
const HEATMAP_RADIUS = 25;
const HEATMAP_BLUR = 15;
const HEATMAP_MAX = 0.75;
const HEATMAP_GRADIENT = {
0.0: '#1a237e', // Deep blue (-140 dBm, no service)
0.1: '#0d47a1', // Dark blue (-130 dBm)
0.2: '#2196f3', // Blue (-120 dBm)
0.3: '#00bcd4', // Cyan (-110 dBm, weak)
0.4: '#00897b', // Teal (-100 dBm)
0.5: '#4caf50', // Green ( -90 dBm, fair)
0.6: '#8bc34a', // Light green ( -80 dBm)
0.7: '#ffeb3b', // Yellow ( -70 dBm, good)
0.8: '#ffc107', // Amber ( -60 dBm)
0.9: '#ff9800', // Orange ( -50 dBm, excellent)
1.0: '#f44336', // Red ( -40 dBm, very strong)
};
export default function Heatmap({ points, visible, opacity = 0.7 }: HeatmapProps) {
const map = useMap();
useEffect(() => {
if (!visible || points.length === 0) return;
const heatData: Array<[number, number, number]> = points.map((p) => [
p.lat,
p.lon,
rsrpToIntensity(p.rsrp),
]);
// Debug: log RSRP stats (dev only)
if (import.meta.env.DEV && heatData.length > 0) {
const rsrpValues = points.map((p) => p.rsrp);
const intensityValues = heatData.map((d) => d[2]);
const normalizedSample = points.slice(0, 5).map((p) => ({
rsrp: p.rsrp,
normalized: rsrpToIntensity(p.rsrp),
}));
console.log('🔍 Heatmap Debug:', {
zoom: map.getZoom(),
totalPoints: points.length,
rsrpRange: `${Math.min(...rsrpValues).toFixed(1)} to ${Math.max(...rsrpValues).toFixed(1)} dBm`,
intensityRange: `${Math.min(...intensityValues).toFixed(3)} to ${Math.max(...intensityValues).toFixed(3)}`,
radius: HEATMAP_RADIUS,
blur: HEATMAP_BLUR,
max: HEATMAP_MAX,
note: 'ALL FIXED — no zoom dependency',
sample: normalizedSample,
});
}
const heatLayer = L.heatLayer(heatData, {
radius: HEATMAP_RADIUS,
blur: HEATMAP_BLUR,
max: HEATMAP_MAX,
maxZoom: 17,
minOpacity: 0.3,
gradient: HEATMAP_GRADIENT,
});
heatLayer.addTo(map);
// Apply opacity to the canvas element
const container = (heatLayer as unknown as { _canvas?: HTMLCanvasElement })._canvas;
if (container) {
container.style.opacity = String(opacity);
}
return () => {
map.removeLayer(heatLayer);
};
}, [map, points, visible, opacity]);
return null;
}