perf(beacons): coverage map uses Leaflet canvas renderer, no sticky tooltips

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 <path> 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 <canvas> element drawn in one pass
   per redraw instead of thousands of <path> 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).
This commit is contained in:
Graham McIntire 2026-05-04 08:20:20 -05:00
parent b4d6b0417b
commit 90195d082e
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59

View file

@ -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 <path> 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 <canvas> 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}<br>${c.rx_dbm} dBm<br>${formatDistanceKm(c.distance_km)} · score ${c.score}`,
{sticky: true}
`${c.label}<br>${c.rx_dbm} dBm<br>${formatDistanceKm(c.distance_km)} · score ${c.score}`
)
rect.addTo(cellLayer)
cellBounds.push(sw, ne)