From 92ef6c67fc61fa2e4e9a555b65905d3da8aca3f3 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 14 Apr 2026 17:13:04 -0500 Subject: [PATCH] Defer beacon coverage estimate until the toggle is flipped on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- assets/js/beacon_map_hook.ts | 80 +++++++++++------- .../live/beacon_live/show.ex | 81 ++++++++++++++----- .../live/beacon_live_test.exs | 42 ++++++++++ 3 files changed, 152 insertions(+), 51 deletions(-) diff --git a/assets/js/beacon_map_hook.ts b/assets/js/beacon_map_hook.ts index 348ff5e8..14d01ec2 100644 --- a/assets/js/beacon_map_hook.ts +++ b/assets/js/beacon_map_hook.ts @@ -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}
${c.rx_dbm} dBm
${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}
${c.rx_dbm} dBm
${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) { diff --git a/lib/microwaveprop_web/live/beacon_live/show.ex b/lib/microwaveprop_web/live/beacon_live/show.ex index 9ea631f9..3b97918a 100644 --- a/lib/microwaveprop_web/live/beacon_live/show.ex +++ b/lib/microwaveprop_web/live/beacon_live/show.ex @@ -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)} > @@ -78,7 +75,10 @@ defmodule MicrowavepropWeb.BeaconLive.Show do -
+
Band {@estimate.band_label} · EIRP {@estimate.eirp_dbm} dBm · @@ -96,7 +96,7 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
-
+
<%= for tier <- @estimate.tiers 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 diff --git a/test/microwaveprop_web/live/beacon_live_test.exs b/test/microwaveprop_web/live/beacon_live_test.exs index ba198d7d..5a011acb 100644 --- a/test/microwaveprop_web/live/beacon_live_test.exs +++ b/test/microwaveprop_web/live/beacon_live_test.exs @@ -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