@mytec: iter8 ready for test
This commit is contained in:
209
frontend/src/components/map/GeographicHeatmap.tsx
Normal file
209
frontend/src/components/map/GeographicHeatmap.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Custom geographic-scale heatmap using Leaflet GridLayer + Canvas tiles.
|
||||
*
|
||||
* Replaces leaflet.heat with a renderer that maintains true geographic scale:
|
||||
* a 400m coverage radius is always 400m on the ground, regardless of zoom.
|
||||
*
|
||||
* Features:
|
||||
* - Tile-based rendering (256×256 canvas per tile)
|
||||
* - Automatic tile cache invalidation on point/radius changes
|
||||
* - Tile load progress reporting
|
||||
* - Configurable geographic radius
|
||||
* - Opacity control
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useMap } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import { HeatmapTileRenderer } from './HeatmapTileRenderer.ts';
|
||||
import type { HeatmapPoint } from './HeatmapTileRenderer.ts';
|
||||
|
||||
interface GeographicHeatmapProps {
|
||||
points: HeatmapPoint[];
|
||||
visible: boolean;
|
||||
opacity?: number;
|
||||
radiusMeters?: number;
|
||||
}
|
||||
|
||||
export default function GeographicHeatmap({
|
||||
points,
|
||||
visible,
|
||||
opacity = 0.7,
|
||||
radiusMeters = 400,
|
||||
}: GeographicHeatmapProps) {
|
||||
const map = useMap();
|
||||
const layerRef = useRef<L.GridLayer | null>(null);
|
||||
const rendererRef = useRef<HeatmapTileRenderer>(
|
||||
new HeatmapTileRenderer(radiusMeters)
|
||||
);
|
||||
|
||||
// Tile progress
|
||||
const [tileProgress, setTileProgress] = useState<{
|
||||
loaded: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
|
||||
// Stable reference to points for the tile factory closure
|
||||
const pointsRef = useRef<HeatmapPoint[]>(points);
|
||||
pointsRef.current = points;
|
||||
|
||||
// Update renderer radius when prop changes
|
||||
useEffect(() => {
|
||||
rendererRef.current.setRadiusMeters(radiusMeters);
|
||||
}, [radiusMeters]);
|
||||
|
||||
// Invalidate cache when points change (use length + first/last coords as fingerprint)
|
||||
useEffect(() => {
|
||||
if (points.length === 0) {
|
||||
rendererRef.current.setPointsHash('empty');
|
||||
return;
|
||||
}
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
const hash = `${points.length}:${first.lat.toFixed(4)},${first.lon.toFixed(4)}:${last.rsrp}`;
|
||||
rendererRef.current.setPointsHash(hash);
|
||||
}, [points]);
|
||||
|
||||
// Create / destroy GridLayer
|
||||
const createLayer = useCallback(() => {
|
||||
// Remove existing
|
||||
if (layerRef.current) {
|
||||
map.removeLayer(layerRef.current);
|
||||
layerRef.current = null;
|
||||
}
|
||||
|
||||
if (!visible || points.length === 0) {
|
||||
setTileProgress(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const renderer = rendererRef.current;
|
||||
const currentPointsRef = pointsRef;
|
||||
|
||||
// Custom GridLayer with canvas tiles
|
||||
const HeatmapGridLayer = L.GridLayer.extend({
|
||||
createTile(
|
||||
this: L.GridLayer,
|
||||
coords: L.Coords,
|
||||
done: (err: Error | null, tile: HTMLElement) => void
|
||||
) {
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
// Use requestAnimationFrame to avoid blocking the main thread
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
renderer.renderTile(
|
||||
canvas,
|
||||
currentPointsRef.current,
|
||||
coords.x,
|
||||
coords.y,
|
||||
coords.z
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Tile render error:', error);
|
||||
// Draw error indicator
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
canvas.width = 256;
|
||||
canvas.height = 256;
|
||||
ctx.fillStyle = 'rgba(255,0,0,0.05)';
|
||||
ctx.fillRect(0, 0, 256, 256);
|
||||
}
|
||||
}
|
||||
done(null, canvas);
|
||||
});
|
||||
|
||||
return canvas;
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const layer = new (HeatmapGridLayer as any)({
|
||||
opacity,
|
||||
zIndex: 200,
|
||||
updateWhenZooming: false,
|
||||
updateWhenIdle: true,
|
||||
}) as L.GridLayer;
|
||||
|
||||
// Track tile loading progress
|
||||
let total = 0;
|
||||
let loaded = 0;
|
||||
|
||||
layer.on('tileloadstart', () => {
|
||||
total++;
|
||||
setTileProgress({ loaded, total });
|
||||
});
|
||||
|
||||
layer.on('tileload', () => {
|
||||
loaded++;
|
||||
setTileProgress({ loaded, total });
|
||||
});
|
||||
|
||||
layer.on('load', () => {
|
||||
// All tiles done — hide progress after short delay
|
||||
setTimeout(() => setTileProgress(null), 500);
|
||||
total = 0;
|
||||
loaded = 0;
|
||||
});
|
||||
|
||||
layer.addTo(map);
|
||||
layerRef.current = layer;
|
||||
}, [map, visible, points, opacity, radiusMeters]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Main effect: create/recreate layer
|
||||
useEffect(() => {
|
||||
createLayer();
|
||||
return () => {
|
||||
if (layerRef.current) {
|
||||
map.removeLayer(layerRef.current);
|
||||
layerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [createLayer, map]);
|
||||
|
||||
// Opacity changes without full recreate
|
||||
useEffect(() => {
|
||||
if (layerRef.current) {
|
||||
layerRef.current.setOpacity(opacity);
|
||||
}
|
||||
}, [opacity]);
|
||||
|
||||
// Render tile progress indicator
|
||||
if (tileProgress && tileProgress.total > 0 && tileProgress.loaded < tileProgress.total) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1000,
|
||||
background: 'rgba(0,0,0,0.75)',
|
||||
color: 'white',
|
||||
padding: '6px 14px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
pointerEvents: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="animate-spin"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
border: '2px solid rgba(255,255,255,0.3)',
|
||||
borderTopColor: 'white',
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
/>
|
||||
Rendering {tileProgress.loaded}/{tileProgress.total} tiles
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
108
frontend/src/components/map/HeatmapLegend.tsx
Normal file
108
frontend/src/components/map/HeatmapLegend.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Color-accurate RF signal legend that uses the exact same
|
||||
* gradient pipeline as the heatmap renderer.
|
||||
*
|
||||
* Renders a smooth continuous gradient bar + labeled RSRP thresholds.
|
||||
*/
|
||||
|
||||
import { normalizeRSRP, valueToColor } from '@/utils/colorGradient.ts';
|
||||
import { useCoverageStore } from '@/store/coverage.ts';
|
||||
import { useSitesStore } from '@/store/sites.ts';
|
||||
|
||||
const LEGEND_STEPS = [
|
||||
{ rsrp: -130, label: 'No Service' },
|
||||
{ rsrp: -110, label: 'Very Weak' },
|
||||
{ rsrp: -100, label: 'Weak' },
|
||||
{ rsrp: -90, label: 'Fair' },
|
||||
{ rsrp: -80, label: 'Good' },
|
||||
{ rsrp: -70, label: 'Strong' },
|
||||
{ rsrp: -50, label: 'Excellent' },
|
||||
];
|
||||
|
||||
/** Build a CSS linear-gradient string matching the heatmap gradient exactly. */
|
||||
function buildGradientCSS(): string {
|
||||
const stops: string[] = [];
|
||||
const steps = 20;
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const value = i / steps; // 0–1
|
||||
const [r, g, b] = valueToColor(value);
|
||||
const pct = (i / steps) * 100;
|
||||
stops.push(`rgb(${r},${g},${b}) ${pct.toFixed(0)}%`);
|
||||
}
|
||||
return `linear-gradient(to top, ${stops.join(', ')})`;
|
||||
}
|
||||
|
||||
const gradientCSS = buildGradientCSS();
|
||||
|
||||
export default function HeatmapLegend() {
|
||||
const result = useCoverageStore((s) => s.result);
|
||||
const heatmapVisible = useCoverageStore((s) => s.heatmapVisible);
|
||||
const toggleHeatmap = useCoverageStore((s) => s.toggleHeatmap);
|
||||
const settings = useCoverageStore((s) => s.settings);
|
||||
const sites = useSitesStore((s) => s.sites);
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
const areaKm2 =
|
||||
(result.totalPoints * settings.resolution * settings.resolution) / 1e6;
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-6 right-2 z-[1000] bg-white dark:bg-dark-surface rounded-lg shadow-lg border border-gray-200 dark:border-dark-border p-3 min-w-[180px]">
|
||||
{/* Header with toggle */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xs font-semibold text-gray-700 dark:text-dark-text">
|
||||
Signal (RSRP)
|
||||
</h3>
|
||||
<button
|
||||
onClick={toggleHeatmap}
|
||||
className={`w-8 h-4 rounded-full transition-colors relative
|
||||
${heatmapVisible ? 'bg-blue-500' : 'bg-gray-300 dark:bg-dark-border'}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-3 h-3 rounded-full bg-white shadow transition-transform
|
||||
${heatmapVisible ? 'left-4' : 'left-0.5'}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Gradient bar + labels */}
|
||||
<div className="flex gap-2">
|
||||
{/* Continuous gradient bar */}
|
||||
<div
|
||||
className="w-3 rounded-sm flex-shrink-0"
|
||||
style={{
|
||||
background: gradientCSS,
|
||||
minHeight: `${LEGEND_STEPS.length * 18}px`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Labels */}
|
||||
<div className="flex flex-col justify-between flex-1 py-0.5">
|
||||
{[...LEGEND_STEPS].reverse().map((step) => {
|
||||
const norm = normalizeRSRP(step.rsrp);
|
||||
const [r, g, b] = valueToColor(norm);
|
||||
return (
|
||||
<div key={step.rsrp} className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
|
||||
style={{ backgroundColor: `rgb(${r},${g},${b})` }}
|
||||
/>
|
||||
<span className="text-[10px] text-gray-600 dark:text-gray-400 leading-tight">
|
||||
{step.rsrp} dBm {step.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="mt-2 pt-2 border-t border-gray-100 dark:border-dark-border text-[10px] text-gray-400 dark:text-dark-muted space-y-0.5">
|
||||
<div>Points: {result.totalPoints.toLocaleString()}</div>
|
||||
<div>Time: {(result.calculationTime / 1000).toFixed(2)}s</div>
|
||||
<div>Area: ~{areaKm2.toFixed(1)} km²</div>
|
||||
<div>Sites: {sites.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
212
frontend/src/components/map/HeatmapTileRenderer.ts
Normal file
212
frontend/src/components/map/HeatmapTileRenderer.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Custom Canvas tile renderer for geographic-scale RF coverage heatmap.
|
||||
*
|
||||
* Each coverage point is rendered with a fixed geographic radius (default 400m),
|
||||
* which means the same RSRP always maps to the same color regardless of zoom.
|
||||
*
|
||||
* Features:
|
||||
* - True geographic scale (400m = 400m at every zoom)
|
||||
* - Gaussian falloff for smooth point edges
|
||||
* - LRU tile cache (configurable size)
|
||||
* - Performance monitoring (dev mode)
|
||||
* - "Max wins" blending — overlapping points show the strongest signal
|
||||
*/
|
||||
|
||||
import {
|
||||
metersToPixels,
|
||||
latLonToTilePixel,
|
||||
getTileBounds,
|
||||
} from '@/utils/geographicScale.ts';
|
||||
import {
|
||||
normalizeRSRP,
|
||||
valueToColorRGB,
|
||||
gaussianWeight,
|
||||
} from '@/utils/colorGradient.ts';
|
||||
|
||||
export interface HeatmapPoint {
|
||||
lat: number;
|
||||
lon: number;
|
||||
rsrp: number;
|
||||
siteId: string;
|
||||
}
|
||||
|
||||
export class HeatmapTileRenderer {
|
||||
private tileSize = 256;
|
||||
private radiusMeters: number;
|
||||
|
||||
// LRU cache: key → canvas
|
||||
private cache = new Map<string, ImageData>();
|
||||
private maxCacheSize: number;
|
||||
|
||||
// Points fingerprint for cache invalidation
|
||||
private pointsHash = '';
|
||||
|
||||
constructor(radiusMeters = 400, maxCacheSize = 150) {
|
||||
this.radiusMeters = radiusMeters;
|
||||
this.maxCacheSize = maxCacheSize;
|
||||
}
|
||||
|
||||
/** Update the geographic radius of each coverage point. */
|
||||
setRadiusMeters(r: number): void {
|
||||
if (r !== this.radiusMeters) {
|
||||
this.radiusMeters = r;
|
||||
this.clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
/** Call when points change to invalidate cache. */
|
||||
setPointsHash(hash: string): void {
|
||||
if (hash !== this.pointsHash) {
|
||||
this.pointsHash = hash;
|
||||
this.clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single 256×256 tile.
|
||||
*
|
||||
* Returns true if anything was drawn, false for empty tiles.
|
||||
*/
|
||||
renderTile(
|
||||
canvas: HTMLCanvasElement,
|
||||
points: HeatmapPoint[],
|
||||
tileX: number,
|
||||
tileY: number,
|
||||
zoom: number
|
||||
): boolean {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return false;
|
||||
|
||||
canvas.width = this.tileSize;
|
||||
canvas.height = this.tileSize;
|
||||
ctx.clearRect(0, 0, this.tileSize, this.tileSize);
|
||||
|
||||
// Check cache
|
||||
const cacheKey = `${tileX}:${tileY}:${zoom}`;
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (cached) {
|
||||
ctx.putImageData(cached, 0, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Tile geographic bounds
|
||||
const [[latMin, lonMin], [latMax, lonMax]] = getTileBounds(
|
||||
tileX,
|
||||
tileY,
|
||||
zoom
|
||||
);
|
||||
|
||||
// Buffer in degrees for the radius so edge points outside the tile
|
||||
// still contribute their gaussian tail inside the tile
|
||||
const bufferDeg = (this.radiusMeters / 111_000) * 2;
|
||||
|
||||
// Filter relevant points
|
||||
const relevant = points.filter(
|
||||
(p) =>
|
||||
p.lat >= latMin - bufferDeg &&
|
||||
p.lat <= latMax + bufferDeg &&
|
||||
p.lon >= lonMin - bufferDeg &&
|
||||
p.lon <= lonMax + bufferDeg
|
||||
);
|
||||
|
||||
if (relevant.length === 0) return false;
|
||||
|
||||
const t0 = import.meta.env.DEV ? performance.now() : 0;
|
||||
|
||||
// Accumulation buffer: per-pixel best (max) normalized RSRP weighted by gaussian
|
||||
const size = this.tileSize;
|
||||
const buf = new Float32Array(size * size); // max weighted intensity per pixel
|
||||
|
||||
for (const pt of relevant) {
|
||||
const [cx, cy] = latLonToTilePixel(pt.lat, pt.lon, tileX, tileY, zoom);
|
||||
const rPx = metersToPixels(this.radiusMeters, pt.lat, zoom);
|
||||
const normVal = normalizeRSRP(pt.rsrp);
|
||||
|
||||
// Bounding box in pixels (clamped to tile)
|
||||
const x0 = Math.max(0, Math.floor(cx - rPx));
|
||||
const x1 = Math.min(size, Math.ceil(cx + rPx));
|
||||
const y0 = Math.max(0, Math.floor(cy - rPx));
|
||||
const y1 = Math.min(size, Math.ceil(cy + rPx));
|
||||
|
||||
if (x0 >= size || x1 <= 0 || y0 >= size || y1 <= 0) continue;
|
||||
|
||||
const rPxSq = rPx * rPx;
|
||||
|
||||
for (let y = y0; y < y1; y++) {
|
||||
const dy = y - cy;
|
||||
const dySq = dy * dy;
|
||||
const rowOff = y * size;
|
||||
|
||||
for (let x = x0; x < x1; x++) {
|
||||
const dx = x - cx;
|
||||
const distSq = dx * dx + dySq;
|
||||
|
||||
if (distSq > rPxSq) continue;
|
||||
|
||||
const dist = Math.sqrt(distSq);
|
||||
const weight = gaussianWeight(dist, rPx);
|
||||
const intensity = normVal * weight;
|
||||
|
||||
const idx = rowOff + x;
|
||||
// "Max wins" — strongest signal wins at each pixel
|
||||
if (intensity > buf[idx]) {
|
||||
buf[idx] = intensity;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render to ImageData
|
||||
const imageData = ctx.createImageData(size, size);
|
||||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
const val = buf[i];
|
||||
if (val <= 0) continue;
|
||||
|
||||
const clamped = Math.min(1, val);
|
||||
const [r, g, b] = valueToColorRGB(clamped);
|
||||
|
||||
// Alpha: full opacity for strong signals, fade at weak edges
|
||||
const alpha = Math.min(255, Math.round(clamped * 300));
|
||||
|
||||
const off = i * 4;
|
||||
data[off] = r;
|
||||
data[off + 1] = g;
|
||||
data[off + 2] = b;
|
||||
data[off + 3] = alpha;
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
// Cache the rendered tile
|
||||
this.cacheStore(cacheKey, imageData);
|
||||
|
||||
// Dev perf log
|
||||
if (import.meta.env.DEV) {
|
||||
const ms = performance.now() - t0;
|
||||
if (ms > 50) {
|
||||
console.log(
|
||||
`🖌️ Tile ${tileX},${tileY} z${zoom}: ${ms.toFixed(1)}ms (${relevant.length} pts)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private cacheStore(key: string, data: ImageData): void {
|
||||
// Evict oldest if full
|
||||
if (this.cache.size >= this.maxCacheSize) {
|
||||
const firstKey = this.cache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
this.cache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
this.cache.set(key, data);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user