Use mousemove instead of mouseover so the RF path updates as the cursor moves along the trail. Send the mouse position rather than the nearest-position's coordinates so the path line anchors where the user is actually hovering. Debounce and deduplicate events to avoid flooding the server.
536 lines
16 KiB
TypeScript
536 lines
16 KiB
TypeScript
// 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;
|
|
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 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
|
|
private minMovementThreshold: number = 0.1; // km - minimum total distance to show a trail
|
|
private maxHopDistance: number = 10; // km - max distance between consecutive points before breaking
|
|
private minSegmentPoints: number = 3; // 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;
|
|
|
|
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;
|
|
|
|
// 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, 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;
|
|
});
|
|
}
|
|
|
|
// 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<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);
|
|
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 validPositions: [number, number][] = trailState.positions
|
|
.filter((pos) => {
|
|
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]);
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Break trail into segments at giant jumps (bad data / different igates)
|
|
const segments: [number, number][][] = [[]];
|
|
segments[0].push(validPositions[0]);
|
|
for (let i = 1; i < validPositions.length; i++) {
|
|
const hopDist = this.calculateDistance(
|
|
validPositions[i - 1][0],
|
|
validPositions[i - 1][1],
|
|
validPositions[i][0],
|
|
validPositions[i][1],
|
|
);
|
|
if (hopDist > this.maxHopDistance) {
|
|
// Start a new segment
|
|
segments.push([validPositions[i]]);
|
|
} else {
|
|
segments[segments.length - 1].push(validPositions[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(() => {
|
|
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() {
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
}
|