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;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.home-page main>div {
|
body.home-page main > div {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
@ -151,20 +151,62 @@ body.home-page main>div {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* High-DPI support for APRS symbols */
|
/* High-DPI support for APRS symbols */
|
||||||
@media (-webkit-min-device-pixel-ratio: 2),
|
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
|
||||||
(min-resolution: 192dpi) {
|
|
||||||
.aprs-marker div[style*="aprs-symbols-24-0.png"] {
|
.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;
|
background-size: 384px 144px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aprs-marker div[style*="aprs-symbols-24-1.png"] {
|
.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;
|
background-size: 384px 144px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aprs-marker div[style*="aprs-symbols-24-2.png"] {
|
.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;
|
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 Leaflet as a global variable
|
||||||
declare const L: any;
|
declare const L: any;
|
||||||
|
|
||||||
|
// Import trail management functionality
|
||||||
|
import { TrailManager } from "./features/trail_manager";
|
||||||
|
|
||||||
// APRS Map Hook - handles only basic map interaction
|
// APRS Map Hook - handles only basic map interaction
|
||||||
// All data logic handled by LiveView
|
// All data logic handled by LiveView
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
__aprs_map_mounted?: boolean;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type LiveViewHookContext = {
|
type LiveViewHookContext = {
|
||||||
el: HTMLElement & { _leaflet_id?: any };
|
el: HTMLElement & { _leaflet_id?: any };
|
||||||
pushEvent: (event: string, payload: any) => void;
|
pushEvent: (event: string, payload: any) => void;
|
||||||
|
|
@ -18,6 +15,7 @@ type LiveViewHookContext = {
|
||||||
markers?: Map<string, any>;
|
markers?: Map<string, any>;
|
||||||
markerStates?: Map<string, MarkerState>;
|
markerStates?: Map<string, MarkerState>;
|
||||||
markerLayer?: any;
|
markerLayer?: any;
|
||||||
|
trailManager?: TrailManager;
|
||||||
boundsTimer?: ReturnType<typeof setTimeout>;
|
boundsTimer?: ReturnType<typeof setTimeout>;
|
||||||
resizeHandler?: () => void;
|
resizeHandler?: () => void;
|
||||||
errors?: string[];
|
errors?: string[];
|
||||||
|
|
@ -40,6 +38,7 @@ interface MarkerData {
|
||||||
popup?: string;
|
popup?: string;
|
||||||
historical?: boolean;
|
historical?: boolean;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
timestamp?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BoundsData {
|
interface BoundsData {
|
||||||
|
|
@ -76,11 +75,6 @@ interface MapEventData {
|
||||||
|
|
||||||
let MapAPRSMap = {
|
let MapAPRSMap = {
|
||||||
mounted() {
|
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;
|
const self = this as unknown as LiveViewHookContext;
|
||||||
// Initialize error tracking
|
// Initialize error tracking
|
||||||
self.errors = [];
|
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;
|
let initialCenter: CenterData, initialZoom: number;
|
||||||
try {
|
try {
|
||||||
const centerData = self.el.dataset.center;
|
const saved = localStorage.getItem("aprs_map_state");
|
||||||
const zoomData = self.el.dataset.zoom;
|
if (saved) {
|
||||||
|
const { lat, lng, zoom } = JSON.parse(saved);
|
||||||
if (!centerData || !zoomData) {
|
if (
|
||||||
throw new Error("Missing map data attributes");
|
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");
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
initialCenter = JSON.parse(centerData);
|
// Fallback to server-provided data attributes
|
||||||
initialZoom = parseInt(zoomData);
|
try {
|
||||||
|
const centerData = self.el.dataset.center;
|
||||||
// Validate parsed data
|
const zoomData = self.el.dataset.zoom;
|
||||||
if (
|
if (!centerData || !zoomData) throw new Error("Missing map data attributes");
|
||||||
!initialCenter ||
|
initialCenter = JSON.parse(centerData);
|
||||||
typeof initialCenter.lat !== "number" ||
|
initialZoom = parseInt(zoomData);
|
||||||
typeof initialCenter.lng !== "number"
|
if (
|
||||||
) {
|
!initialCenter ||
|
||||||
throw new Error("Invalid center data");
|
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);
|
self.initializeMap(initialCenter, initialZoom);
|
||||||
|
|
@ -234,6 +238,10 @@ let MapAPRSMap = {
|
||||||
self.markers = new Map<string, any>();
|
self.markers = new Map<string, any>();
|
||||||
self.markerLayer = L.layerGroup().addTo(self.map);
|
self.markerLayer = L.layerGroup().addTo(self.map);
|
||||||
|
|
||||||
|
// Create trail layer and manager
|
||||||
|
const trailLayer = L.layerGroup().addTo(self.map);
|
||||||
|
self.trailManager = new TrailManager(trailLayer);
|
||||||
|
|
||||||
// Track marker states to prevent unnecessary operations
|
// Track marker states to prevent unnecessary operations
|
||||||
self.markerStates = new Map<string, MarkerState>();
|
self.markerStates = new Map<string, MarkerState>();
|
||||||
|
|
||||||
|
|
@ -250,6 +258,16 @@ let MapAPRSMap = {
|
||||||
self.lastZoom = self.map!.getZoom();
|
self.lastZoom = self.map!.getZoom();
|
||||||
self.pushEvent("map_ready", {});
|
self.pushEvent("map_ready", {});
|
||||||
self.sendBoundsToServer();
|
self.sendBoundsToServer();
|
||||||
|
|
||||||
|
// Start periodic cleanup of old trail positions (every 5 minutes)
|
||||||
|
setInterval(
|
||||||
|
() => {
|
||||||
|
if (self.trailManager) {
|
||||||
|
self.trailManager.cleanupOldPositions();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
5 * 60 * 1000,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error in map ready callback:", error);
|
console.error("Error in map ready callback:", error);
|
||||||
}
|
}
|
||||||
|
|
@ -260,6 +278,13 @@ let MapAPRSMap = {
|
||||||
if (self.boundsTimer) clearTimeout(self.boundsTimer);
|
if (self.boundsTimer) clearTimeout(self.boundsTimer);
|
||||||
self.boundsTimer = setTimeout(() => {
|
self.boundsTimer = setTimeout(() => {
|
||||||
self.sendBoundsToServer();
|
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);
|
}, 300);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -277,6 +302,13 @@ let MapAPRSMap = {
|
||||||
|
|
||||||
self.sendBoundsToServer();
|
self.sendBoundsToServer();
|
||||||
self.lastZoom = currentZoom;
|
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);
|
}, 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
|
// Handle new packets from LiveView
|
||||||
self.handleEvent("new_packet", (data: MarkerData) => {
|
self.handleEvent("new_packet", (data: MarkerData) => {
|
||||||
self.addMarker({
|
self.addMarker({
|
||||||
|
|
@ -591,6 +630,11 @@ let MapAPRSMap = {
|
||||||
existingState.symbol_code !== data.symbol_code ||
|
existingState.symbol_code !== data.symbol_code ||
|
||||||
existingState.popup !== data.popup;
|
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) {
|
if (!positionChanged && !dataChanged) {
|
||||||
// No changes needed, skip update
|
// No changes needed, skip update
|
||||||
// But if openPopup is requested, open it
|
// But if openPopup is requested, open it
|
||||||
|
|
@ -644,6 +688,11 @@ let MapAPRSMap = {
|
||||||
historical: data.historical,
|
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
|
// Open popup if requested
|
||||||
if (data.openPopup && marker.openPopup) {
|
if (data.openPopup && marker.openPopup) {
|
||||||
marker.openPopup();
|
marker.openPopup();
|
||||||
|
|
@ -658,6 +707,11 @@ let MapAPRSMap = {
|
||||||
self.markers!.delete(id);
|
self.markers!.delete(id);
|
||||||
self.markerStates!.delete(id);
|
self.markerStates!.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove trail
|
||||||
|
if (self.trailManager) {
|
||||||
|
self.trailManager.removeTrail(id);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
updateMarker(data: MarkerData) {
|
updateMarker(data: MarkerData) {
|
||||||
|
|
@ -671,7 +725,16 @@ let MapAPRSMap = {
|
||||||
const lat = parseFloat(data.lat.toString());
|
const lat = parseFloat(data.lat.toString());
|
||||||
const lng = parseFloat(data.lng.toString());
|
const lng = parseFloat(data.lng.toString());
|
||||||
if (!isNaN(lat) && !isNaN(lng)) {
|
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.markerLayer!.clearLayers();
|
||||||
self.markers!.clear();
|
self.markers!.clear();
|
||||||
self.markerStates!.clear();
|
self.markerStates!.clear();
|
||||||
|
|
||||||
|
// Clear all trails
|
||||||
|
if (self.trailManager) {
|
||||||
|
self.trailManager.clearAllTrails();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
removeMarkersOutsideBounds(bounds: L.LatLngBounds) {
|
removeMarkersOutsideBounds(bounds: L.LatLngBounds) {
|
||||||
|
|
@ -764,16 +832,6 @@ let MapAPRSMap = {
|
||||||
expected: `Row ${row}, Col ${column} should show symbol '${symbolCode}'`,
|
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
|
// 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)
|
// The car symbol ">" should be at position 30 (row 1, col 14)
|
||||||
// But the sprite might have a different arrangement
|
// 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 for auto-zoom
|
||||||
last_known_position: nil,
|
last_known_position: nil,
|
||||||
# Flag to track if packets have been loaded
|
# 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
|
if connected?(socket) do
|
||||||
|
|
@ -451,6 +454,12 @@ defmodule AprsWeb.MapLive.CallsignView do
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.aprs-symbol-info {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-links {
|
.nav-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
@ -559,12 +568,6 @@ defmodule AprsWeb.MapLive.CallsignView do
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aprs-symbol-info {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aprs-comment {
|
.aprs-comment {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #444;
|
color: #444;
|
||||||
|
|
@ -618,6 +621,10 @@ defmodule AprsWeb.MapLive.CallsignView do
|
||||||
<div style="position: relative; width: 100vw; height: 100vh;">
|
<div style="position: relative; width: 100vw; height: 100vh;">
|
||||||
<div class="callsign-header">
|
<div class="callsign-header">
|
||||||
<div class="callsign-title">📡 {@callsign}</div>
|
<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">
|
<div class="nav-links">
|
||||||
<a href="/" class="nav-link">← Back to Map</a>
|
<a href="/" class="nav-link">← Back to Map</a>
|
||||||
<a href="/packets" class="nav-link">All Packets</a>
|
<a href="/packets" class="nav-link">All Packets</a>
|
||||||
|
|
@ -784,11 +791,47 @@ defmodule AprsWeb.MapLive.CallsignView do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_symbol_table_id(%{symbol_table_id: id}), do: id
|
defp get_symbol_table_id(data) do
|
||||||
defp get_symbol_table_id(_), 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
|
is_map(data) && Map.has_key?(data, "symbol_table_id") && data["symbol_table_id"] ->
|
||||||
defp get_symbol_code(_), do: ">"
|
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(%{comment: comment}) when is_binary(comment), do: comment
|
||||||
defp get_comment(_), do: ""
|
defp get_comment(_), do: ""
|
||||||
|
|
@ -865,23 +908,33 @@ defmodule AprsWeb.MapLive.CallsignView do
|
||||||
# Load recent packets (last hour) for this specific callsign
|
# Load recent packets (last hour) for this specific callsign
|
||||||
recent_packets = Packets.get_recent_packets(%{callsign: callsign})
|
recent_packets = Packets.get_recent_packets(%{callsign: callsign})
|
||||||
|
|
||||||
# Find the most recent packet with position data for auto-zoom
|
# Find the most recent packet with position data for auto-zoom and symbol info
|
||||||
last_known_position =
|
latest_packet =
|
||||||
recent_packets
|
recent_packets
|
||||||
|> Enum.filter(&has_position_data?/1)
|
|> Enum.filter(&has_position_data?/1)
|
||||||
|> Enum.sort_by(& &1.received_at, {:desc, DateTime})
|
|> Enum.sort_by(& &1.received_at, {:desc, DateTime})
|
||||||
|> List.first()
|
|> List.first()
|
||||||
|> case do
|
|
||||||
|
last_known_position =
|
||||||
|
case latest_packet do
|
||||||
nil ->
|
nil ->
|
||||||
nil
|
nil
|
||||||
|
|
||||||
packet ->
|
packet ->
|
||||||
{lat, lng} = get_coordinates(packet)
|
{lat, lng} = get_coordinates(packet)
|
||||||
|
if lat && lng, do: %{lat: lat, lng: lng}
|
||||||
|
end
|
||||||
|
|
||||||
if lat && lng do
|
latest_symbol_table_id =
|
||||||
position = %{lat: lat, lng: lng}
|
case latest_packet do
|
||||||
position
|
%{data_extended: %{symbol_table_id: id}} when is_binary(id) -> id
|
||||||
end
|
_ -> "/"
|
||||||
|
end
|
||||||
|
|
||||||
|
latest_symbol_code =
|
||||||
|
case latest_packet do
|
||||||
|
%{data_extended: %{symbol_code: code}} when is_binary(code) -> code
|
||||||
|
_ -> ">"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Convert packets to client-friendly format and send to map
|
# Convert packets to client-friendly format and send to map
|
||||||
|
|
@ -909,7 +962,12 @@ defmodule AprsWeb.MapLive.CallsignView do
|
||||||
socket
|
socket
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
defp map_empty?(socket) do
|
defp map_empty?(socket) do
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,10 @@ defmodule AprsWeb.MapLive.Enhanced do
|
||||||
replay_paused: false,
|
replay_paused: false,
|
||||||
replay_timer: nil,
|
replay_timer: nil,
|
||||||
replay_packets: [],
|
replay_packets: [],
|
||||||
replay_index: 0
|
replay_index: 0,
|
||||||
|
|
||||||
|
# Trail visualization
|
||||||
|
show_trails: true
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
|
|
@ -141,6 +144,13 @@ defmodule AprsWeb.MapLive.Enhanced do
|
||||||
{:noreply, assign(socket, :replay_speed, speed_ms)}
|
{:noreply, assign(socket, :replay_speed, speed_ms)}
|
||||||
end
|
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
|
def handle_info(:cleanup_old_markers, socket) do
|
||||||
socket = cleanup_old_markers(socket)
|
socket = cleanup_old_markers(socket)
|
||||||
|
|
||||||
|
|
@ -268,6 +278,8 @@ defmodule AprsWeb.MapLive.Enhanced do
|
||||||
phx-update="ignore"
|
phx-update="ignore"
|
||||||
data-center={Jason.encode!(@map_center)}
|
data-center={Jason.encode!(@map_center)}
|
||||||
data-zoom={@map_zoom}
|
data-zoom={@map_zoom}
|
||||||
|
data-lat={@map_center.lat}
|
||||||
|
data-lng={@map_center.lng}
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -298,6 +310,28 @@ defmodule AprsWeb.MapLive.Enhanced do
|
||||||
<% end %>
|
<% end %>
|
||||||
</button>
|
</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 %>
|
<%= if @replay_active do %>
|
||||||
<button class="control-button" phx-click="pause_replay" disabled={@replay_paused}>
|
<button class="control-button" phx-click="pause_replay" disabled={@replay_paused}>
|
||||||
<%= if @replay_paused do %>
|
<%= if @replay_paused do %>
|
||||||
|
|
@ -416,7 +450,8 @@ defmodule AprsWeb.MapLive.Enhanced do
|
||||||
symbol_table: data_extended["symbol_table_id"] || "/",
|
symbol_table: data_extended["symbol_table_id"] || "/",
|
||||||
symbol_code: data_extended["symbol_code"] || ">",
|
symbol_code: data_extended["symbol_code"] || ">",
|
||||||
historical: false,
|
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
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -668,8 +668,6 @@ defmodule AprsWeb.MapLive.Index do
|
||||||
phx-update="ignore"
|
phx-update="ignore"
|
||||||
data-center={Jason.encode!(@map_center)}
|
data-center={Jason.encode!(@map_center)}
|
||||||
data-zoom={@map_zoom}
|
data-zoom={@map_zoom}
|
||||||
data-lat={@map_center.lat}
|
|
||||||
data-lng={@map_center.lng}
|
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,18 @@ defmodule AprsWeb.PacketsLive.CallsignView do
|
||||||
|
|
||||||
# Get stored packets for this callsign (up to 100)
|
# Get stored packets for this callsign (up to 100)
|
||||||
stored_packets = get_stored_packets(normalized_callsign, 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 =
|
||||||
socket
|
socket
|
||||||
|> assign(:callsign, normalized_callsign)
|
|> assign(:callsign, normalized_callsign)
|
||||||
|> assign(:packets, stored_packets)
|
|> assign(:packets, stored_packets)
|
||||||
|> assign(:live_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)
|
|> assign(:error, nil)
|
||||||
|
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
|
|
@ -44,6 +49,8 @@ defmodule AprsWeb.PacketsLive.CallsignView do
|
||||||
|> assign(:packets, [])
|
|> assign(:packets, [])
|
||||||
|> assign(:live_packets, [])
|
|> assign(:live_packets, [])
|
||||||
|> assign(:all_packets, [])
|
|> assign(:all_packets, [])
|
||||||
|
|> assign(:latest_symbol_table_id, "/")
|
||||||
|
|> assign(:latest_symbol_code, ">")
|
||||||
|> assign(:error, "Invalid callsign format")
|
|> assign(:error, "Invalid callsign format")
|
||||||
|
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
|
|
@ -62,12 +69,16 @@ defmodule AprsWeb.PacketsLive.CallsignView do
|
||||||
update_packet_lists(current_stored, current_live, sanitized_payload)
|
update_packet_lists(current_stored, current_live, sanitized_payload)
|
||||||
|
|
||||||
all_packets = get_all_packets_list(updated_stored, updated_live)
|
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 =
|
||||||
socket
|
socket
|
||||||
|> assign(:packets, updated_stored)
|
|> assign(:packets, updated_stored)
|
||||||
|> assign(:live_packets, updated_live)
|
|> assign(:live_packets, updated_live)
|
||||||
|> assign(:all_packets, all_packets)
|
|> assign(:all_packets, all_packets)
|
||||||
|
|> assign(:latest_symbol_table_id, symbol_table_id)
|
||||||
|
|> assign(:latest_symbol_code, symbol_code)
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
else
|
else
|
||||||
|
|
@ -190,4 +201,14 @@ defmodule AprsWeb.PacketsLive.CallsignView do
|
||||||
cs -> Regex.match?(~r/^[A-Z0-9]+(-[A-Z0-9]{1,2})?$/i, cs)
|
cs -> Regex.match?(~r/^[A-Z0-9]+(-[A-Z0-9]{1,2})?$/i, cs)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,15 @@
|
||||||
{packet.data_type}
|
{packet.data_type}
|
||||||
</span>
|
</span>
|
||||||
</:col>
|
</: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">
|
<:col :let={packet} label="Destination">
|
||||||
<span class="text-sm text-gray-900">{packet.destination}</span>
|
<span class="text-sm text-gray-900">{packet.destination}</span>
|
||||||
</:col>
|
</:col>
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,15 @@
|
||||||
{packet.data_type}
|
{packet.data_type}
|
||||||
</span>
|
</span>
|
||||||
</:col>
|
</: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">
|
<:col :let={packet} label="Destination">
|
||||||
<span class="text-sm text-gray-900">{packet.destination}</span>
|
<span class="text-sm text-gray-900">{packet.destination}</span>
|
||||||
</:col>
|
</:col>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue