From cd011b9667cc170b95e20db7c0c9e9d1eca12e94 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 21 Jun 2025 13:40:36 -0500 Subject: [PATCH] historical lines --- assets/js/features/trail_manager.ts | 35 ++++++--- assets/js/map.ts | 88 ++++++++++++++++++++-- lib/aprs_web/live/map_live/index.ex | 112 +++++++++++++++++++++++++++- 3 files changed, 217 insertions(+), 18 deletions(-) diff --git a/assets/js/features/trail_manager.ts b/assets/js/features/trail_manager.ts index f60a3f8..e8152cd 100644 --- a/assets/js/features/trail_manager.ts +++ b/assets/js/features/trail_manager.ts @@ -48,16 +48,23 @@ export class TrailManager { if (!trailState) { trailState = { positions: [] }; this.trails.set(baseCallsign, trailState); + console.log(`TrailManager: Created new trail for ${baseCallsign}`); } - // Only add if position is different from the last one (more than ~1 meter) - const lastPos = trailState.positions[trailState.positions.length - 1]; - if ( - !lastPos || - Math.abs(lastPos.lat - lat) > 0.00001 || - Math.abs(lastPos.lng - lng) > 0.00001 - ) { + // Check if this position already exists (avoid duplicates) + const existingPos = trailState.positions.find( + (pos) => + Math.abs(pos.lat - lat) < 0.00001 && + Math.abs(pos.lng - lng) < 0.00001 && + Math.abs(pos.timestamp - timestamp) < 1000, // Within 1 second + ); + + if (!existingPos) { + // Add new position trailState.positions.push({ lat, lng, timestamp }); + + // Sort positions by timestamp to maintain chronological order + trailState.positions.sort((a, b) => a.timestamp - b.timestamp); } // For historical dots, keep all positions. For live positions, filter by time. @@ -79,11 +86,15 @@ export class TrailManager { // Ensure markerId is a string const id = typeof markerId === "string" ? markerId : String(markerId); - // Extract base callsign from various ID formats + // For historical markers like "hist_CALLSIGN_123", extract the CALLSIGN part if (id.startsWith("hist_")) { - // Remove hist_ prefix and any trailing index - return id.replace(/^hist_/, "").replace(/_\d+$/, ""); + // Remove hist_ prefix and any trailing numeric index + const withoutPrefix = id.replace(/^hist_/, ""); + // Remove trailing _digits pattern + return withoutPrefix.replace(/_\d+$/, ""); } + + // For regular callsigns or IDs, return as-is return id; } @@ -113,6 +124,10 @@ export class TrailManager { className: "historical-trail-line", }).addTo(this.trailLayer); + console.log( + `TrailManager: Created trail line for ${baseCallsign} with ${trailState.positions.length} positions`, + ); + // Don't create additional dots here since historical positions are now shown as markers trailState.dots = []; } diff --git a/assets/js/map.ts b/assets/js/map.ts index ea00597..c19dff9 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -499,9 +499,73 @@ let MapAPRSMap = { // Handle new packets from LiveView self.handleEvent("new_packet", (data: MarkerData) => { + // Check if there's already a marker for this callsign + 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; + + 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; + } + }); + + // 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); + + if (existingMarker && existingState) { + // Create historical dot data from existing marker + const historicalData = { + id: `hist_${existingCallsign}_${Date.now()}`, + lat: existingState.lat, + lng: existingState.lng, + callsign: existingCallsign, + callsign_group: existingCallsign, + 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); + + // Add the historical dot marker + self.addMarker({ + ...historicalData, + popup: self.buildPopupContent(historicalData), + }); + } + } + } + + // Add the new marker with proper callsign grouping 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 popup: self.buildPopupContent(data), openPopup: true, }); @@ -683,7 +747,10 @@ let MapAPRSMap = { ? new Date(data.timestamp).getTime() : data.timestamp : Date.now(); - self.trailManager.addPosition(data.id, lat, lng, timestamp, isHistoricalDot); + // Use callsign_group for proper trail grouping - prioritize callsign_group, then callsign, then id + const trailId = data.callsign_group || data.callsign || data.id; + + self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot); } if (!positionChanged && !dataChanged) { @@ -738,7 +805,8 @@ let MapAPRSMap = { popup: data.popup, historical: data.historical, is_most_recent_for_callsign: data.is_most_recent_for_callsign, - callsign_group: data.callsign_group, + callsign_group: data.callsign_group || data.callsign, + callsign: data.callsign, }); // Initialize trail for new marker - always add to trail for line drawing @@ -749,7 +817,10 @@ let MapAPRSMap = { ? new Date(data.timestamp).getTime() : data.timestamp : Date.now(); - self.trailManager.addPosition(data.id, lat, lng, timestamp, isHistoricalDot); + // Use callsign_group for proper trail grouping - prioritize callsign_group, then callsign, then id + const trailId = data.callsign_group || data.callsign || data.id; + + self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot); } // Open popup if requested @@ -764,15 +835,18 @@ let MapAPRSMap = { const markerId = typeof id === "string" ? id : String(id); const marker = self.markers!.get(markerId); + const markerState = self.markerStates!.get(markerId); + if (marker) { self.markerLayer!.removeLayer(marker); self.markers!.delete(markerId); self.markerStates!.delete(markerId); } - // Remove trail + // Remove trail - use callsign_group for proper trail identification if (self.trailManager) { - self.trailManager.removeTrail(markerId); + const trailId = markerState?.callsign_group || markerState?.callsign || markerId; + self.trailManager.removeTrail(trailId); } }, @@ -800,7 +874,9 @@ let MapAPRSMap = { ? new Date(data.timestamp).getTime() : data.timestamp : Date.now(); - self.trailManager.addPosition(data.id, lat, lng, timestamp, isHistoricalDot); + // Use callsign_group for proper trail grouping - prioritize callsign_group, then callsign, then id + const trailId = data.callsign_group || data.callsign || data.id; + self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot); } } } diff --git a/lib/aprs_web/live/map_live/index.ex b/lib/aprs_web/live/map_live/index.ex index 25054ff..699ec90 100644 --- a/lib/aprs_web/live/map_live/index.ex +++ b/lib/aprs_web/live/map_live/index.ex @@ -237,7 +237,8 @@ defmodule AprsWeb.MapLive.Index do @spec process_bounds_update(map(), Socket.t()) :: Socket.t() defp process_bounds_update(map_bounds, socket) do - Logger.debug("process_bounds_update: (no DB query) for bounds #{inspect(map_bounds)}") + Logger.debug("process_bounds_update: Loading historical packets for bounds #{inspect(map_bounds)}") + # Remove out-of-bounds packets and markers immediately new_visible_packets = socket.assigns.visible_packets @@ -259,7 +260,13 @@ defmodule AprsWeb.MapLive.Index do end) end - # Only update visible_packets with the packets still in bounds + # Clear existing historical packets + socket = push_event(socket, "clear_historical_packets", %{}) + + # Load historical packets for the new bounds + socket = load_historical_packets_for_bounds(socket, map_bounds) + + # Update map bounds and visible packets assign(socket, map_bounds: map_bounds, visible_packets: new_visible_packets) end @@ -746,6 +753,107 @@ defmodule AprsWeb.MapLive.Index do Enum.sort_by(packets, fn packet -> packet.received_at end) end + @spec load_historical_packets_for_bounds(Socket.t(), map()) :: Socket.t() + defp load_historical_packets_for_bounds(socket, map_bounds) do + # Get time range for historical data + now = DateTime.utc_now() + one_hour_ago = DateTime.add(now, -60 * 60, :second) + + # Convert map bounds to the format expected by the database query + bounds = [ + map_bounds.west, + map_bounds.south, + map_bounds.east, + map_bounds.north + ] + + # Fetch historical packets with position data within the current map bounds + historical_packets = fetch_historical_packets(bounds, one_hour_ago, now) + + if Enum.empty?(historical_packets) do + # No historical packets found + socket + else + # Group packets by callsign-ssid and process all positions + packet_data_list = + historical_packets + |> Enum.group_by(&generate_callsign/1) + |> Enum.flat_map(fn {callsign, packets} -> + # Sort packets by inserted_at to identify the most recent + sorted_packets = + Enum.sort_by( + packets, + fn packet -> + case packet.inserted_at do + %NaiveDateTime{} = naive_dt -> + DateTime.from_naive!(naive_dt, "Etc/UTC") + + %DateTime{} = dt -> + dt + + _other -> + DateTime.utc_now() + end + end, + {:desc, DateTime} + ) + + # Filter out packets with unchanged positions + unique_position_packets = filter_unique_positions(sorted_packets) + + # Process all packets with unique positions, marking which is the most recent + unique_position_packets + |> Enum.with_index() + |> Enum.map(fn {packet, index} -> + case build_packet_data(packet) do + nil -> + nil + + packet_data -> + # Generate a unique ID for this historical packet + packet_id = + "hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}_#{index}" + + is_most_recent = index == 0 + + packet_data + |> Map.put("id", packet_id) + |> Map.put("is_historical", true) + |> Map.put("is_most_recent_for_callsign", is_most_recent) + |> Map.put("callsign_group", callsign) + |> Map.put( + "timestamp", + case packet.inserted_at do + %NaiveDateTime{} = naive_dt -> + DateTime.to_unix(DateTime.from_naive!(naive_dt, "Etc/UTC"), :millisecond) + + %DateTime{} = dt -> + DateTime.to_unix(dt, :millisecond) + + _other -> + DateTime.to_unix(DateTime.utc_now(), :millisecond) + end + ) + end + end) + |> Enum.filter(& &1) + end) + + # Send all historical packets at once + socket = push_event(socket, "add_historical_packets", %{packets: packet_data_list}) + + # Store historical packets in assigns for reference + historical_packets_map = + packet_data_list + |> Enum.zip(historical_packets) + |> Enum.reduce(%{}, fn {packet_data, packet}, acc -> + Map.put(acc, packet_data["id"], packet) + end) + + assign(socket, historical_packets: historical_packets_map) + end + end + @spec within_bounds?(map() | struct(), map()) :: boolean() defp within_bounds?(packet, bounds) do {lat, lon, _data_extended} = MapHelpers.get_coordinates(packet)