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