aprs.me/assets/js/map_helpers.ts
Graham McIntire 9aa836e462
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.
2026-04-21 09:17:52 -05:00

194 lines
5.2 KiB
TypeScript

// Helper functions for map.ts to reduce code duplication
import type * as L from 'leaflet';
import type { BaseEventPayload, PushEventFunction } from './types/events';
import type { LiveSocket } from './types/map';
export interface MapState {
lat: number;
lng: number;
zoom: number;
}
export interface BoundsData {
north: number;
south: number;
east: number;
west: number;
}
/**
* Escape HTML special characters to prevent XSS when building DOM strings.
*/
export function escapeHtml(str: string): string {
return str
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/**
* Parse timestamp to milliseconds
*/
export function parseTimestamp(timestamp: string | number | Date | undefined): number {
if (!timestamp) return Date.now();
if (typeof timestamp === "number") {
return timestamp;
} else if (typeof timestamp === "string") {
const parsed = new Date(timestamp).getTime();
return Number.isNaN(parsed) ? Date.now() : parsed;
} else if (timestamp instanceof Date) {
const parsed = timestamp.getTime();
return Number.isNaN(parsed) ? Date.now() : parsed;
}
return Date.now();
}
/**
* Get trail ID from marker data
*/
export function getTrailId(data: { callsign_group?: string; callsign?: string; id: string | number }): string {
// Prioritize callsign_group and callsign over extracting from ID
if (data.callsign_group) {
return data.callsign_group;
}
if (data.callsign) {
return data.callsign;
}
// For historical markers like "hist_CALLSIGN_123", extract the base callsign
const id = String(data.id);
if (id.startsWith("hist_")) {
// Remove hist_ prefix and any trailing numeric index
const withoutPrefix = id.replace(/^hist_/, "");
// Remove trailing _digits pattern
return withoutPrefix.replace(/_\d+$/, "");
}
// For regular IDs, return as string
return String(data.id);
}
/**
* Save map state to localStorage and send to server
*/
// PushEventFunction is now imported from types/events
export function saveMapState(map: L.Map, pushEvent: PushEventFunction) {
if (!map || !pushEvent) {
console.warn("saveMapState called with invalid map or pushEvent");
return;
}
try {
const center = map.getCenter();
const zoom = map.getZoom();
// Truncate lat/lng to 5 decimal places for URL
const truncatedLat = Math.round(center.lat * 100000) / 100000;
const truncatedLng = Math.round(center.lng * 100000) / 100000;
// Send combined map state update to server for URL and bounds updating
const payload = {
center: { lat: truncatedLat, lng: truncatedLng },
zoom: zoom,
bounds: {
north: map.getBounds().getNorth(),
south: map.getBounds().getSouth(),
east: map.getBounds().getEast(),
west: map.getBounds().getWest(),
}
};
// Use safePushEvent to handle disconnected state
safePushEvent(pushEvent, "update_map_state", payload);
} catch (error) {
console.error("Error in saveMapState:", error);
}
}
/**
* Safely push event to LiveView
*/
export function safePushEvent<T extends BaseEventPayload = BaseEventPayload>(pushEvent: PushEventFunction | undefined, event: string, payload: T): boolean {
if (!pushEvent || typeof pushEvent !== 'function') {
return false;
}
try {
pushEvent(event, payload);
return true;
} catch (e) {
return false;
}
}
/**
* Check if LiveView socket is available
*/
export function isLiveViewConnected(): boolean {
const liveSocket = getLiveSocket();
if (!liveSocket) {
return false;
}
if (typeof liveSocket.isConnected === "function") {
return liveSocket.isConnected();
}
return liveSocket.connected !== false;
}
/**
* Get LiveView socket
*/
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;
}