Move client-side logic to server-side LiveView

- Server tracks callsign replacements in prepare_packet_state, sends
  convert_to_historical list so JS skips full markerStates scan
- Historical packets get red dot HTML server-side in build_minimal_packet_data
  instead of JS createMarkerIcon generating it
- Add callsign_group field to all packet data output; server pre-sorts
  by group with chronological order so JS iterates directly
- Normalize heat map intensity in Clustering.cluster_packets (min/50, cap 1.0)
  instead of JS doing it per-point
- Remove buildPopupContent JS fallback and unused escapeHtml import;
  server already provides popup in all data paths
This commit is contained in:
Graham McIntire 2026-02-20 08:53:11 -06:00
parent 9365fc4fad
commit b4bbfda620
No known key found for this signature in database
8 changed files with 334 additions and 142 deletions

View file

@ -64,7 +64,6 @@ import {
saveMapState,
safePushEvent,
getLiveSocket,
escapeHtml,
} from "./map_helpers";
// APRS Map Hook - handles only basic map interaction
@ -1117,7 +1116,7 @@ let MapAPRSMap = {
is_most_recent_for_callsign: true,
callsign_group:
data.callsign_group || data.callsign || incomingCallsign,
popup: data.popup || self.buildPopupContent(data),
popup: data.popup,
openPopup: shouldOpenPopup,
});
} catch (error) {
@ -1128,7 +1127,10 @@ let MapAPRSMap = {
// Handle batched new packets from LiveView (performance optimization)
self.handleEvent(
"new_packets",
(payload: { packets: MarkerData[] }) => {
(payload: {
packets: MarkerData[];
convert_to_historical?: string[];
}) => {
try {
if (!self || !self.map || self.isDestroyed) return;
if (!self.map.hasLayer) return;
@ -1138,28 +1140,53 @@ let MapAPRSMap = {
const packets = payload.packets;
if (!packets || packets.length === 0) return;
// Phase 1: Collect ALL markers to convert across the entire batch
// This prevents converting a marker that was just added by an earlier packet
const allNewCallsigns = new Set<string>();
const allNewIds = new Set<string>();
for (const data of packets) {
const cs = data.callsign_group || data.callsign || data.id;
if (cs) allNewCallsigns.add(cs);
if (data.id) allNewIds.add(String(data.id));
}
// Phase 1: Find markers to convert to historical dots.
// If the server provides convert_to_historical callsign keys, use
// those for a targeted lookup instead of scanning all markerStates.
const serverConvertKeys = payload.convert_to_historical;
let markersToConvert: string[] = [];
const markersToConvert: string[] = [];
self.markerStates.forEach((state, id) => {
const stateCallsign = state.callsign_group || state.callsign;
if (
stateCallsign &&
allNewCallsigns.has(stateCallsign) &&
(!state.historical || state.is_most_recent_for_callsign) &&
!allNewIds.has(String(id))
) {
markersToConvert.push(String(id));
if (serverConvertKeys && serverConvertKeys.length > 0) {
// Server told us which callsigns have replacements — find their
// current marker IDs by scanning only the provided callsigns
const convertSet = new Set(serverConvertKeys);
const allNewIds = new Set<string>();
for (const data of packets) {
if (data.id) allNewIds.add(String(data.id));
}
});
self.markerStates.forEach((state, id) => {
const stateCallsign = state.callsign_group || state.callsign;
if (
stateCallsign &&
convertSet.has(stateCallsign) &&
(!state.historical || state.is_most_recent_for_callsign) &&
!allNewIds.has(String(id))
) {
markersToConvert.push(String(id));
}
});
} else {
// Fallback: scan all markerStates (e.g. for batches with no replacements)
const allNewCallsigns = new Set<string>();
const allNewIds = new Set<string>();
for (const data of packets) {
const cs = data.callsign_group || data.callsign || data.id;
if (cs) allNewCallsigns.add(cs);
if (data.id) allNewIds.add(String(data.id));
}
self.markerStates.forEach((state, id) => {
const stateCallsign = state.callsign_group || state.callsign;
if (
stateCallsign &&
allNewCallsigns.has(stateCallsign) &&
(!state.historical || state.is_most_recent_for_callsign) &&
!allNewIds.has(String(id))
) {
markersToConvert.push(String(id));
}
});
}
// Phase 2: Convert all existing live markers to historical dots
for (const id of markersToConvert) {
@ -1198,7 +1225,7 @@ let MapAPRSMap = {
historical: false,
is_most_recent_for_callsign: true,
callsign_group: incomingCallsign,
popup: data.popup || self.buildPopupContent(data),
popup: data.popup,
openPopup: shouldOpenPopup,
});
}
@ -1240,54 +1267,33 @@ let MapAPRSMap = {
self.addMarker({
...data,
historical: true,
popup: data.popup || self.buildPopupContent(data),
popup: data.popup,
});
});
// Handle bulk loading of historical packets
// Server pre-sorts by callsign group with chronological order within each group
self.handleEvent(
"add_historical_packets",
(data: { packets: MarkerData[] }) => {
if (data.packets && Array.isArray(data.packets)) {
// Group packets by callsign to process them in chronological order for proper trail drawing
const packetsByCallsign = new Map<string, MarkerData[]>();
data.packets.forEach((packet) => {
const callsign =
packet.callsign_group || packet.callsign || packet.id;
if (!packetsByCallsign.has(callsign)) {
packetsByCallsign.set(callsign, []);
for (const packet of data.packets) {
try {
self.addMarker({
...packet,
historical: true,
popup: packet.popup,
});
} catch (error) {
console.error("Error adding historical packet:", error, packet);
}
packetsByCallsign.get(callsign)!.push(packet);
});
// Process each callsign group in chronological order (oldest first)
packetsByCallsign.forEach((packets, callsign) => {
// Sort by timestamp (oldest first) to ensure proper trail line drawing
const sortedPackets = packets.sort((a, b) => {
const timeA = parseTimestamp(a.timestamp);
const timeB = parseTimestamp(b.timestamp);
return timeA - timeB;
});
// Add markers in chronological order
sortedPackets.forEach((packet) => {
try {
self.addMarker({
...packet,
historical: true,
popup: packet.popup || self.buildPopupContent(packet),
});
} catch (error) {
console.error("Error adding historical packet:", error, packet);
}
});
});
}
}
},
);
// Handle progressive loading of historical packets (batch processing)
// Server pre-sorts by callsign group with chronological order within each group
self.handleEvent(
"add_historical_packets_batch",
(data: { packets: MarkerData[]; batch: number; is_final: boolean }) => {
@ -1298,48 +1304,24 @@ let MapAPRSMap = {
});
try {
if (data.packets && Array.isArray(data.packets)) {
// Process all packets immediately for maximum speed
const packetsByCallsign = new Map<string, MarkerData[]>();
data.packets.forEach((packet) => {
const callsign =
packet.callsign_group || packet.callsign || packet.id;
if (!packetsByCallsign.has(callsign)) {
packetsByCallsign.set(callsign, []);
for (const packet of data.packets) {
try {
self.addMarker({
...packet,
historical: true,
popup: packet.popup,
});
} catch (error) {
console.error(
"Error adding historical packet:",
error,
packet,
);
}
packetsByCallsign.get(callsign)!.push(packet);
});
// Process each callsign group in chronological order (oldest first)
packetsByCallsign.forEach((packets, callsign) => {
// Sort by timestamp (oldest first) to ensure proper trail line drawing
const sortedPackets = packets.sort((a, b) => {
const timeA = parseTimestamp(a.timestamp);
const timeB = parseTimestamp(b.timestamp);
return timeA - timeB;
});
// Add markers in chronological order
sortedPackets.forEach((packet) => {
try {
self.addMarker({
...packet,
historical: true,
popup: packet.popup || self.buildPopupContent(packet),
});
} catch (error) {
console.error(
"Error adding historical packet:",
error,
packet,
);
}
});
});
}
}
} catch (error) {
console.error("Error processing historical packets batch:", error);
// Continue processing other batches even if one fails
}
},
);
@ -1557,13 +1539,14 @@ let MapAPRSMap = {
}
// Convert heat points to format expected by Leaflet.heat
// Intensity is pre-normalized to 0-1 range by the server
const heatData = data.heat_points.map(
(point) =>
[
point.lat,
point.lng,
Math.min(point.intensity / 50.0, 1.0), // Normalize intensity to 0-1 range, cap at 1
] as [number, number, number],
[point.lat, point.lng, point.intensity] as [
number,
number,
number,
],
);
// Update heat layer data
@ -2267,30 +2250,6 @@ let MapAPRSMap = {
});
},
buildPopupContent(data: MarkerData): string {
const callsign = escapeHtml(String(data.callsign || data.id || "Unknown"));
const comment = data.comment ? escapeHtml(String(data.comment)) : "";
let content = `<div class="aprs-popup">
<div class="aprs-callsign"><strong><a href="/${callsign}" class="aprs-lv-link">${callsign}</a></strong> <a href="/info/${callsign}" class="aprs-lv-link aprs-info-link">info</a></div>`;
if (comment) {
content += `<div class="aprs-comment">${comment}</div>`;
}
if (data.timestamp) {
const timestamp = parseTimestamp(data.timestamp);
const date = new Date(timestamp);
if (!isNaN(date.getTime())) {
content += `<div class="aprs-timestamp">${date.toISOString()}</div>`;
}
}
content += "</div>";
return content;
},
destroyed() {
const self = this as unknown as LiveViewHookContext;

View file

@ -118,10 +118,11 @@ defmodule Aprsme.Packets.Clustering do
end)
|> Enum.map(fn {{_cluster_lat, _cluster_lon}, cluster} ->
# Calculate average position for cluster center
# Normalize intensity to 0-1 range (capped at 1.0) so JS can use it directly
%{
lat: cluster.lat_sum / cluster.intensity,
lng: cluster.lon_sum / cluster.intensity,
intensity: cluster.intensity
intensity: min(cluster.intensity / 50.0, 1.0)
}
end)
end

View file

@ -110,22 +110,29 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
symbol_table_id = get_packet_field(packet, :symbol_table_id, "/")
symbol_code = get_packet_field(packet, :symbol_code, ">")
# Generate symbol HTML using the SymbolRenderer
callsign = display_name(packet)
# For historical (non-most-recent) packets, use a simple red dot HTML
# instead of the full APRS symbol — matches what the JS createMarkerIcon
# would generate anyway, saving the client from icon re-creation
symbol_html =
AprsmeWeb.SymbolRenderer.render_marker_symbol(
symbol_table_id,
symbol_code,
callsign,
32
)
if is_most_recent do
AprsmeWeb.SymbolRenderer.render_marker_symbol(
symbol_table_id,
symbol_code,
callsign,
32
)
else
historical_dot_html(callsign)
end
%{
"id" => if(is_most_recent, do: "current_#{get_packet_id(packet)}", else: "hist_#{get_packet_id(packet)}"),
"lat" => to_float(lat),
"lng" => to_float(lon),
"callsign" => callsign,
"callsign_group" => callsign,
"symbol_table_id" => symbol_table_id,
"symbol_code" => symbol_code,
"symbol_html" => symbol_html,
@ -173,7 +180,9 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
# Build weather callsign set from packets themselves (no DB query needed)
weather_callsigns = build_weather_callsign_set(historical_packets)
# For each callsign group, find the most recent packet and mark it appropriately
# For each callsign group, find the most recent packet and mark it appropriately.
# Output is grouped by callsign with chronological (oldest first) order within
# each group, so the JS can iterate directly for trail drawing without re-sorting.
grouped_packets
|> Enum.flat_map(fn {callsign, packets} ->
# Sort by received_at to find most recent
@ -202,8 +211,9 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
# Build data for remaining historical packets
historical_data = build_historical_packet_data(filtered_historical, has_weather)
# Combine most recent and filtered historical
Enum.filter([most_recent_data | historical_data], & &1)
# Sort chronologically (oldest first) within the group for proper trail drawing
group_packets = Enum.filter([most_recent_data | historical_data], & &1)
Enum.sort_by(group_packets, & &1["timestamp"])
end
end)
|> Enum.filter(& &1)
@ -437,6 +447,7 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
%{
"id" => packet_id,
"callsign" => packet_info.callsign,
"callsign_group" => packet_info.callsign,
"base_callsign" => get_packet_field(packet, :base_callsign, ""),
"ssid" => get_packet_field(packet, :ssid, nil),
"lat" => to_float(lat),
@ -650,6 +661,12 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
end
end
defp historical_dot_html(callsign) do
escaped = callsign |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()
"<div style=\"width: 8px; height: 8px; background-color: #FF6B6B; border: 2px solid #FFFFFF; border-radius: 50%; opacity: 0.8; box-shadow: 0 0 2px rgba(0,0,0,0.3);\" title=\"Historical position for #{escaped}\"></div>"
end
defp build_historical_packet_data(filtered_historical, has_weather) do
filtered_historical
|> Enum.map(fn packet -> build_minimal_packet_data(packet, false, has_weather) end)

View file

@ -867,8 +867,19 @@ defmodule AprsmeWeb.MapLive.Index do
DisplayManager.send_heat_map_for_current_bounds(socket)
marker_data_list != [] ->
reversed = Enum.reverse(marker_data_list)
# Extract convert_to_historical callsign keys from markers that replaced existing ones
convert_to_historical =
reversed
|> Enum.map(&Map.get(&1, "convert_from"))
|> Enum.filter(& &1)
|> Enum.uniq()
markers =
Enum.map(Enum.reverse(marker_data_list), fn data ->
Enum.map(reversed, fn data ->
data = Map.delete(data, "convert_from")
if socket.assigns.station_popup_open do
Map.put(data, :openPopup, false)
else
@ -876,7 +887,10 @@ defmodule AprsmeWeb.MapLive.Index do
end
end)
push_event(socket, "new_packets", %{packets: markers})
push_event(socket, "new_packets", %{
packets: markers,
convert_to_historical: convert_to_historical
})
true ->
socket

View file

@ -159,9 +159,15 @@ defmodule AprsmeWeb.MapLive.PacketProcessor do
# Shared: updates visible_packets state and builds marker data.
# Returns {socket, marker_data | nil}. Does NOT push events.
# When an existing visible packet for the same callsign is replaced,
# the marker_data includes a "convert_from" key with the old callsign_key
# so the JS can convert the old marker to a historical dot via O(1) lookup.
defp prepare_packet_state(packet, socket) do
callsign_key = SharedPacketUtils.get_callsign_key(packet)
# Check if we're replacing an existing visible packet for this callsign
had_existing = Map.has_key?(socket.assigns.visible_packets, callsign_key)
new_visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, packet)
new_visible_packets =
@ -175,7 +181,13 @@ defmodule AprsmeWeb.MapLive.PacketProcessor do
marker_data =
if socket.assigns.map_zoom > 8 do
DataBuilder.build_packet_data(packet, true, get_locale(socket))
data = DataBuilder.build_packet_data(packet, true, get_locale(socket))
if data && had_existing do
Map.put(data, "convert_from", callsign_key)
else
data
end
end
{socket, marker_data}
@ -196,6 +208,9 @@ defmodule AprsmeWeb.MapLive.PacketProcessor do
end
defp send_marker_with_popup_check(socket, marker_data) do
# Strip convert_from — the single-packet JS handler uses its own scan logic
marker_data = Map.delete(marker_data, "convert_from")
if socket.assigns.station_popup_open do
LiveView.push_event(socket, "new_packet", Map.put(marker_data, :openPopup, false))
else

View file

@ -46,7 +46,7 @@ defmodule Aprsme.Packets.ClusteringTest do
assert Map.has_key?(first_cluster, :intensity)
end
test "groups nearby packets into single cluster" do
test "groups nearby packets into single cluster with normalized intensity" do
# Two very close packets
packets = [
%Packet{id: 1, lat: Decimal.new("40.7128"), lon: Decimal.new("-74.0060")},
@ -56,7 +56,8 @@ defmodule Aprsme.Packets.ClusteringTest do
result = Clustering.cluster_packets(packets, 5, %{})
assert {:heat_map, clusters} = result
assert length(clusters) == 1
assert hd(clusters).intensity == 2
# Intensity is normalized: min(count / 50.0, 1.0) => min(2/50, 1.0) = 0.04
assert hd(clusters).intensity == 2 / 50.0
end
test "separates distant packets into different clusters" do
@ -69,7 +70,20 @@ defmodule Aprsme.Packets.ClusteringTest do
result = Clustering.cluster_packets(packets, 5, %{})
assert {:heat_map, clusters} = result
assert length(clusters) == 2
assert Enum.all?(clusters, &(&1.intensity == 1))
# Each cluster has 1 packet: min(1/50.0, 1.0) = 0.02
assert Enum.all?(clusters, &(&1.intensity == 1 / 50.0))
end
test "intensity is capped at 1.0 for large clusters" do
# 100 packets at the same location — intensity should cap at 1.0
packets =
for i <- 1..100 do
%Packet{id: i, lat: Decimal.new("40.7128"), lon: Decimal.new("-74.0060")}
end
result = Clustering.cluster_packets(packets, 5, %{})
assert {:heat_map, [cluster]} = result
assert cluster.intensity == 1.0
end
test "handles packets with nil coordinates" do

View file

@ -135,6 +135,152 @@ defmodule AprsmeWeb.MapLive.DataBuilderTest do
assert result["callsign"] == "P-K5SGD"
end
test "build_minimal_packet_data uses red dot HTML for historical (non-most-recent) packets" do
packet = %{
id: Ecto.UUID.generate(),
sender: "K5GVL-10",
base_callsign: "K5GVL",
ssid: "10",
lat: 33.1,
lon: -96.5,
received_at: DateTime.utc_now(),
symbol_table_id: "/",
symbol_code: "-",
comment: "Test",
path: "WIDE1-1"
}
result = DataBuilder.build_minimal_packet_data(packet, false, false)
assert result
assert result["historical"] == true
# Historical dot should use inline red dot HTML, not a full symbol
assert result["symbol_html"] =~ "background-color"
assert result["symbol_html"] =~ "#FF6B6B"
end
test "build_minimal_packet_data uses full symbol for most-recent packets" do
packet = %{
id: Ecto.UUID.generate(),
sender: "K5GVL-10",
base_callsign: "K5GVL",
ssid: "10",
lat: 33.1,
lon: -96.5,
received_at: DateTime.utc_now(),
symbol_table_id: "/",
symbol_code: "-",
comment: "Test",
path: "WIDE1-1"
}
result = DataBuilder.build_minimal_packet_data(packet, true, false)
assert result
assert result["is_most_recent_for_callsign"] == true
# Most recent should NOT use the red dot
refute result["symbol_html"] =~ "#FF6B6B"
end
test "build_minimal_packet_data includes callsign_group field" do
packet = %{
id: Ecto.UUID.generate(),
sender: "K5GVL-10",
base_callsign: "K5GVL",
ssid: "10",
lat: 33.1,
lon: -96.5,
received_at: DateTime.utc_now(),
symbol_table_id: "/",
symbol_code: "-",
comment: "Test",
path: "WIDE1-1"
}
result = DataBuilder.build_minimal_packet_data(packet, true, false)
assert result["callsign_group"] == "K5GVL-10"
end
test "build_packet_data includes callsign_group field" do
packet = %{
id: Ecto.UUID.generate(),
sender: "K5GVL-10",
base_callsign: "K5GVL",
ssid: "10",
lat: 33.1,
lon: -96.5,
received_at: DateTime.utc_now(),
symbol_table_id: "/",
symbol_code: "-",
comment: "Test",
data_type: "position",
path: "WIDE1-1"
}
result = DataBuilder.build_packet_data(packet, true)
assert result["callsign_group"] == "K5GVL-10"
end
test "build_packet_data_list output is sorted by callsign group then chronologically" do
now = DateTime.utc_now()
# Two callsigns, each with 2 packets at different times
packets = [
%{
id: Ecto.UUID.generate(),
sender: "ZZZ-1",
base_callsign: "ZZZ",
ssid: "1",
lat: 33.1,
lon: -96.5,
received_at: DateTime.add(now, -300, :second),
symbol_table_id: "/",
symbol_code: "-",
comment: "",
path: ""
},
%{
id: Ecto.UUID.generate(),
sender: "AAA-1",
base_callsign: "AAA",
ssid: "1",
lat: 34.0,
lon: -97.0,
received_at: now,
symbol_table_id: "/",
symbol_code: ">",
comment: "",
path: ""
},
%{
id: Ecto.UUID.generate(),
sender: "ZZZ-1",
base_callsign: "ZZZ",
ssid: "1",
lat: 33.2,
lon: -96.4,
received_at: now,
symbol_table_id: "/",
symbol_code: "-",
comment: "",
path: ""
}
]
results = DataBuilder.build_packet_data_list(packets)
# All results should have callsign_group
assert Enum.all?(results, &Map.has_key?(&1, "callsign_group"))
# Packets should be grouped by callsign (all AAA together, all ZZZ together)
callsigns = Enum.map(results, & &1["callsign"])
groups = Enum.chunk_by(callsigns, & &1)
# Each group should contain only one unique callsign
assert Enum.all?(groups, fn group -> length(Enum.uniq(group)) == 1 end)
end
test "build_packet_data_list groups by object_name for objects" do
now = DateTime.utc_now()
earlier = DateTime.add(now, -600, :second)

View file

@ -93,5 +93,31 @@ defmodule AprsmeWeb.MapLive.PacketProcessorTest do
push_events = get_in(updated_socket.private, [:push_events]) || []
assert push_events == []
end
test "returns convert_from when replacing an existing visible packet" do
# First packet for K5GVL-10 — get_callsign_key uses :id when present
first_packet = build_packet(%{id: "packet-1", lat: 33.1, lon: -96.5})
# Use a packet without :id so get_callsign_key falls through to :sender
first_packet_no_id = Map.delete(first_packet, :id)
socket = build_socket(%{visible_packets: %{"K5GVL-10" => first_packet_no_id}})
# Second packet for same callsign (also no :id so key = sender)
second_packet = %{lat: 33.2, lon: -96.4} |> build_packet() |> Map.delete(:id)
{_updated_socket, marker_data} = PacketProcessor.process_packet_for_marker_data(second_packet, socket)
assert marker_data
assert marker_data["convert_from"] == "K5GVL-10"
end
test "returns nil convert_from for first packet from a callsign" do
socket = build_socket()
packet = build_packet()
{_updated_socket, marker_data} = PacketProcessor.process_packet_for_marker_data(packet, socket)
assert marker_data
refute Map.has_key?(marker_data, "convert_from")
end
end
end