@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

@@ -7,8 +7,8 @@ import { RFCalculator } from '@/rf/calculator.ts';
import { useToastStore } from '@/components/ui/Toast.tsx';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts.ts';
import MapView from '@/components/map/Map.tsx';
import Heatmap from '@/components/map/Heatmap.tsx';
import Legend from '@/components/map/Legend.tsx';
import GeographicHeatmap from '@/components/map/GeographicHeatmap.tsx';
import HeatmapLegend from '@/components/map/HeatmapLegend.tsx';
import SiteList from '@/components/panels/SiteList.tsx';
import SiteForm from '@/components/panels/SiteForm.tsx';
import ExportPanel from '@/components/panels/ExportPanel.tsx';
@@ -261,14 +261,15 @@ export default function App() {
<div className="flex-1 relative">
<MapView onMapClick={handleMapClick} onEditSite={handleEditSite}>
{coverageResult && (
<Heatmap
<GeographicHeatmap
points={coverageResult.points}
visible={heatmapVisible}
opacity={settings.heatmapOpacity}
radiusMeters={settings.heatmapRadius}
/>
)}
</MapView>
<Legend />
<HeatmapLegend />
</div>
{/* Side panel */}
@@ -386,6 +387,27 @@ export default function App() {
className="w-full h-1.5 bg-gray-200 dark:bg-dark-border rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
</div>
<div>
<label className="text-xs text-gray-500 dark:text-dark-muted">
Heatmap Quality: {settings.heatmapRadius}m
</label>
<select
value={settings.heatmapRadius}
onChange={(e) =>
useCoverageStore
.getState()
.updateSettings({
heatmapRadius: Number(e.target.value),
})
}
className="w-full mt-1 px-2 py-1 text-xs bg-white dark:bg-dark-border border border-gray-200 dark:border-dark-border rounded text-gray-700 dark:text-dark-text"
>
<option value={200}>200m Fast</option>
<option value={400}>400m Balanced</option>
<option value={600}>600m Smooth</option>
<option value={800}>800m Wide</option>
</select>
</div>
<div>
<label className="text-xs text-gray-500 dark:text-dark-muted">
Terrain Opacity: {Math.round(terrainOpacity * 100)}%

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

View File

@@ -22,6 +22,7 @@ export const useCoverageStore = create<CoverageState>((set) => ({
resolution: 200,
rsrpThreshold: -120,
heatmapOpacity: 0.7,
heatmapRadius: 400,
},
heatmapVisible: true,

View File

@@ -17,6 +17,7 @@ export interface CoverageSettings {
resolution: number; // meters (grid resolution)
rsrpThreshold: number; // dBm (minimum signal to display)
heatmapOpacity: number; // 0.3-1.0 (heatmap layer opacity)
heatmapRadius: number; // meters (coverage point visual radius, 200/400/600)
}
export interface GridPoint {

View File

@@ -0,0 +1,117 @@
/**
* RSRP → color mapping with smooth gradient interpolation.
*
* Gradient stops are chosen to match standard RF planning tools:
* -130 dBm = deep blue (no service)
* -90 dBm = green (fair)
* -50 dBm = red (excellent)
*
* All functions are pure and allocation-free on the hot path
* (pre-built lookup table for fast per-pixel color resolution).
*/
interface GradientStop {
value: number; // normalized 01
r: number;
g: number;
b: number;
}
const GRADIENT_STOPS: GradientStop[] = [
{ value: 0.0, r: 26, g: 35, b: 126 }, // #1a237e — deep blue
{ value: 0.15, r: 13, g: 71, b: 161 }, // #0d47a1
{ value: 0.25, r: 33, g: 150, b: 243 }, // #2196f3 — blue
{ value: 0.35, r: 0, g: 188, b: 212 }, // #00bcd4 — cyan
{ value: 0.45, r: 0, g: 137, b: 123 }, // #00897b — teal
{ value: 0.55, r: 76, g: 175, b: 80 }, // #4caf50 — green
{ value: 0.65, r: 139, g: 195, b: 74 }, // #8bc34a — light green
{ value: 0.75, r: 255, g: 235, b: 59 }, // #ffeb3b — yellow
{ value: 0.85, r: 255, g: 152, b: 0 }, // #ff9800 — orange
{ value: 1.0, r: 244, g: 67, b: 54 }, // #f44336 — red
];
/**
* Pre-built 256-entry lookup table for O(1) color resolution.
* Each entry is [r, g, b].
*/
const COLOR_LUT: Uint8Array = buildColorLUT();
function buildColorLUT(): Uint8Array {
const size = 256;
const lut = new Uint8Array(size * 3);
for (let i = 0; i < size; i++) {
const value = i / (size - 1); // 01
const [r, g, b] = interpolateGradient(value);
lut[i * 3] = r;
lut[i * 3 + 1] = g;
lut[i * 3 + 2] = b;
}
return lut;
}
function interpolateGradient(value: number): [number, number, number] {
// Find surrounding stops
let lower = GRADIENT_STOPS[0];
let upper = GRADIENT_STOPS[GRADIENT_STOPS.length - 1];
for (let i = 0; i < GRADIENT_STOPS.length - 1; i++) {
if (value >= GRADIENT_STOPS[i].value && value <= GRADIENT_STOPS[i + 1].value) {
lower = GRADIENT_STOPS[i];
upper = GRADIENT_STOPS[i + 1];
break;
}
}
const range = upper.value - lower.value;
const t = range > 0 ? (value - lower.value) / range : 0;
return [
Math.round(lower.r + (upper.r - lower.r) * t),
Math.round(lower.g + (upper.g - lower.g) * t),
Math.round(lower.b + (upper.b - lower.b) * t),
];
}
/**
* Normalize RSRP (dBm) to 01 range.
*
* -130 dBm → 0.0
* -50 dBm → 1.0
*/
export function normalizeRSRP(rsrp: number): number {
const minRSRP = -130;
const maxRSRP = -50;
const normalized = (rsrp - minRSRP) / (maxRSRP - minRSRP);
return Math.max(0, Math.min(1, normalized));
}
/**
* Fast color lookup from normalized value (01).
* Uses pre-built LUT — O(1), no allocations.
*
* Returns [r, g, b] (0255 each).
*/
export function valueToColorRGB(value: number): [number, number, number] {
const idx = Math.max(0, Math.min(255, Math.round(value * 255))) * 3;
return [COLOR_LUT[idx], COLOR_LUT[idx + 1], COLOR_LUT[idx + 2]];
}
/**
* Full gradient interpolation (slower, for non-hot-path use like legends).
*/
export function valueToColor(value: number): [number, number, number] {
return interpolateGradient(Math.max(0, Math.min(1, value)));
}
/**
* Gaussian falloff for smooth point edges.
*
* Returns 01 weight based on distance from point center.
* sigma defaults to radius/3 for a natural falloff.
*/
export function gaussianWeight(distance: number, radius: number, sigma?: number): number {
const s = sigma ?? radius / 3;
return Math.exp(-(distance * distance) / (2 * s * s));
}

View File

@@ -0,0 +1,81 @@
/**
* Geographic scale utilities for Web Mercator (EPSG:3857) projection.
*
* Provides accurate meters ↔ pixels conversions, tile bounds,
* and lat/lon → tile pixel coordinate transforms.
*/
const EARTH_CIRCUMFERENCE_M = 40075017;
/**
* Calculate pixels per meter at given latitude and zoom level.
* Uses the Web Mercator distortion factor: cos(lat).
*/
export function getPixelsPerMeter(lat: number, zoom: number): number {
const tileSize = 256;
const numTiles = Math.pow(2, zoom);
const worldWidthPixels = tileSize * numTiles;
const metersPerPixelEquator = EARTH_CIRCUMFERENCE_M / worldWidthPixels;
const latRad = (lat * Math.PI) / 180;
const metersPerPixel = metersPerPixelEquator * Math.cos(latRad);
return 1 / metersPerPixel;
}
/**
* Convert geographic distance (meters) to pixel distance at given zoom/latitude.
*/
export function metersToPixels(meters: number, lat: number, zoom: number): number {
return meters * getPixelsPerMeter(lat, zoom);
}
/**
* Get tile geographic bounds (lat/lon) for given tile coordinates.
* Returns [[latMin, lonMin], [latMax, lonMax]].
*/
export function getTileBounds(
x: number,
y: number,
zoom: number
): [[number, number], [number, number]] {
const n = Math.pow(2, zoom);
const lonMin = (x / n) * 360 - 180;
const lonMax = ((x + 1) / n) * 360 - 180;
const latMin =
(Math.atan(Math.sinh(Math.PI * (1 - (2 * (y + 1)) / n))) * 180) / Math.PI;
const latMax =
(Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / n))) * 180) / Math.PI;
return [
[latMin, lonMin],
[latMax, lonMax],
];
}
/**
* Convert lat/lon to pixel position within a specific tile.
* Returns [pixelX, pixelY] relative to tile's top-left corner.
*/
export function latLonToTilePixel(
lat: number,
lon: number,
tileX: number,
tileY: number,
zoom: number
): [number, number] {
const n = Math.pow(2, zoom);
// Point's world-tile coordinates
const worldX = ((lon + 180) / 360) * n;
const latRad = (lat * Math.PI) / 180;
const worldY =
((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) *
n;
// Offset within this tile (in pixels)
const pixelX = (worldX - tileX) * 256;
const pixelY = (worldY - tileY) * 256;
return [pixelX, pixelY];
}