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 %>