Optimize contact map rendering performance
Some checks failed
Build and Push / Build and Push Docker Image (push) Failing after 4m52s

- Switch polylines and circle markers to shared L.canvas() renderer,
  eliminating ~58k SVG DOM nodes in favor of 1 GPU-composited canvas
- Add server-side callsign/date filtering to /api/contacts/map endpoint
  to reduce payload size when filters are active
- Add early-exit fast path in applyCallsignFilter when no filter active
- Batch rebuildDots with requestAnimationFrame to avoid redundant work
  during rapid filter changes
This commit is contained in:
Graham McIntire 2026-08-02 13:21:15 -05:00
parent ac19e4795d
commit 2e46ceffcc
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
4 changed files with 116 additions and 38 deletions

View file

@ -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 <canvas> instead of thousands of SVG <path> 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)
}

View file

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

View file

@ -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"]

View file

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