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:
parent
0ed2dd3806
commit
f777dcbb35
2 changed files with 80 additions and 20 deletions
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue