Files
rfcp/frontend/src/components/map/HeatmapTileRenderer.ts
2026-01-30 14:11:45 +02:00

213 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}
}