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[]
|
lines: LineEntry[]
|
||||||
dotLayer: L.LayerGroup
|
dotLayer: L.LayerGroup
|
||||||
map: L.Map
|
map: L.Map
|
||||||
|
canvasRenderer: L.Canvas
|
||||||
delegatedChange: (e: Event) => void
|
delegatedChange: (e: Event) => void
|
||||||
delegatedClick: (e: Event) => void
|
delegatedClick: (e: Event) => void
|
||||||
initMap(this: ContactsMapHook): void
|
initMap(this: ContactsMapHook): void
|
||||||
|
|
@ -100,6 +101,12 @@ export const ContactsMap = {
|
||||||
this.enabledBands = new Set()
|
this.enabledBands = new Set()
|
||||||
this.bandGroups = new Map()
|
this.bandGroups = new Map()
|
||||||
this.lines = []
|
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()
|
this.dotLayer = L.layerGroup()
|
||||||
|
|
||||||
// Callsign filter still roundtrips through LiveView (debounced) — it's
|
// Callsign filter still roundtrips through LiveView (debounced) — it's
|
||||||
|
|
@ -211,17 +218,18 @@ export const ContactsMap = {
|
||||||
|
|
||||||
buildLines(this: ContactsMapHook) {
|
buildLines(this: ContactsMapHook) {
|
||||||
// One-time build of every polyline, grouped into per-band LayerGroups.
|
// One-time build of every polyline, grouped into per-band LayerGroups.
|
||||||
// After this, band toggling is just addLayer/removeLayer on the map —
|
// All polylines share a single Canvas renderer — no SVG DOM nodes.
|
||||||
// no polyline recreation ever.
|
// After this, band toggling is just addLayer/removeLayer on the map.
|
||||||
this.lines = []
|
this.lines = []
|
||||||
this.bandGroups = new Map()
|
this.bandGroups = new Map()
|
||||||
|
const renderer = this.canvasRenderer
|
||||||
|
|
||||||
for (const c of this.allContacts) {
|
for (const c of this.allContacts) {
|
||||||
const [lat1, lon1, lat2, lon2, band, s1, s2, mode, dist, ts, id] = c
|
const [lat1, lon1, lat2, lon2, band, s1, s2, mode, dist, ts, id] = c
|
||||||
const color = bandColor(band)
|
const color = bandColor(band)
|
||||||
|
|
||||||
const line = L.polyline([[lat1, lon1], [lat2, lon2]], {
|
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)
|
const distStr = formatDistanceKm(dist)
|
||||||
|
|
@ -240,7 +248,7 @@ export const ContactsMap = {
|
||||||
|
|
||||||
let group = this.bandGroups.get(band)
|
let group = this.bandGroups.get(band)
|
||||||
if (!group) {
|
if (!group) {
|
||||||
group = L.layerGroup()
|
group = L.layerGroup([], {renderer})
|
||||||
this.bandGroups.set(band, group)
|
this.bandGroups.set(band, group)
|
||||||
}
|
}
|
||||||
group.addLayer(line)
|
group.addLayer(line)
|
||||||
|
|
@ -250,9 +258,6 @@ export const ContactsMap = {
|
||||||
band,
|
band,
|
||||||
s1: (s1 || "").toUpperCase(),
|
s1: (s1 || "").toUpperCase(),
|
||||||
s2: (s2 || "").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),
|
tsDate: (ts || "").substring(0, 10),
|
||||||
lat1, lon1, lat2, lon2
|
lat1, lon1, lat2, lon2
|
||||||
})
|
})
|
||||||
|
|
@ -271,11 +276,20 @@ export const ContactsMap = {
|
||||||
|
|
||||||
applyCallsignFilter(this: ContactsMapHook) {
|
applyCallsignFilter(this: ContactsMapHook) {
|
||||||
// Callsign + date filter: iterate individual polylines and add/remove
|
// Callsign + date filter: iterate individual polylines and add/remove
|
||||||
// them from their band's group. Cheap compared to rebuild (no polyline
|
// them from their band's group. Early-exits when no filter is active.
|
||||||
// creation). Runs when either filter changes.
|
|
||||||
const match = this.callsignFilter
|
const match = this.callsignFilter
|
||||||
const startDate = this.startDate
|
const startDate = this.startDate
|
||||||
const endDate = this.endDate
|
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) {
|
for (const entry of this.lines) {
|
||||||
const group = this.bandGroups.get(entry.band)
|
const group = this.bandGroups.get(entry.band)
|
||||||
if (!group) continue
|
if (!group) continue
|
||||||
|
|
@ -289,6 +303,16 @@ export const ContactsMap = {
|
||||||
},
|
},
|
||||||
|
|
||||||
rebuildDots(this: ContactsMapHook) {
|
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()
|
this.dotLayer.clearLayers()
|
||||||
const match = this.callsignFilter
|
const match = this.callsignFilter
|
||||||
const startDate = this.startDate
|
const startDate = this.startDate
|
||||||
|
|
@ -314,7 +338,8 @@ export const ContactsMap = {
|
||||||
L.circleMarker([lat, lon], {
|
L.circleMarker([lat, lon], {
|
||||||
radius, color: "#fff", weight: 1,
|
radius, color: "#fff", weight: 1,
|
||||||
fillColor: "#3b82f6", fillOpacity: 0.7,
|
fillColor: "#3b82f6", fillOpacity: 0.7,
|
||||||
interactive: false
|
interactive: false,
|
||||||
|
renderer: this.canvasRenderer
|
||||||
}).addTo(this.dotLayer)
|
}).addTo(this.dotLayer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ defmodule Microwaveprop.Radio do
|
||||||
|
|
||||||
# ── Contacts ──
|
# ── 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_for_user(owner, viewer \\ nil), to: Contacts
|
||||||
defdelegate list_contacts_involving_callsign(callsign, viewer \\ nil), to: Contacts
|
defdelegate list_contacts_involving_callsign(callsign, viewer \\ nil), to: Contacts
|
||||||
defdelegate list_contacts(opts \\ []), to: Contacts
|
defdelegate list_contacts(opts \\ []), to: Contacts
|
||||||
|
|
|
||||||
|
|
@ -30,24 +30,31 @@ defmodule Microwaveprop.Radio.Contacts do
|
||||||
|
|
||||||
# ── Map payload ──
|
# ── Map payload ──
|
||||||
|
|
||||||
@spec contact_map_payload() :: %{json: iodata(), count: non_neg_integer(), bands: [integer()]}
|
@type map_filter :: {:callsign, String.t()} | {:start_date, String.t()} | {:end_date, String.t()}
|
||||||
def contact_map_payload do
|
|
||||||
if Application.get_env(:microwaveprop, :cache_contact_map, true) do
|
@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 ->
|
Cache.fetch_or_store(@map_payload_cache_key, @map_payload_ttl_ms, fn ->
|
||||||
build_contact_map_payload()
|
build_contact_map_payload([])
|
||||||
end)
|
end)
|
||||||
else
|
else
|
||||||
build_contact_map_payload()
|
build_contact_map_payload(filters)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_contact_map_payload do
|
defp build_contact_map_payload(filters) do
|
||||||
contacts = load_contacts_for_map()
|
contacts = load_contacts_for_map(filters)
|
||||||
bands = contacts |> Enum.map(fn [_, _, _, _, band | _] -> band end) |> Enum.uniq() |> Enum.sort()
|
bands = contacts |> Enum.map(fn [_, _, _, _, band | _] -> band end) |> Enum.uniq() |> Enum.sort()
|
||||||
%{json: Jason.encode_to_iodata!(contacts), count: length(contacts), bands: bands}
|
%{json: Jason.encode_to_iodata!(contacts), count: length(contacts), bands: bands}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp load_contacts_for_map do
|
defp load_contacts_for_map(filters) do
|
||||||
|
query =
|
||||||
from(c in Contact,
|
from(c in Contact,
|
||||||
where: not is_nil(c.pos1) and not is_nil(c.pos2) and c.private == false,
|
where: not is_nil(c.pos1) and not is_nil(c.pos2) and c.private == false,
|
||||||
select: %{
|
select: %{
|
||||||
|
|
@ -62,12 +69,34 @@ defmodule Microwaveprop.Radio.Contacts do
|
||||||
qso_timestamp: c.qso_timestamp
|
qso_timestamp: c.qso_timestamp
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
query = apply_map_filters(query, filters)
|
||||||
|
|
||||||
|
query
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|> Enum.map(&format_map_contact/1)
|
|> Enum.map(&format_map_contact/1)
|
||||||
|> Enum.reject(&is_nil/1)
|
|> Enum.reject(&is_nil/1)
|
||||||
|> dedup_map_reciprocals()
|
|> dedup_map_reciprocals()
|
||||||
end
|
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
|
defp format_map_contact(c) do
|
||||||
lat1 = c.pos1["lat"]
|
lat1 = c.pos1["lat"]
|
||||||
lon1 = c.pos1["lon"]
|
lon1 = c.pos1["lon"]
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,9 @@ defmodule MicrowavepropWeb.ContactMapController do
|
||||||
def cache_key, do: @cache_key
|
def cache_key, do: @cache_key
|
||||||
|
|
||||||
@spec show(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
@spec show(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||||
def show(conn, _params) do
|
def show(conn, params) do
|
||||||
{body, encoding} = gzipped_or_plain_body(conn)
|
filters = extract_filters(params)
|
||||||
|
{body, encoding} = gzipped_or_plain_body(conn, filters)
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> put_resp_content_type("application/json")
|
|> put_resp_content_type("application/json")
|
||||||
|
|
@ -32,18 +33,41 @@ defmodule MicrowavepropWeb.ContactMapController do
|
||||||
|> send_resp(200, body)
|
|> send_resp(200, body)
|
||||||
end
|
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
|
if accepts_gzip?(conn) do
|
||||||
{gzipped_payload(), :gzip}
|
{gzipped_payload(), :gzip}
|
||||||
else
|
else
|
||||||
%{json: iodata} = Radio.contact_map_payload()
|
%{json: iodata} = Radio.contact_map_payload(filters)
|
||||||
{IO.iodata_to_binary(iodata), :identity}
|
{IO.iodata_to_binary(iodata), :identity}
|
||||||
end
|
end
|
||||||
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
|
defp gzipped_payload do
|
||||||
Cache.fetch_or_store(@cache_key, @cache_ttl_ms, fn ->
|
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))
|
:zlib.gzip(IO.iodata_to_binary(iodata))
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue