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:
parent
9365fc4fad
commit
b4bbfda620
8 changed files with 334 additions and 142 deletions
209
assets/js/map.ts
209
assets/js/map.ts
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue