// Import type definitions import type { LiveViewHookContext, MarkerData, BoundsData, CenterData, MarkerState, MapEventData, } from "./types/map"; import type { Map as LeafletMap, Marker, TileLayer, LayerGroup, DivIcon, LatLngBounds, Polyline, } from "leaflet"; import type { LeafletTouchEvent, LeafletPopupEvent, } from "./types/leaflet-events"; import type { HeatLayer, MarkerClusterGroup, OverlappingMarkerSpiderfier, MarkerClusterGroupOptions, HeatLayerOptions, MarkerCluster, HeatLatLng, } from "./types/leaflet-plugins"; import type { APRSMarker } from "./types/marker-extensions"; import type { BaseEventPayload } from "./types/events"; // Leaflet and plugins are loaded globally from vendor bundle // We'll access window.L dynamically when needed instead of storing it at module load time // CSS files are bundled in vendor.js // The OverlappingMarkerSpiderfier is attached to window by the UMD module declare global { interface Window { OverlappingMarkerSpiderfier: any; } } // Add plugin types to Leaflet declare module "leaflet" { export function heatLayer( latlngs: HeatLatLng[], options?: HeatLayerOptions, ): HeatLayer; export function markerClusterGroup( options?: MarkerClusterGroupOptions, ): MarkerClusterGroup; } // Import trail management functionality import { TrailManager } from "./features/trail_manager"; // Import helper functions import { parseTimestamp, getTrailId, saveMapState, safePushEvent, getLiveSocket, escapeHtml, isValidCoordinate, unwrapLongitudes, } from "./map_helpers"; // APRS Map Hook - handles only basic map interaction // All data logic handled by LiveView // Zoom level at which marker clustering is disabled and individual markers are shown. // Trails are only visible at this zoom level and above to avoid showing trails // for markers hidden inside cluster bubbles. const DISABLE_CLUSTERING_AT_ZOOM = 11; let MapAPRSMap = { mounted() { const self = this as unknown as LiveViewHookContext; // Initialize error tracking self.errors = []; self.initializationAttempts = 0; self.maxInitializationAttempts = 3; self.attemptInitialization(); }, attemptInitialization() { const self = this as unknown as LiveViewHookContext; self.initializationAttempts!++; // Check if Leaflet is available if (typeof window.L === "undefined") { console.error("Leaflet library not loaded!"); self.errors!.push("Leaflet library not available"); if (self.initializationAttempts! < self.maxInitializationAttempts!) { setTimeout(() => self.attemptInitialization(), 1000); return; } else { self.handleFatalError( "Leaflet library failed to load after multiple attempts", ); return; } } // Create a local reference to Leaflet for this function const L = window.L; // Initialize with URL parameters first (from data attributes) let initialCenter: CenterData, initialZoom: number; let useUrlParams = false; try { const centerData = self.el.dataset.center; const zoomData = self.el.dataset.zoom; if (!centerData || !zoomData) throw new Error("Missing map data attributes"); initialCenter = JSON.parse(centerData); initialZoom = parseInt(zoomData); if ( !initialCenter || typeof initialCenter.lat !== "number" || typeof initialCenter.lng !== "number" ) throw new Error("Invalid center data"); if (isNaN(initialZoom) || initialZoom < 1 || initialZoom > 20) throw new Error("Invalid zoom data"); // Check if URL has explicit parameters (not default values) const urlParams = new URLSearchParams(window.location.search); useUrlParams = urlParams.has("lat") || urlParams.has("lng") || urlParams.has("z"); } catch (error) { console.error("Error parsing map data attributes:", error); initialCenter = { lat: 39.8283, lng: -98.5795 }; initialZoom = 5; } self.initializeMap(initialCenter, initialZoom); }, initializeMap(initialCenter: CenterData, initialZoom: number) { const self = this as unknown as LiveViewHookContext; // Check if Leaflet is still available if (typeof window.L === "undefined") { console.error("Leaflet library not loaded!"); self.handleFatalError("Leaflet library not available"); return; } // Create a local reference to Leaflet for this function const L = window.L; // Ensure the element and its parent exist if (!self.el || !self.el.parentNode) { console.warn("Map element or parent not found, retrying..."); if (self.initializationAttempts! < self.maxInitializationAttempts!) { setTimeout(() => self.attemptInitialization(), 500); return; } else { self.handleFatalError("Map element structure invalid"); return; } } // Check element dimensions const rect = self.el.getBoundingClientRect(); // Validate element has dimensions if (rect.width === 0 || rect.height === 0) { console.warn("Map element has no dimensions, retrying..."); self.errors!.push("Map element has zero dimensions"); // Try to force dimensions self.el.style.width = "100%"; self.el.style.height = "100vh"; if (self.initializationAttempts! < self.maxInitializationAttempts!) { setTimeout(() => self.attemptInitialization(), 500); return; } else { self.handleFatalError("Map element never gained proper dimensions"); return; } } // Initialize basic map try { // Check if map is already initialized if (self.map) { console.warn("Map already exists, reinitializing..."); self.map.remove(); self.map = null; } // Ensure element ID is unique and not already initialized if (self.el._leaflet_id) { console.warn("Map element already has Leaflet ID, cleaning up..."); const el = L.DomUtil.get(self.el.id) as HTMLElement | null; if (el !== null) { L.DomUtil.remove(el); } delete self.el._leaflet_id; } // Detect if mobile device const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( navigator.userAgent, ); self.map = L.map(self.el, { zoomControl: !isMobile, // Hide default zoom control on mobile, we'll add a better one attributionControl: true, closePopupOnClick: true, tap: true, // Enable tap events for mobile tapTolerance: 30, // Increase tap tolerance for mobile touchZoom: true, bounceAtZoomLimits: false, // Disable bouncing at zoom limits worldCopyJump: true, // Better panning behavior preferCanvas: isMobile, // Use canvas renderer on mobile for better performance zoomAnimation: !isMobile, // Disable zoom animations on mobile for performance fadeAnimation: !isMobile, // Disable fade animations on mobile markerZoomAnimation: !isMobile, // Disable marker animations on mobile }).setView([initialCenter.lat, initialCenter.lng], initialZoom); // Add mobile-friendly zoom control if on mobile if (isMobile) { L.control .zoom({ position: "bottomright", }) .addTo(self.map); // Add touch gesture handling self.setupMobileGestures(); } } catch (error) { console.error("Error initializing map:", error); self.errors!.push( "Map initialization failed: " + (error instanceof Error ? error.message : error), ); if (self.initializationAttempts! < self.maxInitializationAttempts!) { setTimeout(() => self.attemptInitialization(), 1000); return; } else { self.handleFatalError( "Map initialization failed after multiple attempts", ); return; } } // Add OpenStreetMap tile layer with improved settings try { // Tile provider options const tileProviders = { osm: { url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", attribution: '© OpenStreetMap contributors | APRS.me', subdomains: ["a", "b", "c"], }, osmDE: { url: "https://{s}.tile.openstreetmap.de/{z}/{x}/{y}.png", attribution: '© OpenStreetMap contributors | APRS.me', subdomains: ["a", "b", "c"], }, carto: { url: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", attribution: '© OpenStreetMap contributors © CARTO | APRS.me', subdomains: ["a", "b", "c", "d"], }, }; // Create raster tile layer const provider = tileProviders.osm; const tileLayer = L.tileLayer(provider.url, { attribution: provider.attribution, maxZoom: 19, subdomains: provider.subdomains, tileSize: 256, keepBuffer: 2, updateWhenZooming: false, updateInterval: 200, // Remove errorTileUrl to let Leaflet handle tile errors gracefully // errorTileUrl causes issues with some browsers when using data URLs }); // Add event handler for tile errors with retry logic let retryCount = new Map(); tileLayer.on("tileerror", function (error: any) { const src = error.tile.src; const count = retryCount.get(src) || 0; if (count < 3) { console.warn(`Tile load error (attempt ${count + 1}/3):`, src); retryCount.set(src, count + 1); // Exponential backoff setTimeout( () => { error.tile.src = src + (src.includes("?") ? "&" : "?") + "_retry=" + Date.now(); }, Math.pow(2, count) * 500, ); } else { console.error("Tile failed after 3 attempts:", src); retryCount.delete(src); } }); tileLayer.addTo(self.map); } catch (error) { self.errors!.push( "Tile layer failed: " + (error instanceof Error ? error.message : error), ); } // Store markers for management self.markers = new Map(); // Create marker cluster group for medium zoom levels (9-14) if (L.markerClusterGroup) { self.markerClusterGroup = L.markerClusterGroup({ showCoverageOnHover: false, zoomToBoundsOnClick: true, spiderfyOnMaxZoom: true, removeOutsideVisibleBounds: true, disableClusteringAtZoom: DISABLE_CLUSTERING_AT_ZOOM, maxClusterRadius: 80, iconCreateFunction: function (cluster: MarkerCluster) { const count = cluster.getChildCount(); let className = "marker-cluster-small"; if (count > 10) { className = "marker-cluster-medium"; } if (count > 50) { className = "marker-cluster-large"; } return createDivIcon("" + count + "", { className: "marker-cluster " + className, iconSize: [40, 40], iconAnchor: [20, 20], }); }, }); self.markerClusterGroup.addTo(self.map); self.markerLayer = self.markerClusterGroup; } else { // Fallback to regular layer group if clustering not available self.markerLayer = L.layerGroup().addTo(self.map); } // Create heat layer (hidden by default) try { if (L.heatLayer) { self.heatLayer = L.heatLayer([], { radius: 25, blur: 15, maxZoom: 8, gradient: { 0.4: "blue", 0.65: "lime", 0.85: "yellow", 1.0: "red", }, }); } } catch (error) { console.error("Error creating heat layer:", error); } // Create trail layer and manager with RF path hover callbacks // Only add to map if zoom is high enough (trails hidden when markers are clustered) const trailLayer = L.layerGroup(); if (self.map!.getZoom() >= DISABLE_CLUSTERING_AT_ZOOM) { trailLayer.addTo(self.map!); } self.trailLayer = trailLayer; self.trailManager = new TrailManager( trailLayer, 60 * 60 * 1000, // onTrailHover: send RF path visualization event to backend (lat: number, lng: number, path: string) => { if ( self.pushEvent && typeof self.pushEvent === "function" && !self.isDestroyed ) { try { self.pushEvent.call(self, "marker_hover_start", { id: "trail_hover", callsign: "", path: path, lat: lat, lng: lng, }); } catch (error) { console.warn("Failed to send trail hover event:", error); } } }, // onTrailHoverEnd: clear RF path lines () => { if ( self.pushEvent && typeof self.pushEvent === "function" && !self.isDestroyed ) { try { self.pushEvent.call(self, "marker_hover_end", { id: "trail_hover", }); } catch (error) { console.warn("Failed to send trail hover end event:", error); } } }, ); // Track marker states to prevent unnecessary operations self.markerStates = new Map(); // Reverse index: callsign_group → Set of marker IDs for O(1) lookup self.callsignIndex = new Map>(); // Store RF path lines for hover visualization self.rfPathLines = []; // Force initial size calculation try { self.map.invalidateSize(); } catch (error) { console.error("Error invalidating map size:", error); } // Helper function to send map ready events with retry self.sendMapReadyEvents = (retryCount = 0) => { if ( self.pushEvent && typeof self.pushEvent === "function" && !self.isDestroyed ) { try { self.pushEvent("map_ready", {}); } catch (e) { console.debug("Failed to push map_ready event:", e); } if (self.map) { saveMapState(self.map, self.pushEvent.bind(self)); self.sendBoundsToServer(); // Send map state update after a delay setTimeout(() => { if (self.map && self.pushEvent && !self.isDestroyed) { saveMapState(self.map, self.pushEvent.bind(self)); } }, 500); } } else if (retryCount < 3) { // Retry up to 3 times console.warn( `pushEvent not available, retrying... (attempt ${retryCount + 1})`, ); setTimeout(() => self.sendMapReadyEvents(retryCount + 1), 200); } }; // Track when map is ready self.map!.whenReady(() => { if (self.isDestroyed) return; try { self.lastZoom = self.map!.getZoom(); self.sendMapReadyEvents(); // Process any pending markers that were queued before map was ready if (self.pendingMarkers && self.pendingMarkers.length > 0) { console.debug( `Processing ${self.pendingMarkers.length} pending markers`, ); self.pendingMarkers.forEach((markerData: MarkerData) => { try { self.addMarker(markerData); } catch (error) { console.error("Error adding pending marker:", error, markerData); } }); self.pendingMarkers = []; } } catch (error) { console.error("Error in map ready callback:", error); } }); // Start periodic cleanup of old trail positions (every 5 minutes) self.cleanupInterval = setInterval( () => { if (!self.isDestroyed && self.trailManager) { self.trailManager.cleanupOldPositions(); } }, 5 * 60 * 1000, ); // Track map event handlers for cleanup self.mapEventHandlers = new Map(); self.isDestroyed = false; // Send bounds to LiveView when map moves const moveEndHandler = () => { // Skip if this is a programmatic move from the server if (self.programmaticMoveId) { return; } if (self.boundsTimer) clearTimeout(self.boundsTimer); self.boundsTimer = setTimeout(() => { if ( self.map && !self.isDestroyed && self.pushEvent && typeof self.pushEvent === "function" ) { saveMapState(self.map, self.pushEvent.bind(self)); } }, 300); }; self.map!.on("moveend", moveEndHandler); self.mapEventHandlers!.set("moveend", moveEndHandler); // Handle zoom changes with optimization for large zoom differences const zoomEndHandler = () => { // Skip if this is a programmatic move from the server if (self.programmaticMoveId) { return; } if (self.boundsTimer) clearTimeout(self.boundsTimer); self.boundsTimer = setTimeout(() => { const currentZoom = self.map!.getZoom(); const zoomDifference = self.lastZoom ? Math.abs(currentZoom - self.lastZoom) : 0; // Only rebuild OMS when crossing the clustering threshold const wasUnclustered = self.lastZoom !== undefined && self.lastZoom >= DISABLE_CLUSTERING_AT_ZOOM; const isUnclustered = currentZoom >= DISABLE_CLUSTERING_AT_ZOOM; if (self.oms && (wasUnclustered !== isUnclustered || self.lastZoom === undefined)) { self.oms.clearMarkers(); self.markers.forEach((marker, id) => { const markerState = self.markerStates.get(String(id)); const shouldAddToOms = markerState?.is_most_recent_for_callsign === true || (markerState?.is_most_recent_for_callsign == null && !marker._isHistorical); if ( marker && !marker._isClusterMarker && markerState && shouldAddToOms ) { self.oms.addMarker(marker); } }); } // Hide trails when markers are clustered, show when unclustered if (self.trailLayer && self.map) { if (currentZoom >= DISABLE_CLUSTERING_AT_ZOOM) { if (!self.map.hasLayer(self.trailLayer)) { self.trailLayer.addTo(self.map); self.trailLayer.bringToFront(); } } else { if (self.map.hasLayer(self.trailLayer)) { self.map.removeLayer(self.trailLayer); } } } // If zoom changed significantly (more than 2 levels), request reload of normal markers // but preserve historical ones and current position if (zoomDifference > 2) { try { self.pushEvent("refresh_markers", {}); } catch (e) { console.debug("Failed to push refresh_markers event:", e); } } self.lastZoom = currentZoom; // Save map state and update URL if ( self.map && !self.isDestroyed && self.pushEvent && typeof self.pushEvent === "function" ) { saveMapState(self.map, self.pushEvent.bind(self)); } }, 300); }; self.map!.on("zoomend", zoomEndHandler); self.mapEventHandlers!.set("zoomend", zoomEndHandler); // Handle resize self.resizeHandler = () => { try { if (self.map) { self.map.invalidateSize(); } } catch (error) { console.error("Error invalidating map size on resize:", error); } }; window.addEventListener("resize", self.resizeHandler); // Add a delayed size check self.sizeCheckTimeout = setTimeout(() => { if (self.el && self.el.parentNode) { const rect = self.el.getBoundingClientRect(); if (self.map && rect.width > 0 && rect.height > 0) { try { self.map.invalidateSize(); } catch (error) { console.error("Error re-invalidating map size:", error); } } } }, 1000); // Queue to hold markers that arrive before map is ready self.pendingMarkers = []; // LiveView event handlers self.setupLiveViewHandlers(); // Process any pending markers once the map is ready self.map.whenReady(() => { if (self.pendingMarkers && self.pendingMarkers.length > 0) { console.debug(`Processing ${self.pendingMarkers.length} pending markers`); self.pendingMarkers.forEach((markerData) => { self.addMarker(markerData); }); self.pendingMarkers = []; } }); // Set up event delegation for popup navigation links self.setupPopupNavigation(); // Only initialize OMS if clustering is not available or at high zoom levels if (typeof window.OverlappingMarkerSpiderfier !== "undefined") { self.oms = new window.OverlappingMarkerSpiderfier(self.map, { keepSpiderfied: true, nearbyDistance: 65, circleSpiralSwitchover: 10, circleFootSeparation: 80, spiralFootSeparation: 50, spiralLengthStart: 30, spiralLengthFactor: 6, legWeight: 2, legColors: { usual: "#222", highlighted: "#f00", }, spiderfyDistanceMultiplier: 6, }); // Add click handler for spiderfied markers self.oms.addListener("click", function (marker: Marker) { const aprsMarker = marker as APRSMarker; if (aprsMarker._markerId) { self.userOpenedPopupMarkerId = aprsMarker._markerId; } if (marker.openPopup) marker.openPopup(); }); // Style the spider legs self.oms.addListener("spiderfy", function (markers: Marker[]) { self.map.closePopup(); }); self.oms.addListener("unspiderfy", function (markers: Marker[]) { // Markers return to normal positions }); } }, handleFatalError(message: string) { const self = this as unknown as LiveViewHookContext; console.error("Fatal map error:", message); console.error("All errors:", self.errors); // Display error message to user if (self.el) { self.el.innerHTML = ` Map Loading Error Please refresh the page or check the browser console for details. `; const msgEl = self.el.querySelector('#map-error-message'); if (msgEl) msgEl.textContent = message; } }, setupMobileGestures() { const self = this as unknown as LiveViewHookContext; // Long press timer let longPressTimer: ReturnType | null = null; let startX = 0; let startY = 0; const longPressDuration = 750; // 750ms for long press const movementThreshold = 10; // pixels const handleLongPress = (clientX: number, clientY: number) => { const latlng = self.map!.containerPointToLatLng( L.point(clientX, clientY), ); // Find the nearest marker let nearestMarker: APRSMarker | null = null; let minDistance = Infinity; self.markers.forEach((marker) => { const markerLatLng = marker.getLatLng(); const distance = latlng.distanceTo(markerLatLng); if (distance < minDistance && distance < 50) { // Within 50 meters minDistance = distance; nearestMarker = marker; } }); if (nearestMarker) { const aprsMarker = nearestMarker as APRSMarker; if (aprsMarker._markerId) { self.userOpenedPopupMarkerId = aprsMarker._markerId; } nearestMarker.openPopup(); } }; self.map!.on("touchstart", (e: LeafletTouchEvent) => { if (e.originalEvent.touches.length !== 1) return; const touch = e.originalEvent.touches[0]; startX = touch.clientX; startY = touch.clientY; longPressTimer = setTimeout(() => { if (!self.isDestroyed && self.map) { handleLongPress(touch.clientX, touch.clientY); } }, longPressDuration); }); self.map!.on("touchmove", (e: LeafletTouchEvent) => { if (!longPressTimer) return; const touch = e.originalEvent.touches[0]; const deltaX = Math.abs(touch.clientX - startX); const deltaY = Math.abs(touch.clientY - startY); // Cancel long press if moved too much if (deltaX > movementThreshold || deltaY > movementThreshold) { clearTimeout(longPressTimer); longPressTimer = null; } }); self.map!.on("touchend", () => { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } }); self.map!.on("touchcancel", () => { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } }); }, setupPopupNavigation() { const self = this as unknown as LiveViewHookContext; // Store the event handler so we can remove it later self.popupNavigationHandler = (e: Event) => { const target = e.target; if (!(target instanceof Element)) { return; } // Check if clicked element or its parent is a LiveView navigation link const navLink = target.closest(".aprs-lv-link"); if (navLink instanceof HTMLAnchorElement && navLink.href) { e.preventDefault(); e.stopPropagation(); // Use Phoenix LiveView's built-in navigation const liveSocket = getLiveSocket(); if (liveSocket) { try { liveSocket.pushHistoryPatch(navLink.href, "push", navLink); return; } catch (error) { console.warn("LiveView navigation failed, falling back to full navigation", error); } } // Fallback to regular navigation if LiveView socket is not available or patching fails window.location.href = navLink.href; } }; // Use event delegation to handle clicks on popup navigation links document.addEventListener("click", self.popupNavigationHandler); }, setupLiveViewHandlers() { const self = this as unknown as LiveViewHookContext; // Add single marker self.handleEvent("add_marker", (data: MarkerData) => { // Check if map is ready before adding markers if (!self.map || !self.markerLayer) { console.warn("Map not ready, queueing marker for later:", data.id); // Queue the marker to be added when map is ready if (!self.pendingMarkers) { self.pendingMarkers = []; } self.pendingMarkers.push(data); return; } self.addMarker(data); }); // Add multiple markers at once self.handleEvent("add_markers", (data: { markers: MarkerData[] }) => { if (data.markers && Array.isArray(data.markers)) { // Check if map is ready before adding markers if (!self.map || !self.markerLayer) { console.warn("Map not ready, queueing markers for later"); // Queue the markers to be added when map is ready if (!self.pendingMarkers) { self.pendingMarkers = []; } self.pendingMarkers.push(...data.markers); return; } data.markers.forEach((marker) => self.addMarker(marker)); } }); // Remove marker self.handleEvent("remove_marker", (data: { id: string }) => { self.removeMarker(data.id); }); // Remove markers in batch self.handleEvent("remove_markers_batch", (data: { ids: string[] }) => { data.ids.forEach((id) => self.removeMarker(id)); }); // Clear all markers self.handleEvent("clear_markers", () => { self.clearAllMarkers(); }); // Update marker self.handleEvent("update_marker", (data: MarkerData) => { self.updateMarker(data); }); // Zoom to location self.handleEvent( "zoom_to_location", (data: { lat: number; lng: number; zoom?: number }) => { if (!self.map) { console.error("Map not initialized, cannot zoom"); return; } if (data.lat && data.lng) { const lat = parseFloat(data.lat.toString()); const lng = parseFloat(data.lng.toString()); const zoom = parseInt(data.zoom?.toString() || "12"); // Validate coordinates if (!isValidCoordinate(lat, lng)) { console.error("Invalid coordinates for zoom:", lat, lng); return; } if (isNaN(zoom) || zoom < 1 || zoom > 20) { console.error("Invalid zoom level:", zoom); return; } try { // Check element dimensions before zoom const beforeRect = self.el.getBoundingClientRect(); // Force map size recalculation before zoom self.map.invalidateSize(); // Use a slight delay to ensure map is ready setTimeout(() => { if (self.map) { // Generate a unique ID for this programmatic move const moveId = `move_${Date.now()}_${Math.random()}`; self.programmaticMoveId = moveId; // Clear any existing timeout if (self.programmaticMoveTimeout) { clearTimeout(self.programmaticMoveTimeout); } // Set a timeout to clear the programmatic move flag // This ensures we don't block user interactions indefinitely self.programmaticMoveTimeout = setTimeout(() => { if (self.programmaticMoveId === moveId) { self.programmaticMoveId = undefined; } }, 1500); self.map.setView([lat, lng], zoom, { animate: true, duration: 1, }); // Check element dimensions after zoom const dimensionCheckTimeout = setTimeout(() => { // Check if map still exists and not destroyed if (!self.map || self.isDestroyed) { return; } const afterRect = self.el.getBoundingClientRect(); if (afterRect.width === 0 || afterRect.height === 0) { console.error("Map element lost dimensions after zoom!"); // Try to restore dimensions self.el.style.width = "100vw"; self.el.style.height = "100vh"; if (self.map) { self.map.invalidateSize(); } } }, 1000); // Store timeout for cleanup if (!self.cleanupTimeouts) { self.cleanupTimeouts = []; } self.cleanupTimeouts.push(dimensionCheckTimeout); } }, 100); } catch (error) { console.error("Error during zoom operation:", error); } } else { console.warn("Missing lat/lng data for zoom operation:", data); } }, ); // Handle geolocation requests self.handleEvent("request_geolocation", () => { if ("geolocation" in navigator) { navigator.geolocation.getCurrentPosition( (position) => { if ( !self.pushEvent || typeof self.pushEvent !== "function" || self.isDestroyed ) { return; } const { latitude, longitude } = position.coords; self.pushEvent("set_location", { lat: latitude, lng: longitude }); }, (error) => { if ( !self.pushEvent || typeof self.pushEvent !== "function" || self.isDestroyed ) { return; } console.warn("Geolocation error:", error.message); self.pushEvent("geolocation_error", { error: error.message }); }, ); } else { if ( !self.pushEvent || typeof self.pushEvent !== "function" || self.isDestroyed ) { return; } console.warn("Geolocation not available"); self.pushEvent("geolocation_error", { error: "Geolocation not supported", }); } }); // Toggle trails visibility self.handleEvent("toggle_trails", (data: { show: boolean }) => { if (self.trailManager) { self.trailManager.setShowTrails(data.show); } }); // Handle trail duration updates from LiveView self.handleEvent( "update_trail_duration", (data: { duration_hours: number }) => { if (self.trailManager) { self.trailManager.setTrailDuration(data.duration_hours); } }, ); // Handle new packets from LiveView self.handleEvent("new_packet", (data: MarkerData) => { try { // Skip if context is lost if (!self || !self.map || self.isDestroyed) { console.warn( "Map context not ready or destroyed, skipping new packet", ); return; } // Check if map exists and has the hasLayer method if (!self.map.hasLayer) { console.warn( "Map hasLayer method not available, skipping new packet", ); return; } // If heat layer is visible, we're in heat map mode - skip individual markers if (self.heatLayer && self.map.hasLayer(self.heatLayer)) { return; } // Check if there's already a marker for this callsign const incomingCallsign = data.callsign_group || data.callsign || data.id; if (incomingCallsign) { // Find existing live markers for this callsign using reverse index for O(1) lookup const markersToConvert: string[] = []; // Ensure markerStates exists if (!self.markerStates) { console.warn("markerStates not initialized, skipping conversion"); return; } const existingIds = self.callsignIndex?.get(incomingCallsign); if (existingIds) { for (const id of existingIds) { const state = self.markerStates.get(id); if ( state && (!state.historical || state.is_most_recent_for_callsign) && String(id) !== String(data.id) ) { markersToConvert.push(String(id)); } } } // Convert existing live markers to historical dots by updating their icon markersToConvert.forEach((id) => { if (!self.markers || !self.markerStates) { console.warn( "markers or markerStates not available during conversion", ); return; } const existingMarker = self.markers.get(id); const existingState = self.markerStates.get(id); if (existingMarker && existingState) { // Update the state to historical existingState.historical = true; existingState.is_most_recent_for_callsign = false; // Create new historical dot icon const historicalIconData = { id: String(id), lat: existingState.lat, lng: existingState.lng, callsign: existingState.callsign || incomingCallsign, callsign_group: existingState.callsign_group || incomingCallsign, symbol_table_id: existingState.symbol_table, symbol_code: existingState.symbol_code, historical: true, is_most_recent_for_callsign: false, popup: existingState.popup, }; // Update the marker's icon to show as a dot existingMarker.setIcon(self.createMarkerIcon(historicalIconData)); // Mark as historical (existingMarker as APRSMarker)._isHistorical = true; } }); } // Add the new marker as the most recent for this callsign // Check if openPopup is explicitly set to false to prevent interrupting user const shouldOpenPopup = data.openPopup !== false; self.addMarker({ ...data, historical: false, is_most_recent_for_callsign: true, callsign_group: data.callsign_group || data.callsign || incomingCallsign, popup: data.popup, openPopup: shouldOpenPopup, }); } catch (error) { console.error("Error in new_packet handler:", error); } }); // Handle batched new packets from LiveView (performance optimization) self.handleEvent( "new_packets", (payload: { packets: MarkerData[]; convert_to_historical?: string[]; }) => { try { if (!self || !self.map || self.isDestroyed) return; if (!self.map.hasLayer) return; if (self.heatLayer && self.map.hasLayer(self.heatLayer)) return; if (!self.markerStates || !self.markers) return; const packets = payload.packets; if (!packets || packets.length === 0) return; // Phase 1: Find markers to convert to historical dots. // If the server provides convert_to_historical callsign keys, use // those for a targeted lookup instead of scanning all markerStates. const serverConvertKeys = payload.convert_to_historical; let markersToConvert: string[] = []; // Single pass: build new IDs set and callsigns to convert const allNewIds = new Set(); const callsignsToConvert: Set = new Set(); if (serverConvertKeys && serverConvertKeys.length > 0) { for (const cs of serverConvertKeys) callsignsToConvert.add(cs); for (const data of packets) { if (data.id) allNewIds.add(String(data.id)); } } else { for (const data of packets) { if (data.id) allNewIds.add(String(data.id)); const cs = data.callsign_group || data.callsign || data.id; if (cs) callsignsToConvert.add(cs); } } // Use callsignIndex for O(1) lookup per callsign instead of scanning all markerStates if (self.callsignIndex) { for (const cs of callsignsToConvert) { const ids = self.callsignIndex.get(cs); if (!ids) continue; for (const id of ids) { if (allNewIds.has(String(id))) continue; const state = self.markerStates.get(id); if ( state && (!state.historical || state.is_most_recent_for_callsign) ) { markersToConvert.push(String(id)); } } } } // Phase 2: Convert all existing live markers to historical dots for (const id of markersToConvert) { const existingMarker = self.markers.get(id); const existingState = self.markerStates.get(id); if (existingMarker && existingState) { existingState.historical = true; existingState.is_most_recent_for_callsign = false; const incomingCallsign = existingState.callsign_group || existingState.callsign; const historicalIconData = { id: String(id), lat: existingState.lat, lng: existingState.lng, callsign: existingState.callsign || incomingCallsign, callsign_group: existingState.callsign_group || incomingCallsign, symbol_table_id: existingState.symbol_table, symbol_code: existingState.symbol_code, historical: true, is_most_recent_for_callsign: false, popup: existingState.popup, }; existingMarker.setIcon(self.createMarkerIcon(historicalIconData)); (existingMarker as APRSMarker)._isHistorical = true; } } // Phase 3: Add all new markers for (const data of packets) { const shouldOpenPopup = data.openPopup !== false; const incomingCallsign = data.callsign_group || data.callsign || data.id; self.addMarker({ ...data, historical: false, is_most_recent_for_callsign: true, callsign_group: incomingCallsign, popup: data.popup, openPopup: shouldOpenPopup, }); } } catch (error) { console.error("Error in new_packets handler:", error); } }, ); // Handle highlighting the latest packet (open its popup) self.currentPopupMarkerId = null; self.userOpenedPopupMarkerId = null; self.handleEvent("highlight_packet", (data: { id: string }) => { if (!data.id || !self.markers || !self.markerStates) return; if (self.userOpenedPopupMarkerId) return; // Close previous popup if open if ( self.currentPopupMarkerId && self.markers.has(self.currentPopupMarkerId) ) { const prevMarker = self.markers.get(self.currentPopupMarkerId); if (prevMarker && prevMarker.closePopup) prevMarker.closePopup(); } // Try to open popup directly first if marker exists const marker = self.markers.get(data.id); if (marker && marker.openPopup) { marker.openPopup(); } else { // Fallback: re-add marker with openPopup flag if it doesn't exist const markerData = self.markerStates.get(data.id); if (markerData) { self.addMarker({ ...markerData, id: data.id, openPopup: true }); } } self.currentPopupMarkerId = data.id; }); // Handle historical packets during replay self.handleEvent("historical_packet", (data: MarkerData) => { self.addMarker({ ...data, historical: true, popup: data.popup, }); }); // Handle bulk loading of historical packets // Server pre-sorts by callsign group with chronological order within each group self.handleEvent( "add_historical_packets", (data: { packets: MarkerData[] }) => { if (data.packets && Array.isArray(data.packets)) { for (const packet of data.packets) { try { self.addMarker({ ...packet, historical: true, popup: packet.popup, }); } catch (error) { console.error("Error adding historical packet:", error, packet); } } } }, ); // Handle progressive loading of historical packets (batch processing) // Server pre-sorts by callsign group with chronological order within each group self.handleEvent( "add_historical_packets_batch", (data: { packets: MarkerData[]; batch: number; is_final: boolean }) => { console.debug("Received add_historical_packets_batch event:", { packetCount: data.packets?.length || 0, batch: data.batch, is_final: data.is_final, }); try { if (data.packets && Array.isArray(data.packets)) { for (const packet of data.packets) { try { self.addMarker({ ...packet, historical: true, popup: packet.popup, }); } catch (error) { console.error( "Error adding historical packet:", error, packet, ); } } } } catch (error) { console.error("Error processing historical packets batch:", error); } }, ); // Handle refresh markers event self.handleEvent("refresh_markers", () => { // Remove markers that are outside current bounds if (self.map) { const bounds = self.map.getBounds(); self.removeMarkersOutsideBounds(bounds); } }); // Handle clearing historical packets self.handleEvent("clear_historical_packets", () => { // Remove only historical markers (preserve live markers and their trails) const markersToRemove: string[] = []; self.markers!.forEach((marker: APRSMarker, id: string) => { const markerState = self.markerStates!.get(String(id)); // Only remove markers that are explicitly historical if ( (marker as APRSMarker)._isHistorical || (markerState && markerState.historical) ) { markersToRemove.push(String(id)); } }); // Remove historical markers without affecting trails of live markers markersToRemove.forEach((id) => { const marker = self.markers!.get(id); const markerState = self.markerStates!.get(id); if (marker) { // Remove from map and storage self.markerLayer!.removeLayer(marker); self.markers!.delete(id); self.markerStates!.delete(id); // We now NEVER remove trails when clearing historical packets // This preserves the historical trail visibility } }); // Make sure trail layer is visible and on top if (self.trailLayer && self.trailLayer.bringToFront) { self.trailLayer.bringToFront(); } }); // Handle drawing RF path lines when hovering over a station self.handleEvent( "draw_rf_path", (data: { station_lat: number; station_lng: number; path_stations: Array<{ callsign: string; lat: number; lng: number }>; }) => { if (!self.map || !data.path_stations || data.path_stations.length === 0) return; // Validate initial station coordinates if (!isFinite(data.station_lat) || !isFinite(data.station_lng)) { console.warn("Invalid initial station coordinates for RF path:", { lat: data.station_lat, lng: data.station_lng, }); return; } // Clear any existing path lines if (self.rfPathLines) { self.rfPathLines.forEach((line) => self.map!.removeLayer(line)); } self.rfPathLines = []; // Draw lines from station to each digipeater/igate in sequence let prevLat = data.station_lat; let prevLng = data.station_lng; data.path_stations.forEach((station, index) => { // Validate coordinates before drawing if ( !isFinite(prevLat) || !isFinite(prevLng) || !isFinite(station.lat) || !isFinite(station.lng) ) { console.warn("Invalid coordinates for RF path:", { prevLat, prevLng, station, }); return; } // Draw line from previous position to this station const line = L.polyline( [ [prevLat, prevLng], [station.lat, station.lng], ], { color: "#FF6B6B", weight: 3, opacity: 0.8, dashArray: index === 0 ? undefined : "5, 10", // Solid line for first hop, dashed for subsequent }, ); line.addTo(self.map!); self.rfPathLines.push(line); // Add a small circle marker at the digipeater/igate location const circle = L.circleMarker([station.lat, station.lng], { radius: 6, fillColor: "#2563eb", color: "#fff", weight: 2, opacity: 1, fillOpacity: 0.8, }); circle.bindTooltip(station.callsign, { permanent: false, direction: "top", offset: [0, -10], }); circle.addTo(self.map!); self.rfPathLines.push(circle); // Update prev position for next line prevLat = station.lat; prevLng = station.lng; }); }, ); // Handle clearing RF path lines self.handleEvent("clear_rf_path", () => { self.clearRfPathLines(); }); // Resize map when slideover toggles (class changes handled by LiveView JS commands) self.mapResizeHandler = () => { setTimeout(() => { if (self.map) { self.map.invalidateSize(); } }, 350); }; self.el.addEventListener("phx:map-resize", self.mapResizeHandler); // Handle bounds-based marker filtering self.handleEvent( "filter_markers_by_bounds", (data: { bounds: BoundsData }) => { if (data.bounds) { // Create Leaflet bounds object from server data const bounds = L.latLngBounds( [data.bounds.south, data.bounds.west], [data.bounds.north, data.bounds.east], ); self.removeMarkersOutsideBounds(bounds); } }, ); // Handle clearing all markers and reloading visible ones self.handleEvent("clear_and_reload_markers", () => { // This event is just a trigger - the server will handle clearing and adding markers }); // Handle clear_all_markers event self.handleEvent("clear_all_markers", () => { // Clear trail line if present if (self.trailLine) { self.map?.removeLayer(self.trailLine); self.trailLine = undefined; } // Modified to preserve historical trails - only removes non-historical markers self.clearAllMarkers(); // Force trail layer to front to ensure visibility if (self.trailLayer && self.trailLayer.bringToFront) { self.trailLayer.bringToFront(); } }); // Handle heat map data for low zoom levels self.handleEvent("show_heat_map", (data: { heat_points: HeatLatLng[] }) => { try { if (!self.map || self.isDestroyed) { console.warn("Map not ready or destroyed, skipping heat map update"); return; } // Clear trail line if present if (self.trailLine) { self.map.removeLayer(self.trailLine); self.trailLine = undefined; } if (!L.heatLayer) { console.error("Leaflet.heat plugin not loaded!"); return; } if (!self.heatLayer) { try { self.heatLayer = L.heatLayer([], { radius: 25, blur: 15, maxZoom: 8, gradient: { 0.4: "blue", 0.65: "lime", 0.85: "yellow", 1.0: "red", }, }); } catch (error) { console.error("Failed to create heat layer:", error); return; } } // Convert heat points to format expected by Leaflet.heat. // Server pre-normalizes intensity to [0, 1] but validate defensively // so malformed rows can't distort the whole heat layer. const heatData = data.heat_points .filter( (point) => isValidCoordinate(point.lat, point.lng) && typeof point.intensity === "number" && isFinite(point.intensity) && point.intensity >= 0 && point.intensity <= 1, ) .map( (point) => [point.lat, point.lng, point.intensity] as [ number, number, number, ], ); // Update heat layer data self.heatLayer.setLatLngs(heatData); // Show heat layer and hide marker layer if (!self.map.hasLayer(self.heatLayer)) { self.map.addLayer(self.heatLayer); } if (self.markerLayer && self.map.hasLayer(self.markerLayer)) { self.map.removeLayer(self.markerLayer); } } catch (error) { console.error("Error in show_heat_map handler:", error); } }); // Handle switching back to markers self.handleEvent("show_markers", () => { try { if (!self.map || self.isDestroyed) { console.warn("Map not ready or destroyed, skipping marker display"); return; } // Clear trail line if present if (self.trailLine) { self.map.removeLayer(self.trailLine); self.trailLine = undefined; } // Hide heat layer and show marker layer if (self.heatLayer && self.map.hasLayer(self.heatLayer)) { self.map.removeLayer(self.heatLayer); } if (self.markerLayer && !self.map.hasLayer(self.markerLayer)) { self.map.addLayer(self.markerLayer); } } catch (error) { console.error("Error in show_markers handler:", error); } }); // Handle trail line display for tracked callsigns at low zoom self.handleEvent("show_trail_line", (payload: BaseEventPayload) => { const data = payload as { callsign: string; points: Array<{ lat: number; lng: number; timestamp: string }> }; if (!self.map) { console.warn("Map not initialized, cannot show trail line"); return; } // Clear existing trail line if present if (self.trailLine) { self.map.removeLayer(self.trailLine); self.trailLine = undefined; } // Clear heat map if present (switching from heat map to trail line) if (self.heatLayer) { self.map.removeLayer(self.heatLayer); self.heatLayer = undefined; } // Filter out any invalid coordinates before building the polyline — // NaN / Infinity / out-of-range values make Leaflet render junk. const validPoints = data.points.filter((point) => isValidCoordinate(point.lat, point.lng), ); // If we have less than 2 valid points, don't draw a line if (validPoints.length < 2) { console.debug( `Not enough valid points for trail line: ${validPoints.length} of ${data.points.length}`, ); return; } // 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 = unwrapLongitudes( validPoints.map((p) => [p.lat, p.lng] as [number, number]), ); // Access Leaflet from window const L = (window as any).L; // Create polyline self.trailLine = L.polyline(latlngs, { color: '#3B82F6', // blue-500 weight: 3, opacity: 0.8, lineCap: 'round', lineJoin: 'round', interactive: true }); // Add to map self.trailLine.addTo(self.map); console.debug(`Trail line added for ${data.callsign} with ${validPoints.length} points`); }); // Handle clearing trail line self.handleEvent("clear_trail_line", (_payload: BaseEventPayload) => { if (!self.map) { return; } // Remove trail line if present if (self.trailLine) { self.map.removeLayer(self.trailLine); self.trailLine = undefined; console.debug("Trail line cleared"); } }); }, sendBoundsToServer() { const self = this as unknown as LiveViewHookContext; console.debug( "sendBoundsToServer called, map:", !!self.map, "isDestroyed:", self.isDestroyed, ); if (!self.map || self.isDestroyed) return; try { const bounds = self.map!.getBounds(); const center = self.map!.getCenter(); const zoom = self.map!.getZoom(); // We're no longer removing markers outside bounds during normal panning/zooming // to preserve historical positions and the current marker // self.removeMarkersOutsideBounds(bounds); // Use direct pushEvent call with proper context if (self.pushEvent && typeof self.pushEvent === "function") { const boundsData = { bounds: { north: bounds.getNorth(), south: bounds.getSouth(), east: bounds.getEast(), west: bounds.getWest(), }, center: { lat: center.lat, lng: center.lng, }, zoom: zoom, }; console.debug("Sending bounds_changed event:", boundsData); self.pushEvent("bounds_changed", boundsData); } } catch (error) { console.error("Error sending bounds to server:", error); } }, addMarker(data: MarkerData & { openPopup?: boolean }) { const self = this as unknown as LiveViewHookContext; const L = window.L; if ( !data || !data.id || data.lat == null || data.lng == null || typeof data.lat !== "number" || typeof data.lng !== "number" ) { console.warn("Invalid marker data:", data); return; } // Ensure maps are initialized if (!self.markers || !self.markerStates || !self.markerLayer) { console.warn("Map data structures not initialized, skipping marker add"); return; } // Additional check to ensure map is fully ready if ( !self.map || !self.map.getContainer() || typeof self.map.getZoom !== "function" ) { console.warn("Map not fully initialized, queueing marker:", data.id); if (!self.pendingMarkers) { self.pendingMarkers = []; } self.pendingMarkers.push(data); return; } // Extract coordinates - handle both direct values and nested arrays const lat = extractCoordinate(data.lat); const lng = extractCoordinate(data.lng); // Validate coordinates if (!isValidCoordinate(lat, lng)) { console.warn("Invalid coordinates for marker:", { id: data.id, lat, lng, callsign: data.callsign, rawLat: data.lat, rawLng: data.lng, }); return; } // Check if marker already exists with same position and data const existingMarker = self.markers.get(data.id); const existingState = self.markerStates.get(data.id); if (existingMarker && existingState) { // Check if marker needs updating const currentPos = existingMarker.getLatLng(); const positionChanged = Math.abs(currentPos.lat - lat) > 0.0001 || Math.abs(currentPos.lng - lng) > 0.0001; const dataChanged = existingState.symbol_table !== data.symbol_table_id || existingState.symbol_code !== data.symbol_code || existingState.popup !== data.popup; if (positionChanged && self.trailManager) { // Position changed, update trail const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign; const timestamp = parseTimestamp(data.timestamp); // Use callsign_group for proper trail grouping const trailId = getTrailId(data); self.trailManager.addPosition( trailId, lat, lng, timestamp, isHistoricalDot, data.path, ); } if (!positionChanged && !dataChanged) { // No changes needed, skip update // But if openPopup is requested and no user popup is locked, open it if (data.openPopup && existingMarker.openPopup && !self.userOpenedPopupMarkerId) { existingMarker.openPopup(); } return; } } // Remove existing marker if it exists (without affecting trails since we're just replacing it) self.removeMarkerWithoutTrail(data.id); // When adding a "most recent" marker, convert any existing "current" markers // for the same callsign_group to historical dots. This prevents duplicate labels // when multiple senders report the same object (e.g. two stations tracking one radiosonde). if (data.is_most_recent_for_callsign && self.callsignIndex) { const csGroup = data.callsign_group || data.callsign; if (csGroup) { const existingIds = self.callsignIndex.get(csGroup); if (existingIds) { for (const eid of existingIds) { if (String(eid) === String(data.id)) continue; const eState = self.markerStates.get(eid); const eMarker = self.markers.get(eid); if ( eState && eMarker && eState.is_most_recent_for_callsign ) { eState.historical = true; eState.is_most_recent_for_callsign = false; eMarker.setIcon( self.createMarkerIcon({ id: String(eid), lat: eState.lat, lng: eState.lng, callsign: eState.callsign || csGroup, callsign_group: eState.callsign_group || csGroup, symbol_table_id: eState.symbol_table, symbol_code: eState.symbol_code, historical: true, is_most_recent_for_callsign: false, popup: eState.popup, }), ); (eMarker as APRSMarker)._isHistorical = true; } } } } } // Create marker - use simple dot for older historical positions, APRS icon for most recent const marker = L.marker([lat, lng], { icon: self.createMarkerIcon(data), }); (marker as APRSMarker)._markerId = data.id; // Add popup if content provided if (data.popup) { marker.bindPopup(data.popup, { autoPan: false }); // Handle popup close events - check if hook is still connected marker.on("popupclose", () => { if (self.userOpenedPopupMarkerId === data.id) { self.userOpenedPopupMarkerId = null; } if ( !self.isDestroyed && self.pushEvent && typeof self.pushEvent === "function" ) { try { self.pushEvent("popup_closed", {}); } catch (e) { console.debug("Unable to send popup_closed event:", e); } } }); } // Handle marker click marker.on("click", (e: any) => { // Don't open popup if the marker is managed by OMS and there are nearby markers // The OMS will handle the click and open the popup after spiderfying if (self.oms && marker._oms && marker._omsData) { // Marker is currently spiderfied, allow normal popup self.userOpenedPopupMarkerId = data.id; if (marker.openPopup) marker.openPopup(); } else if (!self.oms || !marker._oms) { // Normal marker not managed by OMS self.userOpenedPopupMarkerId = data.id; if (marker.openPopup) marker.openPopup(); } // If marker._oms is true but no _omsData, let OMS handle it (will spiderfy) // Bring the clicked marker to front using a counter instead of DOM scan if (marker.getElement) { const element = marker.getElement(); if (element) { self.markerZIndexCounter = (self.markerZIndexCounter || 1000) + 1; element.style.zIndex = self.markerZIndexCounter.toString(); } } // Use bound pushEvent function to preserve context if ( self.pushEvent && typeof self.pushEvent === "function" && !self.isDestroyed ) { safePushEvent(self.pushEvent.bind(self), "marker_clicked", { id: data.id, callsign: data.callsign, lat: lat, lng: lng, }); } }); // Handle marker hover for RF path visualization if (data.path && data.path.trim() !== "") { marker.on("mouseover", () => { // Check if LiveView is still connected before sending event if ( self.pushEvent && typeof self.pushEvent === "function" && !self.isDestroyed ) { try { self.pushEvent.call(self, "marker_hover_start", { id: data.id, callsign: data.callsign, path: data.path, lat: lat, lng: lng, }); } catch (error) { console.warn("Failed to send marker_hover_start event:", error); } } else { console.warn("LiveView not connected, cannot send hover event"); } }); marker.on("mouseout", () => { // Check if LiveView is still connected before sending event if ( self.pushEvent && typeof self.pushEvent === "function" && !self.isDestroyed ) { try { self.pushEvent.call(self, "marker_hover_end", { id: data.id, }); } catch (error) { console.warn("Failed to send marker_hover_end event:", error); } } }); } // Mark historical markers for identification if (data.historical) { (marker as APRSMarker)._isHistorical = true; } // Add to map and store reference marker.addTo(self.markerLayer); self.markers.set(data.id, marker); // Make sure historical markers and trails stay visible if (data.historical || data.is_most_recent_for_callsign) { if (self.trailLayer && self.trailLayer.bringToFront) { setTimeout(() => self.trailLayer.bringToFront(), 50); } } // Store marker state for optimization self.markerStates.set(data.id, { lat: lat, lng: lng, symbol_table: data.symbol_table_id || "/", symbol_code: data.symbol_code || ">", popup: data.popup, historical: data.historical, is_most_recent_for_callsign: data.is_most_recent_for_callsign, callsign_group: data.callsign_group || data.callsign, callsign: data.callsign, }); // Update callsign reverse index const csGroup = data.callsign_group || data.callsign; if (csGroup && self.callsignIndex) { let ids = self.callsignIndex.get(csGroup); if (!ids) { ids = new Set(); self.callsignIndex.set(csGroup, ids); } ids.add(data.id); } // Initialize trail for new marker - always add to trail for line drawing if (self.trailManager) { const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign; const timestamp = parseTimestamp(data.timestamp); // Use callsign_group for proper trail grouping const trailId = getTrailId(data); self.trailManager.addPosition( trailId, lat, lng, timestamp, isHistoricalDot, data.path, ); } // Open popup if requested and no user popup is locked if (data.openPopup && marker.openPopup && !self.userOpenedPopupMarkerId) { marker.openPopup(); } // Add to OMS for overlapping marker handling (only most recent packets with icons) // Fallback: if is_most_recent_for_callsign is undefined, exclude historical markers as before const shouldAddToOms = data.is_most_recent_for_callsign === true || (data.is_most_recent_for_callsign == null && !(marker as APRSMarker)._isHistorical); if ( self.oms && marker && self.map && !marker._isClusterMarker && shouldAddToOms ) { self.oms.addMarker(marker); } }, removeMarker(id: string) { const self = this as unknown as LiveViewHookContext; // Ensure id is a string const markerId = typeof id === "string" ? id : String(id); const marker = self.markers!.get(markerId); const markerState = self.markerStates!.get(markerId); if (marker) { try { // Remove marker from appropriate layer with safety checks if ( self.markerClusterGroup && self.markerClusterGroup.hasLayer(marker) ) { // Check if cluster group is ready before removing if ( self.markerClusterGroup._map && self.markerClusterGroup._topClusterLevel ) { self.markerClusterGroup.removeLayer(marker); } else { console.warn( "Cluster group not ready, skipping marker removal:", markerId, ); } } else if (self.markerLayer && self.markerLayer.hasLayer(marker)) { self.markerLayer.removeLayer(marker); } self.markers!.delete(markerId); self.markerStates!.delete(markerId); } catch (error) { console.error("Error removing marker:", markerId, error); // Still clean up tracking maps even if layer removal failed self.markers!.delete(markerId); self.markerStates!.delete(markerId); } } // Update callsign reverse index if (markerState && self.callsignIndex) { const csGroup = markerState.callsign_group || markerState.callsign; if (csGroup) { const ids = self.callsignIndex.get(csGroup); if (ids) { ids.delete(markerId); if (ids.size === 0) self.callsignIndex.delete(csGroup); } } } // Remove trail - use callsign_group for proper trail identification if (self.trailManager) { const trailId = markerState?.callsign_group || markerState?.callsign || markerId; self.trailManager.removeTrail(trailId); } if (self.oms && marker) { try { self.oms.removeMarker(marker); } catch (e) { console.debug("Error removing marker from OMS:", e); } } }, updateMarker(data: MarkerData) { const self = this as unknown as LiveViewHookContext; if (!data.id) return; const existingMarker = self.markers!.get(data.id); if (existingMarker) { // Update position if provided if (data.lat && data.lng) { const lat = parseFloat(data.lat.toString()); const lng = parseFloat(data.lng.toString()); if (isValidCoordinate(lat, lng)) { const currentPos = existingMarker.getLatLng(); const positionChanged = Math.abs(currentPos.lat - lat) > 0.0001 || Math.abs(currentPos.lng - lng) > 0.0001; if (positionChanged) { existingMarker.setLatLng([lat, lng]); if (self.trailManager) { const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign; const timestamp = parseTimestamp(data.timestamp); // Use callsign_group for proper trail grouping const trailId = getTrailId(data); self.trailManager.addPosition( trailId, lat, lng, timestamp, isHistoricalDot, data.path, ); } } } } // Update popup if provided if (data.popup) { existingMarker.setPopupContent(data.popup); // Force popup to refresh by unbinding and rebinding existingMarker.unbindPopup(); existingMarker.bindPopup(data.popup, { autoPan: false }); } } else { // Marker doesn't exist, create it self.addMarker(data); } }, clearAllMarkers() { const self = this as unknown as LiveViewHookContext; // Clear any RF path lines first self.clearRfPathLines(); // Instead of clearing all markers, only remove non-historical and non-current markers const markersToPreserve = new Map(); const statesToPreserve = new Map(); // Identify historical markers and most recent markers to preserve self.markers!.forEach((marker, id) => { const markerState = self.markerStates!.get(String(id)); const isHistorical = (marker as APRSMarker)._isHistorical || (markerState && markerState.historical); const isMostRecent = markerState && markerState.is_most_recent_for_callsign; // Keep historical markers and current position markers if (isHistorical || isMostRecent) { markersToPreserve.set(String(id), marker); if (markerState) { statesToPreserve.set(String(id), markerState); } } else { // Remove only non-historical, non-current markers self.markerLayer!.removeLayer(marker); } }); // Replace the markers and states with just the preserved ones self.markers!.clear(); self.markerStates!.clear(); if (self.callsignIndex) self.callsignIndex.clear(); markersToPreserve.forEach((marker, id) => { self.markers!.set(id, marker); }); statesToPreserve.forEach((state, id) => { self.markerStates!.set(id, state); // Rebuild callsign index from preserved markers if (self.callsignIndex) { const csGroup = state.callsign_group || state.callsign; if (csGroup) { let ids = self.callsignIndex.get(csGroup); if (!ids) { ids = new Set(); self.callsignIndex.set(csGroup, ids); } ids.add(id); } } }); // Don't clear trails - keep them visible // if (self.trailManager) { // self.trailManager.clearAllTrails(); // } // Make sure trail layer is on top if (self.trailLayer && self.trailLayer.bringToFront) { setTimeout(() => self.trailLayer.bringToFront(), 100); } }, removeMarkersOutsideBounds(bounds: L.LatLngBounds) { const self = this as unknown as LiveViewHookContext; if (!bounds || !self.markers) return; const markersToRemove: string[] = []; self.markers!.forEach((marker: L.Marker, id: string) => { // Check if this is a historical marker or the most recent marker for a callsign const markerState = self.markerStates!.get(String(id)); const isHistorical = (marker as APRSMarker)._isHistorical || (markerState && markerState.historical); const isMostRecent = markerState && markerState.is_most_recent_for_callsign; // Always preserve historical markers and the most recent marker for a callsign if (isHistorical || isMostRecent) { return; // Skip this marker, don't remove it } const position = marker.getLatLng(); const lat = position.lat; const lng = position.lng; // Check latitude bounds (straightforward) const latOutOfBounds = lat < bounds.getSouth() || lat > bounds.getNorth(); // Check longitude bounds (handle potential wrapping) let lngOutOfBounds: boolean; if (bounds.getWest() <= bounds.getEast()) { // Normal case: bounds don't cross antimeridian lngOutOfBounds = lng < bounds.getWest() || lng > bounds.getEast(); } else { // Bounds cross antimeridian (e.g., west=170, east=-170) lngOutOfBounds = lng < bounds.getWest() && lng > bounds.getEast(); } // Check if marker is outside the current bounds if (latOutOfBounds || lngOutOfBounds) { markersToRemove.push(id); } }); // Remove out-of-bounds markers without affecting trails markersToRemove.forEach((id) => self.removeMarkerWithoutTrail(String(id))); }, clearRfPathLines() { const self = this as unknown as LiveViewHookContext; if (self.rfPathLines && self.rfPathLines.length > 0) { self.rfPathLines.forEach((line) => { try { if (self.map && self.map.hasLayer(line)) { self.map.removeLayer(line); } } catch (e) { console.debug("Error removing RF path line:", e); } }); self.rfPathLines = []; } }, removeMarkerWithoutTrail(id: string) { const self = this as unknown as LiveViewHookContext; // Ensure id is a string const markerId = typeof id === "string" ? id : String(id); const marker = self.markers!.get(markerId); if (marker) { // Get state before deletion for index cleanup const state = self.markerStates!.get(markerId); try { // Remove marker from appropriate layer with safety checks if ( self.markerClusterGroup && self.markerClusterGroup.hasLayer(marker) ) { // Check if cluster group is ready before removing if ( self.markerClusterGroup._map && self.markerClusterGroup._topClusterLevel ) { self.markerClusterGroup.removeLayer(marker); } else { console.warn( "Cluster group not ready, skipping marker removal:", markerId, ); } } else if (self.markerLayer && self.markerLayer.hasLayer(marker)) { self.markerLayer.removeLayer(marker); } // Always clean up the tracking maps self.markers!.delete(markerId); self.markerStates!.delete(markerId); // Note: We intentionally don't remove trails here } catch (error) { console.error("Error removing marker:", markerId, error); // Still clean up tracking maps even if layer removal failed self.markers!.delete(markerId); self.markerStates!.delete(markerId); } // Update callsign reverse index if (state && self.callsignIndex) { const csGroup = state.callsign_group || state.callsign; if (csGroup) { const ids = self.callsignIndex.get(csGroup); if (ids) { ids.delete(markerId); if (ids.size === 0) self.callsignIndex.delete(csGroup); } } } } }, createMarkerIcon(data: MarkerData): L.DivIcon { const self = this as unknown as LiveViewHookContext; const L = window.L; // If this is the most recent for the callsign, always show the APRS icon if (data.is_most_recent_for_callsign) { // Remove any historical dot at the same position for this callsign // Use callsignIndex for O(1) lookup instead of scanning all markers if (self.markers && self.markerStates && self.callsignIndex && data.callsign_group) { const ids = self.callsignIndex.get(data.callsign_group); if (ids) { for (const id of ids) { const state = self.markerStates.get(String(id)); if ( state && state.historical && Math.abs(state.lat - data.lat) < 0.00001 && Math.abs(state.lng - data.lng) < 0.00001 ) { self.removeMarkerWithoutTrail(String(id)); } } } } // Use server-generated symbol HTML if available if (data.symbol_html) { return createDivIcon(data.symbol_html, { iconSize: [120, 32], // Increased width to accommodate callsign label iconAnchor: [16, 16], }); } } // For historical packets that are not the most recent for their callsign, // show a simple dot instead of the full APRS symbol if (data.historical && !data.is_most_recent_for_callsign) { // Always show a red dot for historical positions const iconHtml = ``; return createDivIcon(iconHtml, { className: "historical-dot-marker", iconSize: [12, 12], iconAnchor: [6, 6], }); } // Fallback: Use server-generated symbol HTML if available if (data.symbol_html) { return createDivIcon(data.symbol_html); } // Final fallback: Simple dot const iconHtml = ``; return createDivIcon(iconHtml, { iconSize: [12, 12], iconAnchor: [6, 6], }); }, destroyed() { const self = this as unknown as LiveViewHookContext; // Mark as destroyed immediately self.isDestroyed = true; // Disable pushEvent to prevent any events from being sent during cleanup const originalPushEvent = self.pushEvent; self.pushEvent = () => {}; // No-op function // Clear interval timer if (self.cleanupInterval !== undefined) { clearInterval(self.cleanupInterval); self.cleanupInterval = undefined; } // Clear programmatic move timeout if (self.programmaticMoveTimeout !== undefined) { clearTimeout(self.programmaticMoveTimeout); self.programmaticMoveTimeout = undefined; } // Clear size check timeout if (self.sizeCheckTimeout !== undefined) { clearTimeout(self.sizeCheckTimeout); self.sizeCheckTimeout = undefined; } // Clear initialization timeout if (self.initializationTimeout !== undefined) { clearTimeout(self.initializationTimeout); self.initializationTimeout = undefined; } // Clear any dimension check timeouts if (self.cleanupTimeouts !== undefined) { self.cleanupTimeouts.forEach((timeout: number) => { clearTimeout(timeout); }); self.cleanupTimeouts = []; } // Clean up trail manager if (self.trailManager) { self.trailManager.destroy(); } // Remove popup navigation event listener if (self.popupNavigationHandler) { document.removeEventListener("click", self.popupNavigationHandler); self.popupNavigationHandler = undefined; } // Close any open popups before cleanup if (self.map !== undefined) { try { self.map.closePopup(); } catch (e) { console.debug("Error closing popup during cleanup:", e); } } if (self.boundsTimer !== undefined) { clearTimeout(self.boundsTimer); self.boundsTimer = undefined; } if (self.resizeHandler !== undefined) { window.removeEventListener("resize", self.resizeHandler); self.resizeHandler = undefined; } if (self.mapResizeHandler !== undefined) { self.el.removeEventListener("phx:map-resize", self.mapResizeHandler); self.mapResizeHandler = undefined; } // Remove map event handlers if (self.map !== undefined && self.mapEventHandlers !== undefined) { self.mapEventHandlers.forEach((handler, event) => { try { self.map.off(event, handler); } catch (e) { console.debug(`Error removing ${event} handler:`, e); } }); self.mapEventHandlers.clear(); } // Clear any RF path lines self.clearRfPathLines(); // Remove all event listeners from markers before clearing layers if (self.markers !== undefined) { self.markers.forEach((marker: APRSMarker) => { try { marker.off(); // Remove all event listeners if (marker.getPopup()) { marker.unbindPopup(); // Unbind popup to prevent events } } catch (e) { console.debug(`Error cleaning up marker:`, e); } }); } // Clean up OMS (OverlappingMarkerSpiderfier) if (self.oms !== undefined) { try { self.oms.clearMarkers(); self.oms.clearListeners("click"); self.oms.clearListeners("spiderfy"); self.oms.clearListeners("unspiderfy"); self.oms = undefined; } catch (e) { console.debug("Error cleaning up OMS:", e); } } // Clean up heat layer if (self.heatLayer !== undefined && self.map !== undefined) { try { if (self.map.hasLayer(self.heatLayer)) { self.map.removeLayer(self.heatLayer); } self.heatLayer = undefined; } catch (e) { console.debug("Error removing heat layer:", e); } } if (self.markerLayer !== undefined) { self.markerLayer!.clearLayers(); // If it's a cluster group, remove it from the map if (self.markerClusterGroup !== undefined && self.map !== undefined) { try { self.map.removeLayer(self.markerClusterGroup); self.markerClusterGroup = undefined; } catch (e) { console.debug("Error removing cluster group:", e); } } } if (self.markers !== undefined) { self.markers!.clear(); } if (self.markerStates !== undefined) { self.markerStates!.clear(); } if (self.callsignIndex !== undefined) { self.callsignIndex!.clear(); } if (self.map !== undefined) { self.map!.remove(); self.map = undefined; } // Restore original pushEvent (though it won't be used since we're destroyed) self.pushEvent = originalPushEvent; }, }; // Helper to extract coordinate from various formats (number, string, or nested array) function extractCoordinate(value: any): number { // Handle null/undefined if (value === null || value === undefined) { return NaN; } // Handle arrays (sometimes coordinates come as nested arrays) if (Array.isArray(value)) { // Recursively extract from first element if it's an array return extractCoordinate(value[0]); } // Handle numbers if (typeof value === "number") { return value; } // Handle strings if (typeof value === "string") { return parseFloat(value); } return NaN; } // Helper to create divIcon with common defaults function createDivIcon( html: string, options: Partial<{ className: string; iconSize: [number, number]; iconAnchor: [number, number]; }> = {}, ) { return L.divIcon({ html, className: options.className || "", iconSize: options.iconSize || [32, 32], iconAnchor: options.iconAnchor || [16, 16], }); } // Helper to validate and fallback symbol code per aprs.fi logic function getValidSymbolCode( symbolCode: string | undefined, symbolTableId: string | undefined, ): string { if (!symbolCode || symbolCode.length !== 1) { return symbolTableId === "\\" ? "O" : ">"; } const code = symbolCode.charCodeAt(0); if (code < 33 || code > 126) { return symbolTableId === "\\" ? "O" : ">"; } return symbolCode; } export default MapAPRSMap;
Please refresh the page or check the browser console for details.