Optimize contact map rendering performance
Some checks failed
Build and Push / Build and Push Docker Image (push) Failing after 4m52s
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:
parent
ac19e4795d
commit
2e46ceffcc
4 changed files with 116 additions and 38 deletions
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue