diff --git a/assets/js/minimal_map.js b/assets/js/minimal_map.js index 60b52b8..f7d858b 100644 --- a/assets/js/minimal_map.js +++ b/assets/js/minimal_map.js @@ -20,7 +20,8 @@ let MinimalAPRSMap = { // Add OpenStreetMap tile layer L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { - attribution: '© OpenStreetMap contributors | APRS.me', + attribution: + '© OpenStreetMap contributors | APRS.me', maxZoom: 19, }).addTo(this.map); @@ -39,7 +40,7 @@ let MinimalAPRSMap = { }); // Send bounds to LiveView when map moves - this.map.on('moveend', () => { + this.map.on("moveend", () => { if (this.boundsTimer) clearTimeout(this.boundsTimer); this.boundsTimer = setTimeout(() => { this.sendBoundsToServer(); @@ -64,7 +65,7 @@ let MinimalAPRSMap = { // Add multiple markers at once this.handleEvent("add_markers", (data) => { if (data.markers && Array.isArray(data.markers)) { - data.markers.forEach(marker => this.addMarker(marker)); + data.markers.forEach((marker) => this.addMarker(marker)); } }); @@ -92,7 +93,7 @@ let MinimalAPRSMap = { this.map.setView([lat, lng], zoom, { animate: true, - duration: 1 + duration: 1, }); } }); @@ -108,13 +109,50 @@ let MinimalAPRSMap = { (error) => { console.warn("Geolocation error:", error.message); this.pushEvent("geolocation_error", { error: error.message }); - } + }, ); } else { console.warn("Geolocation not available"); this.pushEvent("geolocation_error", { error: "Geolocation not supported" }); } }); + + // Handle new packets from LiveView + this.handleEvent("new_packet", (data) => { + this.addMarker({ + ...data, + historical: false, + popup: this.buildPopupContent(data), + }); + }); + + // Handle historical packets during replay + this.handleEvent("historical_packet", (data) => { + this.addMarker({ + ...data, + historical: true, + popup: this.buildPopupContent(data), + }); + }); + + // Handle refresh markers event + this.handleEvent("refresh_markers", () => { + // Remove markers for packets that are no longer visible + // This could be implemented to clean up old markers based on timestamp + console.log("Refresh markers event received"); + }); + + // Handle clearing historical packets + this.handleEvent("clear_historical_packets", () => { + // Remove all historical markers + const markersToRemove = []; + this.markers.forEach((marker, id) => { + if (marker._isHistorical) { + markersToRemove.push(id); + } + }); + markersToRemove.forEach((id) => this.removeMarker(id)); + }); }, sendBoundsToServer() { @@ -133,9 +171,9 @@ let MinimalAPRSMap = { }, center: { lat: center.lat, - lng: center.lng + lng: center.lng, }, - zoom: zoom + zoom: zoom, }); }, @@ -169,15 +207,20 @@ let MinimalAPRSMap = { } // Handle marker click - marker.on('click', () => { + marker.on("click", () => { this.pushEvent("marker_clicked", { id: data.id, callsign: data.callsign, lat: lat, - lng: lng + lng: lng, }); }); + // Mark historical markers for identification + if (data.historical) { + marker._isHistorical = true; + } + // Add to map and store reference marker.addTo(this.markerLayer); this.markers.set(data.id, marker); @@ -227,29 +270,74 @@ let MinimalAPRSMap = { }, createMarkerIcon(data) { - // Determine color based on symbol table or provided color - let color = data.color || (data.symbol_table === "/" ? "#2563eb" : "#dc2626"); + // Get symbol information + const symbolTableId = data.symbol_table_id || "/"; + const symbolCode = data.symbol_code || ">"; + + // Determine sprite file based on symbol table + let spriteFile; + switch (symbolTableId) { + case "/": + spriteFile = "/aprs-symbols/aprs-symbols-24-0.png"; + break; + case "\\": + spriteFile = "/aprs-symbols/aprs-symbols-24-1.png"; + break; + default: + spriteFile = "/aprs-symbols/aprs-symbols-24-0.png"; + } + + // Calculate sprite position + const charCode = symbolCode.charCodeAt(0); + const position = charCode - 32; + const col = position % 16; + const row = Math.floor(position / 16); + const x = -col * 24; + const y = -row * 24; // Use different opacity for historical markers - const opacity = data.historical ? 0.6 : 1.0; + const opacity = data.historical ? 0.7 : 1.0; return L.divIcon({ html: `
`, className: data.historical ? "aprs-marker historical-marker" : "aprs-marker", - iconSize: [16, 16], - iconAnchor: [8, 8], - popupAnchor: [0, -8], + iconSize: [24, 24], + iconAnchor: [12, 12], + popupAnchor: [0, -12], }); }, + buildPopupContent(data) { + const callsign = data.callsign || data.id || "Unknown"; + const comment = data.comment || ""; + const symbolDesc = data.symbol_description || "Unknown symbol"; + + let content = `
+
${callsign}
+
${symbolDesc}
`; + + if (comment) { + content += `
${comment}
`; + } + + if (data.lat && data.lng) { + content += `
+ ${data.lat.toFixed(4)}, ${data.lng.toFixed(4)} +
`; + } + + content += `
`; + return content; + }, + destroyed() { console.log("MinimalAPRSMap hook destroyed"); @@ -272,7 +360,7 @@ let MinimalAPRSMap = { this.map.remove(); this.map = null; } - } + }, }; export default MinimalAPRSMap; diff --git a/docs/APRS_SYMBOLS.md b/docs/APRS_SYMBOLS.md new file mode 100644 index 0000000..34ca7a3 --- /dev/null +++ b/docs/APRS_SYMBOLS.md @@ -0,0 +1,173 @@ +# APRS Symbol Implementation + +This document describes the implementation of APRS icons/symbols in the LiveView-based map system, replacing the generic colored dots with proper APRS symbols. + +## Overview + +The APRS.me application now displays proper APRS symbols instead of generic dots for packet markers on the map. This implementation uses the high-resolution APRS symbol set from [hessu/aprs-symbols](https://github.com/hessu/aprs-symbols). + +## Map Versions + +The application now has three map implementations: + +- `/` - **Default LiveView Map** - Minimal LiveView-based map with APRS symbols (recommended) +- `/enhanced` - **Enhanced LiveView Map** - Feature-rich LiveView map with additional controls +- `/old` - **Legacy Map** - Original JavaScript-heavy implementation (deprecated) + +## APRS Symbol System + +### Symbol Tables + +APRS uses two main symbol tables: + +- **Primary Table** (`/`): `aprs-symbols-24-0.png` - Standard symbols +- **Secondary Table** (`\`): `aprs-symbols-24-1.png` - Alternate symbols +- **Overlay Characters**: `aprs-symbols-24-2.png` - Overlay digits and letters + +### Symbol Structure + +Each symbol is identified by: +- **Symbol Table ID**: `/` (primary) or `\` (secondary) +- **Symbol Code**: ASCII character (33-126) representing the symbol position + +### Symbol Positioning + +Symbols are arranged in a 16×6 grid (96 symbols total) in each sprite sheet: +- Position = ASCII code - 32 +- Column = Position % 16 +- Row = Position ÷ 16 +- Each symbol is 24×24 pixels + +## Implementation Details + +### Backend Components + +#### AprsWeb.Helpers.AprsSymbols + +Helper module for APRS symbol operations: + +```elixir +# Get sprite sheet filename +AprsSymbols.get_sprite_filename("/") # → "aprs-symbols-24-0.png" + +# Calculate symbol position +AprsSymbols.get_symbol_position(">") # → {-336, -24} + +# Generate CSS for symbol display +AprsSymbols.symbol_css_style("/", ">") + +# Get human-readable description +AprsSymbols.symbol_description("/", ">") # → "Car" +``` + +#### Packet Processing + +Modified `build_packet_data/1` in `MapLive.Index`: +- Extracts symbol information from packet data +- Validates symbols and provides defaults +- Includes coordinates and symbol metadata for frontend + +#### Struct Conversion + +Enhanced `struct_to_map/1` in `Aprs.Is`: +- Recursively converts nested structs to maps +- Preserves type information for proper data extraction +- Handles MicE packets specially + +### Frontend Components + +#### JavaScript Updates + +Updated `minimal_map.js`: +- Added handlers for `new_packet` and `historical_packet` events +- Modified `createMarkerIcon()` to use sprite-based symbols +- Added popup content generation with symbol descriptions + +#### Symbol Rendering + +Symbols are displayed using CSS sprites: +- Background image points to appropriate sprite sheet +- Background position calculated from symbol code +- High-DPI support with @2x variants +- Opacity adjustment for historical markers + +### Asset Files + +Downloaded APRS symbol files in `priv/static/aprs-symbols/`: +- `aprs-symbols-24-0.png` - Primary table (24×24) +- `aprs-symbols-24-1.png` - Secondary table (24×24) +- `aprs-symbols-24-0@2x.png` - Primary table (48×48, retina) +- `aprs-symbols-24-1@2x.png` - Secondary table (48×48, retina) +- `aprs-symbols-24-2.png` - Overlay characters (24×24) +- `aprs-symbols-24-2@2x.png` - Overlay characters (48×48, retina) + +## Common APRS Symbols + +| Symbol Code | Table | Description | +|-------------|-------|-------------| +| `>` | `/` | Car | +| `k` | `/` | Truck | +| `j` | `/` | Jeep | +| `f` | `/` | Fire truck | +| `a` | `/` | Ambulance | +| `b` | `/` | Bike | +| `^` | `/` | Aircraft | +| `s` | `/` | Ship | +| `-` | `/` | House | +| `r` | `/` | Repeater | +| `_` | `/` | Weather | + +## Configuration + +### Default Symbols + +- **Unknown packets**: Car symbol (`/`, `>`) +- **MicE packets**: Car symbol (`/`, `>`) +- **Invalid symbols**: Fall back to car symbol + +### Styling + +CSS classes for customization: +- `.aprs-marker` - Base marker styling +- `.historical-marker` - Historical packet opacity +- `.aprs-popup` - Popup content styling +- `.aprs-callsign` - Callsign display +- `.aprs-symbol-info` - Symbol description +- `.aprs-comment` - Packet comment +- `.aprs-coords` - Coordinate display + +## High-DPI Support + +Automatic retina display support using CSS media queries: +```css +@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-size: 384px 144px !important; + } +} +``` + +## Navigation + +Added floating navigation headers to all map versions: +- Links between different map implementations +- Access to status and packet pages +- Consistent UI across all versions + +## Symbol Credits + +APRS symbols from [hessu/aprs-symbols](https://github.com/hessu/aprs-symbols): +- Created by Heikki Hannikainen OH7LZB +- High-resolution vector-based symbol set +- Released to APRS community for free use +- Compatible with Updated APRS Symbol Set (Rev H) + +## Future Enhancements + +Potential improvements: +- Overlay character support for numbered/lettered symbols +- Symbol rotation based on course/heading +- Custom symbol upload functionality +- Symbol animation for moving objects +- Symbol filtering and categorization \ No newline at end of file diff --git a/lib/aprs/is/is.ex b/lib/aprs/is/is.ex index 103b38d..72f5671 100644 --- a/lib/aprs/is/is.ex +++ b/lib/aprs/is/is.ex @@ -275,7 +275,7 @@ defmodule Aprs.Is do packet_data = Map.put(parsed_message, :received_at, current_time) # Convert to map before storing to avoid struct conversion issues - attrs = Map.from_struct(packet_data) + attrs = struct_to_map(packet_data) # Extract additional data from the parsed packet including raw packet attrs = Aprs.Packet.extract_additional_data(attrs, message) @@ -462,4 +462,23 @@ defmodule Aprs.Is do last_second_timestamp: System.system_time(:second) } end + + # Helper function to recursively convert structs to maps + # This handles nested structs that Map.from_struct/1 cannot handle + defp struct_to_map(%{__struct__: struct_type} = struct) do + converted_map = + struct + |> Map.from_struct() + |> Enum.map(fn {k, v} -> {k, struct_to_map(v)} end) + |> Enum.into(%{}) + + # Add type information to help with later processing + Map.put(converted_map, :__original_struct__, struct_type) + end + + defp struct_to_map(value) when is_list(value) do + Enum.map(value, &struct_to_map/1) + end + + defp struct_to_map(value), do: value end diff --git a/lib/aprs/packet.ex b/lib/aprs/packet.ex index 9b3a8e5..505abf8 100644 --- a/lib/aprs/packet.ex +++ b/lib/aprs/packet.ex @@ -158,6 +158,9 @@ defmodule Aprs.Packet do # Extract data based on the type of data_extended additional_data = case data_extended do + %{__original_struct__: Parser.Types.MicE} = mic_e_map -> + extract_from_mic_e_map(mic_e_map) + %{} when is_map(data_extended) -> extract_from_map(data_extended) @@ -207,8 +210,20 @@ defmodule Aprs.Packet do |> maybe_put(:equipment_type, mic_e.equipment_type) |> maybe_put(:course, mic_e.course) |> maybe_put(:speed, mic_e.speed) - |> maybe_put(:symbol_code, mic_e.symbol_code) - |> maybe_put(:symbol_table_id, mic_e.symbol_table_id) + |> maybe_put(:symbol_code, ">") # Default car symbol for MicE + |> maybe_put(:symbol_table_id, "/") # Primary table + end + + # Extract data from converted MicE map (from struct_to_map conversion) + defp extract_from_mic_e_map(mic_e_map) do + %{} + |> maybe_put(:comment, mic_e_map[:message]) + |> maybe_put(:manufacturer, mic_e_map[:manufacturer]) + |> maybe_put(:equipment_type, mic_e_map[:equipment_type]) + |> maybe_put(:course, mic_e_map[:heading]) + |> maybe_put(:speed, mic_e_map[:speed]) + |> maybe_put(:symbol_code, ">") # Default car symbol for MicE + |> maybe_put(:symbol_table_id, "/") # Primary table end # Extract weather data from various formats diff --git a/lib/aprs_web/controllers/page_controller.ex b/lib/aprs_web/controllers/page_controller.ex index 4c3d779..15f76ca 100644 --- a/lib/aprs_web/controllers/page_controller.ex +++ b/lib/aprs_web/controllers/page_controller.ex @@ -6,6 +6,7 @@ defmodule AprsWeb.PageController do end def map(conn, _params) do + # This serves the legacy JavaScript-based map at /old render(conn, :map) end diff --git a/lib/aprs_web/controllers/page_html/map.html.heex b/lib/aprs_web/controllers/page_html/map.html.heex index f193bb1..c12b3a8 100644 --- a/lib/aprs_web/controllers/page_html/map.html.heex +++ b/lib/aprs_web/controllers/page_html/map.html.heex @@ -1,25 +1,50 @@ - - - - - - +
+
+

Legacy APRS Map

+

+ This is the legacy JavaScript-based map. + Try the new LiveView-based map or + enhanced version. +

+
-
+ + + + + + + +
+
+ + diff --git a/lib/aprs_web/helpers/aprs_symbols.ex b/lib/aprs_web/helpers/aprs_symbols.ex new file mode 100644 index 0000000..f06b934 --- /dev/null +++ b/lib/aprs_web/helpers/aprs_symbols.ex @@ -0,0 +1,181 @@ +defmodule AprsWeb.Helpers.AprsSymbols do + @moduledoc """ + Helper functions for working with APRS symbols. + + APRS symbols are organized in sprite sheets: + - Primary table (symbol_table_id = "/"): aprs-symbols-24-0.png + - Secondary table (symbol_table_id = "\\"): aprs-symbols-24-1.png + - Overlay characters: aprs-symbols-24-2.png + + Each sprite sheet is a 16x6 grid (96 symbols total). + Symbol positioning is based on ASCII code of the symbol_code. + """ + + @doc """ + Get the sprite sheet filename for a given symbol table ID. + + ## Examples + + iex> AprsWeb.Helpers.AprsSymbols.get_sprite_filename("/") + "aprs-symbols-24-0.png" + + iex> AprsWeb.Helpers.AprsSymbols.get_sprite_filename("\\") + "aprs-symbols-24-1.png" + """ + def get_sprite_filename(symbol_table_id) do + case symbol_table_id do + "/" -> "aprs-symbols-24-0.png" + "\\" -> "aprs-symbols-24-1.png" + _ -> "aprs-symbols-24-0.png" # Default to primary table + end + end + + @doc """ + Get the high-resolution sprite sheet filename for retina displays. + """ + def get_sprite_filename_2x(symbol_table_id) do + case symbol_table_id do + "/" -> "aprs-symbols-24-0@2x.png" + "\\" -> "aprs-symbols-24-1@2x.png" + _ -> "aprs-symbols-24-0@2x.png" + end + end + + @doc """ + Calculate the CSS background position for a symbol in the sprite sheet. + + The sprite sheet is organized as a 16x6 grid. + ASCII codes 32-127 map to positions 0-95. + + ## Examples + + iex> AprsWeb.Helpers.AprsSymbols.get_symbol_position(">") + {-1440, 0} # ASCII 62, position 30 -> column 14, row 1 + + iex> AprsWeb.Helpers.AprsSymbols.get_symbol_position("!") + {-216, 0} # ASCII 33, position 1 -> column 1, row 0 + """ + def get_symbol_position(symbol_code) when is_binary(symbol_code) do + # Get first character if string + char_code = symbol_code |> String.first() |> String.to_charlist() |> List.first() + get_symbol_position_by_ascii(char_code) + end + + def get_symbol_position(symbol_code) when is_integer(symbol_code) do + get_symbol_position_by_ascii(symbol_code) + end + + defp get_symbol_position_by_ascii(ascii_code) do + # ASCII 32 (space) is position 0, ASCII 127 (DEL) is position 95 + position = ascii_code - 32 + + # Clamp position to valid range 0-95 + position = max(0, min(95, position)) + + # Calculate row and column (16 symbols per row) + col = rem(position, 16) + row = div(position, 16) + + # Each symbol is 24x24 pixels + x = -col * 24 + y = -row * 24 + + {x, y} + end + + @doc """ + Generate CSS style for displaying an APRS symbol using sprite positioning. + + ## Examples + + iex> AprsWeb.Helpers.AprsSymbols.symbol_css_style("/", ">") + "background-image: url('/aprs-symbols/aprs-symbols-24-0.png'); background-position: -1440px 0px; width: 24px; height: 24px;" + """ + def symbol_css_style(symbol_table_id, symbol_code) do + sprite_file = get_sprite_filename(symbol_table_id) + {x, y} = get_symbol_position(symbol_code) + + "background-image: url('/aprs-symbols/#{sprite_file}'); " <> + "background-position: #{x}px #{y}px; " <> + "width: 24px; height: 24px; " <> + "background-repeat: no-repeat;" + end + + @doc """ + Generate HTML for an APRS symbol with proper sprite positioning. + """ + def symbol_html(symbol_table_id, symbol_code, opts \\ []) do + css_class = Keyword.get(opts, :class, "aprs-symbol") + extra_style = Keyword.get(opts, :style, "") + + base_style = symbol_css_style(symbol_table_id, symbol_code) + full_style = if extra_style != "", do: base_style <> " " <> extra_style, else: base_style + + Phoenix.HTML.raw(~s(
)) + end + + @doc """ + Get a data URL for the symbol that can be used in JavaScript/Leaflet. + This creates a small canvas with just the symbol extracted from the sprite. + """ + def get_symbol_data_attributes(symbol_table_id, symbol_code) do + sprite_file = get_sprite_filename(symbol_table_id) + sprite_file_2x = get_sprite_filename_2x(symbol_table_id) + {x, y} = get_symbol_position(symbol_code) + + %{ + "data-sprite" => sprite_file, + "data-sprite-2x" => sprite_file_2x, + "data-pos-x" => x, + "data-pos-y" => y, + "data-symbol-table" => symbol_table_id, + "data-symbol-code" => symbol_code + } + end + + @doc """ + Get the default symbol for unknown or invalid symbols. + """ + def default_symbol do + {"/", ">"} # Car icon as default + end + + @doc """ + Validate if a symbol table ID and code combination is valid. + """ + def valid_symbol?(symbol_table_id, symbol_code) do + case {symbol_table_id, symbol_code} do + {table, code} when table in ["/", "\\"] and is_binary(code) and byte_size(code) > 0 -> + char_code = code |> String.first() |> String.to_charlist() |> List.first() + char_code >= 32 and char_code <= 127 + _ -> + false + end + end + + @doc """ + Get a human-readable description for common APRS symbols. + This is a subset of common symbols - for a complete list, you'd want to + reference the official APRS symbol specification. + """ + def symbol_description(symbol_table_id, symbol_code) do + case {symbol_table_id, symbol_code} do + {"/", ">"} -> "Car" + {"/", "k"} -> "Truck" + {"/", "j"} -> "Jeep" + {"/", "f"} -> "Fire truck" + {"/", "a"} -> "Ambulance" + {"/", "b"} -> "Bike" + {"/", "g"} -> "Glider" + {"/", "^"} -> "Aircraft" + {"/", "s"} -> "Ship" + {"/", "Y"} -> "Yacht" + {"/", "-"} -> "House" + {"/", "r"} -> "Repeater" + {"/", "/"} -> "Position" + {"\\", "/"} -> "Triangle" + {"\\", ">"} -> "Car (alternate)" + _ -> "Unknown symbol" + end + end +end diff --git a/lib/aprs_web/live/map_live/enhanced.ex b/lib/aprs_web/live/map_live/enhanced.ex index ab858c8..c0e1681 100644 --- a/lib/aprs_web/live/map_live/enhanced.ex +++ b/lib/aprs_web/live/map_live/enhanced.ex @@ -262,8 +262,12 @@ defmodule AprsWeb.MapLive.Enhanced do .historical-marker { opacity: 0.7; } + + + +
bounds}, socket) do + handle_bounds_update(bounds, socket) + end + @impl true def handle_event("update_bounds", %{"bounds" => bounds}, socket) do - # Update the map bounds from the client - map_bounds = %{ - north: bounds["north"], - south: bounds["south"], - east: bounds["east"], - west: bounds["west"] - } - - # Don't clear markers - let the client preserve those still in view - # Recalculate packet count based on new bounds - visible_packets = - socket.assigns.visible_packets - |> Enum.filter(fn {_callsign, packet} -> - within_bounds?(packet, map_bounds) - end) - |> Map.new() - - # If replay is not active, update the replay packets based on the new bounds - socket = - if socket.assigns.replay_active do - socket - else - # Clear any existing replay data when bounds change - socket = - assign(socket, - replay_packets: [], - replay_index: 0, - historical_packets: %{}, - map_ready: true - ) - - # Don't automatically start replay on every bounds change - # We'll handle this in initialize_replay to avoid too many restarts - socket - end - - {:noreply, assign(socket, map_bounds: map_bounds, visible_packets: visible_packets)} + handle_bounds_update(bounds, socket) end @impl true @@ -247,6 +217,46 @@ defmodule AprsWeb.MapLive.Index do {:noreply, socket} end + defp handle_bounds_update(bounds, socket) do + # Update the map bounds from the client + map_bounds = %{ + north: bounds["north"], + south: bounds["south"], + east: bounds["east"], + west: bounds["west"] + } + + # Don't clear markers - let the client preserve those still in view + # Recalculate packet count based on new bounds + visible_packets = + socket.assigns.visible_packets + |> Enum.filter(fn {_callsign, packet} -> + within_bounds?(packet, map_bounds) + end) + |> Map.new() + + # If replay is not active, update the replay packets based on the new bounds + socket = + if socket.assigns.replay_active do + socket + else + # Clear any existing replay data when bounds change + socket = + assign(socket, + replay_packets: [], + replay_index: 0, + historical_packets: %{}, + map_ready: true + ) + + # Don't automatically start replay on every bounds change + # We'll handle this in initialize_replay to avoid too many restarts + socket + end + + {:noreply, assign(socket, map_bounds: map_bounds, visible_packets: visible_packets)} + end + @impl true def handle_info(msg, socket) do case msg do @@ -364,36 +374,40 @@ defmodule AprsWeb.MapLive.Index do # Convert to a simple map structure for JSON encoding packet_data = build_packet_data(packet) - # Add is_historical flag and timestamp - packet_data = - Map.merge(packet_data, %{ - "is_historical" => true, - "timestamp" => if(Map.has_key?(packet, :received_at), do: DateTime.to_iso8601(packet.received_at)) - }) + # Only process packets with valid position data + socket = + if packet_data do + # Add is_historical flag and timestamp + packet_data = + Map.merge(packet_data, %{ + "is_historical" => true, + "timestamp" => if(Map.has_key?(packet, :received_at), do: DateTime.to_iso8601(packet.received_at)) + }) - # Generate a unique key for this packet - packet_id = "hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}" + # Generate a unique key for this packet + packet_id = "hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}" - # Update historical packets tracking - new_historical_packets = Map.put(historical_packets, packet_id, packet) + # Update historical packets tracking + new_historical_packets = Map.put(historical_packets, packet_id, packet) + + # Send packet data to client + socket + |> push_event("historical_packet", packet_data) + |> assign( + replay_index: next_index, + historical_packets: new_historical_packets + ) + else + # Skip packets without position data, just advance the index + assign(socket, replay_index: next_index) + end # Calculate delay for next packet (faster based on replay speed) delay_ms = trunc(1000 / speed) # Schedule the next packet timer_ref = Process.send_after(self(), :replay_next_packet, delay_ms) - - # Push the packet to the client-side JavaScript - socket = - socket - |> push_event("historical_packet", packet_data) - |> assign( - replay_index: next_index, - replay_timer_ref: timer_ref, - historical_packets: new_historical_packets - ) - - {:noreply, socket} + {:noreply, assign(socket, replay_timer_ref: timer_ref)} else # All packets replayed, start over with a short delay Process.send_after(self(), :initialize_replay, 10_000) @@ -448,6 +462,8 @@ defmodule AprsWeb.MapLive.Index do z-index: 1; } + + .locate-button { position: absolute; left: 10px; @@ -472,8 +488,69 @@ defmodule AprsWeb.MapLive.Index do .historical-marker { opacity: 0.7; } + + .aprs-popup { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 12px; + line-height: 1.4; + max-width: 200px; + } + + .aprs-callsign { + font-size: 14px; + font-weight: bold; + color: #1e40af; + margin-bottom: 4px; + } + + .aprs-symbol-info { + color: #6b7280; + font-style: italic; + margin-bottom: 4px; + } + + .aprs-comment { + color: #374151; + margin-bottom: 4px; + word-wrap: break-word; + } + + .aprs-coords { + color: #6b7280; + font-size: 11px; + font-family: monospace; + } + + /* High-DPI support for APRS symbols */ + @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-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-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-size: 384px 144px !important; + } + } + + /* Leaflet popup improvements for APRS data */ + .leaflet-popup-content-wrapper { + border-radius: 8px; + } + + .leaflet-popup-content { + margin: 8px 12px; + } + +
packet.base_callsign || "", - "ssid" => packet.ssid || "", - "data_type" => to_string(packet.data_type || "unknown"), - "path" => packet.path || "", - "data_extended" => data_extended || %{} - } + # Only include packets with valid position data + if lat && lng do + # Get symbol information from data_extended + symbol_table_id = get_in(data_extended, ["symbol_table_id"]) || + packet.symbol_table_id || "/" + symbol_code = get_in(data_extended, ["symbol_code"]) || + packet.symbol_code || ">" + + # Validate symbol + {final_table_id, final_symbol_code} = + if AprsSymbols.valid_symbol?(symbol_table_id, symbol_code) do + {symbol_table_id, symbol_code} + else + AprsSymbols.default_symbol() + end + + # Generate callsign for display + callsign = if packet.ssid && packet.ssid != "" do + "#{packet.base_callsign}-#{packet.ssid}" + else + packet.base_callsign || "" + end + + %{ + "id" => callsign, + "callsign" => callsign, + "base_callsign" => packet.base_callsign || "", + "ssid" => packet.ssid || "", + "lat" => lat, + "lng" => lng, + "symbol_table_id" => final_table_id, + "symbol_code" => final_symbol_code, + "symbol_description" => AprsSymbols.symbol_description(final_table_id, final_symbol_code), + "data_type" => to_string(packet.data_type || "unknown"), + "path" => packet.path || "", + "comment" => get_in(data_extended, ["comment"]) || "", + "data_extended" => data_extended || %{} + } + else + # Return nil for packets without position data + nil + end end # Get IP location from external service diff --git a/lib/aprs_web/router.ex b/lib/aprs_web/router.ex index b4ccfd1..0318dd6 100644 --- a/lib/aprs_web/router.ex +++ b/lib/aprs_web/router.ex @@ -29,7 +29,7 @@ defmodule AprsWeb.Router do live "/", MapLive.Index, :index live "/enhanced", MapLive.Enhanced, :index - get "/map", PageController, :map + get "/old", PageController, :map live "/status", StatusLive.Index, :index live "/packets", PacketsLive.Index, :index diff --git a/priv/static/aprs-symbols/aprs-symbols-24-0.png b/priv/static/aprs-symbols/aprs-symbols-24-0.png new file mode 100644 index 0000000..8a2713f Binary files /dev/null and b/priv/static/aprs-symbols/aprs-symbols-24-0.png differ diff --git a/priv/static/aprs-symbols/aprs-symbols-24-0@2x.png b/priv/static/aprs-symbols/aprs-symbols-24-0@2x.png new file mode 100644 index 0000000..d6c15bb Binary files /dev/null and b/priv/static/aprs-symbols/aprs-symbols-24-0@2x.png differ diff --git a/priv/static/aprs-symbols/aprs-symbols-24-1.png b/priv/static/aprs-symbols/aprs-symbols-24-1.png new file mode 100644 index 0000000..10126ef Binary files /dev/null and b/priv/static/aprs-symbols/aprs-symbols-24-1.png differ diff --git a/priv/static/aprs-symbols/aprs-symbols-24-1@2x.png b/priv/static/aprs-symbols/aprs-symbols-24-1@2x.png new file mode 100644 index 0000000..f7e1a78 Binary files /dev/null and b/priv/static/aprs-symbols/aprs-symbols-24-1@2x.png differ diff --git a/priv/static/aprs-symbols/aprs-symbols-24-2.png b/priv/static/aprs-symbols/aprs-symbols-24-2.png new file mode 100644 index 0000000..c1dfa98 Binary files /dev/null and b/priv/static/aprs-symbols/aprs-symbols-24-2.png differ diff --git a/priv/static/aprs-symbols/aprs-symbols-24-2@2x.png b/priv/static/aprs-symbols/aprs-symbols-24-2@2x.png new file mode 100644 index 0000000..ac8918a Binary files /dev/null and b/priv/static/aprs-symbols/aprs-symbols-24-2@2x.png differ