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.
194 lines
5.2 KiB
TypeScript
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, "<")
|
|
.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<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;
|
|
}
|