// Declare Leaflet as a global variable declare const L: any; // Minimal APRS Map Hook - handles only basic map interaction // All data logic handled by LiveView type LiveViewHookContext = { el: HTMLElement & { _leaflet_id?: any }; pushEvent: (event: string, payload: any) => void; handleEvent: (event: string, callback: Function) => void; map?: any; markers?: Map; markerStates?: Map; markerLayer?: any; boundsTimer?: ReturnType; resizeHandler?: () => void; errors?: string[]; initializationAttempts?: number; maxInitializationAttempts?: number; lastZoom?: number; [key: string]: any; }; interface MarkerData { id: string; lat: number; lng: number; callsign?: string; comment?: string; symbol_table_id?: string; symbol_code?: string; symbol_description?: string; popup?: string; historical?: boolean; color?: string; } interface BoundsData { north: number; south: number; east: number; west: number; } interface CenterData { lat: number; lng: number; } interface MarkerState { lat: number; lng: number; symbol_table: string; symbol_code: string; popup?: string; historical?: boolean; } interface MapEventData { bounds?: BoundsData; center?: CenterData; zoom?: number; id?: string; callsign?: string; lat?: number; lng?: number; markers?: MarkerData[]; } let MinimalAPRSMap = { 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 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; } } // Get initial center and zoom from server-provided data attributes let initialCenter: CenterData, initialZoom: number; 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); // Validate parsed data 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"); } } catch (error) { console.error("Error parsing map data attributes:", error); // Fallback values 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; // 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; } self.map = L.map(self.el, { zoomControl: true, attributionControl: true, closePopupOnClick: true, }).setView([initialCenter.lat, initialCenter.lng], initialZoom); } 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 try { const tileLayer = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { attribution: '© OpenStreetMap contributors | APRS.me', maxZoom: 19, }); 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(); self.markerLayer = L.layerGroup().addTo(self.map); // Track marker states to prevent unnecessary operations self.markerStates = new Map(); // Force initial size calculation try { self.map.invalidateSize(); } catch (error) { console.error("Error invalidating map size:", error); } // Track when map is ready self.map!.whenReady(() => { try { self.lastZoom = self.map!.getZoom(); self.pushEvent("map_ready", {}); self.sendBoundsToServer(); } catch (error) { console.error("Error in map ready callback:", error); } }); // Send bounds to LiveView when map moves self.map!.on("moveend", () => { if (self.boundsTimer) clearTimeout(self.boundsTimer); self.boundsTimer = setTimeout(() => { self.sendBoundsToServer(); }, 300); }); // Handle zoom changes with optimization for large zoom differences self.map!.on("zoomend", () => { if (self.boundsTimer) clearTimeout(self.boundsTimer); self.boundsTimer = setTimeout(() => { const currentZoom = self.map!.getZoom(); const zoomDifference = self.lastZoom ? Math.abs(currentZoom - self.lastZoom) : 0; // If zoom changed significantly (more than 2 levels), clear and reload if (zoomDifference > 2) { self.pushEvent("clear_and_reload_markers", {}); } self.sendBoundsToServer(); self.lastZoom = currentZoom; }, 300); }); // 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 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); // LiveView event handlers self.setupLiveViewHandlers(); }, 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

${message}

Please refresh the page or check the browser console for details.

`; } }, setupLiveViewHandlers() { const self = this as unknown as LiveViewHookContext; // Add single marker self.handleEvent("add_marker", (data: MarkerData) => { self.addMarker(data); }); // Add multiple markers at once self.handleEvent("add_markers", (data: { markers: MarkerData[] }) => { if (data.markers && Array.isArray(data.markers)) { data.markers.forEach((marker) => self.addMarker(marker)); } }); // Remove marker self.handleEvent("remove_marker", (data: { id: string }) => { self.removeMarker(data.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 (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) { 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) { self.map.setView([lat, lng], zoom, { animate: true, duration: 1, }); // Check element dimensions after zoom setTimeout(() => { 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"; self.map.invalidateSize(); } }, 1000); } }, 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) => { const { latitude, longitude } = position.coords; self.pushEvent("set_location", { lat: latitude, lng: longitude }); }, (error) => { console.warn("Geolocation error:", error.message); self.pushEvent("geolocation_error", { error: error.message }); }, ); } else { console.warn("Geolocation not available"); self.pushEvent("geolocation_error", { error: "Geolocation not supported" }); } }); // Handle new packets from LiveView self.handleEvent("new_packet", (data: MarkerData) => { self.addMarker({ ...data, historical: false, popup: self.buildPopupContent(data), }); }); // Handle historical packets during replay self.handleEvent("historical_packet", (data: MarkerData) => { self.addMarker({ ...data, historical: true, popup: self.buildPopupContent(data), }); }); // 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 all historical markers const markersToRemove: string[] = []; self.markers!.forEach((marker: any, id: any) => { if ((marker as any)._isHistorical) { markersToRemove.push(id); } }); markersToRemove.forEach((id) => self.removeMarker(id)); }); // 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 }); }, sendBoundsToServer() { const self = this as unknown as LiveViewHookContext; if (!self.map) return; const bounds = self.map!.getBounds(); const center = self.map!.getCenter(); const zoom = self.map!.getZoom(); // Remove markers that are now outside the visible bounds self.removeMarkersOutsideBounds(bounds); self.pushEvent("bounds_changed", { bounds: { north: bounds.getNorth(), south: bounds.getSouth(), east: bounds.getEast(), west: bounds.getWest(), }, center: { lat: center.lat, lng: center.lng, }, zoom: zoom, }); }, addMarker(data: MarkerData) { const self = this as unknown as LiveViewHookContext; if (!data.id || !data.lat || !data.lng) { console.warn("Invalid marker data:", data); return; } const lat = parseFloat(data.lat.toString()); const lng = parseFloat(data.lng.toString()); // Validate coordinates if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) { console.warn("Invalid coordinates:", lat, 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 && !dataChanged) { // No changes needed, skip update return; } } // Remove existing marker if it exists self.removeMarker(data.id); // Create marker icon const icon = self.createMarkerIcon(data); // Create marker const marker = L.marker([lat, lng], { icon: icon }); // Add popup if content provided if (data.popup) { marker.bindPopup(data.popup); } // Handle marker click marker.on("click", () => { self.pushEvent("marker_clicked", { id: data.id, callsign: data.callsign, lat: lat, lng: lng, }); }); // Mark historical markers for identification if (data.historical) { (marker as any)._isHistorical = true; } // Add to map and store reference marker.addTo(self.markerLayer!); self.markers!.set(data.id, marker); // 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, }); }, removeMarker(id: string) { const self = this as unknown as LiveViewHookContext; const marker = self.markers!.get(id); if (marker) { self.markerLayer!.removeLayer(marker); self.markers!.delete(id); self.markerStates!.delete(id); } }, 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 (!isNaN(lat) && !isNaN(lng)) { existingMarker.setLatLng([lat, lng]); } } // Update popup if provided if (data.popup) { existingMarker.setPopupContent(data.popup); } // Update icon if data changed if (data.symbol_table_id || data.symbol_code || data.color) { const newIcon = self.createMarkerIcon(data); existingMarker.setIcon(newIcon); } } else { // Marker doesn't exist, create it self.addMarker(data); } }, clearAllMarkers() { const self = this as unknown as LiveViewHookContext; self.markerLayer!.clearLayers(); self.markers!.clear(); self.markerStates!.clear(); }, 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) => { 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 markersToRemove.forEach((id) => self.removeMarker(id)); }, createMarkerIcon(data: MarkerData): L.DivIcon { const symbolTableId = data.symbol_table_id || "/"; const symbolCode = data.symbol_code || ">"; // Get the correct sprite sheet based on symbol table // Use high-DPI versions (@2x) for better quality const spriteFile = symbolTableId === "/" ? "/aprs-symbols/aprs-symbols-24-0@2x.png" : "/aprs-symbols/aprs-symbols-24-1@2x.png"; // Calculate sprite position // The sprite sheet is organized as a 16x8 grid (128 symbols total) // ASCII codes 32-127 map to positions 0-95 const charCode = symbolCode.charCodeAt(0); // Convert ASCII to sprite sheet position // The sprite sheet is organized in a specific way: // - First row (0): ASCII 32-47 // - Second row (1): ASCII 48-63 // - Third row (2): ASCII 64-79 // - Fourth row (3): ASCII 80-95 // - Fifth row (4): ASCII 96-111 // - Sixth row (5): ASCII 112-127 // - Rows 6-7: Reserved for future use const position = charCode - 32; const row = Math.floor(position / 16); const column = position % 16; // Each symbol is 48x48 pixels in @2x version (24x24 * 2) const x = -column * 48; const y = -row * 48; // Debug info console.log('Symbol debug:', { symbolTableId, symbolCode, charCode, position, row, column, x, y, spriteFile }); // Create icon element const icon = document.createElement('div'); icon.style.width = '24px'; icon.style.height = '24px'; icon.style.backgroundImage = `url(${spriteFile})`; icon.style.backgroundPosition = `${x}px ${y}px`; icon.style.backgroundSize = '768px 384px'; // 16x8 grid of 48x48 symbols (@2x) icon.style.backgroundRepeat = 'no-repeat'; icon.style.imageRendering = 'pixelated'; // Ensure crisp pixel rendering icon.style.opacity = data.historical ? '0.7' : '1.0'; return L.divIcon({ html: icon, className: 'aprs-symbol', iconSize: [24, 24], iconAnchor: [12, 12] }); }, buildPopupContent(data: MarkerData): string { const callsign = data.callsign || data.id || "Unknown"; const comment = data.comment || ""; const symbolTableId = data.symbol_table_id || "/"; const symbolCode = data.symbol_code || ">"; const symbolDesc = data.symbol_description || `Symbol: ${symbolTableId}${symbolCode}`; let content = `
${symbolDesc}
`; if (comment) { content += `
${comment}
`; } if (data.lat && data.lng) { content += `
${data.lat.toFixed(4)}, ${data.lng.toFixed(4)}
`; } content += "
"; return content; }, destroyed() { const self = this as unknown as LiveViewHookContext; if (self.boundsTimer !== undefined) { clearTimeout(self.boundsTimer); } if (self.resizeHandler !== undefined) { window.removeEventListener("resize", self.resizeHandler); } if (self.markerLayer !== undefined) { self.markerLayer!.clearLayers(); } if (self.markers !== undefined) { self.markers!.clear(); } if (self.markerStates !== undefined) { self.markerStates!.clear(); } if (self.map !== undefined) { self.map!.remove(); self.map = undefined; } }, }; export default MinimalAPRSMap;