/** * 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(); 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); } }