symbols
This commit is contained in:
parent
846cf8a74b
commit
fe96abca59
16 changed files with 735 additions and 117 deletions
|
|
@ -20,7 +20,8 @@ let MinimalAPRSMap = {
|
|||
|
||||
// Add OpenStreetMap tile layer
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors | APRS.me',
|
||||
attribution:
|
||||
'© <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
173
docs/APRS_SYMBOLS.md
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
181
lib/aprs_web/helpers/aprs_symbols.ex
Normal file
181
lib/aprs_web/helpers/aprs_symbols.ex
Normal 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
|
||||
|
|
@ -262,8 +262,12 @@ defmodule AprsWeb.MapLive.Enhanced do
|
|||
.historical-marker {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
<!-- Map Container -->
|
||||
<div
|
||||
id="aprs-map"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
BIN
priv/static/aprs-symbols/aprs-symbols-24-0.png
Normal file
BIN
priv/static/aprs-symbols/aprs-symbols-24-0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
BIN
priv/static/aprs-symbols/aprs-symbols-24-0@2x.png
Normal file
BIN
priv/static/aprs-symbols/aprs-symbols-24-0@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
BIN
priv/static/aprs-symbols/aprs-symbols-24-1.png
Normal file
BIN
priv/static/aprs-symbols/aprs-symbols-24-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
BIN
priv/static/aprs-symbols/aprs-symbols-24-1@2x.png
Normal file
BIN
priv/static/aprs-symbols/aprs-symbols-24-1@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
BIN
priv/static/aprs-symbols/aprs-symbols-24-2.png
Normal file
BIN
priv/static/aprs-symbols/aprs-symbols-24-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
priv/static/aprs-symbols/aprs-symbols-24-2@2x.png
Normal file
BIN
priv/static/aprs-symbols/aprs-symbols-24-2@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Loading…
Add table
Reference in a new issue