// Trail management module for APRS position history visualization import type { LayerGroup, Polyline, CircleMarker, PolylineOptions, } from "leaflet"; // Declare Leaflet as a global declare const L: typeof import("leaflet"); export interface PositionHistory { lat: number; lng: number; timestamp: number; } export interface TrailState { positions: PositionHistory[]; trail?: Polyline; dots?: CircleMarker[]; color?: string; } export class TrailManager { private trailLayer: LayerGroup; private trails: Map; private showTrails: boolean; private trailDuration: number; // in milliseconds private maxTrails: number = 500; // Maximum number of trails to keep in memory private maxPositionsPerTrail: number = 1000; // Maximum positions per trail private colorPalette: string[] = [ "#1E90FF", // Dodger Blue "#00CED1", // Dark Turquoise "#32CD32", // Lime Green "#8B008B", // Dark Magenta "#9370DB", // Medium Purple "#FF8C00", // Dark Orange "#4682B4", // Steel Blue "#00FA9A", // Medium Spring Green "#DA70D6", // Orchid "#008B8B", // Dark Cyan "#48D1CC", // Medium Turquoise "#228B22", // Forest Green "#6495ED", // Cornflower Blue "#FF1493", // Deep Pink (distinct from highways) "#20B2AA", // Light Sea Green ]; private proximityThreshold: number = 5.5; // kilometers constructor(trailLayer: LayerGroup, trailDuration: number = 60 * 60 * 1000) { this.trailLayer = trailLayer; this.trails = new Map(); this.showTrails = true; this.trailDuration = trailDuration; // Ensure trail layer is added to the map and visible if (this.trailLayer && this.trailLayer.bringToFront) { setTimeout(() => this.trailLayer.bringToFront(), 100); } } setShowTrails(show: boolean) { this.showTrails = show; if (!show) { // When hiding trails, just remove them from the layer but keep the data this.trails.forEach((trailState) => { if (trailState.trail) { this.trailLayer.removeLayer(trailState.trail); trailState.trail = undefined; } if (trailState.dots) { trailState.dots.forEach((dot) => this.trailLayer.removeLayer(dot)); trailState.dots = []; } }); } else { // When re-enabling trails, recreate all trails immediately, preserving colors this.trails.forEach((trailState, baseCallsign) => { this.updateTrailVisualization(baseCallsign, trailState, true); }); } } setTrailDuration(durationHours: number) { this.trailDuration = durationHours * 60 * 60 * 1000; // Convert hours to milliseconds // Clean up existing trails based on new duration const cutoffTime = Date.now() - this.trailDuration; this.trails.forEach((trailState, baseCallsign) => { // Filter positions based on new duration (skip historical dots) trailState.positions = trailState.positions.filter((pos) => { const posTimestamp = typeof pos.timestamp === "string" ? new Date(pos.timestamp).getTime() : pos.timestamp; return posTimestamp >= cutoffTime; }); // Update trail visualization this.updateTrailVisualization(baseCallsign, trailState, true); }); } addPosition( markerId: string, lat: number, lng: number, timestamp: number, isHistoricalDot: boolean = false, ) { if (!this.showTrails) return; // Validate coordinates before processing if ( typeof lat !== "number" || typeof lng !== "number" || isNaN(lat) || isNaN(lng) || !isFinite(lat) || !isFinite(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180 ) { console.warn("Invalid coordinates provided to addPosition:", { markerId, lat, lng, timestamp, }); return; } // Extract base callsign from markerId to group positions by callsign const baseCallsign = this.extractBaseCallsign(markerId); let trailState = this.trails.get(baseCallsign); if (!trailState) { // Check if we've reached the maximum number of trails if (this.trails.size >= this.maxTrails) { // Remove the oldest trail (first in the Map) const oldestKey = this.trails.keys().next().value; if (oldestKey) { this.removeTrail(oldestKey); } } trailState = { positions: [] }; this.trails.set(baseCallsign, trailState); } // Check if this position already exists (avoid duplicates) const existingPos = trailState.positions.find( (pos) => Math.abs(pos.lat - lat) < 0.00001 && Math.abs(pos.lng - lng) < 0.00001 && Math.abs(pos.timestamp - timestamp) < 1000, // Within 1 second ); if (!existingPos) { // Add new position trailState.positions.push({ lat, lng, timestamp }); // Sort positions by timestamp to maintain chronological order trailState.positions.sort((a, b) => a.timestamp - b.timestamp); // Limit the number of positions per trail if (trailState.positions.length > this.maxPositionsPerTrail) { // Keep the most recent positions trailState.positions = trailState.positions.slice( -this.maxPositionsPerTrail, ); } } // For historical dots, keep all positions. For live positions, filter by time. if (!isHistoricalDot) { const cutoffTime = Date.now() - this.trailDuration; trailState.positions = trailState.positions.filter((pos) => { // Ensure timestamp is a number for comparison const posTimestamp = typeof pos.timestamp === "string" ? new Date(pos.timestamp).getTime() : pos.timestamp; return posTimestamp >= cutoffTime; }); } // Always update trail visualization, with immediate=true for historical dots // This ensures historical trails are immediately visible when loaded all at once this.updateTrailVisualization(baseCallsign, trailState, isHistoricalDot); } private extractBaseCallsign(markerId: string | number): string { // Ensure markerId is a string const id = String(markerId); // For historical markers like "hist_CALLSIGN_123", extract the CALLSIGN part if (id.startsWith("hist_")) { // Remove hist_ prefix and any trailing numeric index const withoutPrefix = id.replace(/^hist_/, ""); // Remove trailing _digits pattern return withoutPrefix.replace(/_\d+$/, ""); } // For regular callsigns or IDs, return as-is return id; } // Calculate distance between two points using Haversine formula private calculateDistance( lat1: number, lng1: number, lat2: number, lng2: number, ): number { const R = 6371; // Earth's radius in km const dLat = ((lat2 - lat1) * Math.PI) / 180; const dLng = ((lng2 - lng1) * Math.PI) / 180; const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLng / 2) * Math.sin(dLng / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } // Get the average position of a trail private getTrailCenter(positions: PositionHistory[]): { lat: number; lng: number; } { if (positions.length === 0) return { lat: 0, lng: 0 }; const sum = positions.reduce( (acc, pos) => ({ lat: acc.lat + pos.lat, lng: acc.lng + pos.lng, }), { lat: 0, lng: 0 }, ); return { lat: sum.lat / positions.length, lng: sum.lng / positions.length, }; } // Find nearby trails and get their colors private getNearbyTrailColors( baseCallsign: string, center: { lat: number; lng: number }, ): Set { const nearbyColors = new Set(); this.trails.forEach((trailState, callsign) => { if ( callsign === baseCallsign || !trailState.color || trailState.positions.length === 0 ) return; const otherCenter = this.getTrailCenter(trailState.positions); const distance = this.calculateDistance( center.lat, center.lng, otherCenter.lat, otherCenter.lng, ); if (distance < this.proximityThreshold) { nearbyColors.add(trailState.color); } }); return nearbyColors; } // Assign a color to a trail based on nearby trails private assignTrailColor( baseCallsign: string, positions: PositionHistory[], ): string { if (positions.length === 0) return this.colorPalette[0]; const center = this.getTrailCenter(positions); const nearbyColors = this.getNearbyTrailColors(baseCallsign, center); // Find the first available color not used by nearby trails for (const color of this.colorPalette) { if (!nearbyColors.has(color)) { return color; } } // If all colors are used, return the first color return this.colorPalette[0]; } private updateTrailVisualization( baseCallsign: string, trailState: TrailState, immediate: boolean = false, ) { // 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 with valid coordinates if (trailState.positions.length >= 2) { // Filter out positions with invalid coordinates and create coordinate pairs const latLngs: [number, number][] = trailState.positions .filter((pos) => { // Validate coordinates are finite numbers within valid ranges return ( pos && typeof pos.lat === "number" && typeof pos.lng === "number" && !isNaN(pos.lat) && !isNaN(pos.lng) && isFinite(pos.lat) && isFinite(pos.lng) && pos.lat >= -90 && pos.lat <= 90 && pos.lng >= -180 && pos.lng <= 180 ); }) .map((pos) => [pos.lat, pos.lng]); // Only create trail if we still have at least 2 valid positions after filtering if (latLngs.length >= 2) { // Assign color if not already assigned if (!trailState.color) { trailState.color = this.assignTrailColor( baseCallsign, trailState.positions, ); } // Create polyline with assigned color // For historical positions (immediate=true), use higher opacity for better visibility const polylineOptions: PolylineOptions = { color: trailState.color, weight: 3, opacity: immediate ? 0.9 : 0.8, smoothFactor: 1, lineCap: "round", lineJoin: "round", className: "historical-trail-line", }; try { trailState.trail = L.polyline(latLngs, polylineOptions).addTo( this.trailLayer, ); } catch (error) { console.error( "Error creating trail polyline for", baseCallsign, ":", error, ); console.error("Invalid coordinates:", latLngs); } } // Don't create additional dots here since historical positions are now shown as markers trailState.dots = []; } } removeTrail(markerId: string) { const baseCallsign = this.extractBaseCallsign(markerId); const trailState = this.trails.get(baseCallsign); if (trailState) { if (trailState.trail) { this.trailLayer.removeLayer(trailState.trail); } if (trailState.dots) { trailState.dots.forEach((dot) => this.trailLayer.removeLayer(dot)); } this.trails.delete(baseCallsign); } } clearAllTrails() { this.trails.forEach((_, markerId) => { this.removeTrail(markerId); }); } cleanupOldPositions() { // Don't clean up historical positions - only clean up very old live positions const veryOldCutoff = Date.now() - 24 * 60 * 60 * 1000; // 24 hours this.trails.forEach((trailState, markerId) => { const originalLength = trailState.positions.length; trailState.positions = trailState.positions.filter((pos) => { // Ensure timestamp is a number for comparison const posTimestamp = typeof pos.timestamp === "string" ? new Date(pos.timestamp).getTime() : pos.timestamp; // Keep all positions newer than 24 hours (includes all historical data we care about) return posTimestamp >= veryOldCutoff; }); if (trailState.positions.length === 0) { this.removeTrail(markerId); } else if (trailState.positions.length !== originalLength) { this.updateTrailVisualization(markerId, trailState); } }); } }