prop/lib/microwaveprop_web/live/contact_map_live.ex
Graham McIntire 578b4ede86 Serve /contacts/map payload via HTTP endpoint, parallel hydrate
- Add ContactMapController serving /api/contacts/map with pre-gzipped
  JSON from Microwaveprop.Cache. Browsers handle Content-Encoding: gzip
  natively; payload drops from 5.7 MB raw to 1.5 MB on the wire (73%).
  Cache TTL 10 min, invalidated on contact insert alongside the other
  contact-map cache keys.
- Move contact map payload out of the LiveView's initial HTML entirely.
  Shell renders with the filter chrome (count + band list); the hook
  fetches contacts from the HTTP endpoint in parallel with map init.
  This also avoids Phoenix.Socket's 64 KB frame limit (push_event would
  have required chunked transfer or a global frame size bump).
- Parallelize ContactLive.Show hydrate: five independent DB loads
  (weather, solar, hrrr_path, terrain, iemre) now run concurrently via
  Task.async + Task.await_many. Total latency drops from sum(each) to
  max(each), cutting contact show hydrate time roughly 5x.
2026-04-12 13:10:51 -05:00

296 lines
10 KiB
Elixir

defmodule MicrowavepropWeb.ContactMapLive do
@moduledoc false
use MicrowavepropWeb, :live_view
alias Microwaveprop.Radio
@band_colors %{
1296 => "#475569",
2304 => "#7c3aed",
3456 => "#4f46e5",
5760 => "#2563eb",
10_000 => "#059669",
24_000 => "#d97706",
47_000 => "#ea580c",
68_000 => "#dc2626",
75_000 => "#c026d3",
122_000 => "#db2777",
134_000 => "#e11d48",
241_000 => "#b91c1c"
}
@impl true
def mount(_params, _session, socket) do
# Shell renders immediately with filter chrome (count + bands list). The
# JS hook fetches the ~5 MB contacts payload from `/api/contacts/map`
# after mount, which streams it as pre-gzipped bytes via the browser's
# native `Content-Encoding: gzip` handling. This gets the heavy payload
# off the initial HTML entirely.
%{count: count, bands: bands} = Radio.contact_map_payload()
{:ok,
assign(socket,
page_title: "Contact Map",
contact_count: count,
all_bands: bands,
enabled_bands: MapSet.new(bands),
callsign_filter: ""
)}
end
@impl true
def handle_event("toggle_band", %{"band" => band_str}, socket) do
band = String.to_integer(band_str)
enabled =
if MapSet.member?(socket.assigns.enabled_bands, band) do
MapSet.delete(socket.assigns.enabled_bands, band)
else
MapSet.put(socket.assigns.enabled_bands, band)
end
socket = assign(socket, enabled_bands: enabled)
{:noreply, push_filter(socket)}
end
def handle_event("bands_all", _params, socket) do
socket = assign(socket, enabled_bands: MapSet.new(socket.assigns.all_bands))
{:noreply, push_filter(socket)}
end
def handle_event("bands_none", _params, socket) do
socket = assign(socket, enabled_bands: MapSet.new())
{:noreply, push_filter(socket)}
end
def handle_event("filter_callsign", %{"callsign" => callsign}, socket) do
socket = assign(socket, callsign_filter: String.trim(callsign))
{:noreply, push_filter(socket)}
end
defp push_filter(socket) do
push_event(socket, "apply_filter", %{
enabled_bands: MapSet.to_list(socket.assigns.enabled_bands),
callsign: socket.assigns.callsign_filter
})
end
defp band_label(mhz) when mhz >= 1000 do
ghz = mhz / 1000
label = if ghz == trunc(ghz), do: "#{trunc(ghz)}", else: "#{Float.round(ghz / 1, 1)}"
"#{label} GHz"
end
defp band_label(mhz), do: "#{mhz} MHz"
defp band_color(mhz), do: Map.get(@band_colors, mhz, "#64748b")
@impl true
def render(assigns) do
~H"""
<div class="fixed inset-0 flex">
<div class="flex-1 relative">
<div
id="contacts-map"
phx-hook="ContactsMap"
phx-update="ignore"
data-fetch-url="/api/contacts/map"
class="absolute inset-0 z-0"
>
</div>
<%!-- Mobile floating controls --%>
<div
id="map-controls"
class="md:hidden absolute top-2 left-12 z-[1000] flex flex-col gap-2 max-w-[calc(100vw-4rem)]"
>
<div
data-theme="dark"
class="bg-neutral text-neutral-content shadow rounded-box border border-base-300 p-2 flex flex-col gap-2"
>
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
<div class="min-w-0">
<span>Contact Map</span>
<div id="contact-count-mobile" class="font-normal text-xs opacity-70">
{Integer.to_string(@contact_count)} contacts
</div>
</div>
<button
id="mobile-panel-toggle"
class="btn btn-xs btn-square btn-ghost"
onclick="document.getElementById('panel-extras').classList.toggle('hidden')"
>
<.icon name="hero-bars-3" class="size-4" />
</button>
</div>
<div id="panel-extras" class="hidden flex-col gap-2">
<input
type="text"
placeholder="Filter callsign..."
phx-keyup="filter_callsign"
phx-key="Enter"
phx-change="filter_callsign"
phx-debounce="300"
name="callsign"
value={@callsign_filter}
class="input input-xs w-full"
/>
<div class="flex items-center justify-between">
<span class="text-xs font-bold">Bands</span>
<span class="flex gap-2">
<button phx-click="bands_all" class="text-[10px] underline opacity-70">All</button>
<button phx-click="bands_none" class="text-[10px] underline opacity-70">
None
</button>
</span>
</div>
<%= for band <- @all_bands do %>
<label class="flex items-center gap-2 cursor-pointer text-xs">
<input
type="checkbox"
checked={MapSet.member?(@enabled_bands, band)}
phx-click="toggle_band"
phx-value-band={band}
style={"accent-color:#{band_color(band)}"}
/>
<span
class="inline-block w-3 h-0.5 rounded-sm shrink-0"
style={"background:#{band_color(band)}"}
>
</span>
<span>{band_label(band)}</span>
</label>
<% end %>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<.link navigate="/map" class="btn btn-xs btn-ghost justify-start">
Propagation Map
</.link>
<.link navigate="/path" class="btn btn-xs btn-ghost justify-start">
Path Calculator
</.link>
<.link navigate="/beacons" class="btn btn-xs btn-ghost justify-start">
Beacons
</.link>
<.link navigate="/contacts" class="btn btn-xs btn-ghost justify-start">
Contacts
</.link>
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start">
Submit a Contact
</.link>
<.link navigate="/about" class="btn btn-xs btn-ghost justify-start">
About
</.link>
</div>
</div>
</div>
</div>
</div>
<%!-- Desktop right sidebar --%>
<div
id="sidebar"
data-theme="dark"
class="hidden md:flex flex-col w-56 shrink-0 h-full bg-neutral text-neutral-content border-l border-base-300 overflow-hidden"
>
<div class="p-3 flex flex-col gap-2 shrink-0 overflow-y-auto flex-1">
<div class="font-bold text-sm leading-tight px-1">
<span>Contact Map</span>
<div id="contact-count" class="font-normal text-xs opacity-70">
{Integer.to_string(@contact_count)} contacts
</div>
</div>
<%!-- Callsign filter --%>
<input
type="text"
placeholder="Filter callsign..."
phx-keyup="filter_callsign"
phx-key="Enter"
phx-change="filter_callsign"
phx-debounce="300"
name="callsign"
value={@callsign_filter}
class="input input-sm w-full"
/>
<%!-- Band checkboxes --%>
<div class="flex items-center justify-between px-1">
<span class="text-xs font-bold">Bands</span>
<span class="flex gap-2">
<button phx-click="bands_all" class="text-[10px] underline opacity-70">All</button>
<button phx-click="bands_none" class="text-[10px] underline opacity-70">None</button>
</span>
</div>
<%= for band <- @all_bands do %>
<label class="flex items-center gap-2 cursor-pointer px-1 text-sm leading-relaxed">
<input
type="checkbox"
checked={MapSet.member?(@enabled_bands, band)}
phx-click="toggle_band"
phx-value-band={band}
style={"accent-color:#{band_color(band)}"}
/>
<span
class="inline-block w-3.5 h-0.5 rounded-sm shrink-0"
style={"background:#{band_color(band)}"}
>
</span>
<span>{band_label(band)}</span>
</label>
<% end %>
<%!-- Navigation --%>
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
<li>
<.link navigate="/map">Propagation Map</.link>
</li>
<li>
<.link navigate="/path">Path Calculator</.link>
</li>
<li>
<.link navigate="/beacons">Beacons</.link>
</li>
<li>
<.link navigate="/contacts">Contacts</.link>
</li>
<li>
<.link navigate="/submit">Submit a Contact</.link>
</li>
<li>
<.link navigate="/about">About</.link>
</li>
</ul>
<%!-- Auth --%>
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
<%= if @current_scope && @current_scope.user do %>
<li class="menu-title text-xs opacity-70">{@current_scope.user.callsign}</li>
<li>
<.link href={~p"/users/settings"}>Settings</.link>
</li>
<li>
<.link href={~p"/users/log-out"} method="delete">Log out</.link>
</li>
<% else %>
<li>
<.link href={~p"/users/register"}>Register</.link>
</li>
<li>
<.link href={~p"/users/log-in"}>Log in</.link>
</li>
<% end %>
</ul>
</div>
</div>
</div>
<Layouts.flash_group flash={@flash} />
"""
end
end