diff --git a/assets/js/contacts_map_hook.ts b/assets/js/contacts_map_hook.ts index 7ca739a6..3c5ffb28 100644 --- a/assets/js/contacts_map_hook.ts +++ b/assets/js/contacts_map_hook.ts @@ -38,6 +38,7 @@ interface ContactsMapHook extends ViewHook { lines: LineEntry[] dotLayer: L.LayerGroup map: L.Map + canvasRenderer: L.Canvas delegatedChange: (e: Event) => void delegatedClick: (e: Event) => void initMap(this: ContactsMapHook): void @@ -100,6 +101,12 @@ export const ContactsMap = { this.enabledBands = new Set() this.bandGroups = new Map() this.lines = [] + + // Single shared Canvas renderer — all polylines and circle markers + // render into one instead of thousands of SVG elements. + // This eliminates ~58k DOM nodes and makes pan/zoom GPU-composited. + this.canvasRenderer = L.canvas({padding: 0.5}) + this.dotLayer = L.layerGroup() // Callsign filter still roundtrips through LiveView (debounced) — it's @@ -211,17 +218,18 @@ export const ContactsMap = { buildLines(this: ContactsMapHook) { // One-time build of every polyline, grouped into per-band LayerGroups. - // After this, band toggling is just addLayer/removeLayer on the map — - // no polyline recreation ever. + // All polylines share a single Canvas renderer — no SVG DOM nodes. + // After this, band toggling is just addLayer/removeLayer on the map. this.lines = [] this.bandGroups = new Map() + const renderer = this.canvasRenderer for (const c of this.allContacts) { const [lat1, lon1, lat2, lon2, band, s1, s2, mode, dist, ts, id] = c const color = bandColor(band) const line = L.polyline([[lat1, lon1], [lat2, lon2]], { - color, weight: 2, opacity: 0.5 + color, weight: 2, opacity: 0.5, renderer }) const distStr = formatDistanceKm(dist) @@ -240,7 +248,7 @@ export const ContactsMap = { let group = this.bandGroups.get(band) if (!group) { - group = L.layerGroup() + group = L.layerGroup([], {renderer}) this.bandGroups.set(band, group) } group.addLayer(line) @@ -250,9 +258,6 @@ export const ContactsMap = { band, s1: (s1 || "").toUpperCase(), s2: (s2 || "").toUpperCase(), - // Date inputs come in as YYYY-MM-DD; ts is "YYYY-MM-DD HH:MM" so - // pre-slicing the date portion lets the range check be a string - // compare without having to substring per-line every render. tsDate: (ts || "").substring(0, 10), lat1, lon1, lat2, lon2 }) @@ -271,11 +276,20 @@ export const ContactsMap = { applyCallsignFilter(this: ContactsMapHook) { // Callsign + date filter: iterate individual polylines and add/remove - // them from their band's group. Cheap compared to rebuild (no polyline - // creation). Runs when either filter changes. + // them from their band's group. Early-exits when no filter is active. const match = this.callsignFilter const startDate = this.startDate const endDate = this.endDate + + // No filter active — ensure all lines are in their groups (fast path). + if (!match && !startDate && !endDate) { + for (const entry of this.lines) { + const group = this.bandGroups.get(entry.band) + if (group && !group.hasLayer(entry.line)) group.addLayer(entry.line) + } + return + } + for (const entry of this.lines) { const group = this.bandGroups.get(entry.band) if (!group) continue @@ -289,6 +303,16 @@ export const ContactsMap = { }, rebuildDots(this: ContactsMapHook) { + // Defer dot rebuild to next frame so rapid filter changes (e.g. typing + // in the callsign box with debounce) don't stack redundant work. + if (this._dotRafId) cancelAnimationFrame(this._dotRafId) + this._dotRafId = requestAnimationFrame(() => { + this._dotRafId = null + this._rebuildDotsNow() + }) + }, + + _rebuildDotsNow(this: ContactsMapHook) { this.dotLayer.clearLayers() const match = this.callsignFilter const startDate = this.startDate @@ -314,7 +338,8 @@ export const ContactsMap = { L.circleMarker([lat, lon], { radius, color: "#fff", weight: 1, fillColor: "#3b82f6", fillOpacity: 0.7, - interactive: false + interactive: false, + renderer: this.canvasRenderer }).addTo(this.dotLayer) } diff --git a/lib/microwaveprop/radio.ex b/lib/microwaveprop/radio.ex index 6385782f..24046a21 100644 --- a/lib/microwaveprop/radio.ex +++ b/lib/microwaveprop/radio.ex @@ -6,7 +6,7 @@ defmodule Microwaveprop.Radio do # ── Contacts ── - defdelegate contact_map_payload(), to: Contacts + defdelegate contact_map_payload(filters \\ []), to: Contacts defdelegate list_contacts_for_user(owner, viewer \\ nil), to: Contacts defdelegate list_contacts_involving_callsign(callsign, viewer \\ nil), to: Contacts defdelegate list_contacts(opts \\ []), to: Contacts diff --git a/lib/microwaveprop/radio/contacts.ex b/lib/microwaveprop/radio/contacts.ex index 80222cc4..0c0952dd 100644 --- a/lib/microwaveprop/radio/contacts.ex +++ b/lib/microwaveprop/radio/contacts.ex @@ -30,44 +30,73 @@ defmodule Microwaveprop.Radio.Contacts do # ── Map payload ── - @spec contact_map_payload() :: %{json: iodata(), count: non_neg_integer(), bands: [integer()]} - def contact_map_payload do - if Application.get_env(:microwaveprop, :cache_contact_map, true) do + @type map_filter :: {:callsign, String.t()} | {:start_date, String.t()} | {:end_date, String.t()} + + @spec contact_map_payload(keyword(map_filter)) :: %{ + json: iodata(), + count: non_neg_integer(), + bands: [integer()] + } + def contact_map_payload(filters \\ []) do + if filters == [] and Application.get_env(:microwaveprop, :cache_contact_map, true) do Cache.fetch_or_store(@map_payload_cache_key, @map_payload_ttl_ms, fn -> - build_contact_map_payload() + build_contact_map_payload([]) end) else - build_contact_map_payload() + build_contact_map_payload(filters) end end - defp build_contact_map_payload do - contacts = load_contacts_for_map() + defp build_contact_map_payload(filters) do + contacts = load_contacts_for_map(filters) bands = contacts |> Enum.map(fn [_, _, _, _, band | _] -> band end) |> Enum.uniq() |> Enum.sort() %{json: Jason.encode_to_iodata!(contacts), count: length(contacts), bands: bands} end - defp load_contacts_for_map do - from(c in Contact, - where: not is_nil(c.pos1) and not is_nil(c.pos2) and c.private == false, - select: %{ - id: c.id, - pos1: c.pos1, - pos2: c.pos2, - band: c.band, - station1: c.station1, - station2: c.station2, - mode: c.mode, - distance_km: c.distance_km, - qso_timestamp: c.qso_timestamp - } - ) + defp load_contacts_for_map(filters) do + query = + from(c in Contact, + where: not is_nil(c.pos1) and not is_nil(c.pos2) and c.private == false, + select: %{ + id: c.id, + pos1: c.pos1, + pos2: c.pos2, + band: c.band, + station1: c.station1, + station2: c.station2, + mode: c.mode, + distance_km: c.distance_km, + qso_timestamp: c.qso_timestamp + } + ) + + query = apply_map_filters(query, filters) + + query |> Repo.all() |> Enum.map(&format_map_contact/1) |> Enum.reject(&is_nil/1) |> dedup_map_reciprocals() end + defp apply_map_filters(query, []), do: query + + defp apply_map_filters(query, [{:callsign, callsign} | rest]) do + pattern = "%#{String.upcase(callsign)}%" + query = from(c in query, where: ilike(c.station1, ^pattern) or ilike(c.station2, ^pattern)) + apply_map_filters(query, rest) + end + + defp apply_map_filters(query, [{:start_date, date} | rest]) do + query = from(c in query, where: c.qso_timestamp >= ^date) + apply_map_filters(query, rest) + end + + defp apply_map_filters(query, [{:end_date, date} | rest]) do + query = from(c in query, where: c.qso_timestamp <= ^date) + apply_map_filters(query, rest) + end + defp format_map_contact(c) do lat1 = c.pos1["lat"] lon1 = c.pos1["lon"] diff --git a/lib/microwaveprop_web/controllers/contact_map_controller.ex b/lib/microwaveprop_web/controllers/contact_map_controller.ex index 7da78ee5..ad3dcd8f 100644 --- a/lib/microwaveprop_web/controllers/contact_map_controller.ex +++ b/lib/microwaveprop_web/controllers/contact_map_controller.ex @@ -22,8 +22,9 @@ defmodule MicrowavepropWeb.ContactMapController do def cache_key, do: @cache_key @spec show(Plug.Conn.t(), map()) :: Plug.Conn.t() - def show(conn, _params) do - {body, encoding} = gzipped_or_plain_body(conn) + def show(conn, params) do + filters = extract_filters(params) + {body, encoding} = gzipped_or_plain_body(conn, filters) conn |> put_resp_content_type("application/json") @@ -32,18 +33,41 @@ defmodule MicrowavepropWeb.ContactMapController do |> send_resp(200, body) end - defp gzipped_or_plain_body(conn) do + defp extract_filters(params) do + [] + |> maybe_add_filter(:callsign, params["callsign"]) + |> maybe_add_filter(:start_date, params["start_date"]) + |> maybe_add_filter(:end_date, params["end_date"]) + end + + defp maybe_add_filter(filters, _key, nil), do: filters + defp maybe_add_filter(filters, _key, ""), do: filters + defp maybe_add_filter(filters, key, value), do: [{key, value} | filters] + + defp gzipped_or_plain_body(conn, [] = filters) do if accepts_gzip?(conn) do {gzipped_payload(), :gzip} else - %{json: iodata} = Radio.contact_map_payload() + %{json: iodata} = Radio.contact_map_payload(filters) {IO.iodata_to_binary(iodata), :identity} end end + defp gzipped_or_plain_body(conn, filters) do + # Filtered requests vary by parameter — don't cache the gzip blob. + %{json: iodata} = Radio.contact_map_payload(filters) + body = IO.iodata_to_binary(iodata) + + if accepts_gzip?(conn) do + {:zlib.gzip(body), :gzip} + else + {body, :identity} + end + end + defp gzipped_payload do Cache.fetch_or_store(@cache_key, @cache_ttl_ms, fn -> - %{json: iodata} = Radio.contact_map_payload() + %{json: iodata} = Radio.contact_map_payload([]) :zlib.gzip(IO.iodata_to_binary(iodata)) end) end