prop/assets/js/beacon_map_hook.ts
Graham McIntire 90195d082e
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).
2026-05-04 08:20:20 -05:00

141 lines
4.3 KiB
TypeScript

import {formatDistanceKm} from "./format"
interface CoverageCell {
lat: number
lon: number
color: string
label: string
rx_dbm: number
distance_km: number
score: number
}
interface ToggleCoveragePayload {
show: boolean
}
interface LoadCoveragePayload {
grid_step: number
cells: CoverageCell[]
show: boolean
}
interface BeaconMapHook extends ViewHook {
mounted(this: BeaconMapHook): void
}
export const BeaconMap = {
mounted(this: BeaconMapHook) {
const dataset = this.el.dataset
const lat = parseFloat(dataset.lat!)
const lon = parseFloat(dataset.lon!)
const label = dataset.label || ""
const onTheAir = dataset.onTheAir === "true"
const map = L.map(this.el, {
zoomControl: true,
attributionControl: false,
scrollWheelZoom: false
}).setView([lat, lon], 11)
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19
}).addTo(map)
// Beacon marker on top.
const markerColor = onTheAir ? "#16a34a" : "#94a3b8"
const markerFill = onTheAir ? "#22c55e" : "#cbd5e1"
L.circleMarker([lat, lon], {
radius: 7,
color: markerColor,
fillColor: markerFill,
fillOpacity: 1.0,
weight: 2
}).addTo(map).bindTooltip(label, {
permanent: true,
direction: "top",
offset: [0, -10]
})
// Coverage layer is lazy — populated on the first `load_coverage`
// 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][] = []
const buildLayer = (cells: CoverageCell[], step: number): void => {
if (cellLayer) {
cellLayer.clearLayers()
} else {
cellLayer = L.layerGroup()
}
cellBounds = []
const half = step / 2.0
for (const c of cells) {
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,
fillOpacity: 0.55,
interactive: true
})
rect.bindTooltip(
`${c.label}<br>${c.rx_dbm} dBm<br>${formatDistanceKm(c.distance_km)} · score ${c.score}`
)
rect.addTo(cellLayer)
cellBounds.push(sw, ne)
}
}
const showLayer = (): void => {
if (!cellLayer) return
if (!map.hasLayer(cellLayer)) cellLayer.addTo(map)
if (cellBounds.length > 0) {
map.fitBounds(L.latLngBounds(cellBounds), {padding: [20, 20]})
map.setZoom(map.getZoom() + 2)
}
}
const hideLayer = (): void => {
if (cellLayer && map.hasLayer(cellLayer)) map.removeLayer(cellLayer)
map.setView([lat, lon], 11)
}
this.handleEvent("load_coverage", ({grid_step, cells, show}: LoadCoveragePayload) => {
buildLayer(cells, grid_step)
if (show) showLayer()
})
this.handleEvent("toggle_coverage", ({show}: ToggleCoveragePayload) => {
if (show) {
showLayer()
} else {
hideLayer()
}
})
setTimeout(() => map.invalidateSize(), 50)
}
}