diff --git a/assets/js/map.ts b/assets/js/map.ts index 4677e28..5d967b5 100644 --- a/assets/js/map.ts +++ b/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(); - const allNewIds = new Set(); - 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(); + 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(); + const allNewIds = new Set(); + 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(); - - 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(); - - 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 = `
- `; - - if (comment) { - content += `
${comment}
`; - } - - if (data.timestamp) { - const timestamp = parseTimestamp(data.timestamp); - const date = new Date(timestamp); - - if (!isNaN(date.getTime())) { - content += `
${date.toISOString()}
`; - } - } - - content += "
"; - return content; - }, - destroyed() { const self = this as unknown as LiveViewHookContext; diff --git a/lib/aprsme/packets/clustering.ex b/lib/aprsme/packets/clustering.ex index 7f1228e..c3f8f6c 100644 --- a/lib/aprsme/packets/clustering.ex +++ b/lib/aprsme/packets/clustering.ex @@ -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 diff --git a/lib/aprsme_web/live/map_live/data_builder.ex b/lib/aprsme_web/live/map_live/data_builder.ex index d027f17..6a52d73 100644 --- a/lib/aprsme_web/live/map_live/data_builder.ex +++ b/lib/aprsme_web/live/map_live/data_builder.ex @@ -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() + + "
" + 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) diff --git a/lib/aprsme_web/live/map_live/index.ex b/lib/aprsme_web/live/map_live/index.ex index e371c67..98d06a2 100644 --- a/lib/aprsme_web/live/map_live/index.ex +++ b/lib/aprsme_web/live/map_live/index.ex @@ -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 diff --git a/lib/aprsme_web/live/map_live/packet_processor.ex b/lib/aprsme_web/live/map_live/packet_processor.ex index fbee0ec..7902ea8 100644 --- a/lib/aprsme_web/live/map_live/packet_processor.ex +++ b/lib/aprsme_web/live/map_live/packet_processor.ex @@ -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 diff --git a/test/aprsme/packets/clustering_test.exs b/test/aprsme/packets/clustering_test.exs index fa5b932..86c0529 100644 --- a/test/aprsme/packets/clustering_test.exs +++ b/test/aprsme/packets/clustering_test.exs @@ -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 diff --git a/test/aprsme_web/live/map_live/data_builder_test.exs b/test/aprsme_web/live/map_live/data_builder_test.exs index 2ec865b..cd8f978 100644 --- a/test/aprsme_web/live/map_live/data_builder_test.exs +++ b/test/aprsme_web/live/map_live/data_builder_test.exs @@ -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) diff --git a/test/aprsme_web/live/map_live/packet_processor_test.exs b/test/aprsme_web/live/map_live/packet_processor_test.exs index 042aa1b..d275065 100644 --- a/test/aprsme_web/live/map_live/packet_processor_test.exs +++ b/test/aprsme_web/live/map_live/packet_processor_test.exs @@ -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