@mytec: iter8 ready for test
This commit is contained in:
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