tracks
This commit is contained in:
parent
66891a89b5
commit
08965143fb
9 changed files with 449 additions and 80 deletions
|
|
@ -61,7 +61,7 @@ body.home-page main {
|
|||
height: 100vh;
|
||||
}
|
||||
|
||||
body.home-page main>div {
|
||||
body.home-page main > div {
|
||||
max-width: none;
|
||||
height: 100%;
|
||||
}
|
||||
|
|
@ -151,20 +151,62 @@ body.home-page main>div {
|
|||
}
|
||||
|
||||
/* High-DPI support for APRS symbols */
|
||||
@media (-webkit-min-device-pixel-ratio: 2),
|
||||
(min-resolution: 192dpi) {
|
||||
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
|
||||
.aprs-marker div[style*="aprs-symbols-24-0.png"] {
|
||||
background-image: url('/aprs-symbols/aprs-symbols-24-0@2x.png') !important;
|
||||
background-image: url("/aprs-symbols/aprs-symbols-24-0@2x.png") !important;
|
||||
background-size: 384px 144px !important;
|
||||
}
|
||||
|
||||
.aprs-marker div[style*="aprs-symbols-24-1.png"] {
|
||||
background-image: url('/aprs-symbols/aprs-symbols-24-1@2x.png') !important;
|
||||
background-image: url("/aprs-symbols/aprs-symbols-24-1@2x.png") !important;
|
||||
background-size: 384px 144px !important;
|
||||
}
|
||||
|
||||
.aprs-marker div[style*="aprs-symbols-24-2.png"] {
|
||||
background-image: url('/aprs-symbols/aprs-symbols-24-2@2x.png') !important;
|
||||
background-image: url("/aprs-symbols/aprs-symbols-24-2@2x.png") !important;
|
||||
background-size: 384px 144px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Trail visualization styles */
|
||||
.trail-tooltip {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
border: 1px solid #1e90ff;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Trail polyline hover effect */
|
||||
.leaflet-interactive:hover {
|
||||
stroke-width: 4;
|
||||
stroke-opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Position dot hover effect */
|
||||
.leaflet-marker-icon.leaflet-interactive:hover {
|
||||
transform: scale(1.5);
|
||||
transition: transform 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Trail controls styling */
|
||||
.trail-toggle-control {
|
||||
background-color: white;
|
||||
border: 2px solid rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.trail-toggle-control:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.trail-toggle-control.active {
|
||||
background-color: #1e90ff;
|
||||
color: white;
|
||||
}
|
||||
|
|
|
|||
139
assets/js/features/trail_manager.ts
Normal file
139
assets/js/features/trail_manager.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// Trail management module for APRS position history visualization
|
||||
|
||||
export interface PositionHistory {
|
||||
lat: number;
|
||||
lng: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface TrailState {
|
||||
positions: PositionHistory[];
|
||||
trail?: any; // L.Polyline
|
||||
dots?: any[]; // L.CircleMarker[]
|
||||
}
|
||||
|
||||
export class TrailManager {
|
||||
private trailLayer: any; // L.LayerGroup
|
||||
private trails: Map<string, TrailState>;
|
||||
private showTrails: boolean;
|
||||
private trailDuration: number; // in milliseconds
|
||||
|
||||
constructor(trailLayer: any, trailDuration: number = 60 * 60 * 1000) {
|
||||
this.trailLayer = trailLayer;
|
||||
this.trails = new Map();
|
||||
this.showTrails = true;
|
||||
this.trailDuration = trailDuration;
|
||||
}
|
||||
|
||||
setShowTrails(show: boolean) {
|
||||
this.showTrails = show;
|
||||
if (!show) {
|
||||
this.clearAllTrails();
|
||||
}
|
||||
}
|
||||
|
||||
addPosition(markerId: string, lat: number, lng: number, timestamp: number) {
|
||||
if (!this.showTrails) return;
|
||||
|
||||
let trailState = this.trails.get(markerId);
|
||||
if (!trailState) {
|
||||
trailState = { positions: [] };
|
||||
this.trails.set(markerId, trailState);
|
||||
}
|
||||
|
||||
// Only add if position is different from the last one
|
||||
const lastPos = trailState.positions[trailState.positions.length - 1];
|
||||
if (!lastPos || Math.abs(lastPos.lat - lat) > 0.0001 || Math.abs(lastPos.lng - lng) > 0.0001) {
|
||||
trailState.positions.push({ lat, lng, timestamp });
|
||||
}
|
||||
|
||||
// Filter positions to keep only recent ones
|
||||
const cutoffTime = Date.now() - this.trailDuration;
|
||||
trailState.positions = trailState.positions.filter((pos) => pos.timestamp >= cutoffTime);
|
||||
|
||||
this.updateTrailVisualization(markerId, trailState);
|
||||
}
|
||||
|
||||
private updateTrailVisualization(markerId: string, trailState: TrailState) {
|
||||
// Remove old trail and dots
|
||||
if (trailState.trail) {
|
||||
this.trailLayer.removeLayer(trailState.trail);
|
||||
}
|
||||
if (trailState.dots) {
|
||||
trailState.dots.forEach((dot) => this.trailLayer.removeLayer(dot));
|
||||
trailState.dots = [];
|
||||
}
|
||||
|
||||
// Create new trail if we have at least 2 positions
|
||||
if (trailState.positions.length >= 2) {
|
||||
const L = (window as any).L;
|
||||
const latLngs = trailState.positions.map((pos) => [pos.lat, pos.lng]);
|
||||
|
||||
// Create polyline for the trail
|
||||
trailState.trail = L.polyline(latLngs, {
|
||||
color: "#1E90FF",
|
||||
weight: 3,
|
||||
opacity: 0.5,
|
||||
smoothFactor: 1,
|
||||
}).addTo(this.trailLayer);
|
||||
|
||||
// Create dots for each position
|
||||
trailState.dots = trailState.positions.map((pos, index) => {
|
||||
const age = (Date.now() - pos.timestamp) / this.trailDuration;
|
||||
const opacity = Math.max(0.4, 1 - age * 0.6);
|
||||
const isCurrentPosition = index === trailState.positions.length - 1;
|
||||
const radius = isCurrentPosition ? 5 : 3;
|
||||
|
||||
const dot = L.circleMarker([pos.lat, pos.lng], {
|
||||
radius: radius,
|
||||
fillColor: "#FF4500",
|
||||
color: "#8B0000",
|
||||
weight: 1,
|
||||
opacity: opacity,
|
||||
fillOpacity: opacity * 0.8,
|
||||
});
|
||||
|
||||
// Add tooltip with timestamp
|
||||
const time = new Date(pos.timestamp);
|
||||
dot.bindTooltip(time.toLocaleTimeString(), {
|
||||
permanent: false,
|
||||
direction: "top",
|
||||
className: "trail-tooltip",
|
||||
});
|
||||
|
||||
return dot.addTo(this.trailLayer);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
removeTrail(markerId: string) {
|
||||
const trailState = this.trails.get(markerId);
|
||||
if (trailState) {
|
||||
if (trailState.trail) {
|
||||
this.trailLayer.removeLayer(trailState.trail);
|
||||
}
|
||||
if (trailState.dots) {
|
||||
trailState.dots.forEach((dot) => this.trailLayer.removeLayer(dot));
|
||||
}
|
||||
this.trails.delete(markerId);
|
||||
}
|
||||
}
|
||||
|
||||
clearAllTrails() {
|
||||
this.trails.forEach((_, markerId) => {
|
||||
this.removeTrail(markerId);
|
||||
});
|
||||
}
|
||||
|
||||
cleanupOldPositions() {
|
||||
const cutoffTime = Date.now() - this.trailDuration;
|
||||
this.trails.forEach((trailState, markerId) => {
|
||||
trailState.positions = trailState.positions.filter((pos) => pos.timestamp >= cutoffTime);
|
||||
if (trailState.positions.length === 0) {
|
||||
this.removeTrail(markerId);
|
||||
} else {
|
||||
this.updateTrailVisualization(markerId, trailState);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
156
assets/js/map.ts
156
assets/js/map.ts
|
|
@ -1,15 +1,12 @@
|
|||
// Declare Leaflet as a global variable
|
||||
declare const L: any;
|
||||
|
||||
// Import trail management functionality
|
||||
import { TrailManager } from "./features/trail_manager";
|
||||
|
||||
// APRS Map Hook - handles only basic map interaction
|
||||
// All data logic handled by LiveView
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__aprs_map_mounted?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
type LiveViewHookContext = {
|
||||
el: HTMLElement & { _leaflet_id?: any };
|
||||
pushEvent: (event: string, payload: any) => void;
|
||||
|
|
@ -18,6 +15,7 @@ type LiveViewHookContext = {
|
|||
markers?: Map<string, any>;
|
||||
markerStates?: Map<string, MarkerState>;
|
||||
markerLayer?: any;
|
||||
trailManager?: TrailManager;
|
||||
boundsTimer?: ReturnType<typeof setTimeout>;
|
||||
resizeHandler?: () => void;
|
||||
errors?: string[];
|
||||
|
|
@ -40,6 +38,7 @@ interface MarkerData {
|
|||
popup?: string;
|
||||
historical?: boolean;
|
||||
color?: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
interface BoundsData {
|
||||
|
|
@ -76,11 +75,6 @@ interface MapEventData {
|
|||
|
||||
let MapAPRSMap = {
|
||||
mounted() {
|
||||
if (window.__aprs_map_mounted) {
|
||||
console.warn("APRS Map already mounted, skipping duplicate mount.");
|
||||
return;
|
||||
}
|
||||
window.__aprs_map_mounted = true;
|
||||
const self = this as unknown as LiveViewHookContext;
|
||||
// Initialize error tracking
|
||||
self.errors = [];
|
||||
|
|
@ -108,37 +102,47 @@ let MapAPRSMap = {
|
|||
}
|
||||
}
|
||||
|
||||
// Get initial center and zoom from server-provided data attributes
|
||||
// Try to restore from localStorage
|
||||
let initialCenter: CenterData, initialZoom: number;
|
||||
try {
|
||||
const centerData = self.el.dataset.center;
|
||||
const zoomData = self.el.dataset.zoom;
|
||||
|
||||
if (!centerData || !zoomData) {
|
||||
throw new Error("Missing map data attributes");
|
||||
const saved = localStorage.getItem("aprs_map_state");
|
||||
if (saved) {
|
||||
const { lat, lng, zoom } = JSON.parse(saved);
|
||||
if (
|
||||
typeof lat === "number" &&
|
||||
typeof lng === "number" &&
|
||||
typeof zoom === "number" &&
|
||||
lat >= -90 && lat <= 90 &&
|
||||
lng >= -180 && lng <= 180 &&
|
||||
zoom >= 1 && zoom <= 20
|
||||
) {
|
||||
initialCenter = { lat, lng };
|
||||
initialZoom = zoom;
|
||||
} else {
|
||||
throw new Error("Invalid saved map state");
|
||||
}
|
||||
} else {
|
||||
throw new Error("No saved map state");
|
||||
}
|
||||
|
||||
initialCenter = JSON.parse(centerData);
|
||||
initialZoom = parseInt(zoomData);
|
||||
|
||||
// Validate parsed data
|
||||
if (
|
||||
!initialCenter ||
|
||||
typeof initialCenter.lat !== "number" ||
|
||||
typeof initialCenter.lng !== "number"
|
||||
) {
|
||||
throw new Error("Invalid center data");
|
||||
} catch (e) {
|
||||
// Fallback to server-provided data attributes
|
||||
try {
|
||||
const centerData = self.el.dataset.center;
|
||||
const zoomData = self.el.dataset.zoom;
|
||||
if (!centerData || !zoomData) throw new Error("Missing map data attributes");
|
||||
initialCenter = JSON.parse(centerData);
|
||||
initialZoom = parseInt(zoomData);
|
||||
if (
|
||||
!initialCenter ||
|
||||
typeof initialCenter.lat !== "number" ||
|
||||
typeof initialCenter.lng !== "number"
|
||||
) throw new Error("Invalid center data");
|
||||
if (isNaN(initialZoom) || initialZoom < 1 || initialZoom > 20) throw new Error("Invalid zoom data");
|
||||
} catch (error) {
|
||||
console.error("Error parsing map data attributes:", error);
|
||||
initialCenter = { lat: 39.8283, lng: -98.5795 };
|
||||
initialZoom = 5;
|
||||
}
|
||||
|
||||
if (isNaN(initialZoom) || initialZoom < 1 || initialZoom > 20) {
|
||||
throw new Error("Invalid zoom data");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing map data attributes:", error);
|
||||
|
||||
// Fallback values
|
||||
initialCenter = { lat: 39.8283, lng: -98.5795 };
|
||||
initialZoom = 5;
|
||||
}
|
||||
|
||||
self.initializeMap(initialCenter, initialZoom);
|
||||
|
|
@ -234,6 +238,10 @@ let MapAPRSMap = {
|
|||
self.markers = new Map<string, any>();
|
||||
self.markerLayer = L.layerGroup().addTo(self.map);
|
||||
|
||||
// Create trail layer and manager
|
||||
const trailLayer = L.layerGroup().addTo(self.map);
|
||||
self.trailManager = new TrailManager(trailLayer);
|
||||
|
||||
// Track marker states to prevent unnecessary operations
|
||||
self.markerStates = new Map<string, MarkerState>();
|
||||
|
||||
|
|
@ -250,6 +258,16 @@ let MapAPRSMap = {
|
|||
self.lastZoom = self.map!.getZoom();
|
||||
self.pushEvent("map_ready", {});
|
||||
self.sendBoundsToServer();
|
||||
|
||||
// Start periodic cleanup of old trail positions (every 5 minutes)
|
||||
setInterval(
|
||||
() => {
|
||||
if (self.trailManager) {
|
||||
self.trailManager.cleanupOldPositions();
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error in map ready callback:", error);
|
||||
}
|
||||
|
|
@ -260,6 +278,13 @@ let MapAPRSMap = {
|
|||
if (self.boundsTimer) clearTimeout(self.boundsTimer);
|
||||
self.boundsTimer = setTimeout(() => {
|
||||
self.sendBoundsToServer();
|
||||
// Save map state
|
||||
const center = self.map.getCenter();
|
||||
const zoom = self.map.getZoom();
|
||||
localStorage.setItem(
|
||||
"aprs_map_state",
|
||||
JSON.stringify({ lat: center.lat, lng: center.lng, zoom })
|
||||
);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
|
|
@ -277,6 +302,13 @@ let MapAPRSMap = {
|
|||
|
||||
self.sendBoundsToServer();
|
||||
self.lastZoom = currentZoom;
|
||||
// Save map state
|
||||
const center = self.map.getCenter();
|
||||
const zoom = self.map.getZoom();
|
||||
localStorage.setItem(
|
||||
"aprs_map_state",
|
||||
JSON.stringify({ lat: center.lat, lng: center.lng, zoom })
|
||||
);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
|
|
@ -449,6 +481,13 @@ let MapAPRSMap = {
|
|||
}
|
||||
});
|
||||
|
||||
// Toggle trails visibility
|
||||
self.handleEvent("toggle_trails", (data: { show: boolean }) => {
|
||||
if (self.trailManager) {
|
||||
self.trailManager.setShowTrails(data.show);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle new packets from LiveView
|
||||
self.handleEvent("new_packet", (data: MarkerData) => {
|
||||
self.addMarker({
|
||||
|
|
@ -591,6 +630,11 @@ let MapAPRSMap = {
|
|||
existingState.symbol_code !== data.symbol_code ||
|
||||
existingState.popup !== data.popup;
|
||||
|
||||
if (positionChanged && self.trailManager) {
|
||||
// Position changed, update trail
|
||||
self.trailManager.addPosition(data.id, lat, lng, data.timestamp || Date.now());
|
||||
}
|
||||
|
||||
if (!positionChanged && !dataChanged) {
|
||||
// No changes needed, skip update
|
||||
// But if openPopup is requested, open it
|
||||
|
|
@ -644,6 +688,11 @@ let MapAPRSMap = {
|
|||
historical: data.historical,
|
||||
});
|
||||
|
||||
// Initialize trail for new marker
|
||||
if (self.trailManager) {
|
||||
self.trailManager.addPosition(data.id, lat, lng, data.timestamp || Date.now());
|
||||
}
|
||||
|
||||
// Open popup if requested
|
||||
if (data.openPopup && marker.openPopup) {
|
||||
marker.openPopup();
|
||||
|
|
@ -658,6 +707,11 @@ let MapAPRSMap = {
|
|||
self.markers!.delete(id);
|
||||
self.markerStates!.delete(id);
|
||||
}
|
||||
|
||||
// Remove trail
|
||||
if (self.trailManager) {
|
||||
self.trailManager.removeTrail(id);
|
||||
}
|
||||
},
|
||||
|
||||
updateMarker(data: MarkerData) {
|
||||
|
|
@ -671,7 +725,16 @@ let MapAPRSMap = {
|
|||
const lat = parseFloat(data.lat.toString());
|
||||
const lng = parseFloat(data.lng.toString());
|
||||
if (!isNaN(lat) && !isNaN(lng)) {
|
||||
existingMarker.setLatLng([lat, lng]);
|
||||
const currentPos = existingMarker.getLatLng();
|
||||
const positionChanged =
|
||||
Math.abs(currentPos.lat - lat) > 0.0001 || Math.abs(currentPos.lng - lng) > 0.0001;
|
||||
|
||||
if (positionChanged) {
|
||||
existingMarker.setLatLng([lat, lng]);
|
||||
if (self.trailManager) {
|
||||
self.trailManager.addPosition(data.id, lat, lng, data.timestamp || Date.now());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -690,6 +753,11 @@ let MapAPRSMap = {
|
|||
self.markerLayer!.clearLayers();
|
||||
self.markers!.clear();
|
||||
self.markerStates!.clear();
|
||||
|
||||
// Clear all trails
|
||||
if (self.trailManager) {
|
||||
self.trailManager.clearAllTrails();
|
||||
}
|
||||
},
|
||||
|
||||
removeMarkersOutsideBounds(bounds: L.LatLngBounds) {
|
||||
|
|
@ -764,16 +832,6 @@ let MapAPRSMap = {
|
|||
expected: `Row ${row}, Col ${column} should show symbol '${symbolCode}'`,
|
||||
});
|
||||
|
||||
// Test if sprite image is accessible
|
||||
const testImg = new Image();
|
||||
testImg.onerror = () => {
|
||||
console.error(`Failed to load sprite: ${spriteFile}`);
|
||||
};
|
||||
testImg.onload = () => {
|
||||
console.log(`Sprite loaded successfully: ${spriteFile}`);
|
||||
};
|
||||
testImg.src = spriteFile;
|
||||
|
||||
// Try adjusting the position to see if we can find the correct icon
|
||||
// The car symbol ">" should be at position 30 (row 1, col 14)
|
||||
// But the sprite might have a different arrangement
|
||||
|
|
|
|||
|
|
@ -57,7 +57,10 @@ defmodule AprsWeb.MapLive.CallsignView do
|
|||
# Last known position for auto-zoom
|
||||
last_known_position: nil,
|
||||
# Flag to track if packets have been loaded
|
||||
packets_loaded: false
|
||||
packets_loaded: false,
|
||||
# Latest symbol table ID and code
|
||||
latest_symbol_table_id: "/",
|
||||
latest_symbol_code: ">"
|
||||
)
|
||||
|
||||
if connected?(socket) do
|
||||
|
|
@ -451,6 +454,12 @@ defmodule AprsWeb.MapLive.CallsignView do
|
|||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.aprs-symbol-info {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
|
@ -559,12 +568,6 @@ defmodule AprsWeb.MapLive.CallsignView do
|
|||
color: #333;
|
||||
}
|
||||
|
||||
.aprs-symbol-info {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.aprs-comment {
|
||||
font-size: 11px;
|
||||
color: #444;
|
||||
|
|
@ -618,6 +621,10 @@ defmodule AprsWeb.MapLive.CallsignView do
|
|||
<div style="position: relative; width: 100vw; height: 100vh;">
|
||||
<div class="callsign-header">
|
||||
<div class="callsign-title">📡 {@callsign}</div>
|
||||
<div class="aprs-symbol-info">
|
||||
<span>Symbol Table: <code>{@latest_symbol_table_id}</code></span>
|
||||
<span style="margin-left:8px;">Symbol Code: <code>{@latest_symbol_code}</code></span>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link">← Back to Map</a>
|
||||
<a href="/packets" class="nav-link">All Packets</a>
|
||||
|
|
@ -784,11 +791,47 @@ defmodule AprsWeb.MapLive.CallsignView do
|
|||
end
|
||||
end
|
||||
|
||||
defp get_symbol_table_id(%{symbol_table_id: id}), do: id
|
||||
defp get_symbol_table_id(_), do: "/"
|
||||
defp get_symbol_table_id(data) do
|
||||
cond do
|
||||
is_map(data) && Map.has_key?(data, :symbol_table_id) && data.symbol_table_id ->
|
||||
data.symbol_table_id
|
||||
|
||||
defp get_symbol_code(%{symbol_code: code}), do: code
|
||||
defp get_symbol_code(_), do: ">"
|
||||
is_map(data) && Map.has_key?(data, "symbol_table_id") && data["symbol_table_id"] ->
|
||||
data["symbol_table_id"]
|
||||
|
||||
is_map(data) && Map.has_key?(data, :packet) && Map.has_key?(data.packet, :symbol_table_id) &&
|
||||
data.packet.symbol_table_id ->
|
||||
data.packet.symbol_table_id
|
||||
|
||||
is_map(data) && Map.has_key?(data, "packet") &&
|
||||
Map.has_key?(data["packet"], "symbol_table_id") && data["packet"]["symbol_table_id"] ->
|
||||
data["packet"]["symbol_table_id"]
|
||||
|
||||
true ->
|
||||
"/"
|
||||
end
|
||||
end
|
||||
|
||||
defp get_symbol_code(data) do
|
||||
cond do
|
||||
is_map(data) && Map.has_key?(data, :symbol_code) && data.symbol_code ->
|
||||
data.symbol_code
|
||||
|
||||
is_map(data) && Map.has_key?(data, "symbol_code") && data["symbol_code"] ->
|
||||
data["symbol_code"]
|
||||
|
||||
is_map(data) && Map.has_key?(data, :packet) && Map.has_key?(data.packet, :symbol_code) &&
|
||||
data.packet.symbol_code ->
|
||||
data.packet.symbol_code
|
||||
|
||||
is_map(data) && Map.has_key?(data, "packet") && Map.has_key?(data["packet"], "symbol_code") &&
|
||||
data["packet"]["symbol_code"] ->
|
||||
data["packet"]["symbol_code"]
|
||||
|
||||
true ->
|
||||
">"
|
||||
end
|
||||
end
|
||||
|
||||
defp get_comment(%{comment: comment}) when is_binary(comment), do: comment
|
||||
defp get_comment(_), do: ""
|
||||
|
|
@ -865,23 +908,33 @@ defmodule AprsWeb.MapLive.CallsignView do
|
|||
# Load recent packets (last hour) for this specific callsign
|
||||
recent_packets = Packets.get_recent_packets(%{callsign: callsign})
|
||||
|
||||
# Find the most recent packet with position data for auto-zoom
|
||||
last_known_position =
|
||||
# Find the most recent packet with position data for auto-zoom and symbol info
|
||||
latest_packet =
|
||||
recent_packets
|
||||
|> Enum.filter(&has_position_data?/1)
|
||||
|> Enum.sort_by(& &1.received_at, {:desc, DateTime})
|
||||
|> List.first()
|
||||
|> case do
|
||||
|
||||
last_known_position =
|
||||
case latest_packet do
|
||||
nil ->
|
||||
nil
|
||||
|
||||
packet ->
|
||||
{lat, lng} = get_coordinates(packet)
|
||||
if lat && lng, do: %{lat: lat, lng: lng}
|
||||
end
|
||||
|
||||
if lat && lng do
|
||||
position = %{lat: lat, lng: lng}
|
||||
position
|
||||
end
|
||||
latest_symbol_table_id =
|
||||
case latest_packet do
|
||||
%{data_extended: %{symbol_table_id: id}} when is_binary(id) -> id
|
||||
_ -> "/"
|
||||
end
|
||||
|
||||
latest_symbol_code =
|
||||
case latest_packet do
|
||||
%{data_extended: %{symbol_code: code}} when is_binary(code) -> code
|
||||
_ -> ">"
|
||||
end
|
||||
|
||||
# Convert packets to client-friendly format and send to map
|
||||
|
|
@ -909,7 +962,12 @@ defmodule AprsWeb.MapLive.CallsignView do
|
|||
socket
|
||||
end
|
||||
|
||||
assign(socket, last_known_position: last_known_position, visible_packets: visible_packets)
|
||||
assign(socket,
|
||||
last_known_position: last_known_position,
|
||||
visible_packets: visible_packets,
|
||||
latest_symbol_table_id: latest_symbol_table_id,
|
||||
latest_symbol_code: latest_symbol_code
|
||||
)
|
||||
end
|
||||
|
||||
defp map_empty?(socket) do
|
||||
|
|
|
|||
|
|
@ -45,7 +45,10 @@ defmodule AprsWeb.MapLive.Enhanced do
|
|||
replay_paused: false,
|
||||
replay_timer: nil,
|
||||
replay_packets: [],
|
||||
replay_index: 0
|
||||
replay_index: 0,
|
||||
|
||||
# Trail visualization
|
||||
show_trails: true
|
||||
)
|
||||
|
||||
{:ok, socket}
|
||||
|
|
@ -141,6 +144,13 @@ defmodule AprsWeb.MapLive.Enhanced do
|
|||
{:noreply, assign(socket, :replay_speed, speed_ms)}
|
||||
end
|
||||
|
||||
def handle_event("toggle_trails", _params, socket) do
|
||||
show_trails = !Map.get(socket.assigns, :show_trails, true)
|
||||
socket = assign(socket, :show_trails, show_trails)
|
||||
socket = push_event(socket, "toggle_trails", %{show: show_trails})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info(:cleanup_old_markers, socket) do
|
||||
socket = cleanup_old_markers(socket)
|
||||
|
||||
|
|
@ -268,6 +278,8 @@ defmodule AprsWeb.MapLive.Enhanced do
|
|||
phx-update="ignore"
|
||||
data-center={Jason.encode!(@map_center)}
|
||||
data-zoom={@map_zoom}
|
||||
data-lat={@map_center.lat}
|
||||
data-lng={@map_center.lng}
|
||||
>
|
||||
</div>
|
||||
|
||||
|
|
@ -298,6 +310,28 @@ defmodule AprsWeb.MapLive.Enhanced do
|
|||
<% end %>
|
||||
</button>
|
||||
|
||||
<button class="control-button" phx-click="toggle_trails" title="Toggle position trails">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M3 12h18m-18 0l3-3m-3 3l3 3" />
|
||||
<circle cx="6" cy="12" r="1" fill="currentColor" />
|
||||
<circle cx="12" cy="12" r="1" fill="currentColor" />
|
||||
<circle cx="18" cy="12" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
<%= if assigns[:show_trails] != false do %>
|
||||
Hide Trails
|
||||
<% else %>
|
||||
Show Trails
|
||||
<% end %>
|
||||
</button>
|
||||
|
||||
<%= if @replay_active do %>
|
||||
<button class="control-button" phx-click="pause_replay" disabled={@replay_paused}>
|
||||
<%= if @replay_paused do %>
|
||||
|
|
@ -416,7 +450,8 @@ defmodule AprsWeb.MapLive.Enhanced do
|
|||
symbol_table: data_extended["symbol_table_id"] || "/",
|
||||
symbol_code: data_extended["symbol_code"] || ">",
|
||||
historical: false,
|
||||
popup: build_popup_content(packet, callsign, false)
|
||||
popup: build_popup_content(packet, callsign, false),
|
||||
timestamp: DateTime.to_unix(packet.created_at, :millisecond)
|
||||
}
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -668,8 +668,6 @@ defmodule AprsWeb.MapLive.Index do
|
|||
phx-update="ignore"
|
||||
data-center={Jason.encode!(@map_center)}
|
||||
data-zoom={@map_zoom}
|
||||
data-lat={@map_center.lat}
|
||||
data-lng={@map_center.lng}
|
||||
>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -27,13 +27,18 @@ defmodule AprsWeb.PacketsLive.CallsignView do
|
|||
|
||||
# Get stored packets for this callsign (up to 100)
|
||||
stored_packets = get_stored_packets(normalized_callsign, 100)
|
||||
all_packets = stored_packets
|
||||
latest_packet = List.first(all_packets)
|
||||
{symbol_table_id, symbol_code} = extract_symbol_info(latest_packet)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:callsign, normalized_callsign)
|
||||
|> assign(:packets, stored_packets)
|
||||
|> assign(:live_packets, [])
|
||||
|> assign(:all_packets, stored_packets)
|
||||
|> assign(:all_packets, all_packets)
|
||||
|> assign(:latest_symbol_table_id, symbol_table_id)
|
||||
|> assign(:latest_symbol_code, symbol_code)
|
||||
|> assign(:error, nil)
|
||||
|
||||
{:ok, socket}
|
||||
|
|
@ -44,6 +49,8 @@ defmodule AprsWeb.PacketsLive.CallsignView do
|
|||
|> assign(:packets, [])
|
||||
|> assign(:live_packets, [])
|
||||
|> assign(:all_packets, [])
|
||||
|> assign(:latest_symbol_table_id, "/")
|
||||
|> assign(:latest_symbol_code, ">")
|
||||
|> assign(:error, "Invalid callsign format")
|
||||
|
||||
{:ok, socket}
|
||||
|
|
@ -62,12 +69,16 @@ defmodule AprsWeb.PacketsLive.CallsignView do
|
|||
update_packet_lists(current_stored, current_live, sanitized_payload)
|
||||
|
||||
all_packets = get_all_packets_list(updated_stored, updated_live)
|
||||
latest_packet = List.first(all_packets)
|
||||
{symbol_table_id, symbol_code} = extract_symbol_info(latest_packet)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:packets, updated_stored)
|
||||
|> assign(:live_packets, updated_live)
|
||||
|> assign(:all_packets, all_packets)
|
||||
|> assign(:latest_symbol_table_id, symbol_table_id)
|
||||
|> assign(:latest_symbol_code, symbol_code)
|
||||
|
||||
{:noreply, socket}
|
||||
else
|
||||
|
|
@ -190,4 +201,14 @@ defmodule AprsWeb.PacketsLive.CallsignView do
|
|||
cs -> Regex.match?(~r/^[A-Z0-9]+(-[A-Z0-9]{1,2})?$/i, cs)
|
||||
end
|
||||
end
|
||||
|
||||
# Helper to extract symbol table and code from a packet
|
||||
defp extract_symbol_info(nil), do: {"/", ">"}
|
||||
|
||||
defp extract_symbol_info(packet) do
|
||||
data = Map.get(packet, :data_extended) || %{}
|
||||
table = Map.get(data, :symbol_table_id) || Map.get(data, "symbol_table_id") || "/"
|
||||
code = Map.get(data, :symbol_code) || Map.get(data, "symbol_code") || ">"
|
||||
{table, code}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -71,6 +71,15 @@
|
|||
{packet.data_type}
|
||||
</span>
|
||||
</:col>
|
||||
<:col :let={packet} label="Symbol">
|
||||
<span class="text-xs text-gray-700 font-mono">
|
||||
<% data = Map.get(packet, :data_extended) || %{}
|
||||
raw_table = Map.get(data, :symbol_table_id) || Map.get(data, "symbol_table_id") || "/"
|
||||
table = if raw_table in ["/", "\\", "]"], do: raw_table, else: "/"
|
||||
code = Map.get(data, :symbol_code) || Map.get(data, "symbol_code") || ">" %>
|
||||
{table}{code}
|
||||
</span>
|
||||
</:col>
|
||||
<:col :let={packet} label="Destination">
|
||||
<span class="text-sm text-gray-900">{packet.destination}</span>
|
||||
</:col>
|
||||
|
|
|
|||
|
|
@ -44,6 +44,15 @@
|
|||
{packet.data_type}
|
||||
</span>
|
||||
</:col>
|
||||
<:col :let={packet} label="Symbol">
|
||||
<span class="text-xs text-gray-700 font-mono">
|
||||
<% data = Map.get(packet, :data_extended) || %{}
|
||||
raw_table = Map.get(data, :symbol_table_id) || Map.get(data, "symbol_table_id") || "/"
|
||||
table = if raw_table in ["/", "\\", "]"], do: raw_table, else: "/"
|
||||
code = Map.get(data, :symbol_code) || Map.get(data, "symbol_code") || ">" %>
|
||||
{table}{code}
|
||||
</span>
|
||||
</:col>
|
||||
<:col :let={packet} label="Destination">
|
||||
<span class="text-sm text-gray-900">{packet.destination}</span>
|
||||
</:col>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue