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
|
show: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LoadCoveragePayload {
|
||||||
|
grid_step: number
|
||||||
|
cells: CoverageCell[]
|
||||||
|
show: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface BeaconMapHook extends ViewHook {
|
interface BeaconMapHook extends ViewHook {
|
||||||
mounted(this: BeaconMapHook): void
|
mounted(this: BeaconMapHook): void
|
||||||
}
|
}
|
||||||
|
|
@ -23,9 +29,6 @@ export const BeaconMap = {
|
||||||
const lon = parseFloat(dataset.lon!)
|
const lon = parseFloat(dataset.lon!)
|
||||||
const label = dataset.label || ""
|
const label = dataset.label || ""
|
||||||
const onTheAir = dataset.onTheAir === "true"
|
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, {
|
const map = L.map(this.el, {
|
||||||
zoomControl: true,
|
zoomControl: true,
|
||||||
|
|
@ -37,30 +40,6 @@ export const BeaconMap = {
|
||||||
maxZoom: 19
|
maxZoom: 19
|
||||||
}).addTo(map)
|
}).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.
|
// Beacon marker on top.
|
||||||
const markerColor = onTheAir ? "#16a34a" : "#94a3b8"
|
const markerColor = onTheAir ? "#16a34a" : "#94a3b8"
|
||||||
const markerFill = onTheAir ? "#22c55e" : "#cbd5e1"
|
const markerFill = onTheAir ? "#22c55e" : "#cbd5e1"
|
||||||
|
|
@ -76,20 +55,59 @@ export const BeaconMap = {
|
||||||
offset: [0, -10]
|
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 => {
|
const showLayer = (): void => {
|
||||||
|
if (!cellLayer) return
|
||||||
if (!map.hasLayer(cellLayer)) cellLayer.addTo(map)
|
if (!map.hasLayer(cellLayer)) cellLayer.addTo(map)
|
||||||
if (allBounds.length > 0) {
|
if (cellBounds.length > 0) {
|
||||||
map.fitBounds(L.latLngBounds(allBounds), {padding: [20, 20]})
|
map.fitBounds(L.latLngBounds(cellBounds), {padding: [20, 20]})
|
||||||
map.setZoom(map.getZoom() + 2)
|
map.setZoom(map.getZoom() + 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hideLayer = (): void => {
|
const hideLayer = (): void => {
|
||||||
if (map.hasLayer(cellLayer)) map.removeLayer(cellLayer)
|
if (cellLayer && map.hasLayer(cellLayer)) map.removeLayer(cellLayer)
|
||||||
map.setView([lat, lon], 11)
|
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) => {
|
this.handleEvent("toggle_coverage", ({show}: ToggleCoveragePayload) => {
|
||||||
if (show) {
|
if (show) {
|
||||||
|
|
|
||||||
|
|
@ -53,9 +53,6 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
|
||||||
data-lon={@beacon.lon}
|
data-lon={@beacon.lon}
|
||||||
data-label={"#{@beacon.callsign} · #{Beacon.format_freq(@beacon.frequency_mhz)} MHz"}
|
data-label={"#{@beacon.callsign} · #{Beacon.format_freq(@beacon.frequency_mhz)} MHz"}
|
||||||
data-on-the-air={to_string(@beacon.on_the_air)}
|
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>
|
</div>
|
||||||
|
|
||||||
|
|
@ -78,7 +75,10 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
<span>
|
||||||
Band <strong>{@estimate.band_label}</strong> ·
|
Band <strong>{@estimate.band_label}</strong> ·
|
||||||
EIRP <strong>{@estimate.eirp_dbm} dBm</strong> ·
|
EIRP <strong>{@estimate.eirp_dbm} dBm</strong> ·
|
||||||
|
|
@ -96,7 +96,7 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 %>
|
<%= for tier <- @estimate.tiers do %>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
|
|
@ -169,7 +169,9 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
|
||||||
|> assign(:beacon, beacon)
|
|> assign(:beacon, beacon)
|
||||||
|> assign(:show_coverage, false)
|
|> assign(:show_coverage, false)
|
||||||
|> assign(:coverage_supported, coverage_supported?(beacon))
|
|> 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
|
end
|
||||||
|
|
||||||
# Estimated current coverage only makes sense from 5.76 GHz upward,
|
# Estimated current coverage only makes sense from 5.76 GHz upward,
|
||||||
|
|
@ -180,15 +182,32 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("toggle_coverage", _params, socket) do
|
def handle_event("toggle_coverage", _params, socket) do
|
||||||
if socket.assigns.coverage_supported do
|
cond do
|
||||||
show = not socket.assigns.show_coverage
|
not socket.assigns.coverage_supported ->
|
||||||
|
{:noreply, socket}
|
||||||
|
|
||||||
{:noreply,
|
socket.assigns.show_coverage ->
|
||||||
socket
|
# Already on, user is turning it off.
|
||||||
|> assign(:show_coverage, show)
|
{:noreply,
|
||||||
|> push_event("toggle_coverage", %{show: show})}
|
socket
|
||||||
else
|
|> assign(:show_coverage, false)
|
||||||
{:noreply, socket}
|
|> 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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -208,13 +227,35 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:updated, %Beacon{id: id} = beacon}, %{assigns: %{beacon: %{id: id}}} = socket) do
|
def handle_info({:updated, %Beacon{id: id} = beacon}, %{assigns: %{beacon: %{id: id}}} = socket) do
|
||||||
supported = coverage_supported?(beacon)
|
supported = coverage_supported?(beacon)
|
||||||
|
show? = socket.assigns.show_coverage and supported
|
||||||
|
|
||||||
{:noreply,
|
# Only recompute the estimate if coverage was already displayed —
|
||||||
socket
|
# otherwise leave it nil and let the next toggle lazy-load it.
|
||||||
|> assign(:beacon, beacon)
|
estimate =
|
||||||
|> assign(:coverage_supported, supported)
|
if show? and socket.assigns.estimate do
|
||||||
|> assign(:show_coverage, socket.assigns.show_coverage and supported)
|
RangeEstimate.estimate(beacon)
|
||||||
|> assign(:estimate, 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
|
end
|
||||||
|
|
||||||
def handle_info({:deleted, %Beacon{id: id}}, %{assigns: %{beacon: %{id: id}}} = socket) do
|
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")
|
assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/beacons/#{beacon}/edit")
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue