From f777dcbb35c24582729edc922e62c7b13e60d22d Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Mon, 20 Apr 2026 18:03:18 -0500 Subject: [PATCH] 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. --- assets/js/features/trail_manager.ts | 66 +++++++++++++++++++++++------ assets/js/map.ts | 34 ++++++++++++--- 2 files changed, 80 insertions(+), 20 deletions(-) diff --git a/assets/js/features/trail_manager.ts b/assets/js/features/trail_manager.ts index 9070a54..9dcc47d 100644 --- a/assets/js/features/trail_manager.ts +++ b/assets/js/features/trail_manager.ts @@ -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; @@ -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]); } } diff --git a/assets/js/map.ts b/assets/js/map.ts index e7ac99d..857b120 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -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);