diff --git a/assets/css/app.css b/assets/css/app.css index de87dc4..9bd8d1b 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -61,7 +61,7 @@ body.home-page main { height: 100vh; } -body.home-page main>div { +body.home-page main > div { max-width: none; height: 100%; } @@ -151,20 +151,62 @@ body.home-page main>div { } /* High-DPI support for APRS symbols */ -@media (-webkit-min-device-pixel-ratio: 2), -(min-resolution: 192dpi) { +@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { .aprs-marker div[style*="aprs-symbols-24-0.png"] { - background-image: url('/aprs-symbols/aprs-symbols-24-0@2x.png') !important; + background-image: url("/aprs-symbols/aprs-symbols-24-0@2x.png") !important; background-size: 384px 144px !important; } .aprs-marker div[style*="aprs-symbols-24-1.png"] { - background-image: url('/aprs-symbols/aprs-symbols-24-1@2x.png') !important; + background-image: url("/aprs-symbols/aprs-symbols-24-1@2x.png") !important; background-size: 384px 144px !important; } .aprs-marker div[style*="aprs-symbols-24-2.png"] { - background-image: url('/aprs-symbols/aprs-symbols-24-2@2x.png') !important; + background-image: url("/aprs-symbols/aprs-symbols-24-2@2x.png") !important; background-size: 384px 144px !important; } -} \ No newline at end of file +} + +/* Trail visualization styles */ +.trail-tooltip { + background-color: rgba(0, 0, 0, 0.8); + border: 1px solid #1e90ff; + color: white; + font-size: 11px; + padding: 2px 6px; + border-radius: 3px; + white-space: nowrap; +} + +/* Trail polyline hover effect */ +.leaflet-interactive:hover { + stroke-width: 4; + stroke-opacity: 0.8; +} + +/* Position dot hover effect */ +.leaflet-marker-icon.leaflet-interactive:hover { + transform: scale(1.5); + transition: transform 0.2s ease-out; +} + +/* Trail controls styling */ +.trail-toggle-control { + background-color: white; + border: 2px solid rgba(0, 0, 0, 0.2); + border-radius: 4px; + padding: 5px 10px; + font-size: 14px; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.trail-toggle-control:hover { + background-color: #f0f0f0; +} + +.trail-toggle-control.active { + background-color: #1e90ff; + color: white; +} diff --git a/assets/js/features/trail_manager.ts b/assets/js/features/trail_manager.ts new file mode 100644 index 0000000..ec6072c --- /dev/null +++ b/assets/js/features/trail_manager.ts @@ -0,0 +1,139 @@ +// Trail management module for APRS position history visualization + +export interface PositionHistory { + lat: number; + lng: number; + timestamp: number; +} + +export interface TrailState { + positions: PositionHistory[]; + trail?: any; // L.Polyline + dots?: any[]; // L.CircleMarker[] +} + +export class TrailManager { + private trailLayer: any; // L.LayerGroup + private trails: Map; + private showTrails: boolean; + private trailDuration: number; // in milliseconds + + constructor(trailLayer: any, trailDuration: number = 60 * 60 * 1000) { + this.trailLayer = trailLayer; + this.trails = new Map(); + this.showTrails = true; + this.trailDuration = trailDuration; + } + + setShowTrails(show: boolean) { + this.showTrails = show; + if (!show) { + this.clearAllTrails(); + } + } + + addPosition(markerId: string, lat: number, lng: number, timestamp: number) { + if (!this.showTrails) return; + + let trailState = this.trails.get(markerId); + if (!trailState) { + trailState = { positions: [] }; + this.trails.set(markerId, trailState); + } + + // Only add if position is different from the last one + const lastPos = trailState.positions[trailState.positions.length - 1]; + if (!lastPos || Math.abs(lastPos.lat - lat) > 0.0001 || Math.abs(lastPos.lng - lng) > 0.0001) { + 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); + + this.updateTrailVisualization(markerId, trailState); + } + + private updateTrailVisualization(markerId: string, trailState: TrailState) { + // Remove old trail and dots + if (trailState.trail) { + this.trailLayer.removeLayer(trailState.trail); + } + if (trailState.dots) { + trailState.dots.forEach((dot) => this.trailLayer.removeLayer(dot)); + trailState.dots = []; + } + + // Create new trail if we have at least 2 positions + if (trailState.positions.length >= 2) { + const L = (window as any).L; + const latLngs = trailState.positions.map((pos) => [pos.lat, pos.lng]); + + // Create polyline for the trail + trailState.trail = L.polyline(latLngs, { + color: "#1E90FF", + weight: 3, + opacity: 0.5, + smoothFactor: 1, + }).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); + }); + } + } + + removeTrail(markerId: string) { + const trailState = this.trails.get(markerId); + if (trailState) { + if (trailState.trail) { + this.trailLayer.removeLayer(trailState.trail); + } + if (trailState.dots) { + trailState.dots.forEach((dot) => this.trailLayer.removeLayer(dot)); + } + this.trails.delete(markerId); + } + } + + clearAllTrails() { + this.trails.forEach((_, markerId) => { + this.removeTrail(markerId); + }); + } + + cleanupOldPositions() { + const cutoffTime = Date.now() - this.trailDuration; + this.trails.forEach((trailState, markerId) => { + trailState.positions = trailState.positions.filter((pos) => pos.timestamp >= cutoffTime); + if (trailState.positions.length === 0) { + this.removeTrail(markerId); + } else { + this.updateTrailVisualization(markerId, trailState); + } + }); + } +} diff --git a/assets/js/map.ts b/assets/js/map.ts index 9dcd397..dee92ed 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -1,15 +1,12 @@ // Declare Leaflet as a global variable declare const L: any; +// Import trail management functionality +import { TrailManager } from "./features/trail_manager"; + // APRS Map Hook - handles only basic map interaction // All data logic handled by LiveView -declare global { - interface Window { - __aprs_map_mounted?: boolean; - } -} - type LiveViewHookContext = { el: HTMLElement & { _leaflet_id?: any }; pushEvent: (event: string, payload: any) => void; @@ -18,6 +15,7 @@ type LiveViewHookContext = { markers?: Map; markerStates?: Map; markerLayer?: any; + trailManager?: TrailManager; boundsTimer?: ReturnType; resizeHandler?: () => void; errors?: string[]; @@ -40,6 +38,7 @@ interface MarkerData { popup?: string; historical?: boolean; color?: string; + timestamp?: number; } interface BoundsData { @@ -76,11 +75,6 @@ interface MapEventData { let MapAPRSMap = { mounted() { - if (window.__aprs_map_mounted) { - console.warn("APRS Map already mounted, skipping duplicate mount."); - return; - } - window.__aprs_map_mounted = true; const self = this as unknown as LiveViewHookContext; // Initialize error tracking self.errors = []; @@ -108,37 +102,47 @@ let MapAPRSMap = { } } - // Get initial center and zoom from server-provided data attributes + // Try to restore from localStorage let initialCenter: CenterData, initialZoom: number; try { - const centerData = self.el.dataset.center; - const zoomData = self.el.dataset.zoom; - - if (!centerData || !zoomData) { - throw new Error("Missing map data attributes"); + const saved = localStorage.getItem("aprs_map_state"); + if (saved) { + const { lat, lng, zoom } = JSON.parse(saved); + if ( + typeof lat === "number" && + typeof lng === "number" && + typeof zoom === "number" && + lat >= -90 && lat <= 90 && + lng >= -180 && lng <= 180 && + zoom >= 1 && zoom <= 20 + ) { + initialCenter = { lat, lng }; + initialZoom = zoom; + } else { + throw new Error("Invalid saved map state"); + } + } else { + throw new Error("No saved map state"); } - - initialCenter = JSON.parse(centerData); - initialZoom = parseInt(zoomData); - - // Validate parsed data - if ( - !initialCenter || - typeof initialCenter.lat !== "number" || - typeof initialCenter.lng !== "number" - ) { - throw new Error("Invalid center data"); + } catch (e) { + // Fallback to server-provided data attributes + try { + const centerData = self.el.dataset.center; + const zoomData = self.el.dataset.zoom; + if (!centerData || !zoomData) throw new Error("Missing map data attributes"); + initialCenter = JSON.parse(centerData); + initialZoom = parseInt(zoomData); + if ( + !initialCenter || + typeof initialCenter.lat !== "number" || + typeof initialCenter.lng !== "number" + ) throw new Error("Invalid center data"); + if (isNaN(initialZoom) || initialZoom < 1 || initialZoom > 20) throw new Error("Invalid zoom data"); + } catch (error) { + console.error("Error parsing map data attributes:", error); + initialCenter = { lat: 39.8283, lng: -98.5795 }; + initialZoom = 5; } - - if (isNaN(initialZoom) || initialZoom < 1 || initialZoom > 20) { - throw new Error("Invalid zoom data"); - } - } catch (error) { - console.error("Error parsing map data attributes:", error); - - // Fallback values - initialCenter = { lat: 39.8283, lng: -98.5795 }; - initialZoom = 5; } self.initializeMap(initialCenter, initialZoom); @@ -234,6 +238,10 @@ let MapAPRSMap = { self.markers = new Map(); self.markerLayer = L.layerGroup().addTo(self.map); + // Create trail layer and manager + const trailLayer = L.layerGroup().addTo(self.map); + self.trailManager = new TrailManager(trailLayer); + // Track marker states to prevent unnecessary operations self.markerStates = new Map(); @@ -250,6 +258,16 @@ let MapAPRSMap = { self.lastZoom = self.map!.getZoom(); self.pushEvent("map_ready", {}); self.sendBoundsToServer(); + + // Start periodic cleanup of old trail positions (every 5 minutes) + setInterval( + () => { + if (self.trailManager) { + self.trailManager.cleanupOldPositions(); + } + }, + 5 * 60 * 1000, + ); } catch (error) { console.error("Error in map ready callback:", error); } @@ -260,6 +278,13 @@ let MapAPRSMap = { if (self.boundsTimer) clearTimeout(self.boundsTimer); self.boundsTimer = setTimeout(() => { self.sendBoundsToServer(); + // Save map state + const center = self.map.getCenter(); + const zoom = self.map.getZoom(); + localStorage.setItem( + "aprs_map_state", + JSON.stringify({ lat: center.lat, lng: center.lng, zoom }) + ); }, 300); }); @@ -277,6 +302,13 @@ let MapAPRSMap = { self.sendBoundsToServer(); self.lastZoom = currentZoom; + // Save map state + const center = self.map.getCenter(); + const zoom = self.map.getZoom(); + localStorage.setItem( + "aprs_map_state", + JSON.stringify({ lat: center.lat, lng: center.lng, zoom }) + ); }, 300); }); @@ -449,6 +481,13 @@ let MapAPRSMap = { } }); + // Toggle trails visibility + self.handleEvent("toggle_trails", (data: { show: boolean }) => { + if (self.trailManager) { + self.trailManager.setShowTrails(data.show); + } + }); + // Handle new packets from LiveView self.handleEvent("new_packet", (data: MarkerData) => { self.addMarker({ @@ -591,6 +630,11 @@ let MapAPRSMap = { existingState.symbol_code !== data.symbol_code || existingState.popup !== data.popup; + if (positionChanged && self.trailManager) { + // Position changed, update trail + self.trailManager.addPosition(data.id, lat, lng, data.timestamp || Date.now()); + } + if (!positionChanged && !dataChanged) { // No changes needed, skip update // But if openPopup is requested, open it @@ -644,6 +688,11 @@ let MapAPRSMap = { historical: data.historical, }); + // Initialize trail for new marker + if (self.trailManager) { + self.trailManager.addPosition(data.id, lat, lng, data.timestamp || Date.now()); + } + // Open popup if requested if (data.openPopup && marker.openPopup) { marker.openPopup(); @@ -658,6 +707,11 @@ let MapAPRSMap = { self.markers!.delete(id); self.markerStates!.delete(id); } + + // Remove trail + if (self.trailManager) { + self.trailManager.removeTrail(id); + } }, updateMarker(data: MarkerData) { @@ -671,7 +725,16 @@ let MapAPRSMap = { const lat = parseFloat(data.lat.toString()); const lng = parseFloat(data.lng.toString()); if (!isNaN(lat) && !isNaN(lng)) { - existingMarker.setLatLng([lat, lng]); + const currentPos = existingMarker.getLatLng(); + const positionChanged = + Math.abs(currentPos.lat - lat) > 0.0001 || Math.abs(currentPos.lng - lng) > 0.0001; + + if (positionChanged) { + existingMarker.setLatLng([lat, lng]); + if (self.trailManager) { + self.trailManager.addPosition(data.id, lat, lng, data.timestamp || Date.now()); + } + } } } @@ -690,6 +753,11 @@ let MapAPRSMap = { self.markerLayer!.clearLayers(); self.markers!.clear(); self.markerStates!.clear(); + + // Clear all trails + if (self.trailManager) { + self.trailManager.clearAllTrails(); + } }, removeMarkersOutsideBounds(bounds: L.LatLngBounds) { @@ -764,16 +832,6 @@ let MapAPRSMap = { expected: `Row ${row}, Col ${column} should show symbol '${symbolCode}'`, }); - // Test if sprite image is accessible - const testImg = new Image(); - testImg.onerror = () => { - console.error(`Failed to load sprite: ${spriteFile}`); - }; - testImg.onload = () => { - console.log(`Sprite loaded successfully: ${spriteFile}`); - }; - testImg.src = spriteFile; - // Try adjusting the position to see if we can find the correct icon // The car symbol ">" should be at position 30 (row 1, col 14) // But the sprite might have a different arrangement diff --git a/lib/aprs_web/live/map_live/callsign_view.ex b/lib/aprs_web/live/map_live/callsign_view.ex index 9cf8265..ff99f3f 100644 --- a/lib/aprs_web/live/map_live/callsign_view.ex +++ b/lib/aprs_web/live/map_live/callsign_view.ex @@ -57,7 +57,10 @@ defmodule AprsWeb.MapLive.CallsignView do # Last known position for auto-zoom last_known_position: nil, # Flag to track if packets have been loaded - packets_loaded: false + packets_loaded: false, + # Latest symbol table ID and code + latest_symbol_table_id: "/", + latest_symbol_code: ">" ) if connected?(socket) do @@ -451,6 +454,12 @@ defmodule AprsWeb.MapLive.CallsignView do margin-bottom: 4px; } + .aprs-symbol-info { + font-size: 11px; + color: #666; + margin-bottom: 2px; + } + .nav-links { display: flex; gap: 12px; @@ -559,12 +568,6 @@ defmodule AprsWeb.MapLive.CallsignView do color: #333; } - .aprs-symbol-info { - font-size: 11px; - color: #666; - margin-bottom: 2px; - } - .aprs-comment { font-size: 11px; color: #444; @@ -618,6 +621,10 @@ defmodule AprsWeb.MapLive.CallsignView do
📡 {@callsign}
+
+ Symbol Table: {@latest_symbol_table_id} + Symbol Code: {@latest_symbol_code} +
@@ -298,6 +310,28 @@ defmodule AprsWeb.MapLive.Enhanced do <% end %> + + <%= if @replay_active do %>
diff --git a/lib/aprs_web/live/packets_live/callsign_view.ex b/lib/aprs_web/live/packets_live/callsign_view.ex index 151d13c..59d67e2 100644 --- a/lib/aprs_web/live/packets_live/callsign_view.ex +++ b/lib/aprs_web/live/packets_live/callsign_view.ex @@ -27,13 +27,18 @@ defmodule AprsWeb.PacketsLive.CallsignView do # Get stored packets for this callsign (up to 100) stored_packets = get_stored_packets(normalized_callsign, 100) + all_packets = stored_packets + latest_packet = List.first(all_packets) + {symbol_table_id, symbol_code} = extract_symbol_info(latest_packet) socket = socket |> assign(:callsign, normalized_callsign) |> assign(:packets, stored_packets) |> assign(:live_packets, []) - |> assign(:all_packets, stored_packets) + |> assign(:all_packets, all_packets) + |> assign(:latest_symbol_table_id, symbol_table_id) + |> assign(:latest_symbol_code, symbol_code) |> assign(:error, nil) {:ok, socket} @@ -44,6 +49,8 @@ defmodule AprsWeb.PacketsLive.CallsignView do |> assign(:packets, []) |> assign(:live_packets, []) |> assign(:all_packets, []) + |> assign(:latest_symbol_table_id, "/") + |> assign(:latest_symbol_code, ">") |> assign(:error, "Invalid callsign format") {:ok, socket} @@ -62,12 +69,16 @@ defmodule AprsWeb.PacketsLive.CallsignView do update_packet_lists(current_stored, current_live, sanitized_payload) all_packets = get_all_packets_list(updated_stored, updated_live) + latest_packet = List.first(all_packets) + {symbol_table_id, symbol_code} = extract_symbol_info(latest_packet) socket = socket |> assign(:packets, updated_stored) |> assign(:live_packets, updated_live) |> assign(:all_packets, all_packets) + |> assign(:latest_symbol_table_id, symbol_table_id) + |> assign(:latest_symbol_code, symbol_code) {:noreply, socket} else @@ -190,4 +201,14 @@ defmodule AprsWeb.PacketsLive.CallsignView do cs -> Regex.match?(~r/^[A-Z0-9]+(-[A-Z0-9]{1,2})?$/i, cs) end end + + # Helper to extract symbol table and code from a packet + defp extract_symbol_info(nil), do: {"/", ">"} + + defp extract_symbol_info(packet) do + data = Map.get(packet, :data_extended) || %{} + table = Map.get(data, :symbol_table_id) || Map.get(data, "symbol_table_id") || "/" + code = Map.get(data, :symbol_code) || Map.get(data, "symbol_code") || ">" + {table, code} + end end diff --git a/lib/aprs_web/live/packets_live/callsign_view.html.heex b/lib/aprs_web/live/packets_live/callsign_view.html.heex index ddab735..9f12ca9 100644 --- a/lib/aprs_web/live/packets_live/callsign_view.html.heex +++ b/lib/aprs_web/live/packets_live/callsign_view.html.heex @@ -71,6 +71,15 @@ {packet.data_type} + <:col :let={packet} label="Symbol"> + + <% data = Map.get(packet, :data_extended) || %{} + raw_table = Map.get(data, :symbol_table_id) || Map.get(data, "symbol_table_id") || "/" + table = if raw_table in ["/", "\\", "]"], do: raw_table, else: "/" + code = Map.get(data, :symbol_code) || Map.get(data, "symbol_code") || ">" %> + {table}{code} + + <:col :let={packet} label="Destination"> {packet.destination} diff --git a/lib/aprs_web/live/packets_live/index.html.heex b/lib/aprs_web/live/packets_live/index.html.heex index 82db31a..4647ae1 100644 --- a/lib/aprs_web/live/packets_live/index.html.heex +++ b/lib/aprs_web/live/packets_live/index.html.heex @@ -44,6 +44,15 @@ {packet.data_type} + <:col :let={packet} label="Symbol"> + + <% data = Map.get(packet, :data_extended) || %{} + raw_table = Map.get(data, :symbol_table_id) || Map.get(data, "symbol_table_id") || "/" + table = if raw_table in ["/", "\\", "]"], do: raw_table, else: "/" + code = Map.get(data, :symbol_code) || Map.get(data, "symbol_code") || ">" %> + {table}{code} + + <:col :let={packet} label="Destination"> {packet.destination}