From a0961f932d38f61532d330597b207beced330bfd Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 27 Feb 2026 12:54:32 -0600 Subject: [PATCH 1/8] docs: add design for trail line visualization at low zoom for tracked callsigns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show polyline trail instead of heat map when tracking callsign at zoom ≤ 8 - Individual markers remain at zoom > 8 - Trail line connects positions chronologically - Respects trail duration setting --- .../2026-02-27-callsign-trail-line-design.md | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 docs/plans/2026-02-27-callsign-trail-line-design.md diff --git a/docs/plans/2026-02-27-callsign-trail-line-design.md b/docs/plans/2026-02-27-callsign-trail-line-design.md new file mode 100644 index 0000000..c11c9d8 --- /dev/null +++ b/docs/plans/2026-02-27-callsign-trail-line-design.md @@ -0,0 +1,151 @@ +# Trail Line Display for Tracked Callsigns at Low Zoom + +## Problem Statement + +When tracking a specific callsign (e.g., W5ISP-9), the map currently displays aggregated heat map data at zoom levels ≤ 8. This makes it impossible to see the individual path points and trajectory of the tracked station when zoomed out. Users need to see the complete path of a tracked callsign regardless of zoom level. + +## Solution Overview + +Implement a trail line visualization that displays when tracking a specific callsign at low zoom levels (≤ 8). Instead of showing a heat map, the system will draw a polyline connecting all the station's position points in chronological order, clearly showing the path traveled. + +## Behavior Specification + +### When Tracking a Callsign + +- **Zoom ≤ 8**: Display trail line (polyline) connecting all positions chronologically + - Clear any existing heat map + - Fetch all packets for tracked callsign within time threshold + - Sort by timestamp (chronological order) + - Draw polyline on map + +- **Zoom > 8**: Display individual markers (current behavior) + - Show APRS symbols at each position + - Enable popup interactions + +### When NOT Tracking a Callsign + +- **Zoom ≤ 8**: Display heat map (current behavior) +- **Zoom > 8**: Display individual markers (current behavior) + +## Technical Design + +### Backend Changes (Elixir) + +**File: `lib/aprsme_web/live/map_live/display_manager.ex`** + +1. Modify `handle_zoom_threshold_crossing/2`: + - Check if `socket.assigns.tracked_callsign != ""` + - If tracking and zoom ≤ 8: call new function to send trail line + - If tracking and zoom > 8: show markers as normal + - If not tracking: use existing heat map/marker logic + +2. Add new function `send_trail_line_for_tracked_callsign/1`: + - Query all packets for tracked callsign within time threshold + - Sort packets by `received_at` (chronological order) + - Extract coordinates (lat, lng, timestamp) + - Push event `show_trail_line` with trail data + +**File: `lib/aprsme_web/live/map_live/index.ex`** + +3. Update bounds update logic: + - When processing bounds updates at zoom ≤ 8 with tracked callsign + - Send trail line instead of heat map + +### Frontend Changes (TypeScript) + +**File: `assets/js/map.ts`** + +1. Add state tracking: + - `currentTrailLine: Polyline | null` - store reference to current trail line + +2. Add event handler `show_trail_line`: + - Clear existing trail line if present + - Create Leaflet Polyline from coordinates + - Style the polyline (color, weight, opacity) + - Add to map + - Store reference in state + +3. Add event handler `clear_trail_line`: + - Remove trail line from map + - Clear state reference + +4. Update existing handlers: + - `show_heat_map`: Clear trail line before showing heat map + - `show_markers`: Clear trail line before showing markers + - `clear_all_markers`: Also clear trail line + +### Data Structure + +**Trail Line Event Payload:** +```typescript +{ + callsign: string, + points: Array<{ + lat: number, + lng: number, + timestamp: string + }> +} +``` + +### Visual Styling + +**Trail Line Appearance:** +- Color: `#3B82F6` (blue-500) - distinct from other map elements +- Weight: 3 pixels - visible but not overwhelming +- Opacity: 0.8 - allows seeing map features underneath +- Line Cap: round - smoother appearance at endpoints +- Line Join: round - smoother corners +- Interactive: true - allow clicking for info +- Dash Pattern: none (solid line) + +**Optional Enhancement (Future):** +- Direction arrows along the line +- Gradient color showing time progression +- Click on line to show timestamp at that point + +## Implementation Notes + +### Time Threshold Handling +- Use existing `socket.assigns.packet_age_threshold` for filtering +- Trail line respects the "Trail Duration" setting (1 hour, 6 hours, etc.) +- When trail duration changes, regenerate trail line + +### Performance Considerations +- Trail lines are only generated for tracked callsigns (not all stations) +- Polylines are efficient for rendering many points +- Clear trail line when switching away from tracked callsign +- Debounce trail line updates during rapid zoom changes + +### Edge Cases +- Empty trail (no packets): Don't draw anything +- Single point: Don't draw line (show marker instead) +- Zoom threshold crossing: Smoothly transition between trail and markers +- Callsign tracking cleared: Remove trail line immediately + +## Testing Strategy + +1. **Manual Testing:** + - Track a callsign and zoom out to ≤ 8: verify trail line appears + - Zoom in to > 8: verify markers appear + - Clear tracking: verify trail line disappears + - Change trail duration: verify trail line updates + +2. **Visual Testing:** + - Verify trail line color and styling + - Check line smoothness and appearance + - Ensure map features remain visible underneath + +3. **Edge Cases:** + - Callsign with no packets + - Callsign with single packet + - Callsign with very long trail (performance) + - International date line crossing + +## Future Enhancements + +1. **Direction Indicators**: Add small arrows along the trail to show direction of travel +2. **Time-based Coloring**: Gradient color from old (gray) to new (bright blue) +3. **Interactive Points**: Click on trail to see packet details at that position +4. **Trail Simplification**: Use line simplification algorithm (Douglas-Peucker) for very long trails +5. **Multiple Callsigns**: Support tracking multiple callsigns with different colored trails From 840285fd7a5d3969fa0d734948627e98be118d4f Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 27 Feb 2026 12:55:37 -0600 Subject: [PATCH 2/8] docs: clarify trail line should connect all points regardless of distance - Continuous path connection for long-distance travel - No distance-based filtering or gap detection - Important for aircraft, vehicles, intermittent coverage scenarios --- docs/plans/2026-02-27-callsign-trail-line-design.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/plans/2026-02-27-callsign-trail-line-design.md b/docs/plans/2026-02-27-callsign-trail-line-design.md index c11c9d8..6f11250 100644 --- a/docs/plans/2026-02-27-callsign-trail-line-design.md +++ b/docs/plans/2026-02-27-callsign-trail-line-design.md @@ -106,6 +106,15 @@ Implement a trail line visualization that displays when tracking a specific call ## Implementation Notes +### Continuous Path Connection +- **Connect all points regardless of distance**: The trail line should connect all position points in chronological order, even if there are large gaps between positions +- This is important for tracking stations that: + - Travel long distances (aircraft, vehicles on highways) + - Have intermittent coverage (gaps in digipeater/igate range) + - Cross large geographic areas +- Do NOT apply distance-based filtering or gap detection +- The continuous line shows the complete journey path for the tracked callsign + ### Time Threshold Handling - Use existing `socket.assigns.packet_age_threshold` for filtering - Trail line respects the "Trail Duration" setting (1 hour, 6 hours, etc.) @@ -122,6 +131,8 @@ Implement a trail line visualization that displays when tracking a specific call - Single point: Don't draw line (show marker instead) - Zoom threshold crossing: Smoothly transition between trail and markers - Callsign tracking cleared: Remove trail line immediately +- **International date line crossing**: Trail line should wrap correctly around the date line (Leaflet handles this automatically with proper coordinate ordering) +- **Large distance gaps**: Continue drawing line even with large geographic gaps between consecutive points ## Testing Strategy From d3ef999ab9f07a386f3b20279271e709ba38b10e Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 27 Feb 2026 12:56:15 -0600 Subject: [PATCH 3/8] chore: ignore .worktrees directory for git worktree isolation --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5dd9977..e1a14e8 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,4 @@ gleam@@compile.erl # Expert language server directory /.expert .envrc +.worktrees/ From 6add35b11a3f98d8b4fcc99707703445032ff86c Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 27 Feb 2026 13:10:34 -0600 Subject: [PATCH 4/8] feat: add send_trail_line_for_tracked_callsign function - Filters packets by tracked callsign and time threshold - Sorts packets chronologically by received_at - Pushes show_trail_line event with coordinates - Returns socket unchanged when no packets found --- .../live/map_live/display_manager.ex | 50 +++++++ .../live/map_live/display_manager_test.exs | 136 ++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 test/aprsme_web/live/map_live/display_manager_test.exs diff --git a/lib/aprsme_web/live/map_live/display_manager.ex b/lib/aprsme_web/live/map_live/display_manager.ex index 482a8b5..a78c8c7 100644 --- a/lib/aprsme_web/live/map_live/display_manager.ex +++ b/lib/aprsme_web/live/map_live/display_manager.ex @@ -74,6 +74,56 @@ defmodule AprsmeWeb.MapLive.DisplayManager do end end + @doc """ + Send trail line data for tracked callsign. + Connects all position points chronologically, regardless of distance. + """ + @spec send_trail_line_for_tracked_callsign(Socket.t()) :: Socket.t() + def send_trail_line_for_tracked_callsign(socket) do + callsign = socket.assigns.tracked_callsign + threshold = socket.assigns.packet_age_threshold + + packets = + socket.assigns.visible_packets + |> Map.values() + |> Enum.filter(fn packet -> + sender = Map.get(packet, :sender) || Map.get(packet, "sender") + received_at = Map.get(packet, :received_at) || Map.get(packet, "received_at") + + String.upcase(sender) == String.upcase(callsign) && + DateTime.compare(received_at, threshold) != :lt + end) + |> Enum.sort_by( + fn packet -> + received_at = Map.get(packet, :received_at) || Map.get(packet, "received_at") + DateTime.to_unix(received_at, :microsecond) + end, + :asc + ) + + if Enum.empty?(packets) do + socket + else + points = + Enum.map(packets, fn packet -> + lat = Map.get(packet, :lat) || Map.get(packet, "lat") + lon = Map.get(packet, :lon) || Map.get(packet, "lon") + received_at = Map.get(packet, :received_at) || Map.get(packet, "received_at") + + %{ + lat: Aprsme.EncodingUtils.to_float(lat) || 0.0, + lng: Aprsme.EncodingUtils.to_float(lon) || 0.0, + timestamp: DateTime.to_iso8601(received_at) + } + end) + + LiveView.push_event(socket, "show_trail_line", %{ + callsign: callsign, + points: points + }) + end + end + @doc """ Send heat map data for filtered packets. """ diff --git a/test/aprsme_web/live/map_live/display_manager_test.exs b/test/aprsme_web/live/map_live/display_manager_test.exs new file mode 100644 index 0000000..6e93267 --- /dev/null +++ b/test/aprsme_web/live/map_live/display_manager_test.exs @@ -0,0 +1,136 @@ +defmodule AprsmeWeb.MapLive.DisplayManagerTest do + use Aprsme.DataCase, async: true + + alias AprsmeWeb.MapLive.DisplayManager + + setup do + # Create a basic socket mock for testing + socket = %Phoenix.LiveView.Socket{ + assigns: %{ + visible_packets: %{}, + tracked_callsign: nil, + packet_age_threshold: nil, + map_bounds: nil, + map_zoom: 10, + historical_packets: %{} + }, + private: %{live_temp: %{}} + } + + {:ok, socket: socket} + end + + describe "send_trail_line_for_tracked_callsign/1" do + test "pushes trail line event with sorted points", %{socket: socket} do + packet1 = %{ + id: "1", + sender: "W5ISP-9", + lat: 30.123, + lon: -97.456, + received_at: ~U[2024-01-01 10:00:00Z] + } + + packet2 = %{ + id: "2", + sender: "W5ISP-9", + lat: 30.124, + lon: -97.457, + received_at: ~U[2024-01-01 10:05:00Z] + } + + packet3 = %{ + id: "3", + sender: "W5ISP-9", + lat: 30.125, + lon: -97.458, + received_at: ~U[2024-01-01 10:10:00Z] + } + + visible_packets = %{"W5ISP-9:1" => packet1, "W5ISP-9:2" => packet2, "W5ISP-9:3" => packet3} + + socket = + Map.put(socket, :assigns, %{ + socket.assigns + | visible_packets: visible_packets, + tracked_callsign: "W5ISP-9", + packet_age_threshold: ~U[2024-01-01 09:00:00Z] + }) + + result_socket = DisplayManager.send_trail_line_for_tracked_callsign(socket) + + push_events = get_in(result_socket.private, [:live_temp, :push_events]) || [] + assert length(push_events) == 1 + + [[event_name, event_data]] = push_events + assert event_name == "show_trail_line" + + assert event_data == %{ + callsign: "W5ISP-9", + points: [ + %{lat: 30.123, lng: -97.456, timestamp: "2024-01-01T10:00:00Z"}, + %{lat: 30.124, lng: -97.457, timestamp: "2024-01-01T10:05:00Z"}, + %{lat: 30.125, lng: -97.458, timestamp: "2024-01-01T10:10:00Z"} + ] + } + end + + test "filters by packet age threshold", %{socket: socket} do + packet1 = %{ + id: "1", + sender: "W5ISP-9", + lat: 30.123, + lon: -97.456, + received_at: ~U[2024-01-01 09:00:00Z] + } + + # Too old + packet2 = %{ + id: "2", + sender: "W5ISP-9", + lat: 30.124, + lon: -97.457, + received_at: ~U[2024-01-01 10:05:00Z] + } + + # Within threshold + + visible_packets = %{"W5ISP-9:1" => packet1, "W5ISP-9:2" => packet2} + + socket = + Map.put(socket, :assigns, %{ + socket.assigns + | visible_packets: visible_packets, + tracked_callsign: "W5ISP-9", + packet_age_threshold: ~U[2024-01-01 10:00:00Z] + }) + + result_socket = DisplayManager.send_trail_line_for_tracked_callsign(socket) + + push_events = get_in(result_socket.private, [:live_temp, :push_events]) || [] + assert length(push_events) == 1 + + [[event_name, event_data]] = push_events + assert event_name == "show_trail_line" + + assert event_data == %{ + callsign: "W5ISP-9", + points: [%{lat: 30.124, lng: -97.457, timestamp: "2024-01-01T10:05:00Z"}] + } + end + + test "returns socket unchanged when no packets", %{socket: socket} do + socket = + Map.put(socket, :assigns, %{ + socket.assigns + | visible_packets: %{}, + tracked_callsign: "W5ISP-9", + packet_age_threshold: ~U[2024-01-01 10:00:00Z] + }) + + result_socket = DisplayManager.send_trail_line_for_tracked_callsign(socket) + + push_events = get_in(result_socket.private, [:live_temp, :push_events]) || [] + assert push_events == [] + end + end +end From 2919402aa9a0cca71f9181e97faf92cb088f27bc Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 27 Feb 2026 13:21:37 -0600 Subject: [PATCH 5/8] feat: show trail line for tracked callsigns at low zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modified handle_zoom_threshold_crossing to detect tracked_callsign - Show trail line instead of heat map when tracking at zoom ≤ 8 - Maintain heat map behavior when not tracking - Maintain marker behavior at zoom > 8 --- .../live/map_live/display_manager.ex | 16 +++- .../live/map_live/display_manager_test.exs | 95 +++++++++++++++++++ 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/lib/aprsme_web/live/map_live/display_manager.ex b/lib/aprsme_web/live/map_live/display_manager.ex index a78c8c7..d150333 100644 --- a/lib/aprsme_web/live/map_live/display_manager.ex +++ b/lib/aprsme_web/live/map_live/display_manager.ex @@ -14,14 +14,22 @@ defmodule AprsmeWeb.MapLive.DisplayManager do @doc """ Handle zoom threshold crossing between heat map and marker modes. + When tracking a callsign at low zoom, show trail line instead of heat map. """ @spec handle_zoom_threshold_crossing(Socket.t(), integer()) :: Socket.t() def handle_zoom_threshold_crossing(socket, zoom) do + tracked_callsign = socket.assigns.tracked_callsign || "" + if zoom <= @heat_map_max_zoom do - # Switching to heat map - socket - |> LiveView.push_event("clear_all_markers", %{}) - |> send_heat_map_for_current_bounds() + # Switching to low zoom mode + socket = LiveView.push_event(socket, "clear_all_markers", %{}) + + # If tracking a callsign, show trail line; otherwise show heat map + if tracked_callsign == "" do + send_heat_map_for_current_bounds(socket) + else + send_trail_line_for_tracked_callsign(socket) + end else # Switching to markers trigger_marker_display(socket) diff --git a/test/aprsme_web/live/map_live/display_manager_test.exs b/test/aprsme_web/live/map_live/display_manager_test.exs index 6e93267..90ba108 100644 --- a/test/aprsme_web/live/map_live/display_manager_test.exs +++ b/test/aprsme_web/live/map_live/display_manager_test.exs @@ -20,6 +20,101 @@ defmodule AprsmeWeb.MapLive.DisplayManagerTest do {:ok, socket: socket} end + # Helper function to assert push events + defp assert_push_event(socket, event_name, expected_data) do + push_events = get_in(socket.private, [:live_temp, :push_events]) || [] + + matching_event = Enum.find(push_events, fn [name, _data] -> name == event_name end) + + assert matching_event != nil, "Expected to find event '#{event_name}' in push_events" + + [_name, data] = matching_event + + # For partial matching, check if all expected keys are present + Enum.each(expected_data, fn {key, value} -> + assert Map.get(data, key) == value, + "Expected #{key} to be #{inspect(value)}, got #{inspect(Map.get(data, key))}" + end) + + socket + end + + describe "handle_zoom_threshold_crossing/2" do + test "shows trail line for tracked callsign at low zoom", %{socket: socket} do + packet1 = %{ + id: "1", + sender: "W5ISP-9", + lat: 30.123, + lon: -97.456, + received_at: ~U[2024-01-01 10:00:00Z] + } + + visible_packets = %{"W5ISP-9:1" => packet1} + + socket = + Map.put(socket, :assigns, %{ + socket.assigns + | visible_packets: visible_packets, + tracked_callsign: "W5ISP-9", + packet_age_threshold: ~U[2024-01-01 09:00:00Z], + map_zoom: 8 + }) + + # Zoom from 9 to 8 (crossing threshold) - socket already has new zoom + result_socket = DisplayManager.handle_zoom_threshold_crossing(socket, 8) + + # Should clear markers and show trail line + assert_push_event(result_socket, "clear_all_markers", %{}) + assert_push_event(result_socket, "show_trail_line", %{callsign: "W5ISP-9"}) + end + + test "shows heat map when no tracked callsign at low zoom", %{socket: socket} do + socket = + Map.put(socket, :assigns, %{ + socket.assigns + | visible_packets: %{}, + tracked_callsign: "", + historical_packets: %{}, + map_bounds: %{north: 90, south: -90, east: 180, west: -180}, + map_zoom: 8 + }) + + # Zoom from 9 to 8 (crossing threshold) - socket already has new zoom + result_socket = DisplayManager.handle_zoom_threshold_crossing(socket, 8) + + # Should clear markers and show heat map + assert_push_event(result_socket, "clear_all_markers", %{}) + assert_push_event(result_socket, "show_heat_map", %{heat_points: []}) + end + + test "shows markers for tracked callsign at high zoom", %{socket: socket} do + packet1 = %{ + id: "1", + sender: "W5ISP-9", + lat: 30.123, + lon: -97.456, + received_at: ~U[2024-01-01 10:00:00Z] + } + + visible_packets = %{"W5ISP-9:1" => packet1} + + socket = + Map.put(socket, :assigns, %{ + socket.assigns + | visible_packets: visible_packets, + tracked_callsign: "W5ISP-9", + packet_age_threshold: ~U[2024-01-01 09:00:00Z], + map_zoom: 9 + }) + + # Zoom from 8 to 9 (crossing threshold) - socket already has new zoom + result_socket = DisplayManager.handle_zoom_threshold_crossing(socket, 9) + + # Should show markers + assert_push_event(result_socket, "show_markers", %{}) + end + end + describe "send_trail_line_for_tracked_callsign/1" do test "pushes trail line event with sorted points", %{socket: socket} do packet1 = %{ From bcbb39f21ceff3f729e3c93a525ce926f1653992 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 27 Feb 2026 13:25:00 -0600 Subject: [PATCH 6/8] feat: add trail line display to map frontend - Add trailLine state to MapHook - Implement show_trail_line event handler with Leaflet polyline - Implement clear_trail_line event handler - Clear trail line when switching to heat map or markers - Style trail line with blue color, 3px weight, 0.8 opacity --- assets/js/map.ts | 81 ++++++++++++++++++++++++++++++++++++++++ assets/js/types/map.d.ts | 1 + 2 files changed, 82 insertions(+) diff --git a/assets/js/map.ts b/assets/js/map.ts index 09d2593..ec3386c 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -1520,6 +1520,12 @@ let MapAPRSMap = { // Handle clear_all_markers event self.handleEvent("clear_all_markers", () => { + // Clear trail line if present + if (self.trailLine) { + self.map?.removeLayer(self.trailLine); + self.trailLine = undefined; + } + // Modified to preserve historical trails - only removes non-historical markers self.clearAllMarkers(); @@ -1537,6 +1543,12 @@ let MapAPRSMap = { return; } + // Clear trail line if present + if (self.trailLine) { + self.map.removeLayer(self.trailLine); + self.trailLine = undefined; + } + if (!L.heatLayer) { console.error("Leaflet.heat plugin not loaded!"); return; @@ -1595,6 +1607,12 @@ let MapAPRSMap = { return; } + // Clear trail line if present + if (self.trailLine) { + self.map.removeLayer(self.trailLine); + self.trailLine = undefined; + } + // Hide heat layer and show marker layer if (self.heatLayer && self.map.hasLayer(self.heatLayer)) { self.map.removeLayer(self.heatLayer); @@ -1606,6 +1624,69 @@ let MapAPRSMap = { console.error("Error in show_markers handler:", error); } }); + + // Handle trail line display for tracked callsigns at low zoom + self.handleEvent("show_trail_line", (payload: BaseEventPayload) => { + const data = payload as { callsign: string; points: Array<{ lat: number; lng: number; timestamp: string }> }; + + if (!self.map) { + console.warn("Map not initialized, cannot show trail line"); + return; + } + + // Clear existing trail line if present + if (self.trailLine) { + self.map.removeLayer(self.trailLine); + self.trailLine = undefined; + } + + // Clear heat map if present (switching from heat map to trail line) + if (self.heatLayer) { + self.map.removeLayer(self.heatLayer); + self.heatLayer = undefined; + } + + // If we have less than 2 points, don't draw a line + if (data.points.length < 2) { + console.debug(`Not enough points for trail line: ${data.points.length}`); + return; + } + + // Extract coordinates for polyline + const latlngs = data.points.map(point => [point.lat, point.lng] as [number, number]); + + // Access Leaflet from window + const L = (window as any).L; + + // Create polyline + self.trailLine = L.polyline(latlngs, { + color: '#3B82F6', // blue-500 + weight: 3, + opacity: 0.8, + lineCap: 'round', + lineJoin: 'round', + interactive: true + }); + + // Add to map + self.trailLine.addTo(self.map); + + console.debug(`Trail line added for ${data.callsign} with ${data.points.length} points`); + }); + + // Handle clearing trail line + self.handleEvent("clear_trail_line", (_payload: BaseEventPayload) => { + if (!self.map) { + return; + } + + // Remove trail line if present + if (self.trailLine) { + self.map.removeLayer(self.trailLine); + self.trailLine = undefined; + console.debug("Trail line cleared"); + } + }); }, sendBoundsToServer() { diff --git a/assets/js/types/map.d.ts b/assets/js/types/map.d.ts index 7f2c48f..77844ca 100644 --- a/assets/js/types/map.d.ts +++ b/assets/js/types/map.d.ts @@ -34,6 +34,7 @@ export interface LiveViewHookContext { zoomEndHandler?: () => void; rfPathLines?: Array; // Array of Leaflet polylines and markers for RF path visualization trailLayer?: LayerGroup; + trailLine?: Polyline; // Polyline for callsign trail display at low zoom markerClusterGroup?: MarkerClusterGroup; cleanupTimeouts?: ReturnType[]; pendingMarkers?: MarkerData[]; From 464ba842029b628d2bb8590440e7a3ed68c909bf Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 27 Feb 2026 13:38:20 -0600 Subject: [PATCH 7/8] feat: clear trail line when clearing callsign tracking - Push clear_trail_line event when user clears tracking - Ensures trail line is removed immediately --- lib/aprsme_web/live/map_live/index.ex | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/aprsme_web/live/map_live/index.ex b/lib/aprsme_web/live/map_live/index.ex index dc714d6..d51aab2 100644 --- a/lib/aprsme_web/live/map_live/index.ex +++ b/lib/aprsme_web/live/map_live/index.ex @@ -510,6 +510,7 @@ defmodule AprsmeWeb.MapLive.Index do socket = socket |> assign(tracked_callsign: "", overlay_callsign: "", other_ssids: []) + |> push_event("clear_trail_line", %{}) |> update_url_with_current_state() {:noreply, socket} @@ -1089,8 +1090,15 @@ defmodule AprsmeWeb.MapLive.Index do defp update_display_for_batch(socket, []), do: socket defp update_display_for_batch(socket, marker_data_list) do + tracked_callsign = socket.assigns.tracked_callsign || "" + if socket.assigns.map_zoom <= 8 do - DisplayManager.send_heat_map_for_current_bounds(socket) + # If tracking a callsign, send trail line instead of heat map + if tracked_callsign == "" do + DisplayManager.send_heat_map_for_current_bounds(socket) + else + DisplayManager.send_trail_line_for_tracked_callsign(socket) + end else send_marker_batch(socket, marker_data_list) end @@ -1987,6 +1995,15 @@ defmodule AprsmeWeb.MapLive.Index do socket = HistoricalLoader.start_progressive_historical_loading(socket) + # If tracking a callsign and at low zoom, send trail line instead of letting + # historical loader send heat map + socket = + if socket.assigns.tracked_callsign != "" and socket.assigns.map_zoom <= 8 do + DisplayManager.send_trail_line_for_tracked_callsign(socket) + else + socket + end + # Mark initial historical as completed if this was the initial load if is_initial_load do assign(socket, initial_historical_completed: true) From 9a071b6e3a20916b119743992955d9392ed523b2 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 27 Feb 2026 13:49:18 -0600 Subject: [PATCH 8/8] feat: refresh trail line when trail duration changes - Regenerate trail line after trail duration update - Trail line now respects trail duration setting --- lib/aprsme_web/live/map_live/index.ex | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/aprsme_web/live/map_live/index.ex b/lib/aprsme_web/live/map_live/index.ex index d51aab2..2a27019 100644 --- a/lib/aprsme_web/live/map_live/index.ex +++ b/lib/aprsme_web/live/map_live/index.ex @@ -535,6 +535,14 @@ defmodule AprsmeWeb.MapLive.Index do # Trigger cleanup to remove packets that are now outside the new duration send(self(), :cleanup_old_packets) + # If tracking a callsign at low zoom, refresh the trail line with new duration + socket = + if socket.assigns.tracked_callsign != "" and socket.assigns.map_zoom <= 8 do + DisplayManager.send_trail_line_for_tracked_callsign(socket) + else + socket + end + {:noreply, socket} end