misc fixes

This commit is contained in:
Graham McIntire 2025-06-22 17:11:58 -05:00
parent 01ee6eada5
commit e7214f5064
No known key found for this signature in database
8 changed files with 197 additions and 153 deletions

View file

@ -64,6 +64,7 @@ interface MarkerState {
historical?: boolean;
is_most_recent_for_callsign?: boolean;
callsign_group?: string;
callsign?: string;
}
interface MapEventData {
@ -503,69 +504,60 @@ let MapAPRSMap = {
const incomingCallsign = data.callsign_group || data.callsign || data.id;
if (incomingCallsign) {
// Find existing marker for this callsign - check both live and historical markers
let existingMarkerId: string | null = null;
let existingCallsign: string | null = null;
// Find existing live markers for this callsign and convert them to historical dots
const markersToConvert: string[] = [];
self.markerStates!.forEach((state, id) => {
// Extract callsign from various sources
const stateCallsign =
state.callsign_group ||
(typeof id === "string" && !id.startsWith("hist_") ? id : null) ||
(typeof id === "string" && id.startsWith("hist_")
? id.replace(/^hist_/, "").replace(/_\d+$/, "")
: null);
// Match callsigns (only consider non-historical markers for conversion)
if (stateCallsign === incomingCallsign && !state.historical) {
existingMarkerId = id;
existingCallsign = stateCallsign;
const stateCallsign = state.callsign_group || state.callsign;
// Only convert non-historical markers for the same callsign, but exclude the incoming packet's ID
if (
stateCallsign === incomingCallsign &&
!state.historical &&
String(id) !== String(data.id)
) {
markersToConvert.push(String(id));
}
});
// If we found an existing live marker, convert it to a historical dot
if (existingMarkerId && existingCallsign) {
const existingMarker = self.markers!.get(existingMarkerId);
const existingState = self.markerStates!.get(existingMarkerId);
// Convert existing live markers to historical dots by updating their icon
markersToConvert.forEach((id) => {
const existingMarker = self.markers!.get(id);
const existingState = self.markerStates!.get(id);
if (existingMarker && existingState) {
// Create historical dot data from existing marker
const historicalData = {
id: `hist_${existingCallsign}_${Date.now()}`,
// Update the state to historical
existingState.historical = true;
existingState.is_most_recent_for_callsign = false;
// Create new historical dot icon
const historicalIconData = {
id: String(id),
lat: existingState.lat,
lng: existingState.lng,
callsign: existingCallsign,
callsign_group: existingCallsign,
callsign: existingState.callsign || incomingCallsign,
callsign_group: existingState.callsign_group || incomingCallsign,
symbol_table_id: existingState.symbol_table,
symbol_code: existingState.symbol_code,
historical: true,
is_most_recent_for_callsign: false,
timestamp: data.timestamp
? typeof data.timestamp === "string"
? new Date(data.timestamp).getTime() - 1000
: data.timestamp - 1000
: Date.now() - 1000,
popup: existingState.popup,
};
// Remove the existing marker (this will also clean up its trail reference)
self.removeMarker(existingMarkerId);
// Update the marker's icon to show as a dot
existingMarker.setIcon(self.createMarkerIcon(historicalIconData));
// Add the historical dot marker
self.addMarker({
...historicalData,
popup: self.buildPopupContent(historicalData),
});
// Mark as historical
(existingMarker as any)._isHistorical = true;
}
}
});
}
// Add the new marker with proper callsign grouping
// Add the new marker as the most recent for this callsign
self.addMarker({
...data,
historical: false,
is_most_recent_for_callsign: true,
callsign_group: data.callsign_group || data.callsign || incomingCallsign, // Ensure callsign_group is set for trail grouping
callsign_group: data.callsign_group || data.callsign || incomingCallsign,
popup: self.buildPopupContent(data),
openPopup: true,
});
@ -655,14 +647,46 @@ let MapAPRSMap = {
// Handle clearing historical packets
self.handleEvent("clear_historical_packets", () => {
// Remove all historical markers
// Remove only historical markers (preserve live markers and their trails)
const markersToRemove: string[] = [];
self.markers!.forEach((marker: any, id: any) => {
if ((marker as any)._isHistorical) {
const markerState = self.markerStates!.get(String(id));
// Only remove markers that are explicitly historical
if ((marker as any)._isHistorical || (markerState && markerState.historical)) {
markersToRemove.push(String(id));
}
});
markersToRemove.forEach((id) => self.removeMarker(id));
// Remove historical markers without affecting trails of live markers
markersToRemove.forEach((id) => {
const marker = self.markers!.get(id);
const markerState = self.markerStates!.get(id);
if (marker) {
// Remove from map and storage
self.markerLayer!.removeLayer(marker);
self.markers!.delete(id);
self.markerStates!.delete(id);
// Only remove trail if this was the only marker for this callsign
if (self.trailManager && markerState) {
const callsign = markerState.callsign_group || markerState.callsign || id;
// Check if there are any live markers left for this callsign
let hasLiveMarkers = false;
self.markerStates!.forEach((state) => {
const stateCallsign = state.callsign_group || state.callsign;
if (stateCallsign === callsign && !state.historical) {
hasLiveMarkers = true;
}
});
// Only remove trail if no live markers remain
if (!hasLiveMarkers) {
self.trailManager.removeTrail(callsign);
}
}
}
});
});
// Handle bounds-based marker filtering
@ -681,6 +705,11 @@ let MapAPRSMap = {
self.handleEvent("clear_and_reload_markers", () => {
// This event is just a trigger - the server will handle clearing and adding markers
});
// Handle clear_all_markers event
self.handleEvent("clear_all_markers", () => {
self.clearAllMarkers();
});
},
sendBoundsToServer() {
@ -763,8 +792,8 @@ let MapAPRSMap = {
}
}
// Remove existing marker if it exists
self.removeMarker(data.id);
// Remove existing marker if it exists (without affecting trails since we're just replacing it)
self.removeMarkerWithoutTrail(data.id);
// Create marker - use simple dot for older historical positions, APRS icon for most recent
const marker = L.marker([lat, lng], {
@ -934,8 +963,24 @@ let MapAPRSMap = {
}
});
// Remove out-of-bounds markers
markersToRemove.forEach((id) => self.removeMarker(String(id)));
// Remove out-of-bounds markers without affecting trails
markersToRemove.forEach((id) => self.removeMarkerWithoutTrail(String(id)));
},
removeMarkerWithoutTrail(id: string | any) {
const self = this as unknown as LiveViewHookContext;
// Ensure id is a string
const markerId = typeof id === "string" ? id : String(id);
const marker = self.markers!.get(markerId);
if (marker) {
// Remove marker from map but don't touch trails
self.markerLayer!.removeLayer(marker);
self.markers!.delete(markerId);
self.markerStates!.delete(markerId);
// Note: We intentionally don't remove trails here
}
},
createMarkerIcon(data: MarkerData): L.DivIcon {

View file

@ -394,20 +394,8 @@ defmodule Aprs.Packets do
defp filter_by_region(query, _), do: query
defp filter_by_callsign(query, %{callsign: callsign}) do
# Support both exact match and partial match
# For exact match, check if callsign contains a hyphen (has SSID)
if String.contains?(callsign, "-") do
# Exact match for callsign with SSID
[base_call, ssid] = String.split(callsign, "-", parts: 2)
from p in query,
where:
ilike(p.base_callsign, ^base_call) and
((is_nil(p.ssid) and ^ssid == "0") or p.ssid == ^ssid)
else
# Match base callsign exactly, regardless of SSID
from p in query, where: ilike(p.base_callsign, ^callsign)
end
# Use sender field for exact callsign matching
from p in query, where: ilike(p.sender, ^callsign)
end
defp filter_by_callsign(query, _), do: query

View file

@ -26,13 +26,8 @@ defmodule AprsWeb.MapLive.CallsignView do
page_title: "APRS Map - #{normalized_callsign}",
# Track visible packets by callsign
visible_packets: %{},
# Default bounds - will be updated based on packet locations
map_bounds: %{
north: 49.0,
south: 24.0,
east: -66.0,
west: -125.0
},
# Map bounds - will be set when first bounds update is received
map_bounds: nil,
map_center: @default_center,
map_zoom: @default_zoom,
default_center: @default_center,
@ -62,7 +57,9 @@ defmodule AprsWeb.MapLive.CallsignView do
packets_loaded: false,
# Latest symbol table ID and code
latest_symbol_table_id: "/",
latest_symbol_code: ">"
latest_symbol_code: ">",
# Flag to track if latest marker was already pushed
latest_marker_pushed: false
)
if connected?(socket) do
@ -164,14 +161,7 @@ defmodule AprsWeb.MapLive.CallsignView do
|> assign(packets_loaded: true)
end
# Auto-start replay if it hasn't been started yet
socket =
if socket.assigns.replay_started do
socket
else
socket = start_historical_replay(socket)
assign(socket, replay_started: true, replay_active: true)
end
# Don't start historical replay yet - wait for bounds to be available
# If we have a pending geolocation, zoom to it after a delay
socket =
@ -208,21 +198,32 @@ defmodule AprsWeb.MapLive.CallsignView do
|> Enum.filter(fn {_k, packet} -> MapHelpers.within_bounds?(packet, normalized_bounds) end)
|> Map.new()
markers_to_remove =
packets_to_remove =
socket.assigns.visible_packets
|> Enum.reject(fn {_k, packet} -> MapHelpers.within_bounds?(packet, normalized_bounds) end)
|> Enum.map(fn {k, _} -> k end)
socket =
if markers_to_remove == [] do
if packets_to_remove == [] do
socket
else
Enum.reduce(markers_to_remove, socket, fn k, acc ->
Enum.reduce(packets_to_remove, socket, fn k, acc ->
push_event(acc, "remove_marker", %{id: k})
end)
end
socket = assign(socket, map_bounds: normalized_bounds, visible_packets: new_visible_packets)
# Start historical replay now that we have bounds (if not already started)
socket =
if socket.assigns.map_ready and not socket.assigns.replay_started and
not is_nil(socket.assigns.map_bounds) do
socket = start_historical_replay(socket)
assign(socket, replay_started: true, replay_active: true)
else
socket
end
{:noreply, socket}
end
@ -249,11 +250,12 @@ defmodule AprsWeb.MapLive.CallsignView do
end
def handle_info(:auto_start_replay, socket) do
if not socket.assigns.replay_started and socket.assigns.map_ready do
if not socket.assigns.replay_started and socket.assigns.map_ready and
not is_nil(socket.assigns.map_bounds) do
socket = start_historical_replay(socket)
{:noreply, assign(socket, replay_started: true, replay_active: true)}
else
if not socket.assigns.map_ready do
if not socket.assigns.map_ready or is_nil(socket.assigns.map_bounds) do
Process.send_after(self(), :auto_start_replay, 1000)
end
@ -266,7 +268,9 @@ defmodule AprsWeb.MapLive.CallsignView do
def handle_info(%Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: packet}, socket) do
# Only process packets for the specific callsign being viewed
if PacketUtils.generate_callsign(packet) == socket.assigns.callsign do
packet_sender = Map.get(packet, :sender, "")
if String.upcase(packet_sender) == String.upcase(socket.assigns.callsign) do
handle_info_postgres_packet(packet, socket)
else
{:noreply, socket}
@ -280,11 +284,12 @@ defmodule AprsWeb.MapLive.CallsignView do
updated_visible_packets = Map.put(socket.assigns.visible_packets, key, packet)
socket = assign(socket, visible_packets: updated_visible_packets)
packet_data = PacketUtils.build_packet_data(packet)
# Live packets are always the most recent for their callsign
packet_data = PacketUtils.build_packet_data(packet, true)
socket =
if packet_data,
do: push_event(socket, "add_markers", %{markers: [packet_data]}),
do: push_event(socket, "new_packet", packet_data),
else: socket
{:noreply, socket}
@ -672,7 +677,8 @@ defmodule AprsWeb.MapLive.CallsignView do
fetch_historical_packets_for_callsign(
socket.assigns.callsign,
one_hour_ago,
now
now,
socket.assigns.map_bounds
)
# Always fetch the latest packet with a position, regardless of age
@ -752,23 +758,23 @@ defmodule AprsWeb.MapLive.CallsignView do
historical_callsigns = MapSet.new(unique_position_packets, &PacketUtils.generate_callsign/1)
socket =
{socket, latest_marker_pushed} =
if latest_packet && !MapSet.member?(historical_callsigns, latest_packet_id) do
# Push the latest marker as a regular marker (not historical)
packet_data = PacketUtils.build_packet_data(latest_packet)
packet_data = PacketUtils.build_packet_data(latest_packet, true)
if packet_data do
push_event(socket, "add_markers", %{markers: [packet_data]})
{push_event(socket, "new_packet", packet_data), true}
else
socket
{socket, false}
end
else
socket
{socket, false}
end
if Enum.empty?(packet_data_list) do
# No historical packets found (but latest marker may have been pushed above)
socket
assign(socket, latest_marker_pushed: latest_marker_pushed)
else
# Clear any previous historical packets from the map
socket = push_event(socket, "clear_historical_packets", %{})
@ -787,18 +793,28 @@ defmodule AprsWeb.MapLive.CallsignView do
replay_packets: [],
replay_index: 0,
replay_start_time: one_hour_ago,
replay_end_time: now
replay_end_time: now,
latest_marker_pushed: latest_marker_pushed
)
end
end
defp fetch_historical_packets_for_callsign(callsign, start_time, end_time) do
Packets.get_packets_for_replay(%{
defp fetch_historical_packets_for_callsign(callsign, start_time, end_time, bounds) do
params = %{
callsign: callsign,
start_time: start_time,
end_time: end_time,
limit: 1000
})
}
params =
if bounds do
Map.put(params, :bounds, [bounds.west, bounds.south, bounds.east, bounds.north])
else
params
end
Packets.get_packets_for_replay(params)
end
# Filter packets to only include those with unique positions (lat/lon changed)
@ -886,7 +902,12 @@ defmodule AprsWeb.MapLive.CallsignView do
defp maybe_push_latest_marker(socket, nil), do: socket
defp maybe_push_latest_marker(socket, packet) do
packet_data = PacketUtils.build_packet_data(packet)
if packet_data, do: push_event(socket, "add_markers", %{markers: [packet_data]}), else: socket
# Only push if we haven't already pushed it during historical replay
if Map.get(socket.assigns, :latest_marker_pushed, false) do
socket
else
packet_data = PacketUtils.build_packet_data(packet, true)
if packet_data, do: push_event(socket, "new_packet", packet_data), else: socket
end
end
end

View file

@ -361,10 +361,10 @@ defmodule AprsWeb.MapLive.Index do
end)
end
# Clear existing historical packets
socket = push_event(socket, "clear_historical_packets", %{})
# Remove only out-of-bounds historical packets instead of clearing all
socket = push_event(socket, "filter_markers_by_bounds", %{bounds: map_bounds})
# Load historical packets for the new bounds
# Load additional historical packets for the new bounds if needed
socket = load_historical_packets_for_bounds(socket, map_bounds)
# Update map bounds and visible packets
@ -496,11 +496,13 @@ defmodule AprsWeb.MapLive.Index do
else: System.unique_integer([:positive])
new_visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, packet)
marker_data = PacketUtils.build_packet_data(packet)
# Live packets are always the most recent for their callsign
marker_data = PacketUtils.build_packet_data(packet, true)
socket =
if marker_data do
push_event(socket, "add_markers", %{markers: [marker_data]})
push_event(socket, "new_packet", marker_data)
else
socket
end
@ -965,8 +967,8 @@ defmodule AprsWeb.MapLive.Index do
# Clear existing historical packets
socket = assign(socket, historical_packets: %{})
# Clear all markers on the client
socket = push_event(socket, "clear_all_markers", %{})
# Clear only historical markers on the client (preserve live markers)
socket = push_event(socket, "clear_historical_packets", %{})
# Load historical packets with new time range
socket = load_historical_packets_for_bounds(socket, socket.assigns.map_bounds)

View file

@ -81,9 +81,9 @@ defmodule AprsWeb.MapLive.PacketUtils do
@spec generate_callsign(map()) :: String.t()
def generate_callsign(packet) do
base_callsign = get_packet_field(packet, :base_callsign, "")
ssid = get_packet_field(packet, :ssid, 0)
ssid = get_packet_field(packet, :ssid, nil)
if ssid != 0 and ssid != "" and ssid != nil do
if ssid != nil and ssid != "" do
"#{base_callsign}-#{ssid}"
else
base_callsign
@ -138,15 +138,20 @@ defmodule AprsWeb.MapLive.PacketUtils do
Map.get(data_extended, to_string(key)) || "N/A"
end
def build_packet_data(packet) do
def build_packet_data(packet, is_most_recent_for_callsign) when is_boolean(is_most_recent_for_callsign) do
{lat, lon, data_extended} = MapHelpers.get_coordinates(packet)
callsign = generate_callsign(packet)
if lat && lon && callsign != "" && callsign != nil do
build_packet_map(packet, lat, lon, data_extended)
packet_data = build_packet_map(packet, lat, lon, data_extended)
Map.put(packet_data, "is_most_recent_for_callsign", is_most_recent_for_callsign)
end
end
def build_packet_data(packet) do
build_packet_data(packet, false)
end
defp build_packet_map(packet, lat, lon, data_extended) do
data_extended = data_extended || %{}
packet_info = extract_packet_info(packet, data_extended)
@ -157,7 +162,7 @@ defmodule AprsWeb.MapLive.PacketUtils do
defp extract_packet_info(packet, data_extended) do
%{
callsign: generate_callsign(packet),
callsign: get_packet_field(packet, :sender, ""),
symbol_table_id: get_packet_field(packet, :symbol_table_id, "/"),
symbol_code: get_packet_field(packet, :symbol_code, ">"),
timestamp: get_timestamp(packet),
@ -197,7 +202,7 @@ defmodule AprsWeb.MapLive.PacketUtils do
"""
<div class="aprs-popup">
<div class="aprs-callsign"><strong><a href="/map/callsign/#{packet_info.callsign}">#{packet_info.callsign}</a></strong></div>
<div class="aprs-callsign"><strong><a href="/#{packet_info.callsign}">#{packet_info.callsign}</a></strong></div>
#{comment_html}
<div class="aprs-coords">#{Float.round(to_float(lat), 4)}, #{Float.round(to_float(lon), 4)}</div>
#{timestamp_html}
@ -205,7 +210,7 @@ defmodule AprsWeb.MapLive.PacketUtils do
"""
end
defp build_weather_popup_html(packet, callsign) do
defp build_weather_popup_html(packet, sender) do
received_at = get_received_at(packet)
timestamp_dt = TimeHelpers.to_datetime(received_at)
@ -222,7 +227,7 @@ defmodule AprsWeb.MapLive.PacketUtils do
end
"""
<strong>#{callsign} - Weather Report</strong>
<strong>#{sender} - Weather Report</strong>
#{timestamp_html}
<hr style="margin-top: 4px; margin-bottom: 4px;">
Temperature: #{get_weather_field(packet, :temperature)}°F<br>
@ -236,11 +241,19 @@ defmodule AprsWeb.MapLive.PacketUtils do
end
defp build_packet_result(packet, packet_info, lat, lon, popup) do
# Generate unique ID for live packets to prevent conflicts
packet_id =
if Map.has_key?(packet, :id) do
"live_#{packet.id}_#{System.unique_integer([:positive])}"
else
"live_#{packet_info.callsign}_#{System.unique_integer([:positive])}"
end
%{
"id" => packet_info.callsign,
"id" => packet_id,
"callsign" => packet_info.callsign,
"base_callsign" => get_packet_field(packet, :base_callsign, ""),
"ssid" => get_packet_field(packet, :ssid, 0),
"ssid" => get_packet_field(packet, :ssid, nil),
"lat" => to_float(lat),
"lng" => to_float(lon),
"data_type" => to_string(get_packet_field(packet, :data_type, "unknown")),

View file

@ -95,20 +95,8 @@ defmodule AprsWeb.PacketsLive.CallsignView do
order_by: [desc: p.received_at],
limit: ^limit
# Apply callsign filter
filtered_query =
if String.contains?(callsign, "-") do
# Exact match for callsign with SSID
[base_call, ssid] = String.split(callsign, "-", parts: 2)
from p in query,
where:
ilike(p.base_callsign, ^base_call) and
((is_nil(p.ssid) and ^ssid == "0") or p.ssid == ^ssid)
else
# Match base callsign exactly, regardless of SSID
from p in query, where: ilike(p.base_callsign, ^callsign)
end
# Apply callsign filter using sender field for exact matching
filtered_query = from p in query, where: ilike(p.sender, ^callsign)
filtered_query
|> Repo.all()
@ -122,20 +110,15 @@ defmodule AprsWeb.PacketsLive.CallsignView do
end
defp packet_matches_callsign?(packet, target_callsign) do
# Check if packet sender or base_callsign matches the target callsign
# Supports both exact matches and SSID variants (e.g., "N0CALL" matches "N0CALL-1")
# Check exact match for sender field only
sender = packet.sender || ""
base_callsign = packet.base_callsign || ""
# Convert to uppercase for case-insensitive comparison
sender_upper = String.upcase(sender)
base_upper = String.upcase(base_callsign)
target_upper = String.upcase(target_callsign)
# Check exact match for sender or base_callsign
# Also check if the sender starts with the target callsign (for SSID variants)
sender_upper == target_upper || base_upper == target_upper ||
String.starts_with?(sender_upper, target_upper <> "-")
# Exact match only
sender_upper == target_upper
end
# Helper to get all packets (stored + live) in chronological order

View file

@ -74,19 +74,16 @@ defmodule Parser do
ssid =
case List.last(callsign_parts) do
nil ->
0
nil
s when is_binary(s) ->
case Integer.parse(s) do
{i, _} -> i
:error -> 0
end
s
i when is_integer(i) ->
i
to_string(i)
_ ->
0
nil
end
{:ok,
@ -1182,17 +1179,12 @@ defmodule Parser do
end
end
defp extract_ssid(nil), do: 0
defp extract_ssid(nil), do: nil
defp extract_ssid(s) when is_binary(s) do
case Integer.parse(s) do
{i, _} -> i
:error -> 0
end
end
defp extract_ssid(s) when is_binary(s), do: s
defp extract_ssid(i) when is_integer(i), do: i
defp extract_ssid(_), do: 0
defp extract_ssid(i) when is_integer(i), do: to_string(i)
defp extract_ssid(_), do: nil
defp split_path_for_tunnel(path) do
split_path(path)

View file

@ -136,7 +136,7 @@ defmodule Aprs.Integration.HistoricalPacketsTest do
test "only loads packets within map bounds", %{conn: conn, packets: mock_packets} do
# Mock to return only packets within the specified bounds
# TEST2 is at lat: 38.8, lon: -97.5, which is outside these bounds
test1_packets = Enum.filter(mock_packets, fn packet -> packet.base_callsign == "TEST1" end)
test1_packets = Enum.filter(mock_packets, fn packet -> packet.sender == "TEST1" end)
expect_packets_for_replay_with_bounds(test1_packets)
{:ok, view, _html} = live(conn, "/")