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. 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], { color: c.color, weight: 0, fillColor: c.color, fillOpacity: 0.55, interactive: true }) rect.bindTooltip( `${c.label}
${c.rx_dbm} dBm
${formatDistanceKm(c.distance_km)} · score ${c.score}`, {sticky: true} ) 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) } }