The coord-validation and antimeridian-unwrap logic added in the last pass had been duplicated across map.ts, trail_manager.ts, and info_map.ts. Move both into map_helpers.ts and import from there so there is one authoritative implementation.
594 lines
19 KiB
TypeScript
594 lines
19 KiB
TypeScript
// 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<string, TrailState>;
|
|
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<string, boolean> = 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<typeof setTimeout>;
|
|
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<string> {
|
|
const nearbyColors = new Set<string>();
|
|
|
|
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<string>();
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
}
|