diff --git a/apps/lrauv-dash2/components/DeploymentMap.tsx b/apps/lrauv-dash2/components/DeploymentMap.tsx index 54531330..425789cf 100644 --- a/apps/lrauv-dash2/components/DeploymentMap.tsx +++ b/apps/lrauv-dash2/components/DeploymentMap.tsx @@ -607,6 +607,7 @@ const DeploymentMap: React.FC = ({ ) : null} { diff --git a/apps/lrauv-dash2/lib/elevationService.ts b/apps/lrauv-dash2/lib/elevationService.ts index 0b557190..5ca96592 100644 --- a/apps/lrauv-dash2/lib/elevationService.ts +++ b/apps/lrauv-dash2/lib/elevationService.ts @@ -1,6 +1,6 @@ import { createLogger } from '@mbari/utils' -// Extend the Window interface +// Extend the Window interface with proper typing declare global { interface Window { [GOOGLE_MAPS_LOADED_FLAG]?: boolean @@ -11,19 +11,155 @@ declare global { const logger = createLogger('ElevationService') const GOOGLE_MAPS_LOADED_FLAG = '_googleMapsElementsRegistered' -// Singleton instance of the ElevationService +// Configuration +const CONFIG = { + CACHE_SIZE_LIMIT: 1000, // Maximum number of cached elevation points + MAX_RETRY_ATTEMPTS: 3, // Maximum retry attempts for failed requests + RETRY_DELAY_MS: 1000, // Delay between retries (milliseconds) + INITIALIZATION_TIMEOUT_MS: 10000, // Timeout for API initialization + POLLING_INTERVAL_MS: 200, // Polling interval during initialization + MAX_POLLING_ATTEMPTS: 50, // Maximum polling attempts (50 * 200ms = 10s) +} + +// -------------------------------- +// Service state management +// -------------------------------- let elevatorInstance: google.maps.ElevationService | null = null let isInitializing = false -let initPromise: Promise | null = null +let initPromise: Promise | null = null +let lastError: Error | null = null + +// Enhanced cache with LRU behavior +class ElevationCache { + private cache = new Map() + private keyTimestamps = new Map() + private size: number = 0 + private readonly maxSize: number + + constructor(maxSize: number = CONFIG.CACHE_SIZE_LIMIT) { + this.maxSize = maxSize + } + + has(key: string): boolean { + return this.cache.has(key) + } + + get(key: string): number | null { + const value = this.cache.get(key) + if (value !== undefined) { + // Update timestamp on access (LRU behavior) + this.keyTimestamps.set(key, Date.now()) + return value + } + return null + } + + set(key: string, value: number): void { + // Evict oldest entry if at capacity + if (this.size >= this.maxSize && !this.cache.has(key)) { + this.evictOldest() + } + + if (!this.cache.has(key)) { + this.size++ + } + + this.cache.set(key, value) + this.keyTimestamps.set(key, Date.now()) + } + + private evictOldest(): void { + let oldestKey: string | null = null + let oldestTime = Infinity + + for (const [key, timestamp] of Array.from(this.keyTimestamps.entries())) { + if (timestamp < oldestTime) { + oldestTime = timestamp + oldestKey = key + } + } + + if (oldestKey) { + this.cache.delete(oldestKey) + this.keyTimestamps.delete(oldestKey) + this.size-- + } + } + clear(): void { + this.cache.clear() + this.keyTimestamps.clear() + this.size = 0 + } + + get stats(): { size: number; maxSize: number } { + return { + size: this.size, + maxSize: this.maxSize, + } + } +} + +// Initialize cache +const elevationCache = new ElevationCache(CONFIG.CACHE_SIZE_LIMIT) + +// Rate limiting implementation +class RateLimiter { + private requestTimes: number[] = [] + private readonly maxRequestsPerSecond: number + + constructor(maxRequestsPerSecond: number = 10) { + this.maxRequestsPerSecond = maxRequestsPerSecond + } + + async waitForAvailableSlot(): Promise { + // Remove timestamps older than 1 second + const now = Date.now() + this.requestTimes = this.requestTimes.filter((time) => now - time < 1000) + + // If at capacity, wait until we can make another request + if (this.requestTimes.length >= this.maxRequestsPerSecond) { + const oldestRequest = this.requestTimes[0] + const timeToWait = 1000 - (now - oldestRequest) + 10 // Add 10ms buffer + + if (timeToWait > 0) { + logger.debug(`Rate limit reached, waiting ${timeToWait}ms`) + await new Promise((resolve) => setTimeout(resolve, timeToWait)) + } + } + + // Record this request + this.requestTimes.push(Date.now()) + } +} + +// Initialize rate limiter (5 requests per second is a safe default) +const rateLimiter = new RateLimiter(5) + +// -------------------------------- +// Initialization and service handling +// -------------------------------- + +/** + * Gets a singleton instance of the Google Maps Elevation Service + * with enhanced error handling and reliability + */ export const getElevationService = - (): Promise => { - // If already initialized, return the instance + async (): Promise => { + // If already initialized successfully, return the instance if (elevatorInstance) { - return Promise.resolve(elevatorInstance) + return elevatorInstance } - // If currently initializing, return the promise + // If there was a previous fatal error, don't retry + if (lastError) { + logger.warn( + `Skipping elevation service - previous fatal error: ${lastError.message}` + ) + return null + } + + // If currently initializing, return the existing promise if (initPromise) { return initPromise } @@ -31,103 +167,264 @@ export const getElevationService = // Start initialization isInitializing = true - initPromise = new Promise( - (resolve, reject) => { - // Check if Google Maps API is available - if (typeof window === 'undefined') { - reject(new Error('Cannot initialize in server environment')) - return - } + // Create initialization promise with timeout + initPromise = Promise.race([ + new Promise( + async (resolve, reject) => { + try { + // Check if running in browser + if (typeof window === 'undefined') { + throw new Error( + 'Cannot initialize elevation service in server environment' + ) + } - // Check if already loaded using flag - if (window[GOOGLE_MAPS_LOADED_FLAG]) { - logger.debug('Skipping Google Maps initialization - already loaded') - } else if (window.google?.maps) { - // Set flag to prevent duplicate initialization - window[GOOGLE_MAPS_LOADED_FLAG] = true - } + // Check if Google Maps API is already available + if ( + window[GOOGLE_MAPS_LOADED_FLAG] && + window.google?.maps?.ElevationService + ) { + logger.debug('Using existing Google Maps API') + try { + elevatorInstance = new window.google.maps.ElevationService() + isInitializing = false + resolve(elevatorInstance) + return + } catch (e) { + logger.error( + 'Failed to create elevation service from existing API:', + e + ) + // Continue with polling approach + } + } else { + logger.debug('Waiting for Google Maps API to become available...') + } + + // Wait for API to become available through polling + let attempts = 0 + while (attempts < CONFIG.MAX_POLLING_ATTEMPTS) { + attempts++ + + if (window.google?.maps?.ElevationService) { + try { + elevatorInstance = new window.google.maps.ElevationService() + window[GOOGLE_MAPS_LOADED_FLAG] = true + logger.info( + `✅ Elevation service initialized after ${attempts} attempts` + ) + isInitializing = false + resolve(elevatorInstance) + return + } catch (e) { + logger.warn(`Failed to initialize on attempt ${attempts}:`, e) + } + } - // Wait for Google Maps API to load - const checkGoogleMaps = () => { - if (window.google?.maps?.ElevationService) { - try { - // Create instance first, then assign - const serviceInstance = new window.google.maps.ElevationService() - elevatorInstance = serviceInstance - logger.info('✅ Elevation service initialized after wait') - resolve(serviceInstance) // Resolve with instance, not the variable - return true - } catch (error) { - logger.error( - '❌ Failed to create elevation service after wait:', - error + // Wait before next attempt + await new Promise((r) => + setTimeout(r, CONFIG.POLLING_INTERVAL_MS) ) - reject(error) - return false // Prevent further attempts } - } - return false - } - // Check immediately - if (checkGoogleMaps()) { - return + // If we get here, polling failed + throw new Error( + `Elevation service initialization failed after ${attempts} attempts` + ) + } catch (error) { + const typedError = + error instanceof Error ? error : new Error(String(error)) + logger.error( + '❌ Elevation service initialization error:', + typedError + ) + lastError = typedError // Store error for future reference + isInitializing = false + resolve(null) // Resolve with null instead of rejecting to prevent unhandled rejections + } } + ), - // Set up polling - const maxAttempts = 20 - let attempts = 0 - const interval = setInterval(() => { - attempts++ - if (checkGoogleMaps() || attempts >= maxAttempts) { - clearInterval(interval) - if (!elevatorInstance && attempts >= maxAttempts) { - const error = new Error('Timed out waiting for Google Maps API') - logger.error('⏱️ Elevation service initialization timed out') - reject(error) - } + // Timeout promise + new Promise((resolve) => { + setTimeout(() => { + if (isInitializing) { + logger.error( + `⏱️ Elevation service initialization timed out after ${CONFIG.INITIALIZATION_TIMEOUT_MS}ms` + ) + isInitializing = false + resolve(null) } - }, 200) - } - ) + }, CONFIG.INITIALIZATION_TIMEOUT_MS) + }), + ]) return initPromise } -// Cache for elevation data - may help reduce API calls -const elevationCache = new Map() +/** + * Reset the elevation service state - useful for recovering from errors + */ +export const resetElevationService = (): void => { + elevatorInstance = null + initPromise = null + isInitializing = false + lastError = null + logger.info('Elevation service state reset') +} -export const getCachedElevation = async ( +// -------------------------------- +// Main API Functions +// -------------------------------- + +/** + * Get elevation data for a specific latitude/longitude with caching, + * retries, and comprehensive error handling + */ +export const getElevation = async ( lat: number, - lng: number + lng: number, + retryCount = 0 ): Promise => { - // Create cache key + // Validate input + if ( + isNaN(lat) || + isNaN(lng) || + lat < -90 || + lat > 90 || + lng < -180 || + lng > 180 + ) { + logger.warn(`Invalid coordinates: lat=${lat}, lng=${lng}`) + return null + } + + // Create cache key with precision control to avoid floating-point issues const key = `${lat.toFixed(6)},${lng.toFixed(6)}` // Check cache first - if (elevationCache.has(key)) { - return elevationCache.get(key)! + const cachedValue = elevationCache.get(key) + if (cachedValue !== null) { + return cachedValue } + // Wait for rate limiter + await rateLimiter.waitForAvailableSlot() + try { // Get elevation service const elevator = await getElevationService() - // Make the request + // If service is unavailable, return null + if (!elevator) { + logger.warn('Elevation service unavailable') + return null + } + + // Make the API request const response = await elevator.getElevationForLocations({ locations: [{ lat, lng }], }) - if (response.results?.[0]) { - const elevation = response.results[0].elevation - // Cache the result - elevationCache.set(key, elevation) - return elevation + // Validate response structure with detailed logging + if (!response) { + logger.warn(`Empty response from elevation service for ${lat},${lng}`) + return null } - return null + if (!response.results) { + logger.warn( + `Missing results array in elevation response for ${lat},${lng}` + ) + return null + } + + if (response.results.length === 0) { + logger.warn(`Empty results array in elevation response for ${lat},${lng}`) + return null + } + + // Extract and validate elevation + const elevation = response.results[0]?.elevation + + if (elevation === undefined || elevation === null) { + logger.warn(`No elevation value in response for ${lat},${lng}`) + return null + } + + // Cache the valid result + elevationCache.set(key, elevation) + return elevation } catch (error) { - logger.error('Error getting elevation!:', error) + const errorMsg = error instanceof Error ? error.message : String(error) + logger.error(`Error getting elevation for ${lat},${lng}: ${errorMsg}`) + + // Implement retry logic for transient errors + if (retryCount < CONFIG.MAX_RETRY_ATTEMPTS) { + logger.info( + `Retrying elevation request (${retryCount + 1}/${ + CONFIG.MAX_RETRY_ATTEMPTS + })` + ) + await new Promise((resolve) => setTimeout(resolve, CONFIG.RETRY_DELAY_MS)) + return getElevation(lat, lng, retryCount + 1) + } + return null } } + +/** + * Get cached elevation data without making an API call + */ +export const getCachedElevation = (lat: number, lng: number): number | null => { + const key = `${lat.toFixed(6)},${lng.toFixed(6)}` + return elevationCache.get(key) +} + +/** + * Batch process multiple elevation requests efficiently + */ +export const getBatchElevations = async ( + points: Array<[number, number]> +): Promise> => { + // Process in smaller batches to stay within API limits (max 512 locations per request) + const BATCH_SIZE = 500 + const results: Array = [] + + // Process in batches + for (let i = 0; i < points.length; i += BATCH_SIZE) { + const batch = points.slice(i, i + BATCH_SIZE) + const batchPromises = batch.map(([lat, lng]) => getElevation(lat, lng)) + + try { + const batchResults = await Promise.all(batchPromises) + results.push(...batchResults) + } catch (error) { + logger.error('Error in batch elevation processing:', error) + + // Fill remaining results with nulls on error + const nulls = new Array(batch.length).fill(null) + results.push(...nulls) + } + } + + return results +} + +/** + * Get elevation service statistics + */ +export const getElevationServiceStats = () => { + return { + serviceAvailable: elevatorInstance !== null, + cacheStats: elevationCache.stats, + lastError: lastError ? lastError.message : null, + } +} + +// Export for testing and debugging +export const _testing = { + resetCache: () => elevationCache.clear(), + CONFIG, +} diff --git a/apps/lrauv-dash2/lib/useGoogleElevator.ts b/apps/lrauv-dash2/lib/useGoogleElevator.ts index ad7e1a71..5e2bc745 100644 --- a/apps/lrauv-dash2/lib/useGoogleElevator.ts +++ b/apps/lrauv-dash2/lib/useGoogleElevator.ts @@ -52,6 +52,12 @@ export function useElevator() { // Make the API request try { + // Add null check before using elevationService + if (!elevationService) { + logger.warn('Elevation service is not available') + return { depth: null, status: 'unavailable' } // Elevation service unavailable + } + const result = await elevationService.getElevationForLocations( request ) diff --git a/apps/lrauv-dash2/lib/useWebSocketListeners.ts b/apps/lrauv-dash2/lib/useWebSocketListeners.ts index 73ec5935..37d1e32f 100644 --- a/apps/lrauv-dash2/lib/useWebSocketListeners.ts +++ b/apps/lrauv-dash2/lib/useWebSocketListeners.ts @@ -1,13 +1,28 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, useRef, useMemo, useCallback } from 'react' import { useTethysApiContext } from '@mbari/api-client' import { useQueryClient } from 'react-query' +import { createLogger } from '@mbari/utils' +const logger = createLogger('WebSocketService') + +// Connection configuration +const CONFIG = { + MAX_RETRIES: 5, + INITIAL_RETRY_DELAY_MS: 1000, + MAX_RETRY_DELAY_MS: 30000, // Max 30 seconds between retries + CONNECTION_TIMEOUT_MS: 10000, // 10 second timeout for connection attempts + NORMAL_CLOSE_CODE: 1000, +} + +// Type definitions type SubscriptionEventType = | 'VehicleConnected' | 'VehiclePingResult' | 'presence' | null +type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error' + export const SUPPORTED_EVENT_TYPES: SubscriptionEventType[] = [ 'VehicleConnected', 'VehiclePingResult', @@ -24,52 +39,341 @@ export interface TethysSubscriptionEvent { email?: string } +/** + * Custom hook for managing WebSocket connections to the Tethys subscription service + * @returns Connection status information + */ export const useTethysSubscription = () => { const { token, profile } = useTethysApiContext() const queryClient = useQueryClient() + const [connectionStatus, setConnectionStatus] = + useState('disconnected') + const [connectionError, setConnectionError] = useState(null) + + // Refs for tracking connection state + const websocketRef = useRef(null) + const reconnectTimeoutRef = useRef(null) + const connectionTimeoutRef = useRef(null) + const retryCountRef = useRef(0) + const isUnmountingRef = useRef(false) + + // Create WebSocket URL with proper formatting to prevent double slashes + const url = useMemo(() => { + if (!profile?.email || !token) return null + + // Remove any trailing slashes from the base URL to prevent double slashes + const baseUrl = (process.env.NEXT_PUBLIC_WEBSOCKET_URL || '').replace( + /\/+$/, + '' + ) + const formattedUrl = `${baseUrl}/${token}?aem=${encodeURIComponent( + profile.email + )}` + + return formattedUrl + }, [token, profile?.email]) + + /** + * Clean up all timers and existing connections + */ + const cleanupResources = useCallback(() => { + // Clear connection timeout + if (connectionTimeoutRef.current) { + clearTimeout(connectionTimeoutRef.current) + connectionTimeoutRef.current = null + } + + // Clear reconnection timeout + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + reconnectTimeoutRef.current = null + } + + // Close WebSocket if open + if (websocketRef.current) { + try { + // Only log if we're not unmounting + if (!isUnmountingRef.current) { + logger.debug('Closing existing WebSocket connection') + } + + // Check if connection isn't already closed + if (websocketRef.current.readyState !== WebSocket.CLOSED) { + websocketRef.current.close(CONFIG.NORMAL_CLOSE_CODE, 'Cleanup') + } + } catch (error) { + // Ignore any errors during cleanup + } finally { + websocketRef.current = null + } + } + }, []) - const url = - profile?.email && - token && - `${process.env.NEXT_PUBLIC_WEBSOCKET_URL as string}/${token}?aem=${ - profile?.email - }` + /** + * Get exponential backoff delay with jitter + */ + const getBackoffDelay = useCallback((retryCount: number) => { + // Calculate exponential backoff: 2^retryCount * initialDelay + const exponentialDelay = Math.min( + CONFIG.INITIAL_RETRY_DELAY_MS * Math.pow(2, retryCount), + CONFIG.MAX_RETRY_DELAY_MS + ) + // Add jitter (±20% variation) to prevent thundering herd + const jitter = exponentialDelay * 0.2 * (Math.random() * 2 - 1) + + return Math.floor(exponentialDelay + jitter) + }, []) + + // Handle WebSocket connection and events useEffect(() => { if (!url) { - return + logger.debug('No WebSocket URL available - missing token or profile') + return cleanupResources } - const websocket = new WebSocket(url) - websocket.onopen = () => { - console.log('connected') + + // Reset unmounting flag + isUnmountingRef.current = false + + // Clean up any existing resources first + cleanupResources() + + /** + * Attempts to establish a WebSocket connection with retry logic + */ + const connectWebSocket = () => { + try { + // Clean up any existing connections first + cleanupResources() + + logger.debug(`Connecting to WebSocket: ${url}`) + setConnectionStatus('connecting') + setConnectionError(null) + + // Create new WebSocket connection + const ws = new WebSocket(url) + websocketRef.current = ws + + // Set connection timeout + connectionTimeoutRef.current = setTimeout(() => { + logger.warn('WebSocket connection attempt timed out') + + // Only proceed if this is still the active connection attempt + if (websocketRef.current === ws) { + setConnectionStatus('error') + setConnectionError(new Error('Connection timed out')) + + // Close the socket if it's still open/connecting + if (ws.readyState !== WebSocket.CLOSED) { + ws.close() + } + + // Try to reconnect + handleReconnect() + } + }, CONFIG.CONNECTION_TIMEOUT_MS) + + // Connection successfully established + ws.onopen = () => { + // Clear connection timeout + if (connectionTimeoutRef.current) { + clearTimeout(connectionTimeoutRef.current) + connectionTimeoutRef.current = null + } + + logger.info('✅ WebSocket connection established') + setConnectionStatus('connected') + retryCountRef.current = 0 // Reset retry counter on successful connection + } + + // Handle incoming messages + ws.onmessage = (event) => { + if (!event.data) return + + try { + const data = JSON.parse(event.data) as TethysSubscriptionEvent + + // Validate event type + if ( + !SUPPORTED_EVENT_TYPES.includes( + (data.eventName ?? null) as SubscriptionEventType + ) + ) { + // logger.debug(`Unsupported event type: ${data.eventName}`) + return + } + + // Create query key from event data + const queryKey = [ + data.eventName, + data.vehicleName, + data.email, + ].filter(Boolean) // Filter out undefined/null values + + // Update React Query cache + queryClient.invalidateQueries({ queryKey }) + queryClient.setQueryData(queryKey, data) + } catch (parseError) { + logger.error('Failed to parse WebSocket message:', parseError) + } + } + + // Handle errors - using the safer approach to avoid React errors + ws.onerror = (event) => { + // Clear connection timeout if it exists + if (connectionTimeoutRef.current) { + clearTimeout(connectionTimeoutRef.current) + connectionTimeoutRef.current = null + } + + // Log the error but don't create a new Error object in the handler + logger.error('WebSocket error occurred', { + url, + readyState: ws.readyState, + }) + + // Update connection state + setTimeout(() => { + if (!isUnmountingRef.current) { + setConnectionStatus('error') + setConnectionError(new Error('Connection failed')) + } + }, 0) + } + + // Handle disconnection + ws.onclose = (event) => { + // Clear connection timeout if it exists + if (connectionTimeoutRef.current) { + clearTimeout(connectionTimeoutRef.current) + connectionTimeoutRef.current = null + } + + logger.debug( + `WebSocket closed with code ${event.code}: ${ + event.reason || 'No reason provided' + }` + ) + + if (!isUnmountingRef.current) { + setConnectionStatus('disconnected') + websocketRef.current = null + + // Don't reconnect on normal closure + if (event.code !== CONFIG.NORMAL_CLOSE_CODE) { + handleReconnect() + } + } + } + } catch (setupError) { + logger.error('Failed to setup WebSocket:', setupError) + + if (!isUnmountingRef.current) { + setConnectionError( + setupError instanceof Error + ? setupError + : new Error(String(setupError)) + ) + setConnectionStatus('error') + + // Try to reconnect + handleReconnect() + } + } } - websocket.onmessage = (event) => { - const data = JSON.parse(event.data) as TethysSubscriptionEvent - if ( - !SUPPORTED_EVENT_TYPES.includes( - (data.eventName ?? null) as SubscriptionEventType + + /** + * Handles reconnection attempts with exponential backoff + */ + const handleReconnect = () => { + if (isUnmountingRef.current) return + + if (retryCountRef.current < CONFIG.MAX_RETRIES) { + retryCountRef.current++ + + const delay = getBackoffDelay(retryCountRef.current) + + logger.debug( + `Attempting to reconnect (${retryCountRef.current}/${CONFIG.MAX_RETRIES}) in ${delay}ms` ) - ) { - console.log('Unsupported event type: ', data.eventName) - return + + reconnectTimeoutRef.current = setTimeout(() => { + if (!isUnmountingRef.current) { + connectWebSocket() + } + }, delay) + } else { + logger.warn(`Max reconnection attempts reached (${CONFIG.MAX_RETRIES})`) + + if (!isUnmountingRef.current) { + setConnectionError( + new Error( + 'Failed to establish WebSocket connection after multiple attempts' + ) + ) + } } - const queryKey = [data.eventName, data.vehicleName, data.email].filter( - (i) => i - ) - queryClient.invalidateQueries({ queryKey }) - queryClient.setQueryData(queryKey, data) } + // Start the initial connection + connectWebSocket() + + // Cleanup function return () => { - websocket.close() + isUnmountingRef.current = true + cleanupResources() } - }, [queryClient, url]) + }, [url, queryClient, cleanupResources, getBackoffDelay]) + + // Return connection info that can be used by consuming components + return { + connectionStatus, + connectionError, + isConnected: connectionStatus === 'connected', + } } +/** + * Hook to subscribe to specific Tethys events by type and scope + * @param eventType - Type of event to subscribe to + * @param scope - Scope of the subscription (e.g., vehicle name) + * @returns The latest event data + */ export const useTethysSubscriptionEvent = ( eventType: SubscriptionEventType, scope: string -) => { +): TethysSubscriptionEvent | undefined => { const queryClient = useQueryClient() - return queryClient.getQueryData([eventType, scope]) as TethysSubscriptionEvent + const [eventData, setEventData] = useState< + TethysSubscriptionEvent | undefined + >(queryClient.getQueryData([eventType, scope]) as TethysSubscriptionEvent) + + // Subscribe to updates for this specific event + useEffect(() => { + const queryKey = [eventType, scope].filter(Boolean) + + // Get initial data + const initialData = queryClient.getQueryData( + queryKey + ) as TethysSubscriptionEvent + if (initialData) { + setEventData(initialData) + } + + // Setup subscription to query updates + const unsubscribe = queryClient.getQueryCache().subscribe((event) => { + if ( + event?.type === 'queryUpdated' && + Array.isArray(event.query.queryKey) && + event.query.queryKey[0] === eventType && + event.query.queryKey[1] === scope + ) { + setEventData(event.query.state.data as TethysSubscriptionEvent) + } + }) + + return unsubscribe + }, [eventType, scope, queryClient]) + + return eventData } diff --git a/apps/lrauv-dash2/pages/index.tsx b/apps/lrauv-dash2/pages/index.tsx index f8386001..37680050 100644 --- a/apps/lrauv-dash2/pages/index.tsx +++ b/apps/lrauv-dash2/pages/index.tsx @@ -602,6 +602,7 @@ const OverViewMap: React.FC<{ ) : null} { logger.debug('🌍 Map ready callback triggered in OverViewMap') diff --git a/packages/react-ui/src/Map/Map.tsx b/packages/react-ui/src/Map/Map.tsx index 860700da..f1f0b309 100644 --- a/packages/react-ui/src/Map/Map.tsx +++ b/packages/react-ui/src/Map/Map.tsx @@ -95,6 +95,7 @@ export interface MapProps extends React.HTMLAttributes { viewMode?: 'center' | 'bounds' | null scrollWheelZoom?: boolean isAddingMarkers?: boolean + mapId?: string onToggleMarkerMode?: () => void onRequestMarkers?: (position?: { top: number; left: number }) => void onRequestDepth?: MouseCoordinatesProps['onRequestDepth'] @@ -141,6 +142,7 @@ const Map = React.forwardRef( className, style, center = [36.7849, -122.12097], + mapId = 'default', centerZoom, zoom = 17, minZoom = 4, @@ -182,7 +184,7 @@ const Map = React.forwardRef( const [isMeasuring, setIsMeasuring] = useState(false) const [isAddingMarkersLocal, setIsAddingMarkersLocal] = useState(isAddingMarkers) - const { baseLayer, setBaseLayer } = useMapBaseLayer() + const { baseLayer, setBaseLayer } = useMapBaseLayer(mapId) const addBaseLayerHandler = useCallback( (layer: BaseLayerOption) => () => { setBaseLayer(layer) diff --git a/packages/react-ui/src/Map/useMapBaseLayer.ts b/packages/react-ui/src/Map/useMapBaseLayer.ts index c2272b09..525ce1d2 100644 --- a/packages/react-ui/src/Map/useMapBaseLayer.ts +++ b/packages/react-ui/src/Map/useMapBaseLayer.ts @@ -1,4 +1,4 @@ -import { atom, useRecoilState } from 'recoil' +import { atom, useRecoilState, RecoilState } from 'recoil' export type BaseLayerOption = | 'Google Hybrid' @@ -11,14 +11,39 @@ export interface UseMapBaseLayerState { baseLayer: BaseLayerOption } -const baseLayerState = atom({ - key: 'baseLayerState', - default: { baseLayer: 'Google Hybrid' }, -}) +// Cache of atoms by ID to prevent recreation +const atomCache: Record> = {} -export const useMapBaseLayer = () => { +/** + * Get or create an atom for a specific map instance + * @param id - Unique identifier for this map instance + * @returns Recoil atom with unique key + */ +const getBaseLayerAtom = (id: string = 'default') => { + // Use cached atom if one exists for this ID + if (!atomCache[id]) { + atomCache[id] = atom({ + key: `baseLayerState-${id}`, + default: { baseLayer: 'Google Hybrid' }, + }) + } + return atomCache[id] +} + +/** + * Hook to manage the base layer state for a map + * @param id - Optional ID for cases where multiple maps exist (defaults to 'default') + * @returns Object containing baseLayer and setBaseLayer + */ +export const useMapBaseLayer = (id: string = 'default') => { + // Get or create atom for this specific map instance + const baseLayerState = getBaseLayerAtom(id) + + // Use the atom with Recoil as normal const [state, setState] = useRecoilState(baseLayerState) + const setBaseLayer = (baseLayer: BaseLayerOption) => setState({ baseLayer }) + return { baseLayer: state?.baseLayer, setBaseLayer, diff --git a/packages/utils/src/useResizeObserver.ts b/packages/utils/src/useResizeObserver.ts index be9ffbd4..7ac507d7 100644 --- a/packages/utils/src/useResizeObserver.ts +++ b/packages/utils/src/useResizeObserver.ts @@ -9,34 +9,56 @@ import { import ResizeObserver from 'resize-observer-polyfill' import { throttle } from 'lodash' -export const useResizeObserver = ({ +type ElementType = Element | null | undefined + +interface Size { + width: number + height: number +} + +/** + * Hook to observe and track element size changes + * @param element - Reference to the DOM element to observe + * @param wait - Throttle delay in ms + * @returns The current size of the observed element + */ +export const useResizeObserver = ({ element: elementRef, wait = 100, }: { element: RefObject | MutableRefObject wait?: number }) => { - const [size, setSize] = useState({ width: 0, height: 0 }) + const [size, setSize] = useState({ width: 0, height: 0 }) const observerRef = useRef(null) + const elementPreviouslyObserved = useRef(null) + // Create throttled callback for performance const callback = useMemo( () => - throttle((entries) => { + throttle((entries: ResizeObserverEntry[]) => { + // Skip processing if no entries + if (!entries.length) return + + const entry = entries[0] let height = 0 let width = 0 - for (let entry of entries) { - if (entry.contentBoxSize) { - // Firefox implements `contentBoxSize` as a single content rect, rather than an array - const contentBoxSize = Array.isArray(entry.contentBoxSize) - ? entry.contentBoxSize[0] - : entry.contentBoxSize - height = contentBoxSize.blockSize - width = contentBoxSize.inlineSize - } else { - height = entry.contentRect.height - width = entry.contentRect.width - } + + if (entry.contentBoxSize) { + // Firefox implements `contentBoxSize` as a single content rect, rather than an array + const contentBoxSize = Array.isArray(entry.contentBoxSize) + ? entry.contentBoxSize[0] + : entry.contentBoxSize + + height = contentBoxSize.blockSize + width = contentBoxSize.inlineSize + } else { + // Fallback for older browsers + height = entry.contentRect.height + width = entry.contentRect.width } + + // Only update state if dimensions actually changed if (size.width !== width || size.height !== height) { setSize({ height, @@ -44,26 +66,79 @@ export const useResizeObserver = ({ }) } }, wait), - [setSize, wait, size] + [wait] // Remove size from dependencies to avoid unnecessary recreations ) useEffect(() => { - const element: Element = elementRef.current as any - if (elementRef.current) { - observerRef.current?.unobserve(element) + // Safely disconnect any existing observer + if (observerRef.current) { + observerRef.current.disconnect() } + + // Create new observer const ResizeObserverOrPolyfill = ResizeObserver observerRef.current = new ResizeObserverOrPolyfill(callback) - if (elementRef.current) { - observerRef.current?.observe(element) + + // Safely get the element + const element = elementRef.current + + // Validate element before observing + if (element && element instanceof Element) { + try { + observerRef.current.observe(element) + elementPreviouslyObserved.current = element + } catch (error) { + console.warn('Failed to observe element with ResizeObserver:', error) + } } + // Cleanup function return () => { - if (elementRef.current) { - observerRef.current?.unobserve(element) + // Cancel any pending throttled callbacks + callback.cancel() + + // Disconnect observer + if (observerRef.current) { + observerRef.current.disconnect() + observerRef.current = null + } + } + }, [elementRef, callback]) // Properly track dependencies + + // Handle element changes (like conditional rendering) + useEffect(() => { + const observer = observerRef.current + if (!observer) return + + const element = elementRef.current + const previousElement = elementPreviouslyObserved.current + + // Skip if the element hasn't changed + if (element === previousElement) return + + // Unobserve previous element if it exists + if (previousElement) { + try { + observer.unobserve(previousElement) + } catch (error) { + // Ignore errors when unobserving + } + elementPreviouslyObserved.current = null + } + + // Observe new element if valid + if (element && element instanceof Element) { + try { + observer.observe(element) + elementPreviouslyObserved.current = element + } catch (error) { + console.warn( + 'Failed to observe new element with ResizeObserver:', + error + ) } } - }, [elementRef, wait, observerRef]) + }, [elementRef.current]) // Track only the current element return { size } }