Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/lrauv-dash2/components/DeploymentMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { createLogger } from '@mbari/utils'
import VehicleColorsModal from './VehicleColorsModal'
import useTrackedVehicles from '../lib/useTrackedVehicles'
import { useDepthRequest } from '@mbari/utils/useDepthRequest'
import { useRefreshPositions } from '../lib/useRefreshPositions'

// This is a tricky workaround to prevent leaflet from crashing next.js
// SSR. If we don't do this, the leaflet map will be loaded server side
Expand Down Expand Up @@ -100,6 +101,23 @@ const DeploymentMap: React.FC<DeploymentMapProps> = ({

const { trackedVehicles } = useTrackedVehicles()
const [showAll, setShowAll] = useState(true)

const vehiclesToRefresh = vehicleName
? [vehicleName]
: trackedVehicles.map((v) => (typeof v === 'string' ? v : v))
const {
refreshAll,
lastRefreshed,
loading: refreshLoading,
markInitialLoadDone,
} = useRefreshPositions(vehiclesToRefresh, {
autoRefreshMinutes: 10,
// Use vehicle's time range so toast counts match the map
preferredParams:
vehicleName && startTime != null
? { [vehicleName]: { from: startTime, to: endTime ?? undefined } }
: undefined,
})
const [isTimelineScrubbing, setIsTimelineScrubbing] = useState(false)
const [center, setCenter] = useState<undefined | [number, number]>()
const [centerZoom, setCenterZoom] = useState<number | undefined>(undefined)
Expand Down Expand Up @@ -571,6 +589,11 @@ const DeploymentMap: React.FC<DeploymentMapProps> = ({
onRequestFitBounds={handleFitBoundsRequest}
onRequestStations={handleLayersRequest}
onRequestVehicleColors={handleVehicleColorRequest}
onRequestRefresh={refreshAll}
refreshLoading={refreshLoading}
refreshLastRefreshed={lastRefreshed}
refreshAutoRefreshMinutes={10}
refreshTooltipPreamble="Reload LRAUV positions"
isAddingMarkers={isAddingMarkers}
onToggleMarkerMode={handleToggleMarkerMode}
onRequestMarkers={handleMarkersRequest}
Expand Down Expand Up @@ -671,6 +694,7 @@ const DeploymentMap: React.FC<DeploymentMapProps> = ({
indicatorTime={indicatorTime}
onScrub={handleMapScrub}
onGPSFix={handleGPSFix}
onPositionDataLoaded={markInitialLoadDone}
// Disable map auto-fit centering when scrubbing the timeline
disableAutoFit={isTimelineScrubbing}
/>
Expand Down
6 changes: 4 additions & 2 deletions apps/lrauv-dash2/components/VehicleList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
calculateRelativeNextComm,
decodeHtmlEntities,
} from '@mbari/utils'
import React, { useEffect } from 'react'
import React, { useEffect, useMemo } from 'react'
import useTrackedVehicles from '../lib/useTrackedVehicles'
import axios from 'axios'
import { DateTime } from 'luxon'
Expand Down Expand Up @@ -61,10 +61,12 @@ const ConnectedVehicleCellComponent: React.FC<{
},
{ staleTime: 5 * 60 * 1000 }
)

const defaultFrom = useMemo(() => Date.now() - 24 * 60 * 60 * 1000, [])
const { data: vehiclePosition, isLoading: positionLoading } = useVehiclePos(
{
vehicle: name,
from: lastDeployment?.lastEvent ?? 0,
from: lastDeployment?.lastEvent ?? defaultFrom,
},
{
enabled: !!lastDeployment?.lastEvent,
Expand Down
28 changes: 26 additions & 2 deletions apps/lrauv-dash2/components/VehiclePath.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { LatLng, LeafletMouseEventHandlerFn } from 'leaflet'
import { useSharedPath } from './SharedPathContextProvider'
import { distance } from '@turf/turf'
import { parseISO, getTime } from 'date-fns'
import { formatElapsedTime } from '@mbari/utils'
import { useVehicleColors } from './VehicleColorsContext'

const getDistance = (a: VPosDetail, b: LatLng) =>
Expand Down Expand Up @@ -54,6 +55,7 @@ interface VehiclePathProps {
indicatorTime?: number | null
onScrub?: (millis?: number | null) => void
onGPSFix?: (gps: VPosDetail) => void
onPositionDataLoaded?: () => void
disableAutoFit?: boolean
}

Expand All @@ -66,6 +68,7 @@ const VehiclePath: React.FC<VehiclePathProps> = ({
indicatorTime,
onScrub: handleScrub,
onGPSFix: handleGPSFix,
onPositionDataLoaded,
disableAutoFit = false,
}) => {
const map = useMap()
Expand All @@ -77,10 +80,13 @@ const VehiclePath: React.FC<VehiclePathProps> = ({
},
{ staleTime: 5 * 60 * 1000, enabled: !from }
)
// Default to 24 hours ago if no deployment data is available
// Memoize to prevent query key from changing on every render
const defaultFrom = useMemo(() => Date.now() - 24 * 60 * 60 * 1000, [])
const { data: vehiclePosition } = useVehiclePos(
{
vehicle: name as string,
from: from ? from : lastDeployment?.startEvent?.unixTime ?? 0,
from: from ? from : lastDeployment?.startEvent?.unixTime ?? defaultFrom,
to: from ? to : lastDeployment?.endEvent?.unixTime,
},
{
Expand Down Expand Up @@ -119,6 +125,7 @@ const VehiclePath: React.FC<VehiclePathProps> = ({

// Handle GPS Fixes
const latestGPS = useRef<[number, number] | undefined>()
const hasNotifiedDataLoaded = useRef(false)

useEffect(() => {
if (vehiclePosition?.gpsFixes && vehiclePosition.gpsFixes.length > 0) {
Expand All @@ -136,6 +143,18 @@ const VehiclePath: React.FC<VehiclePathProps> = ({
}
}, [vehiclePosition, handleScrub, handleGPSFix])

// Notify parent once when position data is available (for refresh "first load" countdown)
useEffect(() => {
if (
!onPositionDataLoaded ||
hasNotifiedDataLoaded.current ||
!vehiclePosition?.gpsFixes?.length
)
return
hasNotifiedDataLoaded.current = true
onPositionDataLoaded()
}, [vehiclePosition?.gpsFixes, onPositionDataLoaded])

const timeout = useRef<ReturnType<typeof setTimeout> | null>(null)

// handleCoordinates
Expand Down Expand Up @@ -309,6 +328,11 @@ const VehiclePath: React.FC<VehiclePathProps> = ({
}
}

// Derived for tooltip (compact format, updates on re-render)
const timeSinceFixDisplay = latestTimeFix
? formatElapsedTime(Date.now() - getTime(parseISO(latestTimeFix)))
: ''

return route?.length ? (
<>
<Polyline
Expand Down Expand Up @@ -410,7 +434,7 @@ const VehiclePath: React.FC<VehiclePathProps> = ({
' ' +
indicatorCoord.isoTime.split('T')[1].split('Z')[0]}
{' - '}
{timeSinceFix}
{timeSinceFixDisplay}
</span>
</Tooltip>
)}
Expand Down
227 changes: 227 additions & 0 deletions apps/lrauv-dash2/lib/useRefreshPositions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { useQueryClient } from 'react-query'
import { GetVPosResponse, GetVPosParams } from '@mbari/api-client'
import toast from 'react-hot-toast'
import { createLogger } from '@mbari/utils'

const logger = createLogger('useRefreshPositions')

export interface VehiclePositionInfo {
vehicleName: string
gpsFixes: number
argoReceives: number
emergencies: number
reachedWaypoints: number
}

export interface PreferredQueryParams {
from: number
to?: number
}

export interface UseRefreshPositionsOptions {
autoRefreshMinutes?: number
preferredParams?: Record<string, PreferredQueryParams>
}

/** Duration the refresh-position toast is shown and throttle window before another refresh is allowed. */
const REFRESH_TOAST_DURATION_MS = 3000

const getRefreshToastId = (vehicleName: string) =>
`refresh-positions-${vehicleName}`

export interface UseRefreshPositionsReturn {
loading: boolean
lastRefreshed: Date | null
refreshAll: () => Promise<Record<string, GetVPosResponse> | undefined>
/** Call once when vehicle position data has first loaded (e.g. from VehiclePath) so "next refresh" countdown can start. */
markInitialLoadDone: () => void
}

export const useRefreshPositions = (
vehicleNames: string[],
options: UseRefreshPositionsOptions = {}
): UseRefreshPositionsReturn => {
const queryClient = useQueryClient()
const [loading, setLoading] = useState(false)
const [lastRefreshed, setLastRefreshed] = useState<Date | null>(null)
const initializedRef = useRef(false)
const lastRefreshRunRef = useRef<number>(0)

const { autoRefreshMinutes, preferredParams } = options

/** Call when vehicle position data has first loaded so "next refresh" countdown can start. Only set once. */
const markInitialLoadDone = useCallback(() => {
if (initializedRef.current) return
setLastRefreshed(new Date())
initializedRef.current = true
}, [])

// Fallback: if no VehiclePath reports data within 30s (e.g. no vehicles or slow load), set lastRefreshed so tooltip still shows
useEffect(() => {
if (vehicleNames.length === 0) return
const timeout = setTimeout(() => {
markInitialLoadDone()
}, 30000)
return () => clearTimeout(timeout)
}, [vehicleNames.length, markInitialLoadDone])

const showPositionNotification = useCallback(
(vehicleNamesList: string[], results: Record<string, GetVPosResponse>) => {
// One toast per vehicle; always show GPS fixes (including 0), others only if > 0
vehicleNamesList.forEach((vehicleName) => {
const data = results[vehicleName]
const gpsFixes = data?.gpsFixes?.length ?? 0
const argoReceives = data?.argoReceives?.length ?? 0
const emergencies = data?.emergencies?.length ?? 0
const reachedWaypoints = data?.reachedWaypoints?.length ?? 0

const hasEmergencies = emergencies > 0
const timeout = hasEmergencies ? 0 : REFRESH_TOAST_DURATION_MS

const message = (
<div>
<strong>Loaded positions: {vehicleName}</strong>
<div style={{ marginTop: '0.5em', marginLeft: '0.5em' }}>
{emergencies > 0 && (
<div style={{ color: 'red' }}>{emergencies} Emergency</div>
)}
<div>{gpsFixes} GPS fixes</div>
{argoReceives > 0 && <div>{argoReceives} Argo</div>}
{reachedWaypoints > 0 && <div>{reachedWaypoints} RWP</div>}
</div>
</div>
)

toast(message, {
id: getRefreshToastId(vehicleName),
position: 'bottom-left',
duration: timeout || undefined,
className: 'refresh-position-toast',
style: {
backgroundColor: '#424242',
color: '#fff',
},
})
})
},
[]
)

const refreshAll = useCallback(async () => {
if (vehicleNames.length === 0) {
logger.warn('No vehicles to refresh')
return
}

const now = Date.now()
if (now - lastRefreshRunRef.current < REFRESH_TOAST_DURATION_MS) {
return
}
lastRefreshRunRef.current = now

setLoading(true)
try {
// Refetch the actual queries that VehiclePath components are using
// This ensures we're refreshing with the same params that are displayed on the map
await Promise.all(
vehicleNames.map((vehicleName) =>
queryClient.refetchQueries({
queryKey: ['info', 'vehiclePos'],
predicate: (query) => {
const queryParams = query.queryKey[2] as GetVPosParams | undefined
// Only refetch queries with valid parameters:
// - vehicle must match
// - from must be present and > 0
return (
queryParams?.vehicle === vehicleName &&
queryParams?.from !== undefined &&
queryParams.from > 0
)
},
})
)
)

// Get the updated data from React Query cache to show notifications.
// Prefer the query that matches preferredParams (e.g. deployment page time range) so toast counts match the current view.
const results: Record<string, GetVPosResponse> = {}
const queries = queryClient.getQueriesData({
queryKey: ['info', 'vehiclePos'],
exact: false,
})

vehicleNames.forEach((vehicleName) => {
const preferred = preferredParams?.[vehicleName]
let match: GetVPosResponse | undefined
let fallback: GetVPosResponse | undefined

for (const [queryKey, data] of queries) {
const queryParams = queryKey[2] as GetVPosParams | undefined
if (queryParams?.vehicle !== vehicleName || !data) continue

const response = data as GetVPosResponse

if (preferred) {
const fromMatch = queryParams.from === preferred.from
const toMatch =
preferred.to == null ? true : queryParams.to === preferred.to
if (fromMatch && toMatch) {
match = response
break
}
} else {
// No preferredParams (e.g. overview): prefer query with to (VehiclePath last-deployment window)
if (queryParams.to !== undefined) {
match = response
break
}
if (!fallback) fallback = response
}
}
const chosen = match ?? fallback
if (chosen) results[vehicleName] = chosen
})

setLastRefreshed(new Date())
initializedRef.current = true // Mark as initialized after first manual refresh
showPositionNotification(vehicleNames, results)

return results
} catch (error) {
logger.error('Error refreshing positions:', error)
toast.error('Failed to refresh vehicle positions', {
position: 'bottom-left',
})
throw error
} finally {
setLoading(false)
}
}, [vehicleNames, preferredParams, showPositionNotification, queryClient])

// Auto-refresh functionality
useEffect(() => {
if (!autoRefreshMinutes || !lastRefreshed) return

const nextRefreshTime =
lastRefreshed.getTime() + autoRefreshMinutes * 60 * 1000
const timeUntil = nextRefreshTime - Date.now()

if (timeUntil <= 0) return

const timeout = setTimeout(() => {
refreshAll().catch((error) => {
logger.error('Auto-refresh failed:', error)
})
}, timeUntil)

return () => clearTimeout(timeout)
}, [lastRefreshed, autoRefreshMinutes, refreshAll])

return {
loading,
lastRefreshed,
refreshAll,
markInitialLoadDone,
}
}
Loading