This commit is contained in:
Graham McIntire 2025-06-16 08:59:28 -05:00
parent 846cf8a74b
commit fe96abca59
No known key found for this signature in database
16 changed files with 735 additions and 117 deletions

View file

@ -20,7 +20,8 @@ let MinimalAPRSMap = {
// Add OpenStreetMap tile layer
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors | APRS.me',
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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: `<div style="
background-color: ${color};
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
background-image: url('${spriteFile}');
background-position: ${x}px ${y}px;
background-repeat: no-repeat;
width: 24px;
height: 24px;
opacity: ${opacity};
background-size: auto;
"></div>`,
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 = `<div class="aprs-popup">
<div class="aprs-callsign"><strong>${callsign}</strong></div>
<div class="aprs-symbol-info">${symbolDesc}</div>`;
if (comment) {
content += `<div class="aprs-comment">${comment}</div>`;
}
if (data.lat && data.lng) {
content += `<div class="aprs-coords">
${data.lat.toFixed(4)}, ${data.lng.toFixed(4)}
</div>`;
}
content += `</div>`;
return content;
},
destroyed() {
console.log("MinimalAPRSMap hook destroyed");
@ -272,7 +360,7 @@ let MinimalAPRSMap = {
this.map.remove();
this.map = null;
}
}
},
};
export default MinimalAPRSMap;

173
docs/APRS_SYMBOLS.md Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,25 +1,50 @@
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.3/dist/leaflet.css"
integrity="sha256-kLaT2GOSpHechhsozzB+flnD+zUyjE2LlfWPgU04xyI="
crossorigin=""
/>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css"
/>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css"
/>
<!-- Make sure you put this AFTER Leaflet's CSS -->
<script
src="https://unpkg.com/leaflet@1.9.3/dist/leaflet.js"
integrity="sha256-WBkoXOwTeyKclOHuWtc+i2uENFpDZ9YPdf5Hf+D7ewM="
crossorigin=""
>
</script>
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js">
</script>
<div class="legacy-map-wrapper">
<div class="legacy-map-header">
<h1 class="text-xl font-bold text-gray-800 mb-2">Legacy APRS Map</h1>
<p class="text-sm text-gray-600 mb-4">
This is the legacy JavaScript-based map.
<a href="/" class="text-blue-600 hover:text-blue-800 underline">Try the new LiveView-based map</a> or
<a href="/enhanced" class="text-blue-600 hover:text-blue-800 underline">enhanced version</a>.
</p>
</div>
<div id="map"></div>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.3/dist/leaflet.css"
integrity="sha256-kLaT2GOSpHechhsozzB+flnD+zUyjE2LlfWPgU04xyI="
crossorigin=""
/>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css"
/>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css"
/>
<!-- Make sure you put this AFTER Leaflet's CSS -->
<script
src="https://unpkg.com/leaflet@1.9.3/dist/leaflet.js"
integrity="sha256-WBkoXOwTeyKclOHuWtc+i2uENFpDZ9YPdf5Hf+D7ewM="
crossorigin=""
>
</script>
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js">
</script>
<div id="map" style="height: 80vh; width: 100%;"></div>
</div>
<style>
.legacy-map-wrapper {
padding: 1rem;
}
.legacy-map-header {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
}
</style>

View file

@ -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(<div class="#{css_class}" style="#{full_style}"></div>))
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

View file

@ -262,8 +262,12 @@ defmodule AprsWeb.MapLive.Enhanced do
.historical-marker {
opacity: 0.7;
}
</style>
<!-- Map Container -->
<div
id="aprs-map"

View file

@ -6,6 +6,7 @@ defmodule AprsWeb.MapLive.Index do
alias Aprs.EncodingUtils
alias AprsWeb.Endpoint
alias AprsWeb.Helpers.AprsSymbols
alias Parser.Types.MicE
@default_center %{lat: 39.8283, lng: -98.5795}
@ -97,45 +98,14 @@ defmodule AprsWeb.MapLive.Index do
{:ok, socket}
end
@impl true
def handle_event("bounds_changed", %{"bounds" => 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;
}
</style>
<div
id="aprs-map"
phx-hook="APRSMap"
@ -667,15 +744,50 @@ defmodule AprsWeb.MapLive.Index do
defp build_packet_data(packet) do
data_extended = build_data_extended(packet.data_extended)
{lat, lng} = get_coordinates(packet)
# Always return a map, even when data_extended is nil
%{
"base_callsign" => 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

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB