fix(map): stop trail spidering across antimeridian and close gaps

- Normalize longitude delta in trail_manager Haversine so wraps across
  180/-180 no longer compute a near-global distance and spuriously
  split the trail into short-segment-filtered pieces.
- Unwrap longitudes before building segments and drawing polylines in
  both the per-station TrailManager and the show_trail_line handler so
  a date-line crossing renders as a short hop instead of a line that
  wraps around the world.
- Raise maxHopDistance (10 -> 100 km) and drop minSegmentPoints (3 -> 2)
  so legitimate fast movers (aircraft, balloons) and short valid hops
  are drawn instead of being dropped.
- Unbind mousemove/mouseout handlers on a trail before removing it so
  queued events can't fire against discarded state.
- Fix self.isValidCoordinate TypeError in updateMarker (function is
  module-level, not on the hook) and add coord validation to
  show_trail_line.
- Null boundsTimer after clearTimeout for cleanup consistency.
This commit is contained in:
Graham McIntire 2026-04-20 18:03:18 -05:00
parent 0ed2dd3806
commit f777dcbb35
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 80 additions and 20 deletions

View file

@ -54,8 +54,11 @@ export class TrailManager {
]; ];
private proximityThreshold: number = 5.5; // kilometers private proximityThreshold: number = 5.5; // kilometers
private minMovementThreshold: number = 0.1; // km - minimum total distance to show a trail 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 // km - max distance between consecutive points before breaking the trail.
private minSegmentPoints: number = 3; // minimum points in a segment to draw it // 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 onTrailHover?: (lat: number, lng: number, path: string) => void;
private onTrailHoverEnd?: () => void; private onTrailHoverEnd?: () => void;
private trailHoverDebounceTimer?: ReturnType<typeof setTimeout>; private trailHoverDebounceTimer?: ReturnType<typeof setTimeout>;
@ -263,7 +266,13 @@ export class TrailManager {
): number { ): number {
const R = 6371; // Earth's radius in km const R = 6371; // Earth's radius in km
const dLat = ((lat2 - lat1) * Math.PI) / 180; const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLng = ((lng2 - lng1) * 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 = const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat1 * Math.PI) / 180) *
@ -274,6 +283,27 @@ export class TrailManager {
return R * c; return R * c;
} }
// Unwrap a longitude sequence so polylines drawn across the antimeridian
// render as a short hop rather than a line wrapping around the world.
// Each point's longitude is shifted by a cumulative multiple of 360 so the
// delta between consecutive points is always the short-way difference.
private unwrapLongitudes(
positions: [number, number][],
): [number, number][] {
if (positions.length === 0) return [];
const unwrapped: [number, number][] = [[positions[0][0], positions[0][1]]];
let offset = 0;
for (let i = 1; i < positions.length; i++) {
const [lat, lng] = positions[i];
const prevRawLng = positions[i - 1][1];
const delta = lng - prevRawLng;
if (delta > 180) offset -= 360;
else if (delta < -180) offset += 360;
unwrapped.push([lat, lng + offset]);
}
return unwrapped;
}
// Get the average position of a trail // Get the average position of a trail
private getTrailCenter(positions: PositionHistory[]): { private getTrailCenter(positions: PositionHistory[]): {
lat: number; lat: number;
@ -365,8 +395,12 @@ export class TrailManager {
trailState: TrailState, trailState: TrailState,
immediate: boolean = false, immediate: boolean = false,
) { ) {
// Remove old trail and dots // 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) { if (trailState.trail) {
trailState.trail.off("mousemove");
trailState.trail.off("mouseout");
this.trailLayer.removeLayer(trailState.trail); this.trailLayer.removeLayer(trailState.trail);
} }
if (trailState.dots) { if (trailState.dots) {
@ -412,21 +446,27 @@ export class TrailManager {
return; return;
} }
// Break trail into segments at giant jumps (bad data / different igates) // 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 = this.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][][] = [[]]; const segments: [number, number][][] = [[]];
segments[0].push(validPositions[0]); segments[0].push(unwrappedPositions[0]);
for (let i = 1; i < validPositions.length; i++) { for (let i = 1; i < unwrappedPositions.length; i++) {
const hopDist = this.calculateDistance( const hopDist = this.calculateDistance(
validPositions[i - 1][0], unwrappedPositions[i - 1][0],
validPositions[i - 1][1], unwrappedPositions[i - 1][1],
validPositions[i][0], unwrappedPositions[i][0],
validPositions[i][1], unwrappedPositions[i][1],
); );
if (hopDist > this.maxHopDistance) { if (hopDist > this.maxHopDistance) {
// Start a new segment // Start a new segment
segments.push([validPositions[i]]); segments.push([unwrappedPositions[i]]);
} else { } else {
segments[segments.length - 1].push(validPositions[i]); segments[segments.length - 1].push(unwrappedPositions[i]);
} }
} }

View file

@ -1648,14 +1648,33 @@ let MapAPRSMap = {
self.heatLayer = undefined; self.heatLayer = undefined;
} }
// If we have less than 2 points, don't draw a line // Filter out any invalid coordinates before building the polyline —
if (data.points.length < 2) { // NaN / Infinity / out-of-range values make Leaflet render junk.
console.debug(`Not enough points for trail line: ${data.points.length}`); const validPoints = data.points.filter((point) =>
isValidCoordinate(point.lat, point.lng),
);
// If we have less than 2 valid points, don't draw a line
if (validPoints.length < 2) {
console.debug(
`Not enough valid points for trail line: ${validPoints.length} of ${data.points.length}`,
);
return; return;
} }
// Extract coordinates for polyline // Unwrap longitudes so a trail that crosses the antimeridian draws as a
const latlngs = data.points.map(point => [point.lat, point.lng] as [number, number]); // short hop instead of a polyline that spans the whole world.
const latlngs: [number, number][] = [
[validPoints[0].lat, validPoints[0].lng],
];
let offset = 0;
for (let i = 1; i < validPoints.length; i++) {
const prevRawLng = validPoints[i - 1].lng;
const delta = validPoints[i].lng - prevRawLng;
if (delta > 180) offset -= 360;
else if (delta < -180) offset += 360;
latlngs.push([validPoints[i].lat, validPoints[i].lng + offset]);
}
// Access Leaflet from window // Access Leaflet from window
const L = (window as any).L; const L = (window as any).L;
@ -1673,7 +1692,7 @@ let MapAPRSMap = {
// Add to map // Add to map
self.trailLine.addTo(self.map); self.trailLine.addTo(self.map);
console.debug(`Trail line added for ${data.callsign} with ${data.points.length} points`); console.debug(`Trail line added for ${data.callsign} with ${validPoints.length} points`);
}); });
// Handle clearing trail line // Handle clearing trail line
@ -2137,7 +2156,7 @@ let MapAPRSMap = {
if (data.lat && data.lng) { if (data.lat && data.lng) {
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 (self.isValidCoordinate(lat, lng)) { if (isValidCoordinate(lat, lng)) {
const currentPos = existingMarker.getLatLng(); const currentPos = existingMarker.getLatLng();
const positionChanged = const positionChanged =
Math.abs(currentPos.lat - lat) > 0.0001 || Math.abs(currentPos.lat - lat) > 0.0001 ||
@ -2499,6 +2518,7 @@ let MapAPRSMap = {
if (self.boundsTimer !== undefined) { if (self.boundsTimer !== undefined) {
clearTimeout(self.boundsTimer); clearTimeout(self.boundsTimer);
self.boundsTimer = undefined;
} }
if (self.resizeHandler !== undefined) { if (self.resizeHandler !== undefined) {
window.removeEventListener("resize", self.resizeHandler); window.removeEventListener("resize", self.resizeHandler);