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:
parent
7c5d187ac5
commit
92ef6c67fc
3 changed files with 152 additions and 51 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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> ·
|
||||
EIRP <strong>{@estimate.eirp_dbm} dBm</strong> ·
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue