diff --git a/assets/js/contacts_map_hook.ts b/assets/js/contacts_map_hook.ts index efeec48d..0e36ea2e 100644 --- a/assets/js/contacts_map_hook.ts +++ b/assets/js/contacts_map_hook.ts @@ -51,7 +51,11 @@ function bandColor(band: number): string { export const ContactsMap = { mounted(this: ContactsMapHook) { - this.allContacts = JSON.parse(this.el.dataset.contacts!) + // Contacts payload (~5 MB) comes from a standalone HTTP endpoint that + // serves pre-gzipped JSON. This keeps it out of the initial HTML and + // lets the browser cache it independently of the LiveView. + const fetchUrl = this.el.dataset.fetchUrl || "/api/contacts/map" + this.allContacts = [] this.callsignFilter = "" this.enabledBands = new Set() this.lineLayer = L.layerGroup() @@ -66,6 +70,23 @@ export const ContactsMap = { // Defer map init until container has dimensions requestAnimationFrame(() => this.initMap()) + + // Fetch contacts in parallel with map init. Once both are done, + // `rebuildMap()` draws everything. Starts earlier than a LiveView + // push_event because the HTTP request doesn't wait for the socket + // handshake. + // No explicit Accept header — the :browser pipeline's `plug :accepts` + // only whitelists "html", so setting accept: application/json triggers + // a 406. The default `*/*` matches fine and we still parse the body + // as JSON ourselves. + fetch(fetchUrl) + .then(resp => resp.json()) + .then(contacts => { + this.allContacts = contacts + for (const c of contacts) this.enabledBands.add(c[4]) + if (this.map) this.rebuildMap() + }) + .catch(err => console.error("Failed to load contacts:", err)) }, initMap(this: ContactsMapHook) { @@ -85,10 +106,11 @@ export const ContactsMap = { this.lineLayer.addTo(this.map) this.dotLayer.addTo(this.map) - // Enable all bands initially - for (const c of this.allContacts) this.enabledBands.add(c[4]) - - this.rebuildMap() + // If contacts arrived before the map was ready, draw them now. + if (this.allContacts.length > 0) { + for (const c of this.allContacts) this.enabledBands.add(c[4]) + this.rebuildMap() + } }, rebuildMap(this: ContactsMapHook) { diff --git a/lib/microwaveprop/radio.ex b/lib/microwaveprop/radio.ex index 77663f7a..eb7a9f99 100644 --- a/lib/microwaveprop/radio.ex +++ b/lib/microwaveprop/radio.ex @@ -538,6 +538,7 @@ defmodule Microwaveprop.Radio do with {:ok, _} <- result do Cache.invalidate(@count_cache_key) Cache.invalidate(@map_payload_cache_key) + Cache.invalidate({MicrowavepropWeb.ContactMapController, :gzipped_payload}) end result diff --git a/lib/microwaveprop_web/controllers/contact_map_controller.ex b/lib/microwaveprop_web/controllers/contact_map_controller.ex new file mode 100644 index 00000000..8a22eb9c --- /dev/null +++ b/lib/microwaveprop_web/controllers/contact_map_controller.ex @@ -0,0 +1,53 @@ +defmodule MicrowavepropWeb.ContactMapController do + @moduledoc """ + Serves the contact map JSON payload as a standalone HTTP endpoint. Keeps + the ~5 MB blob out of the `/contacts/map` LiveView's initial HTML response + and lets the browser's native `Content-Encoding: gzip` handling do the + compression. The payload itself is served from `Microwaveprop.Cache` via + `Radio.contact_map_payload/0`. + """ + use MicrowavepropWeb, :controller + + alias Microwaveprop.Cache + alias Microwaveprop.Radio + + @cache_key {__MODULE__, :gzipped_payload} + @cache_ttl_ms 10 * 60 * 1_000 + + @spec show(Plug.Conn.t(), map()) :: Plug.Conn.t() + def show(conn, _params) do + {body, encoding} = gzipped_or_plain_body(conn) + + conn + |> put_resp_content_type("application/json") + |> maybe_put_encoding(encoding) + |> put_resp_header("cache-control", "public, max-age=60") + |> send_resp(200, body) + end + + defp gzipped_or_plain_body(conn) do + if accepts_gzip?(conn) do + {gzipped_payload(), :gzip} + else + %{json: iodata} = Radio.contact_map_payload() + {IO.iodata_to_binary(iodata), :identity} + end + end + + defp gzipped_payload do + Cache.fetch_or_store(@cache_key, @cache_ttl_ms, fn -> + %{json: iodata} = Radio.contact_map_payload() + :zlib.gzip(IO.iodata_to_binary(iodata)) + end) + end + + defp accepts_gzip?(conn) do + case get_req_header(conn, "accept-encoding") do + [value | _] -> String.contains?(value, "gzip") + _ -> false + end + end + + defp maybe_put_encoding(conn, :gzip), do: put_resp_header(conn, "content-encoding", "gzip") + defp maybe_put_encoding(conn, :identity), do: conn +end diff --git a/lib/microwaveprop_web/live/contact_live/show.ex b/lib/microwaveprop_web/live/contact_live/show.ex index 6b880a80..7c3f0926 100644 --- a/lib/microwaveprop_web/live/contact_live/show.ex +++ b/lib/microwaveprop_web/live/contact_live/show.ex @@ -229,18 +229,22 @@ defmodule MicrowavepropWeb.ContactLive.Show do def handle_info({:hydrate, can_enqueue}, socket) do contact = socket.assigns.contact - weather = load_weather(contact) - solar = if can_enqueue, do: maybe_enqueue_solar(contact), else: load_solar(contact) - hrrr_path = Weather.hrrr_profiles_for_path(contact) + # Five independent DB loads run in parallel — total latency drops from + # sum(each) to max(each). The heaviest is `hrrr_profiles_for_path` + # against the 42M-row partitioned table. + %{ + weather: weather, + solar: solar, + hrrr_path: hrrr_path, + terrain: terrain, + iemre: iemre + } = parallel_hydrate(contact, can_enqueue) + hrrr = List.first(hrrr_path) - - {hrrr, contact} = - if can_enqueue, do: maybe_enqueue_hrrr(hrrr, contact), else: {hrrr, contact} - - terrain = Terrain.get_terrain_profile(contact.id) + {hrrr, contact} = if can_enqueue, do: maybe_enqueue_hrrr(hrrr, contact), else: {hrrr, contact} contact = if can_enqueue, do: maybe_enqueue_terrain(terrain, contact), else: contact contact = if can_enqueue, do: maybe_enqueue_weather(weather, contact), else: contact - iemre = load_iemre(contact) + elevation_profile = compute_elevation_profile(contact, hrrr_path, weather.soundings) propagation_analysis = @@ -397,6 +401,21 @@ defmodule MicrowavepropWeb.ContactLive.Show do defp sounding_sort_key("lifted_index"), do: &(&1.lifted_index || 0) defp sounding_sort_key(_), do: fn s -> s.station.name || s.station.station_code end + defp parallel_hydrate(contact, can_enqueue) do + tasks = %{ + weather: Task.async(fn -> load_weather(contact) end), + solar: + Task.async(fn -> + if can_enqueue, do: maybe_enqueue_solar(contact), else: load_solar(contact) + end), + hrrr_path: Task.async(fn -> Weather.hrrr_profiles_for_path(contact) end), + terrain: Task.async(fn -> Terrain.get_terrain_profile(contact.id) end), + iemre: Task.async(fn -> load_iemre(contact) end) + } + + Map.new(tasks, fn {key, task} -> {key, Task.await(task, 30_000)} end) + end + defp load_iemre(contact) do case Weather.iemre_for_contact(contact) do %{hourly: hourly} = obs when is_list(hourly) and hourly != [] -> diff --git a/lib/microwaveprop_web/live/contact_map_live.ex b/lib/microwaveprop_web/live/contact_map_live.ex index 26ee2a11..6250d8ff 100644 --- a/lib/microwaveprop_web/live/contact_map_live.ex +++ b/lib/microwaveprop_web/live/contact_map_live.ex @@ -21,12 +21,16 @@ defmodule MicrowavepropWeb.ContactMapLive do @impl true def mount(_params, _session, socket) do - %{json: json, count: count, bands: bands} = Radio.contact_map_payload() + # 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", - contacts_json: IO.iodata_to_binary(json), contact_count: count, all_bands: bands, enabled_bands: MapSet.new(bands), @@ -91,8 +95,8 @@ defmodule MicrowavepropWeb.ContactMapLive do id="contacts-map" phx-hook="ContactsMap" phx-update="ignore" + data-fetch-url="/api/contacts/map" class="absolute inset-0 z-0" - data-contacts={@contacts_json} > diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index 71aaf28c..fad9442f 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -66,6 +66,7 @@ defmodule MicrowavepropWeb.Router do pipe_through :browser get "/", PageController, :home + get "/api/contacts/map", ContactMapController, :show live_session :public, on_mount: [{MicrowavepropWeb.UserAuth, :default}] do live "/submit", SubmitLive