@mytec: iter8 ready for test

This commit is contained in:
2026-01-30 14:11:45 +02:00
parent 91618353e6
commit bb9ca9960d
8 changed files with 755 additions and 4 deletions

View 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;
}

View 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; // 01
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>
);
}

View 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);
}
}