From 810f5a22cea39e5be56ebc659941c658db0f5e8a Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 12 Apr 2026 16:23:08 -0500 Subject: [PATCH] Beacon list map, profile page polish, nav ordering fix /beacons gains a Leaflet map above the table showing every approved beacon as a circle marker with a popup that links to the detail page. New BeaconsListMap JS hook (assets/js/beacons_list_map_hook.ts) walks a data-beacons JSON payload, fitBounds over the marker set, and mirrors the format_freq/1 integer-comma formatting used on the detail view so the popups match the rest of the UI. Approved/on-air beacons render green, off-air beacons render gray. BeaconLive.Index.mount/3 now materializes the list into an assign so it can encode it into the data attribute; the live PubSub path keeps the map in sync on create/update/delete. Profile (/u/:callsign) polish: drop the hero-radio, hero-signal, and hero-information-circle icons per request, and replace the daisyUI `avatar placeholder` wrapper with a plain flex-centered circle so the initial letter actually lives inside the circle instead of anchored to the top-left corner (daisyUI 5 removed the old placeholder-centering behavior). Navigation ordering: in the top navbar (layouts.ex) Contacts now sits immediately before Contact Map instead of after it. All three vertical sidebars already had them adjacent in the correct order, so only the horizontal nav changed. --- assets/js/app.ts | 3 +- assets/js/beacons_list_map_hook.ts | 102 ++++++++++++++++++ lib/microwaveprop_web/components/layouts.ex | 2 +- .../live/beacon_live/index.ex | 43 +++++++- .../live/user_profile_live.ex | 22 +--- 5 files changed, 150 insertions(+), 22 deletions(-) create mode 100644 assets/js/beacons_list_map_hook.ts diff --git a/assets/js/app.ts b/assets/js/app.ts index 3eda2b25..1bf46996 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -19,6 +19,7 @@ import {WeatherMap} from "./weather_map_hook" import {LocateMe, CopyLink} from "./locate_me_hook" import {RoverMap} from "./rover_map_hook" import {BeaconMap} from "./beacon_map_hook" +import {BeaconsListMap} from "./beacons_list_map_hook" interface UtcClockHook extends ViewHook { timer: ReturnType @@ -73,7 +74,7 @@ const csrfToken = document.querySelector("meta[name='csrf-token']")!.getAttribut const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: initLiveStash({_csrf_token: csrfToken}), - hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap, LocateMe, CopyLink, RoverMap, BeaconMap, CommaNumber}, + hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap, LocateMe, CopyLink, RoverMap, BeaconMap, BeaconsListMap, CommaNumber}, }) // Show progress bar on live navigation and form submits diff --git a/assets/js/beacons_list_map_hook.ts b/assets/js/beacons_list_map_hook.ts new file mode 100644 index 00000000..2f3b68d6 --- /dev/null +++ b/assets/js/beacons_list_map_hook.ts @@ -0,0 +1,102 @@ +// Map used by /beacons — renders every approved beacon as a circle +// marker with a popup linking to the detail page. + +interface BeaconPoint { + id: string + callsign: string + lat: number + lon: number + frequency_mhz: number + grid: string + keying: string + on_the_air: boolean +} + +interface BeaconsListMapHook extends ViewHook { + mounted(this: BeaconsListMapHook): void +} + +function formatFreq(freq: number): string { + // Mirror Microwaveprop.Beacons.Beacon.format_freq/1 — integers get + // comma separators, floats keep up to three decimals trimmed. + if (Number.isInteger(freq)) return freq.toLocaleString("en-US") + const trimmed = freq.toFixed(3).replace(/\.?0+$/, "") + const [whole, frac] = trimmed.split(".") + const wholeFormatted = parseInt(whole, 10).toLocaleString("en-US") + return frac ? `${wholeFormatted}.${frac}` : wholeFormatted +} + +function escapeHtml(str: string): string { + return str.replace(/[&<>"']/g, ch => { + switch (ch) { + case "&": return "&" + case "<": return "<" + case ">": return ">" + case "\"": return """ + case "'": return "'" + default: return ch + } + }) +} + +export const BeaconsListMap = { + mounted(this: BeaconsListMapHook) { + const beacons: BeaconPoint[] = JSON.parse(this.el.dataset.beacons || "[]") + + // DFW fallback so the map still renders when no beacons exist yet. + const defaultCenter: [number, number] = [32.897, -97.038] + + const map = L.map(this.el, { + zoomControl: true, + scrollWheelZoom: false, + attributionControl: true + }).setView(defaultCenter, 6) + + L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + attribution: "© OpenStreetMap contributors", + maxZoom: 19 + }).addTo(map) + + const bounds: [number, number][] = [] + + for (const b of beacons) { + if (!Number.isFinite(b.lat) || !Number.isFinite(b.lon)) continue + + const color = b.on_the_air ? "#16a34a" : "#94a3b8" + const fill = b.on_the_air ? "#22c55e" : "#cbd5e1" + + const popupHtml = + `
` + + `${escapeHtml(b.callsign)}` + + (b.on_the_air + ? "" + : ` · off-air`) + + `
` + + `${formatFreq(b.frequency_mhz)} MHz
` + + `Grid ${escapeHtml(b.grid)}
` + + `Keying: ${escapeHtml(b.keying)}
` + + `View details →` + + `
` + + L.circleMarker([b.lat, b.lon], { + radius: 7, + color, + fillColor: fill, + fillOpacity: 0.9, + weight: 2 + }) + .bindPopup(popupHtml, { closeButton: true }) + .bindTooltip(b.callsign, { direction: "top", offset: [0, -8] }) + .addTo(map) + + bounds.push([b.lat, b.lon]) + } + + if (bounds.length > 0) { + map.fitBounds(L.latLngBounds(bounds), { padding: [30, 30], maxZoom: 10 }) + } + + // Invalidate once after mount in case the parent was hidden. + setTimeout(() => map.invalidateSize(), 50) + } +} diff --git a/lib/microwaveprop_web/components/layouts.ex b/lib/microwaveprop_web/components/layouts.ex index ab737a95..880a8a54 100644 --- a/lib/microwaveprop_web/components/layouts.ex +++ b/lib/microwaveprop_web/components/layouts.ex @@ -43,8 +43,8 @@ defmodule MicrowavepropWeb.Layouts do <.link navigate="/map" class="btn btn-ghost btn-sm">Map <.link navigate="/path" class="btn btn-ghost btn-sm">Path <.link navigate="/beacons" class="btn btn-ghost btn-sm">Beacons - <.link navigate="/contacts/map" class="btn btn-ghost btn-sm">Contact Map <.link navigate="/contacts" class="btn btn-ghost btn-sm">Contacts + <.link navigate="/contacts/map" class="btn btn-ghost btn-sm">Contact Map <.link navigate="/submit" class="btn btn-ghost btn-sm">Submit <.link navigate="/about" class="btn btn-ghost btn-sm">About <.link diff --git a/lib/microwaveprop_web/live/beacon_live/index.ex b/lib/microwaveprop_web/live/beacon_live/index.ex index 7a4fee42..bfe67450 100644 --- a/lib/microwaveprop_web/live/beacon_live/index.ex +++ b/lib/microwaveprop_web/live/beacon_live/index.ex @@ -19,6 +19,15 @@ defmodule MicrowavepropWeb.BeaconLive.Index do +
+
+ <.table id="beacons" rows={@streams.beacons} @@ -101,6 +110,8 @@ defmodule MicrowavepropWeb.BeaconLive.Index do def mount(_params, _session, socket) do if connected?(socket), do: Beacons.subscribe_beacons() + beacons = Beacons.list_beacons() + pending = if admin?(socket.assigns.current_scope) do Beacons.list_pending_beacons() @@ -112,7 +123,8 @@ defmodule MicrowavepropWeb.BeaconLive.Index do socket |> assign(:page_title, "Beacons") |> assign(:pending, pending) - |> stream(:beacons, Beacons.list_beacons()) + |> assign(:beacons_json, encode_beacons(beacons)) + |> stream(:beacons, beacons) |> stream(:pending, pending)} end @@ -148,6 +160,8 @@ defmodule MicrowavepropWeb.BeaconLive.Index do @impl true def handle_info({type, %Beacon{}}, socket) when type in [:created, :updated, :deleted] do + beacons = Beacons.list_beacons() + pending = if admin?(socket.assigns.current_scope) do Beacons.list_pending_beacons() @@ -158,10 +172,35 @@ defmodule MicrowavepropWeb.BeaconLive.Index do {:noreply, socket |> assign(:pending, pending) - |> stream(:beacons, Beacons.list_beacons(), reset: true) + |> assign(:beacons_json, encode_beacons(beacons)) + |> stream(:beacons, beacons, reset: true) |> stream(:pending, pending, reset: true)} end defp admin?(%{user: %{is_admin: true}}), do: true defp admin?(_), do: false + + # Serialize just the fields the map hook needs. `phx-update="ignore"` + # means this data attribute is only read on initial hook mount — the + # map won't re-render when beacons change without a full LV reload. + # For now that's fine because list churn is rare; upgrade to + # push_event("update_beacons", ...) if we start getting live PubSub + # updates that need to reflect on the map without a refresh. + defp encode_beacons(beacons) do + beacons + |> Enum.filter(&(is_number(&1.lat) and is_number(&1.lon))) + |> Enum.map(fn b -> + %{ + id: b.id, + callsign: b.callsign, + lat: b.lat, + lon: b.lon, + frequency_mhz: b.frequency_mhz, + grid: b.grid, + keying: Beacon.keying_label(b.keying), + on_the_air: b.on_the_air + } + end) + |> Jason.encode!() + end end diff --git a/lib/microwaveprop_web/live/user_profile_live.ex b/lib/microwaveprop_web/live/user_profile_live.ex index 90f77d0a..738af46a 100644 --- a/lib/microwaveprop_web/live/user_profile_live.ex +++ b/lib/microwaveprop_web/live/user_profile_live.ex @@ -44,10 +44,8 @@ defmodule MicrowavepropWeb.UserProfileLive do
-
-
- {initial(@profile)} -
+
+ {initial(@profile)}

{@profile.callsign}

@@ -61,16 +59,10 @@ defmodule MicrowavepropWeb.UserProfileLive do
-
- <.icon name="hero-radio" class="w-8 h-8" /> -
Contacts submitted
{length(@contacts)}
-
- <.icon name="hero-signal" class="w-8 h-8" /> -
Beacons submitted
{length(@beacons)}
@@ -80,10 +72,7 @@ defmodule MicrowavepropWeb.UserProfileLive do

Contacts

<%= if @contacts == [] do %> -
- <.icon name="hero-information-circle" class="w-5 h-5" /> - No contacts submitted yet. -
+

No contacts submitted yet.

<% else %>
@@ -121,10 +110,7 @@ defmodule MicrowavepropWeb.UserProfileLive do

Beacons

<%= if @beacons == [] do %> -
- <.icon name="hero-information-circle" class="w-5 h-5" /> - No beacons submitted yet. -
+

No beacons submitted yet.

<% else %>