// 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, ">") .replace(/"/g, """) .replace(/'/g, "'"); } /** * 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(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; }