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,
|
saveMapState,
|
||||||
safePushEvent,
|
safePushEvent,
|
||||||
getLiveSocket,
|
getLiveSocket,
|
||||||
escapeHtml,
|
|
||||||
} from "./map_helpers";
|
} from "./map_helpers";
|
||||||
|
|
||||||
// APRS Map Hook - handles only basic map interaction
|
// APRS Map Hook - handles only basic map interaction
|
||||||
|
|
@ -1117,7 +1116,7 @@ let MapAPRSMap = {
|
||||||
is_most_recent_for_callsign: true,
|
is_most_recent_for_callsign: true,
|
||||||
callsign_group:
|
callsign_group:
|
||||||
data.callsign_group || data.callsign || incomingCallsign,
|
data.callsign_group || data.callsign || incomingCallsign,
|
||||||
popup: data.popup || self.buildPopupContent(data),
|
popup: data.popup,
|
||||||
openPopup: shouldOpenPopup,
|
openPopup: shouldOpenPopup,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -1128,7 +1127,10 @@ let MapAPRSMap = {
|
||||||
// Handle batched new packets from LiveView (performance optimization)
|
// Handle batched new packets from LiveView (performance optimization)
|
||||||
self.handleEvent(
|
self.handleEvent(
|
||||||
"new_packets",
|
"new_packets",
|
||||||
(payload: { packets: MarkerData[] }) => {
|
(payload: {
|
||||||
|
packets: MarkerData[];
|
||||||
|
convert_to_historical?: string[];
|
||||||
|
}) => {
|
||||||
try {
|
try {
|
||||||
if (!self || !self.map || self.isDestroyed) return;
|
if (!self || !self.map || self.isDestroyed) return;
|
||||||
if (!self.map.hasLayer) return;
|
if (!self.map.hasLayer) return;
|
||||||
|
|
@ -1138,28 +1140,53 @@ let MapAPRSMap = {
|
||||||
const packets = payload.packets;
|
const packets = payload.packets;
|
||||||
if (!packets || packets.length === 0) return;
|
if (!packets || packets.length === 0) return;
|
||||||
|
|
||||||
// Phase 1: Collect ALL markers to convert across the entire batch
|
// Phase 1: Find markers to convert to historical dots.
|
||||||
// This prevents converting a marker that was just added by an earlier packet
|
// If the server provides convert_to_historical callsign keys, use
|
||||||
const allNewCallsigns = new Set<string>();
|
// those for a targeted lookup instead of scanning all markerStates.
|
||||||
const allNewIds = new Set<string>();
|
const serverConvertKeys = payload.convert_to_historical;
|
||||||
for (const data of packets) {
|
let markersToConvert: string[] = [];
|
||||||
const cs = data.callsign_group || data.callsign || data.id;
|
|
||||||
if (cs) allNewCallsigns.add(cs);
|
|
||||||
if (data.id) allNewIds.add(String(data.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
const markersToConvert: string[] = [];
|
if (serverConvertKeys && serverConvertKeys.length > 0) {
|
||||||
self.markerStates.forEach((state, id) => {
|
// Server told us which callsigns have replacements — find their
|
||||||
const stateCallsign = state.callsign_group || state.callsign;
|
// current marker IDs by scanning only the provided callsigns
|
||||||
if (
|
const convertSet = new Set(serverConvertKeys);
|
||||||
stateCallsign &&
|
const allNewIds = new Set<string>();
|
||||||
allNewCallsigns.has(stateCallsign) &&
|
for (const data of packets) {
|
||||||
(!state.historical || state.is_most_recent_for_callsign) &&
|
if (data.id) allNewIds.add(String(data.id));
|
||||||
!allNewIds.has(String(id))
|
|
||||||
) {
|
|
||||||
markersToConvert.push(String(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
|
// Phase 2: Convert all existing live markers to historical dots
|
||||||
for (const id of markersToConvert) {
|
for (const id of markersToConvert) {
|
||||||
|
|
@ -1198,7 +1225,7 @@ let MapAPRSMap = {
|
||||||
historical: false,
|
historical: false,
|
||||||
is_most_recent_for_callsign: true,
|
is_most_recent_for_callsign: true,
|
||||||
callsign_group: incomingCallsign,
|
callsign_group: incomingCallsign,
|
||||||
popup: data.popup || self.buildPopupContent(data),
|
popup: data.popup,
|
||||||
openPopup: shouldOpenPopup,
|
openPopup: shouldOpenPopup,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1240,54 +1267,33 @@ let MapAPRSMap = {
|
||||||
self.addMarker({
|
self.addMarker({
|
||||||
...data,
|
...data,
|
||||||
historical: true,
|
historical: true,
|
||||||
popup: data.popup || self.buildPopupContent(data),
|
popup: data.popup,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle bulk loading of historical packets
|
// Handle bulk loading of historical packets
|
||||||
|
// Server pre-sorts by callsign group with chronological order within each group
|
||||||
self.handleEvent(
|
self.handleEvent(
|
||||||
"add_historical_packets",
|
"add_historical_packets",
|
||||||
(data: { packets: MarkerData[] }) => {
|
(data: { packets: MarkerData[] }) => {
|
||||||
if (data.packets && Array.isArray(data.packets)) {
|
if (data.packets && Array.isArray(data.packets)) {
|
||||||
// Group packets by callsign to process them in chronological order for proper trail drawing
|
for (const packet of data.packets) {
|
||||||
const packetsByCallsign = new Map<string, MarkerData[]>();
|
try {
|
||||||
|
self.addMarker({
|
||||||
data.packets.forEach((packet) => {
|
...packet,
|
||||||
const callsign =
|
historical: true,
|
||||||
packet.callsign_group || packet.callsign || packet.id;
|
popup: packet.popup,
|
||||||
if (!packetsByCallsign.has(callsign)) {
|
});
|
||||||
packetsByCallsign.set(callsign, []);
|
} 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)
|
// Handle progressive loading of historical packets (batch processing)
|
||||||
|
// Server pre-sorts by callsign group with chronological order within each group
|
||||||
self.handleEvent(
|
self.handleEvent(
|
||||||
"add_historical_packets_batch",
|
"add_historical_packets_batch",
|
||||||
(data: { packets: MarkerData[]; batch: number; is_final: boolean }) => {
|
(data: { packets: MarkerData[]; batch: number; is_final: boolean }) => {
|
||||||
|
|
@ -1298,48 +1304,24 @@ let MapAPRSMap = {
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
if (data.packets && Array.isArray(data.packets)) {
|
if (data.packets && Array.isArray(data.packets)) {
|
||||||
// Process all packets immediately for maximum speed
|
for (const packet of data.packets) {
|
||||||
const packetsByCallsign = new Map<string, MarkerData[]>();
|
try {
|
||||||
|
self.addMarker({
|
||||||
data.packets.forEach((packet) => {
|
...packet,
|
||||||
const callsign =
|
historical: true,
|
||||||
packet.callsign_group || packet.callsign || packet.id;
|
popup: packet.popup,
|
||||||
if (!packetsByCallsign.has(callsign)) {
|
});
|
||||||
packetsByCallsign.set(callsign, []);
|
} 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) {
|
} catch (error) {
|
||||||
console.error("Error processing historical packets batch:", 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
|
// 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(
|
const heatData = data.heat_points.map(
|
||||||
(point) =>
|
(point) =>
|
||||||
[
|
[point.lat, point.lng, point.intensity] as [
|
||||||
point.lat,
|
number,
|
||||||
point.lng,
|
number,
|
||||||
Math.min(point.intensity / 50.0, 1.0), // Normalize intensity to 0-1 range, cap at 1
|
number,
|
||||||
] as [number, number, number],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update heat layer data
|
// 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() {
|
destroyed() {
|
||||||
const self = this as unknown as LiveViewHookContext;
|
const self = this as unknown as LiveViewHookContext;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,10 +118,11 @@ defmodule Aprsme.Packets.Clustering do
|
||||||
end)
|
end)
|
||||||
|> Enum.map(fn {{_cluster_lat, _cluster_lon}, cluster} ->
|
|> Enum.map(fn {{_cluster_lat, _cluster_lon}, cluster} ->
|
||||||
# Calculate average position for cluster center
|
# 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,
|
lat: cluster.lat_sum / cluster.intensity,
|
||||||
lng: cluster.lon_sum / cluster.intensity,
|
lng: cluster.lon_sum / cluster.intensity,
|
||||||
intensity: cluster.intensity
|
intensity: min(cluster.intensity / 50.0, 1.0)
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -110,22 +110,29 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
|
||||||
symbol_table_id = get_packet_field(packet, :symbol_table_id, "/")
|
symbol_table_id = get_packet_field(packet, :symbol_table_id, "/")
|
||||||
symbol_code = get_packet_field(packet, :symbol_code, ">")
|
symbol_code = get_packet_field(packet, :symbol_code, ">")
|
||||||
|
|
||||||
# Generate symbol HTML using the SymbolRenderer
|
|
||||||
callsign = display_name(packet)
|
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 =
|
symbol_html =
|
||||||
AprsmeWeb.SymbolRenderer.render_marker_symbol(
|
if is_most_recent do
|
||||||
symbol_table_id,
|
AprsmeWeb.SymbolRenderer.render_marker_symbol(
|
||||||
symbol_code,
|
symbol_table_id,
|
||||||
callsign,
|
symbol_code,
|
||||||
32
|
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)}"),
|
"id" => if(is_most_recent, do: "current_#{get_packet_id(packet)}", else: "hist_#{get_packet_id(packet)}"),
|
||||||
"lat" => to_float(lat),
|
"lat" => to_float(lat),
|
||||||
"lng" => to_float(lon),
|
"lng" => to_float(lon),
|
||||||
"callsign" => callsign,
|
"callsign" => callsign,
|
||||||
|
"callsign_group" => callsign,
|
||||||
"symbol_table_id" => symbol_table_id,
|
"symbol_table_id" => symbol_table_id,
|
||||||
"symbol_code" => symbol_code,
|
"symbol_code" => symbol_code,
|
||||||
"symbol_html" => symbol_html,
|
"symbol_html" => symbol_html,
|
||||||
|
|
@ -173,7 +180,9 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
|
||||||
# Build weather callsign set from packets themselves (no DB query needed)
|
# Build weather callsign set from packets themselves (no DB query needed)
|
||||||
weather_callsigns = build_weather_callsign_set(historical_packets)
|
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
|
grouped_packets
|
||||||
|> Enum.flat_map(fn {callsign, packets} ->
|
|> Enum.flat_map(fn {callsign, packets} ->
|
||||||
# Sort by received_at to find most recent
|
# Sort by received_at to find most recent
|
||||||
|
|
@ -202,8 +211,9 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
|
||||||
# Build data for remaining historical packets
|
# Build data for remaining historical packets
|
||||||
historical_data = build_historical_packet_data(filtered_historical, has_weather)
|
historical_data = build_historical_packet_data(filtered_historical, has_weather)
|
||||||
|
|
||||||
# Combine most recent and filtered historical
|
# Sort chronologically (oldest first) within the group for proper trail drawing
|
||||||
Enum.filter([most_recent_data | historical_data], & &1)
|
group_packets = Enum.filter([most_recent_data | historical_data], & &1)
|
||||||
|
Enum.sort_by(group_packets, & &1["timestamp"])
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|> Enum.filter(& &1)
|
|> Enum.filter(& &1)
|
||||||
|
|
@ -437,6 +447,7 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
|
||||||
%{
|
%{
|
||||||
"id" => packet_id,
|
"id" => packet_id,
|
||||||
"callsign" => packet_info.callsign,
|
"callsign" => packet_info.callsign,
|
||||||
|
"callsign_group" => packet_info.callsign,
|
||||||
"base_callsign" => get_packet_field(packet, :base_callsign, ""),
|
"base_callsign" => get_packet_field(packet, :base_callsign, ""),
|
||||||
"ssid" => get_packet_field(packet, :ssid, nil),
|
"ssid" => get_packet_field(packet, :ssid, nil),
|
||||||
"lat" => to_float(lat),
|
"lat" => to_float(lat),
|
||||||
|
|
@ -650,6 +661,12 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
|
||||||
end
|
end
|
||||||
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
|
defp build_historical_packet_data(filtered_historical, has_weather) do
|
||||||
filtered_historical
|
filtered_historical
|
||||||
|> Enum.map(fn packet -> build_minimal_packet_data(packet, false, has_weather) end)
|
|> 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)
|
DisplayManager.send_heat_map_for_current_bounds(socket)
|
||||||
|
|
||||||
marker_data_list != [] ->
|
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 =
|
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
|
if socket.assigns.station_popup_open do
|
||||||
Map.put(data, :openPopup, false)
|
Map.put(data, :openPopup, false)
|
||||||
else
|
else
|
||||||
|
|
@ -876,7 +887,10 @@ defmodule AprsmeWeb.MapLive.Index do
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
push_event(socket, "new_packets", %{packets: markers})
|
push_event(socket, "new_packets", %{
|
||||||
|
packets: markers,
|
||||||
|
convert_to_historical: convert_to_historical
|
||||||
|
})
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
socket
|
socket
|
||||||
|
|
|
||||||
|
|
@ -159,9 +159,15 @@ defmodule AprsmeWeb.MapLive.PacketProcessor do
|
||||||
|
|
||||||
# Shared: updates visible_packets state and builds marker data.
|
# Shared: updates visible_packets state and builds marker data.
|
||||||
# Returns {socket, marker_data | nil}. Does NOT push events.
|
# 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
|
defp prepare_packet_state(packet, socket) do
|
||||||
callsign_key = SharedPacketUtils.get_callsign_key(packet)
|
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 = Map.put(socket.assigns.visible_packets, callsign_key, packet)
|
||||||
|
|
||||||
new_visible_packets =
|
new_visible_packets =
|
||||||
|
|
@ -175,7 +181,13 @@ defmodule AprsmeWeb.MapLive.PacketProcessor do
|
||||||
|
|
||||||
marker_data =
|
marker_data =
|
||||||
if socket.assigns.map_zoom > 8 do
|
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
|
end
|
||||||
|
|
||||||
{socket, marker_data}
|
{socket, marker_data}
|
||||||
|
|
@ -196,6 +208,9 @@ defmodule AprsmeWeb.MapLive.PacketProcessor do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp send_marker_with_popup_check(socket, marker_data) do
|
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
|
if socket.assigns.station_popup_open do
|
||||||
LiveView.push_event(socket, "new_packet", Map.put(marker_data, :openPopup, false))
|
LiveView.push_event(socket, "new_packet", Map.put(marker_data, :openPopup, false))
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ defmodule Aprsme.Packets.ClusteringTest do
|
||||||
assert Map.has_key?(first_cluster, :intensity)
|
assert Map.has_key?(first_cluster, :intensity)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "groups nearby packets into single cluster" do
|
test "groups nearby packets into single cluster with normalized intensity" do
|
||||||
# Two very close packets
|
# Two very close packets
|
||||||
packets = [
|
packets = [
|
||||||
%Packet{id: 1, lat: Decimal.new("40.7128"), lon: Decimal.new("-74.0060")},
|
%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, %{})
|
result = Clustering.cluster_packets(packets, 5, %{})
|
||||||
assert {:heat_map, clusters} = result
|
assert {:heat_map, clusters} = result
|
||||||
assert length(clusters) == 1
|
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
|
end
|
||||||
|
|
||||||
test "separates distant packets into different clusters" do
|
test "separates distant packets into different clusters" do
|
||||||
|
|
@ -69,7 +70,20 @@ defmodule Aprsme.Packets.ClusteringTest do
|
||||||
result = Clustering.cluster_packets(packets, 5, %{})
|
result = Clustering.cluster_packets(packets, 5, %{})
|
||||||
assert {:heat_map, clusters} = result
|
assert {:heat_map, clusters} = result
|
||||||
assert length(clusters) == 2
|
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
|
end
|
||||||
|
|
||||||
test "handles packets with nil coordinates" do
|
test "handles packets with nil coordinates" do
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,152 @@ defmodule AprsmeWeb.MapLive.DataBuilderTest do
|
||||||
assert result["callsign"] == "P-K5SGD"
|
assert result["callsign"] == "P-K5SGD"
|
||||||
end
|
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
|
test "build_packet_data_list groups by object_name for objects" do
|
||||||
now = DateTime.utc_now()
|
now = DateTime.utc_now()
|
||||||
earlier = DateTime.add(now, -600, :second)
|
earlier = DateTime.add(now, -600, :second)
|
||||||
|
|
|
||||||
|
|
@ -93,5 +93,31 @@ defmodule AprsmeWeb.MapLive.PacketProcessorTest do
|
||||||
push_events = get_in(updated_socket.private, [:push_events]) || []
|
push_events = get_in(updated_socket.private, [:push_events]) || []
|
||||||
assert push_events == []
|
assert push_events == []
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue