diff --git a/assets/js/features/trail_manager.ts b/assets/js/features/trail_manager.ts index 94b8c21..1cceeb5 100644 --- a/assets/js/features/trail_manager.ts +++ b/assets/js/features/trail_manager.ts @@ -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 diff --git a/assets/js/hooks/info_map.ts b/assets/js/hooks/info_map.ts index 155fc98..0bd91c0 100644 --- a/assets/js/hooks/info_map.ts +++ b/assets/js/hooks/info_map.ts @@ -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}`, ); diff --git a/assets/js/map.ts b/assets/js/map.ts index 274e830..c7e9d28 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -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( diff --git a/assets/js/map_helpers.ts b/assets/js/map_helpers.ts index 513b68c..9571654 100644 --- a/assets/js/map_helpers.ts +++ b/assets/js/map_helpers.ts @@ -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; +}