Defer beacon coverage estimate until the toggle is flipped on

Previously RangeEstimate.estimate/1 ran on every beacon page mount,
even though the cells data was only rendered after the user flipped
the coverage toggle — a wasted bbox grid pass per page load. Start
with estimate: nil, compute lazily in handle_event("toggle_coverage")
when flipping on, and push the cells payload to the Leaflet hook via
a new load_coverage event. The JS hook no longer reads data-cells on
mount and only builds the cellLayer when the server sends it. Cached
in the socket so re-toggling is instant.
This commit is contained in:
Graham McIntire 2026-04-14 17:13:04 -05:00
parent 7c5d187ac5
commit 92ef6c67fc
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 152 additions and 51 deletions

View file

@ -12,6 +12,12 @@ interface ToggleCoveragePayload {
show: boolean
}
interface LoadCoveragePayload {
grid_step: number
cells: CoverageCell[]
show: boolean
}
interface BeaconMapHook extends ViewHook {
mounted(this: BeaconMapHook): void
}
@ -23,9 +29,6 @@ export const BeaconMap = {
const lon = parseFloat(dataset.lon!)
const label = dataset.label || ""
const onTheAir = dataset.onTheAir === "true"
const cells: CoverageCell[] = JSON.parse(dataset.cells || "[]")
const step = parseFloat(dataset.gridStep || "0.125")
const initialShowCoverage = dataset.showCoverage === "true"
const map = L.map(this.el, {
zoomControl: true,
@ -37,30 +40,6 @@ export const BeaconMap = {
maxZoom: 19
}).addTo(map)
// Build the coverage cell layer up-front but keep it off the map until
// the user toggles it on. Using a single layerGroup keeps pan/zoom fast.
const cellLayer = L.layerGroup()
const half = step / 2.0
const allBounds: [number, number][] = []
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}<br>${c.rx_dbm} dBm<br>${c.distance_km} km · score ${c.score}`,
{sticky: true}
)
rect.addTo(cellLayer)
allBounds.push(sw, ne)
}
// Beacon marker on top.
const markerColor = onTheAir ? "#16a34a" : "#94a3b8"
const markerFill = onTheAir ? "#22c55e" : "#cbd5e1"
@ -76,20 +55,59 @@ export const BeaconMap = {
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}<br>${c.rx_dbm} dBm<br>${c.distance_km} 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 (allBounds.length > 0) {
map.fitBounds(L.latLngBounds(allBounds), {padding: [20, 20]})
if (cellBounds.length > 0) {
map.fitBounds(L.latLngBounds(cellBounds), {padding: [20, 20]})
map.setZoom(map.getZoom() + 2)
}
}
const hideLayer = (): void => {
if (map.hasLayer(cellLayer)) map.removeLayer(cellLayer)
if (cellLayer && map.hasLayer(cellLayer)) map.removeLayer(cellLayer)
map.setView([lat, lon], 11)
}
if (initialShowCoverage) showLayer()
this.handleEvent("load_coverage", ({grid_step, cells, show}: LoadCoveragePayload) => {
buildLayer(cells, grid_step)
if (show) showLayer()
})
this.handleEvent("toggle_coverage", ({show}: ToggleCoveragePayload) => {
if (show) {

View file

@ -53,9 +53,6 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
data-lon={@beacon.lon}
data-label={"#{@beacon.callsign} · #{Beacon.format_freq(@beacon.frequency_mhz)} MHz"}
data-on-the-air={to_string(@beacon.on_the_air)}
data-grid-step={@estimate.grid_step}
data-cells={Jason.encode!(@estimate.cells)}
data-show-coverage={to_string(@show_coverage)}
>
</div>
@ -78,7 +75,10 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
</label>
</div>
<div :if={@show_coverage} class="text-xs opacity-70 mb-2 flex flex-wrap items-center gap-3">
<div
:if={@show_coverage && @estimate}
class="text-xs opacity-70 mb-2 flex flex-wrap items-center gap-3"
>
<span>
Band <strong>{@estimate.band_label}</strong> &middot;
EIRP <strong>{@estimate.eirp_dbm} dBm</strong> &middot;
@ -96,7 +96,7 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
</span>
</div>
<div :if={@show_coverage} class="flex flex-wrap gap-3 text-xs mb-4">
<div :if={@show_coverage && @estimate} class="flex flex-wrap gap-3 text-xs mb-4">
<%= for tier <- @estimate.tiers do %>
<div class="flex items-center gap-1.5">
<span
@ -169,7 +169,9 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
|> assign(:beacon, beacon)
|> assign(:show_coverage, false)
|> assign(:coverage_supported, coverage_supported?(beacon))
|> assign(:estimate, RangeEstimate.estimate(beacon))}
# Estimate is lazy — computed on the first toggle-on to avoid
# paying the bbox grid cost on every page load.
|> assign(:estimate, nil)}
end
# Estimated current coverage only makes sense from 5.76 GHz upward,
@ -180,15 +182,32 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
@impl true
def handle_event("toggle_coverage", _params, socket) do
if socket.assigns.coverage_supported do
show = not socket.assigns.show_coverage
cond do
not socket.assigns.coverage_supported ->
{:noreply, socket}
{:noreply,
socket
|> assign(:show_coverage, show)
|> push_event("toggle_coverage", %{show: show})}
else
{:noreply, socket}
socket.assigns.show_coverage ->
# Already on, user is turning it off.
{:noreply,
socket
|> assign(:show_coverage, false)
|> push_event("toggle_coverage", %{show: false})}
true ->
# First time turning on (or re-enabling after off) — compute
# the estimate if we don't already have it, then push the
# cell payload to the hook.
estimate = socket.assigns.estimate || RangeEstimate.estimate(socket.assigns.beacon)
{:noreply,
socket
|> assign(:show_coverage, true)
|> assign(:estimate, estimate)
|> push_event("load_coverage", %{
grid_step: estimate.grid_step,
cells: estimate.cells,
show: true
})}
end
end
@ -208,13 +227,35 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
@impl true
def handle_info({:updated, %Beacon{id: id} = beacon}, %{assigns: %{beacon: %{id: id}}} = socket) do
supported = coverage_supported?(beacon)
show? = socket.assigns.show_coverage and supported
{:noreply,
socket
|> assign(:beacon, beacon)
|> assign(:coverage_supported, supported)
|> assign(:show_coverage, socket.assigns.show_coverage and supported)
|> assign(:estimate, RangeEstimate.estimate(beacon))}
# Only recompute the estimate if coverage was already displayed —
# otherwise leave it nil and let the next toggle lazy-load it.
estimate =
if show? and socket.assigns.estimate do
RangeEstimate.estimate(beacon)
else
socket.assigns.estimate
end
socket =
socket
|> assign(:beacon, beacon)
|> assign(:coverage_supported, supported)
|> assign(:show_coverage, show?)
|> assign(:estimate, estimate)
# Re-push fresh cells to the hook if the map is currently visible.
if show? and estimate do
{:noreply,
push_event(socket, "load_coverage", %{
grid_step: estimate.grid_step,
cells: estimate.cells,
show: true
})}
else
{:noreply, socket}
end
end
def handle_info({:deleted, %Beacon{id: id}}, %{assigns: %{beacon: %{id: id}}} = socket) do

View file

@ -168,4 +168,46 @@ defmodule MicrowavepropWeb.BeaconLiveTest do
assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/beacons/#{beacon}/edit")
end
end
describe "Show — lazy coverage" do
test "does not compute the estimate or embed cells on mount", %{conn: conn} do
beacon = approved_beacon_fixture(user_fixture(), frequency_mhz: 10_368.1)
{:ok, view, html} = live(conn, ~p"/beacons/#{beacon}")
# Map div exists but data-cells is absent or empty until the user
# flips the toggle — we don't want to pay the RangeEstimate cost
# on every page load.
refute html =~ "data-cells"
assert has_element?(view, "input[phx-click='toggle_coverage']")
end
test "flipping the toggle computes the estimate and pushes coverage data", %{conn: conn} do
beacon = approved_beacon_fixture(user_fixture(), frequency_mhz: 10_368.1)
{:ok, view, html} = live(conn, ~p"/beacons/#{beacon}")
# Before the click the coverage info bar is absent.
refute html =~ "cells rendered"
render_click(element(view, "input[phx-click='toggle_coverage']"))
html = render(view)
# After toggling on, the coverage info bar (which only renders
# when @estimate is populated and @show_coverage is true) shows.
assert html =~ "cells rendered"
assert html =~ "Atm loss"
end
test "toggle_coverage is a no-op for beacons below 5.76 GHz", %{conn: conn} do
beacon = approved_beacon_fixture(user_fixture(), frequency_mhz: 1296.0)
{:ok, view, html} = live(conn, ~p"/beacons/#{beacon}")
assert html =~ "5.76 GHz and up only"
refute html =~ "data-cells"
refute html =~ "cells rendered"
# Even if a client-side hack forces the click, the server refuses.
render_click(view, "toggle_coverage")
refute render(view) =~ "cells rendered"
end
end
end