diff --git a/assets/css/app.css b/assets/css/app.css index 9bd8d1b..f3827da 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -181,8 +181,22 @@ body.home-page main > div { /* Trail polyline hover effect */ .leaflet-interactive:hover { - stroke-width: 4; + stroke-width: 5; + stroke-opacity: 1 !important; +} + +/* Historical trail lines */ +.leaflet-interactive[stroke="#1E90FF"] { + stroke-width: 3; stroke-opacity: 0.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.leaflet-interactive[stroke="#1E90FF"]:hover { + stroke-width: 5; + stroke-opacity: 1; + filter: drop-shadow(0 0 4px #1e90ff); } /* Position dot hover effect */ @@ -210,3 +224,52 @@ body.home-page main > div { background-color: #1e90ff; color: white; } + +/* Historical dot marker styles */ +.historical-dot-marker { + cursor: pointer; + transition: transform 0.2s ease-out; +} + +.historical-dot-marker:hover { + transform: scale(1.5); + z-index: 1000 !important; +} + +.historical-dot-marker div { + box-shadow: 0 0 4px rgba(0, 0, 0, 0.4); + transition: all 0.2s ease-out; +} + +.historical-dot-marker:hover div { + box-shadow: 0 0 8px rgba(255, 107, 107, 0.8); + border-color: #ff6b6b !important; +} + +/* Historical trail line styling */ +.historical-trail-line { + stroke: #1e90ff; + stroke-width: 3; + stroke-opacity: 0.8; + stroke-linecap: round; + stroke-linejoin: round; + fill: none; + transition: all 0.2s ease-out; + z-index: 1; +} + +.historical-trail-line:hover { + stroke-width: 5; + stroke-opacity: 1; + filter: drop-shadow(0 0 4px #1e90ff); + z-index: 2; +} + +/* Ensure markers are above trail lines */ +.leaflet-marker-icon { + z-index: 10 !important; +} + +.historical-dot-marker { + z-index: 15 !important; +} diff --git a/assets/js/features/trail_manager.ts b/assets/js/features/trail_manager.ts index ec6072c..f60a3f8 100644 --- a/assets/js/features/trail_manager.ts +++ b/assets/js/features/trail_manager.ts @@ -32,29 +32,62 @@ export class TrailManager { } } - addPosition(markerId: string, lat: number, lng: number, timestamp: number) { + addPosition( + markerId: string, + lat: number, + lng: number, + timestamp: number, + isHistoricalDot: boolean = false, + ) { if (!this.showTrails) return; - let trailState = this.trails.get(markerId); + // Extract base callsign from markerId to group positions by callsign + const baseCallsign = this.extractBaseCallsign(markerId); + + let trailState = this.trails.get(baseCallsign); if (!trailState) { trailState = { positions: [] }; - this.trails.set(markerId, trailState); + this.trails.set(baseCallsign, trailState); } - // Only add if position is different from the last one + // 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.0001 || Math.abs(lastPos.lng - lng) > 0.0001) { + if ( + !lastPos || + Math.abs(lastPos.lat - lat) > 0.00001 || + Math.abs(lastPos.lng - lng) > 0.00001 + ) { trailState.positions.push({ lat, lng, timestamp }); } - // Filter positions to keep only recent ones - const cutoffTime = Date.now() - this.trailDuration; - trailState.positions = trailState.positions.filter((pos) => pos.timestamp >= cutoffTime); + // For historical dots, keep all positions. For live positions, filter by time. + if (!isHistoricalDot) { + const cutoffTime = Date.now() - this.trailDuration; + trailState.positions = trailState.positions.filter((pos) => { + // Ensure timestamp is a number for comparison + const posTimestamp = + typeof pos.timestamp === "string" ? new Date(pos.timestamp).getTime() : pos.timestamp; + return posTimestamp >= cutoffTime; + }); + } - this.updateTrailVisualization(markerId, trailState); + // Always update trail visualization + this.updateTrailVisualization(baseCallsign, trailState); } - private updateTrailVisualization(markerId: string, trailState: TrailState) { + private extractBaseCallsign(markerId: string | any): string { + // Ensure markerId is a string + const id = typeof markerId === "string" ? markerId : String(markerId); + + // Extract base callsign from various ID formats + if (id.startsWith("hist_")) { + // Remove hist_ prefix and any trailing index + return id.replace(/^hist_/, "").replace(/_\d+$/, ""); + } + return id; + } + + private updateTrailVisualization(baseCallsign: string, trailState: TrailState) { // Remove old trail and dots if (trailState.trail) { this.trailLayer.removeLayer(trailState.trail); @@ -69,45 +102,25 @@ export class TrailManager { const L = (window as any).L; const latLngs = trailState.positions.map((pos) => [pos.lat, pos.lng]); - // Create polyline for the trail + // Create blue polyline connecting the historical position dots trailState.trail = L.polyline(latLngs, { color: "#1E90FF", weight: 3, - opacity: 0.5, + opacity: 0.8, smoothFactor: 1, + lineCap: "round", + lineJoin: "round", + className: "historical-trail-line", }).addTo(this.trailLayer); - // Create dots for each position - trailState.dots = trailState.positions.map((pos, index) => { - const age = (Date.now() - pos.timestamp) / this.trailDuration; - const opacity = Math.max(0.4, 1 - age * 0.6); - const isCurrentPosition = index === trailState.positions.length - 1; - const radius = isCurrentPosition ? 5 : 3; - - const dot = L.circleMarker([pos.lat, pos.lng], { - radius: radius, - fillColor: "#FF4500", - color: "#8B0000", - weight: 1, - opacity: opacity, - fillOpacity: opacity * 0.8, - }); - - // Add tooltip with timestamp - const time = new Date(pos.timestamp); - dot.bindTooltip(time.toLocaleTimeString(), { - permanent: false, - direction: "top", - className: "trail-tooltip", - }); - - return dot.addTo(this.trailLayer); - }); + // Don't create additional dots here since historical positions are now shown as markers + trailState.dots = []; } } removeTrail(markerId: string) { - const trailState = this.trails.get(markerId); + const baseCallsign = this.extractBaseCallsign(markerId); + const trailState = this.trails.get(baseCallsign); if (trailState) { if (trailState.trail) { this.trailLayer.removeLayer(trailState.trail); @@ -115,7 +128,7 @@ export class TrailManager { if (trailState.dots) { trailState.dots.forEach((dot) => this.trailLayer.removeLayer(dot)); } - this.trails.delete(markerId); + this.trails.delete(baseCallsign); } } @@ -126,12 +139,21 @@ export class TrailManager { } cleanupOldPositions() { - const cutoffTime = Date.now() - this.trailDuration; + // Don't clean up historical positions - only clean up very old live positions + const veryOldCutoff = Date.now() - 24 * 60 * 60 * 1000; // 24 hours this.trails.forEach((trailState, markerId) => { - trailState.positions = trailState.positions.filter((pos) => pos.timestamp >= cutoffTime); + const originalLength = trailState.positions.length; + trailState.positions = trailState.positions.filter((pos) => { + // Ensure timestamp is a number for comparison + const posTimestamp = + typeof pos.timestamp === "string" ? new Date(pos.timestamp).getTime() : pos.timestamp; + // Keep all positions newer than 24 hours (includes all historical data we care about) + return posTimestamp >= veryOldCutoff; + }); + if (trailState.positions.length === 0) { this.removeTrail(markerId); - } else { + } else if (trailState.positions.length !== originalLength) { this.updateTrailVisualization(markerId, trailState); } }); diff --git a/assets/js/map.ts b/assets/js/map.ts index 6a7205b..ea00597 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -39,6 +39,8 @@ interface MarkerData { historical?: boolean; color?: string; timestamp?: number; + is_most_recent_for_callsign?: boolean; + callsign_group?: string; } interface BoundsData { @@ -60,6 +62,8 @@ interface MarkerState { symbol_code: string; popup?: string; historical?: boolean; + is_most_recent_for_callsign?: boolean; + callsign_group?: string; } interface MapEventData { @@ -536,11 +540,41 @@ let MapAPRSMap = { // Handle bulk loading of historical packets 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) => { - self.addMarker({ - ...packet, - historical: true, - popup: self.buildPopupContent(packet), + const callsign = packet.callsign_group || packet.callsign || packet.id; + if (!packetsByCallsign.has(callsign)) { + packetsByCallsign.set(callsign, []); + } + 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 = a.timestamp + ? typeof a.timestamp === "number" + ? a.timestamp + : new Date(a.timestamp).getTime() + : 0; + const timeB = b.timestamp + ? typeof b.timestamp === "number" + ? b.timestamp + : new Date(b.timestamp).getTime() + : 0; + return timeA - timeB; + }); + + // Add markers in chronological order + sortedPackets.forEach((packet) => { + self.addMarker({ + ...packet, + historical: true, + popup: self.buildPopupContent(packet), + }); }); }); } @@ -561,7 +595,7 @@ let MapAPRSMap = { const markersToRemove: string[] = []; self.markers!.forEach((marker: any, id: any) => { if ((marker as any)._isHistorical) { - markersToRemove.push(id); + markersToRemove.push(String(id)); } }); markersToRemove.forEach((id) => self.removeMarker(id)); @@ -643,7 +677,13 @@ let MapAPRSMap = { if (positionChanged && self.trailManager) { // Position changed, update trail - self.trailManager.addPosition(data.id, lat, lng, data.timestamp || Date.now()); + const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign; + const timestamp = data.timestamp + ? typeof data.timestamp === "string" + ? new Date(data.timestamp).getTime() + : data.timestamp + : Date.now(); + self.trailManager.addPosition(data.id, lat, lng, timestamp, isHistoricalDot); } if (!positionChanged && !dataChanged) { @@ -659,7 +699,7 @@ let MapAPRSMap = { // Remove existing marker if it exists self.removeMarker(data.id); - // Create marker using APRS symbol icon + // Create marker - use simple dot for older historical positions, APRS icon for most recent const marker = L.marker([lat, lng], { icon: self.createMarkerIcon(data), }); @@ -697,11 +737,19 @@ let MapAPRSMap = { symbol_code: data.symbol_code || ">", popup: data.popup, historical: data.historical, + is_most_recent_for_callsign: data.is_most_recent_for_callsign, + callsign_group: data.callsign_group, }); - // Initialize trail for new marker + // Initialize trail for new marker - always add to trail for line drawing if (self.trailManager) { - self.trailManager.addPosition(data.id, lat, lng, data.timestamp || Date.now()); + const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign; + const timestamp = data.timestamp + ? typeof data.timestamp === "string" + ? new Date(data.timestamp).getTime() + : data.timestamp + : Date.now(); + self.trailManager.addPosition(data.id, lat, lng, timestamp, isHistoricalDot); } // Open popup if requested @@ -710,18 +758,21 @@ let MapAPRSMap = { } }, - removeMarker(id: string) { + removeMarker(id: string | any) { const self = this as unknown as LiveViewHookContext; - const marker = self.markers!.get(id); + // Ensure id is a string + const markerId = typeof id === "string" ? id : String(id); + + const marker = self.markers!.get(markerId); if (marker) { self.markerLayer!.removeLayer(marker); - self.markers!.delete(id); - self.markerStates!.delete(id); + self.markers!.delete(markerId); + self.markerStates!.delete(markerId); } // Remove trail if (self.trailManager) { - self.trailManager.removeTrail(id); + self.trailManager.removeTrail(markerId); } }, @@ -743,7 +794,13 @@ let MapAPRSMap = { if (positionChanged) { existingMarker.setLatLng([lat, lng]); if (self.trailManager) { - self.trailManager.addPosition(data.id, lat, lng, data.timestamp || Date.now()); + const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign; + const timestamp = data.timestamp + ? typeof data.timestamp === "string" + ? new Date(data.timestamp).getTime() + : data.timestamp + : Date.now(); + self.trailManager.addPosition(data.id, lat, lng, timestamp, isHistoricalDot); } } } @@ -802,10 +859,32 @@ let MapAPRSMap = { }); // Remove out-of-bounds markers - markersToRemove.forEach((id) => self.removeMarker(id)); + markersToRemove.forEach((id) => self.removeMarker(String(id))); }, createMarkerIcon(data: MarkerData): L.DivIcon { + // For historical packets that are not the most recent for their callsign, + // show a simple red dot (only positions where lat/lon actually changed) + if (data.historical && !data.is_most_recent_for_callsign) { + const iconHtml = `
`; + + return L.divIcon({ + html: iconHtml, + className: "historical-dot-marker", + iconSize: [12, 12], + iconAnchor: [6, 6], + }); + } + + // For current packets or most recent historical packets, show the full APRS symbol icon const symbolTableId = data.symbol_table_id || "/"; const symbolCode = getValidSymbolCode(data.symbol_code, symbolTableId); @@ -841,7 +920,7 @@ let MapAPRSMap = { background-size: 512px 192px; background-repeat: no-repeat; image-rendering: pixelated; - opacity: ${data.historical ? "0.7" : "1.0"}; + opacity: ${data.historical && data.is_most_recent_for_callsign ? "0.9" : "1.0"}; overflow: hidden; " data-symbol-table="${symbolTableId}" data-symbol-code="${symbolCode}" data-sprite-position="${x},${y}" data-expected-index="${index}" title="Symbol: ${symbolCode} (${charCode}) at row ${row}, col ${column}">`; diff --git a/lib/aprs_web/live/map_live/callsign_view.ex b/lib/aprs_web/live/map_live/callsign_view.ex index 6809ce3..0d5e59e 100644 --- a/lib/aprs_web/live/map_live/callsign_view.ex +++ b/lib/aprs_web/live/map_live/callsign_view.ex @@ -720,21 +720,94 @@ defmodule AprsWeb.MapLive.CallsignView do now ) - socket = + if Enum.empty?(packets) do + # No historical packets found + socket + else + # Clear any previous historical packets from the map + socket = push_event(socket, "clear_historical_packets", %{}) + + # Sort packets by inserted_at to identify the most recent + # Convert NaiveDateTime to DateTime if needed for proper comparison + 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 (only keep if lat/lon changed) + unique_position_packets = filter_unique_positions(sorted_packets) + + # Build packet data for all positions, marking which is the most recent + packet_data_list = + 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 + callsign = socket.assigns.callsign + + 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) + + # 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(unique_position_packets) + |> Enum.reduce(%{}, fn {packet_data, packet}, acc -> + Map.put(acc, packet_data["id"], packet) + end) + assign(socket, - replay_packets: packets, + historical_packets: historical_packets_map, + replay_packets: [], replay_index: 0, replay_start_time: one_hour_ago, - replay_end_time: now, - historical_packets: %{} + replay_end_time: now ) - - # Start replay immediately if we have packets - if length(packets) > 0 do - Process.send_after(self(), :replay_next_packet, 100) end - - socket end defp fetch_historical_packets_for_callsign(callsign, start_time, end_time) do @@ -746,6 +819,43 @@ defmodule AprsWeb.MapLive.CallsignView do }) end + # Filter packets to only include those with unique positions (lat/lon changed) + @spec filter_unique_positions([struct()]) :: [struct()] + defp filter_unique_positions(packets) do + packets + |> Enum.reduce([], fn packet, acc -> + {lat, lng, _} = MapHelpers.get_coordinates(packet) + + if lat && lng do + # Check if this position is different from the last position + case acc do + [] -> + # First packet, always include + [packet | acc] + + [last_packet | _] -> + if position_changed?(packet, last_packet) do + [packet | acc] + else + acc + end + end + else + acc + end + end) + |> Enum.reverse() + end + + # Check if position changed significantly between two packets (more than ~1 meter) + @spec position_changed?(struct(), struct()) :: boolean() + defp position_changed?(packet1, packet2) do + {lat1, lng1, _} = MapHelpers.get_coordinates(packet1) + {lat2, lng2, _} = MapHelpers.get_coordinates(packet2) + + abs(lat1 - lat2) > 0.00001 || abs(lng1 - lng2) > 0.00001 + end + defp build_packet_data(packet) do {lat, lng, _} = MapHelpers.get_coordinates(packet) callsign = Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", "")) diff --git a/lib/aprs_web/live/map_live/index.ex b/lib/aprs_web/live/map_live/index.ex index 1c41647..25054ff 100644 --- a/lib/aprs_web/live/map_live/index.ex +++ b/lib/aprs_web/live/map_live/index.ex @@ -594,43 +594,79 @@ defmodule AprsWeb.MapLive.Index do # Clear any previous historical packets from the map socket = push_event(socket, "clear_historical_packets", %{}) - # Group packets by callsign and get only the most recent for each - latest_packets_by_callsign = + # Group packets by callsign-ssid and process all positions + packet_data_list = historical_packets |> Enum.group_by(&generate_callsign/1) - |> Enum.map(fn {_callsign, packets} -> - # Sort by received_at descending and take the most recent - Enum.max_by(packets, & &1.received_at, DateTime) + |> Enum.flat_map(fn {callsign, packets} -> + # Sort packets by inserted_at to identify the most recent + # Convert NaiveDateTime to DateTime if needed for proper comparison + 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 (only keep if lat/lon changed) + 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) - # Build packet data for all latest packets - packet_data_list = - latest_packets_by_callsign - |> Enum.map(fn packet -> - 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])}" - - packet_data - |> Map.put("id", packet_id) - |> Map.put("is_historical", true) - end - end) - # Remove nil values - |> Enum.filter(& &1) - # 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(latest_packets_by_callsign) + |> Enum.zip(historical_packets) |> Enum.reduce(%{}, fn {packet_data, packet}, acc -> Map.put(acc, packet_data["id"], packet) end) @@ -645,6 +681,42 @@ defmodule AprsWeb.MapLive.Index do end end + # Filter packets to only include those with unique positions (lat/lon changed) + @spec filter_unique_positions([struct()]) :: [struct()] + defp filter_unique_positions(packets) do + packets + |> Enum.reduce([], fn packet, acc -> + {lat, lng, _} = MapHelpers.get_coordinates(packet) + + if lat && lng do + case acc do + [] -> + # First packet, always include + [packet | acc] + + [last_packet | _] -> + if position_changed?(packet, last_packet) do + [packet | acc] + else + acc + end + end + else + acc + end + end) + |> Enum.reverse() + end + + # Check if position changed significantly between two packets (more than ~1 meter) + @spec position_changed?(struct(), struct()) :: boolean() + defp position_changed?(packet1, packet2) do + {lat1, lng1, _} = MapHelpers.get_coordinates(packet1) + {lat2, lng2, _} = MapHelpers.get_coordinates(packet2) + + abs(lat1 - lat2) > 0.00001 || abs(lng1 - lng2) > 0.00001 + end + # Fetch historical packets from the database @spec fetch_historical_packets(list(), DateTime.t(), DateTime.t()) :: [struct()] defp fetch_historical_packets(bounds, start_time, end_time) do