213 lines
5.6 KiB
TypeScript
213 lines
5.6 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|
||
}
|