// Trail management module for APRS position history visualization import type { LayerGroup, Polyline, CircleMarker, PolylineOptions, } from "leaflet"; import { isValidCoordinate, unwrapLongitudes } from "../map_helpers"; // Declare Leaflet as a global declare const L: typeof import("leaflet"); export interface PositionHistory { lat: number; lng: number; timestamp: number; path?: string; } 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 pendingTrailUpdates: Map = new Map(); // callsign -> isHistorical private trailUpdateRafId: number | null = null; // Colors ordered to maximize contrast between consecutive assignments. // Avoids yellows, oranges, and grays that blend with roads on map tiles. private colorPalette: string[] = [ "#E6194B", // Crimson "#469990", // Teal "#F032E6", // Magenta "#4363D8", // Blue "#D50000", // Red "#3CB44B", // Green "#FF1493", // Deep Pink "#42D4F4", // Cyan "#911EB4", // Purple "#00CC66", // Emerald "#7B68EE", // Slate Blue "#76FF03", // Lime "#E040FB", // Heliotrope "#1E90FF", // Dodger Blue "#00BFA5", // Aquamarine ]; private proximityThreshold: number = 5.5; // kilometers private minMovementThreshold: number = 0.1; // km - minimum total distance to show a trail // km - max distance between consecutive points before breaking the trail. // Must tolerate legitimately fast movers (aircraft, balloons, high-altitude) // reporting ~1/min — too-tight values leave gaps between real movements. private maxHopDistance: number = 100; private minSegmentPoints: number = 2; // minimum points in a segment to draw it private onTrailHover?: (lat: number, lng: number, path: string) => void; private onTrailHoverEnd?: () => void; private trailHoverDebounceTimer?: ReturnType; private lastHoveredPath?: string; private isDestroyed: boolean = false; constructor( trailLayer: LayerGroup, trailDuration: number = 60 * 60 * 1000, onTrailHover?: (lat: number, lng: number, path: string) => void, onTrailHoverEnd?: () => void, ) { this.trailLayer = trailLayer; this.trails = new Map(); this.showTrails = true; this.trailDuration = trailDuration; this.onTrailHover = onTrailHover; this.onTrailHoverEnd = onTrailHoverEnd; // 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, path?: string, ) { if (!this.showTrails) return; if (!isValidCoordinate(lat, lng)) { 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, path }); // 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; }); } // Defer trail visualization to batch multiple updates into one frame this.scheduleTrailUpdate(baseCallsign, isHistoricalDot); } private scheduleTrailUpdate(baseCallsign: string, isHistorical: boolean) { // Track which trails need updating; preserve isHistorical=true if set const existing = this.pendingTrailUpdates.get(baseCallsign); this.pendingTrailUpdates.set(baseCallsign, existing || isHistorical); if (this.trailUpdateRafId === null) { this.trailUpdateRafId = requestAnimationFrame(() => { this.flushPendingTrailUpdates(); }); } } private flushPendingTrailUpdates() { this.trailUpdateRafId = null; if (this.isDestroyed) return; for (const [callsign, isHistorical] of this.pendingTrailUpdates) { const trailState = this.trails.get(callsign); if (trailState) { this.updateTrailVisualization(callsign, trailState, isHistorical); } } this.pendingTrailUpdates.clear(); } 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; // Normalize longitude delta to [-180, 180] so antimeridian-crossing pairs // (e.g. 179.9 → -179.9) compute the real short-way distance instead of a // bogus near-global one that would spuriously split the trail. let lngDiff = lng2 - lng1; if (lngDiff > 180) lngDiff -= 360; else if (lngDiff < -180) lngDiff += 360; const dLng = (lngDiff * 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. Returns null for empty arrays so // callers can't accidentally treat null island (0, 0) as a real center. private getTrailCenter(positions: PositionHistory[]): { lat: number; lng: number; } | null { if (positions.length === 0) return null; 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); if (!otherCenter) return; 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, ensuring global uniqueness when possible private assignTrailColor( baseCallsign: string, positions: PositionHistory[], ): string { // Collect all colors currently used by other trails const usedColors = new Set(); this.trails.forEach((trailState, callsign) => { if (callsign !== baseCallsign && trailState.color) { usedColors.add(trailState.color); } }); // First priority: pick a color not used by any existing trail for (const color of this.colorPalette) { if (!usedColors.has(color)) { return color; } } // All 15 colors in use — pick one not used by nearby trails const center = this.getTrailCenter(positions); if (center) { const nearbyColors = this.getNearbyTrailColors(baseCallsign, center); for (const color of this.colorPalette) { if (!nearbyColors.has(color)) { return color; } } } return this.colorPalette[0]; } private updateTrailVisualization( baseCallsign: string, trailState: TrailState, immediate: boolean = false, ) { // Remove old trail and dots. Unbind listeners first so any queued mouse // events on the outgoing polyline can't fire after we've dropped the // state they close over. if (trailState.trail) { trailState.trail.off("mousemove"); trailState.trail.off("mouseout"); 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 validPositions: [number, number][] = trailState.positions .filter((pos) => pos && isValidCoordinate(pos.lat, pos.lng)) .map((pos) => [pos.lat, pos.lng]); // Calculate total path distance to determine if station is actually moving let totalDistance = 0; for (let i = 1; i < validPositions.length; i++) { totalDistance += this.calculateDistance( validPositions[i - 1][0], validPositions[i - 1][1], validPositions[i][0], validPositions[i][1], ); } // Skip trail if station hasn't moved enough (stationary station) if (totalDistance < this.minMovementThreshold) { trailState.dots = []; return; } // Unwrap longitudes so segments that cross the antimeridian are drawn as // a short local line rather than a polyline stretched across the world. const unwrappedPositions = unwrapLongitudes(validPositions); // Break trail into segments at giant jumps (bad data / different igates). // Hop distance uses the Haversine calculator which normalizes the lng // delta, so antimeridian-crossing hops are measured correctly. const segments: [number, number][][] = [[]]; segments[0].push(unwrappedPositions[0]); for (let i = 1; i < unwrappedPositions.length; i++) { const hopDist = this.calculateDistance( unwrappedPositions[i - 1][0], unwrappedPositions[i - 1][1], unwrappedPositions[i][0], unwrappedPositions[i][1], ); if (hopDist > this.maxHopDistance) { // Start a new segment segments.push([unwrappedPositions[i]]); } else { segments[segments.length - 1].push(unwrappedPositions[i]); } } // Draw only segments with enough points to indicate real movement const drawableSegments = segments.filter( (seg) => seg.length >= this.minSegmentPoints, ); if (drawableSegments.length > 0) { if (!trailState.color) { trailState.color = this.assignTrailColor( baseCallsign, trailState.positions, ); } 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 { // L.polyline accepts an array of arrays for multi-segment lines trailState.trail = L.polyline( drawableSegments, polylineOptions, ).addTo(this.trailLayer); // Attach hover handlers for RF path visualization if (this.onTrailHover && this.onTrailHoverEnd) { const positions = trailState.positions; const hoverCb = this.onTrailHover; const hoverEndCb = this.onTrailHoverEnd; trailState.trail.on("mousemove", (e: L.LeafletMouseEvent) => { const nearest = this.findNearestPositionWithPath( e.latlng.lat, e.latlng.lng, positions, ); if (!nearest) return; if (this.trailHoverDebounceTimer) { clearTimeout(this.trailHoverDebounceTimer); } const mouseLat = e.latlng.lat; const mouseLng = e.latlng.lng; const path = nearest.path!; // Only push to server when the path changes; just reposition locally otherwise if (path !== this.lastHoveredPath) { this.lastHoveredPath = path; this.trailHoverDebounceTimer = setTimeout(() => { if (!this.isDestroyed) hoverCb(mouseLat, mouseLng, path); }, 50); } }); trailState.trail.on("mouseout", () => { this.lastHoveredPath = undefined; if (this.trailHoverDebounceTimer) { clearTimeout(this.trailHoverDebounceTimer); this.trailHoverDebounceTimer = undefined; } hoverEndCb(); }); } } catch (error) { console.error( "Error creating trail polyline for", baseCallsign, ":", error, ); } } trailState.dots = []; } } private findNearestPositionWithPath( lat: number, lng: number, positions: PositionHistory[], ): PositionHistory | null { let nearest: PositionHistory | null = null; let minDist = Infinity; for (const pos of positions) { if (!pos.path || pos.path.trim() === "") continue; const dist = (pos.lat - lat) ** 2 + (pos.lng - lng) ** 2; if (dist < minDist) { minDist = dist; nearest = pos; } } return nearest; } 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() { // Clear pending hover timer before removing all trails if (this.trailHoverDebounceTimer) { clearTimeout(this.trailHoverDebounceTimer); this.trailHoverDebounceTimer = undefined; } this.lastHoveredPath = undefined; this.trails.forEach((_, markerId) => { this.removeTrail(markerId); }); } destroy() { this.isDestroyed = true; if (this.trailUpdateRafId !== null) { cancelAnimationFrame(this.trailUpdateRafId); this.trailUpdateRafId = null; } if (this.trailHoverDebounceTimer) { clearTimeout(this.trailHoverDebounceTimer); this.trailHoverDebounceTimer = undefined; } this.pendingTrailUpdates.clear(); } 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); } }); } }