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 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
// 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>;
@ -263,7 +266,13 @@ export class TrailManager {
): number {
const R = 6371; // Earth's radius in km
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 =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) *
@ -274,6 +283,27 @@ export class TrailManager {
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
private getTrailCenter(positions: PositionHistory[]): {
lat: number;
@ -365,8 +395,12 @@ export class TrailManager {
trailState: TrailState,
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) {
trailState.trail.off("mousemove");
trailState.trail.off("mouseout");
this.trailLayer.removeLayer(trailState.trail);
}
if (trailState.dots) {
@ -412,21 +446,27 @@ export class TrailManager {
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][][] = [[]];
segments[0].push(validPositions[0]);
for (let i = 1; i < validPositions.length; i++) {
segments[0].push(unwrappedPositions[0]);
for (let i = 1; i < unwrappedPositions.length; i++) {
const hopDist = this.calculateDistance(
validPositions[i - 1][0],
validPositions[i - 1][1],
validPositions[i][0],
validPositions[i][1],
unwrappedPositions[i - 1][0],
unwrappedPositions[i - 1][1],
unwrappedPositions[i][0],
unwrappedPositions[i][1],
);
if (hopDist > this.maxHopDistance) {
// Start a new segment
segments.push([validPositions[i]]);
segments.push([unwrappedPositions[i]]);
} 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;
}
// If we have less than 2 points, don't draw a line
if (data.points.length < 2) {
console.debug(`Not enough points for trail line: ${data.points.length}`);
// Filter out any invalid coordinates before building the polyline —
// NaN / Infinity / out-of-range values make Leaflet render junk.
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;
}
// Extract coordinates for polyline
const latlngs = data.points.map(point => [point.lat, point.lng] as [number, number]);
// Unwrap longitudes so a trail that crosses the antimeridian draws as a
// 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
const L = (window as any).L;
@ -1673,7 +1692,7 @@ let MapAPRSMap = {
// Add to 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
@ -2137,7 +2156,7 @@ let MapAPRSMap = {
if (data.lat && data.lng) {
const lat = parseFloat(data.lat.toString());
const lng = parseFloat(data.lng.toString());
if (self.isValidCoordinate(lat, lng)) {
if (isValidCoordinate(lat, lng)) {
const currentPos = existingMarker.getLatLng();
const positionChanged =
Math.abs(currentPos.lat - lat) > 0.0001 ||
@ -2499,6 +2518,7 @@ let MapAPRSMap = {
if (self.boundsTimer !== undefined) {
clearTimeout(self.boundsTimer);
self.boundsTimer = undefined;
}
if (self.resizeHandler !== undefined) {
window.removeEventListener("resize", self.resizeHandler);