From 90195d082e2764d083ba6ff01b6ad242b61065dc Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Mon, 4 May 2026 08:20:20 -0500 Subject: [PATCH] perf(beacons): coverage map uses Leaflet canvas renderer, no sticky tooltips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'show estimated current coverage' toggle pushes 1-8k grid cells to the browser at 5.76 GHz+ and previously rendered each as its own SVG via Leaflet's default vector renderer. That meant 1-8k DOM nodes plus per-element layout/paint on every pan/zoom — enough to lock up tabs on mid-range hardware. Two changes: 1. All coverage rects share a single L.canvas({padding: 0.2}) renderer. Browser sees one element drawn in one pass per redraw instead of thousands of nodes. Hit-testing for tooltips still works through Leaflet's canvas-renderer mouse events. 2. Drop `sticky: true` from the tooltip binding. Sticky tooltips reposition to follow the cursor on every mousemove, which scales O(N) with cell count — across thousands of rects that becomes the dominant cost as the cursor moves. Without sticky, the tooltip anchors to the cell's centre on hover (same info, cheaper). --- assets/js/beacon_map_hook.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/assets/js/beacon_map_hook.ts b/assets/js/beacon_map_hook.ts index f2314af8..c6b51774 100644 --- a/assets/js/beacon_map_hook.ts +++ b/assets/js/beacon_map_hook.ts @@ -61,6 +61,23 @@ export const BeaconMap = { // event, which the server only sends when the user flips the // toggle on. Keeping this off the mount path avoids parsing tens // of thousands of cells into Leaflet rects on every page load. + // + // Coverage rects all share a single L.canvas() renderer instead + // of Leaflet's default SVG renderer. With SVG, each cell becomes + // its own element — at 5.76 GHz+ a typical estimate is + // ~3-8k cells, which means ~3-8k DOM nodes plus per-element layout + // / paint work on every pan/zoom. Canvas collapses that to a + // single element, drawn in one pass per redraw. + // + // Hit-testing for tooltips still works through Leaflet's canvas + // renderer (it walks the layer tree at mousemove time), so the + // per-cell tooltip is preserved. We do drop `sticky: true` + // though: that flag re-projects the tooltip to follow the cursor + // pixel-by-pixel, which against thousands of rects compounds + // every mousemove with N geometry checks. Without sticky, the + // tooltip anchors to the cell's centre — same information, + // multiple-orders-of-magnitude cheaper. + const coverageRenderer = L.canvas({padding: 0.2}) let cellLayer: L.LayerGroup | null = null let cellBounds: [number, number][] = [] @@ -77,6 +94,7 @@ export const BeaconMap = { const sw: [number, number] = [c.lat - half, c.lon - half] const ne: [number, number] = [c.lat + half, c.lon + half] const rect = L.rectangle([sw, ne], { + renderer: coverageRenderer, color: c.color, weight: 0, fillColor: c.color, @@ -84,8 +102,7 @@ export const BeaconMap = { interactive: true }) rect.bindTooltip( - `${c.label}
${c.rx_dbm} dBm
${formatDistanceKm(c.distance_km)} · score ${c.score}`, - {sticky: true} + `${c.label}
${c.rx_dbm} dBm
${formatDistanceKm(c.distance_km)} · score ${c.score}` ) rect.addTo(cellLayer) cellBounds.push(sw, ne)