refactor(map): share isValidCoordinate and unwrapLongitudes helpers

The coord-validation and antimeridian-unwrap logic added in the last
pass had been duplicated across map.ts, trail_manager.ts, and
info_map.ts. Move both into map_helpers.ts and import from there so
there is one authoritative implementation.
This commit is contained in:
Graham McIntire 2026-04-21 09:17:51 -05:00
parent c41e47ed95
commit 9aa836e462
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
4 changed files with 57 additions and 89 deletions

View file

@ -7,6 +7,8 @@ import type {
PolylineOptions,
} from "leaflet";
import { isValidCoordinate, unwrapLongitudes } from "../map_helpers";
// Declare Leaflet as a global
declare const L: typeof import("leaflet");
@ -137,19 +139,7 @@ export class TrailManager {
) {
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
) {
if (!isValidCoordinate(lat, lng)) {
console.warn("Invalid coordinates provided to addPosition:", {
markerId,
lat,
@ -283,27 +273,6 @@ 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. Returns null for empty arrays so
// callers can't accidentally treat null island (0, 0) as a real center.
private getTrailCenter(positions: PositionHistory[]): {
@ -415,21 +384,7 @@ export class TrailManager {
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
);
})
.filter((pos) => pos && isValidCoordinate(pos.lat, pos.lng))
.map((pos) => [pos.lat, pos.lng]);
// Calculate total path distance to determine if station is actually moving
@ -451,7 +406,7 @@ export class TrailManager {
// 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);
const unwrappedPositions = unwrapLongitudes(validPositions);
// Break trail into segments at giant jumps (bad data / different igates).
// Hop distance uses the Haversine calculator which normalizes the lng

View file

@ -1,24 +1,13 @@
// Simple map hook for displaying a single station on the info page
import { isValidCoordinate } from "../map_helpers";
declare const L: typeof import("leaflet");
// ~1.1 m at the equator — enough to ignore float noise but still notice any
// real position change from the server.
const POSITION_EPSILON = 0.00001;
function isValidLatLng(lat: number, lng: number): boolean {
return (
!isNaN(lat) &&
!isNaN(lng) &&
isFinite(lat) &&
isFinite(lng) &&
lat >= -90 &&
lat <= 90 &&
lng >= -180 &&
lng <= 180
);
}
interface InfoMapContext {
el: HTMLElement;
map: import("leaflet").Map | null;
@ -46,7 +35,7 @@ export const InfoMap = {
const symbolHtml = this.el.dataset.symbolHtml || null;
const callsign = this.el.dataset.callsign || "unknown";
if (!isValidLatLng(lat, lon)) {
if (!isValidCoordinate(lat, lon)) {
console.warn(
`InfoMap: Invalid coordinates lat=${lat}, lon=${lon} for ${callsign} during update`,
);
@ -117,7 +106,7 @@ function initializeMap(this: InfoMapContext) {
const callsign = this.el.dataset.callsign || "unknown";
const symbolHtml = this.el.dataset.symbolHtml || null;
if (!isValidLatLng(lat, lon)) {
if (!isValidCoordinate(lat, lon)) {
console.warn(
`InfoMap: Invalid coordinates lat=${lat}, lon=${lon} for ${callsign}`,
);

View file

@ -65,6 +65,8 @@ import {
safePushEvent,
getLiveSocket,
escapeHtml,
isValidCoordinate,
unwrapLongitudes,
} from "./map_helpers";
// APRS Map Hook - handles only basic map interaction
@ -1674,17 +1676,9 @@ let MapAPRSMap = {
// 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]);
}
const latlngs = unwrapLongitudes(
validPoints.map((p) => [p.lat, p.lng] as [number, number]),
);
// Access Leaflet from window
const L = (window as any).L;
@ -2650,19 +2644,6 @@ function extractCoordinate(value: any): number {
return NaN;
}
// Helper to validate coordinates
function isValidCoordinate(lat: number, lng: number): boolean {
return (
!isNaN(lat) &&
!isNaN(lng) &&
isFinite(lat) &&
isFinite(lng) &&
lat >= -90 &&
lat <= 90 &&
lng >= -180 &&
lng <= 180
);
}
// Helper to create divIcon with common defaults
function createDivIcon(

View file

@ -149,3 +149,46 @@ export function isLiveViewConnected(): boolean {
export function getLiveSocket(): LiveSocket | null {
return typeof window !== 'undefined' ? window.liveSocket || null : null;
}
/**
* Validate that a lat/lng pair is a real, in-range, finite coordinate.
* Used everywhere we receive coordinates from the server or the DOM, so
* NaN / Infinity / out-of-range values never reach Leaflet.
*/
export function isValidCoordinate(lat: number, lng: number): boolean {
return (
typeof lat === "number" &&
typeof lng === "number" &&
!isNaN(lat) &&
!isNaN(lng) &&
isFinite(lat) &&
isFinite(lng) &&
lat >= -90 &&
lat <= 90 &&
lng >= -180 &&
lng <= 180
);
}
/**
* Unwrap a sequence of [lat, lng] points so a polyline that crosses the
* antimeridian renders as a short hop instead of a line looping around the
* world. Each point's lng is shifted by a cumulative multiple of 360 so the
* delta between consecutive points is always the short-way difference.
*/
export function 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;
}