import { useQuery, type UseQueryResult } from '@tanstack/react-query'; import { useEffect, useRef, useState } from 'react'; import { api } from '../../api/client'; import type { MonitorMapResponse, MonitorSummary, MonitorWorkspaceResponse, Page, VehicleRealtimeRow } from '../../api/types'; import { normalizeMonitorBounds } from '../domain/monitor'; import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy'; export type MonitorFilters = { keyword: string; protocol: string; status: string; }; export type MonitorViewport = { zoom: number; bounds: string; }; export const MONITOR_REFRESH = { summary: 30_000, fleet: 15_000, selected: 10_000, alerts: 15_000 } as const; export const MONITOR_CACHE = { mapGcTime: 0 } as const; export const MAX_MONITOR_SEARCH_TERMS = 100; export const MONITOR_ADDRESS_REFRESH_MS = 60_000; type MonitorAddressPoint = { vin: string; key: string; longitude: number; latitude: number; }; function monitorAddressPoint(vin: string, longitude?: number, latitude?: number): MonitorAddressPoint | undefined { if (!vin || !Number.isFinite(longitude) || !Number.isFinite(latitude) || Math.abs(longitude ?? 0) > 180 || Math.abs(latitude ?? 0) > 90 || (longitude === 0 && latitude === 0)) return undefined; const roundedLongitude = Number(longitude!.toFixed(4)); const roundedLatitude = Number(latitude!.toFixed(4)); return { vin, key: `${roundedLongitude.toFixed(4)},${roundedLatitude.toFixed(4)}`, longitude: roundedLongitude, latitude: roundedLatitude }; } function useMonitorAddressPoint(vin: string, longitude?: number, latitude?: number) { const next = monitorAddressPoint(vin, longitude, latitude); const [published, setPublished] = useState(); const pendingRef = useRef(); const lastPublishedAtRef = useRef(0); useEffect(() => { if (!next) { pendingRef.current = undefined; lastPublishedAtRef.current = 0; setPublished((current) => current === undefined ? current : undefined); return; } if (!published || published.vin !== next.vin) { pendingRef.current = undefined; lastPublishedAtRef.current = Date.now(); setPublished(next); return; } if (published.key === next.key) { pendingRef.current = undefined; return; } pendingRef.current = next; const elapsed = Date.now() - lastPublishedAtRef.current; const timer = window.setTimeout(() => { const pending = pendingRef.current; if (!pending) return; pendingRef.current = undefined; lastPublishedAtRef.current = Date.now(); setPublished(pending); }, Math.max(0, MONITOR_ADDRESS_REFRESH_MS - elapsed)); return () => window.clearTimeout(timer); }, [next?.key, next?.vin, published?.key, published?.vin]); return published; } export function parseMonitorSearchTerms(value: string) { const terms: string[] = []; const seen = new Set(); for (const raw of value.split(/[\s,,、;;]+/)) { const term = raw.trim(); if (!term) continue; const key = term.toLocaleUpperCase(); if (seen.has(key)) continue; seen.add(key); terms.push(term.toLocaleUpperCase()); if (terms.length === MAX_MONITOR_SEARCH_TERMS) break; } return terms; } export function monitorQueryParams(filters: MonitorFilters, limit: number) { const params = new URLSearchParams({ limit: String(limit) }); const terms = parseMonitorSearchTerms(filters.keyword); if (terms.length === 1) params.set('keyword', terms[0]); if (terms.length > 1) params.set('keywords', terms.join(',')); if (filters.protocol) params.set('protocol', filters.protocol); if (filters.status) params.set('status', filters.status); if (filters.status === 'online' || filters.status === 'offline') params.set('online', filters.status); return params; } export function monitorMapQueryParams(filters: MonitorFilters, viewport: MonitorViewport) { const params = monitorQueryParams(filters, 10_000); params.set('zoom', String(viewport.zoom)); const bounds = normalizeMonitorBounds(viewport.bounds); if (bounds && parseMonitorSearchTerms(filters.keyword).length === 0) params.set('bounds', bounds); return params; } export function monitorFilterScope(filters: MonitorFilters) { const params = monitorQueryParams(filters, 0); params.delete('limit'); return params.toString(); } function projectWorkspaceQuery(query: UseQueryResult, select: (workspace: MonitorWorkspaceResponse) => T): UseQueryResult { return { ...query, data: query.data ? select(query.data) : undefined } as UseQueryResult; } export function useMonitorData(filters: MonitorFilters, viewport: MonitorViewport, selectedVin: string, mapEnabled = true, vehicleEnabled = true) { const params = monitorQueryParams(filters, 200); const mapParams = monitorMapQueryParams(filters, viewport); const filterScope = monitorFilterScope(filters); mapParams.set('railLimit', '200'); const workspace = useQuery({ queryKey: ['monitor', 'workspace', filterScope, mapParams.toString()], queryFn: ({ signal }) => api.monitorWorkspace(mapParams, signal), enabled: mapEnabled, placeholderData: retainPreviousPageWithinScope(filterScope, 2), staleTime: 5_000, gcTime: MONITOR_CACHE.mapGcTime, refetchInterval: mapEnabled ? MONITOR_REFRESH.fleet : false, ...LIVE_QUERY_POLICY }); const summaryQuery = useQuery({ queryKey: ['monitor', 'summary', params.toString()], queryFn: ({ signal }) => api.monitorSummary(params, signal), enabled: !mapEnabled, placeholderData: workspace.data?.summary, refetchInterval: !mapEnabled ? MONITOR_REFRESH.summary : false, gcTime: QUERY_MEMORY.summaryGcTime, ...LIVE_QUERY_POLICY }); const vehiclesQuery = useQuery({ queryKey: ['monitor', 'vehicles', params.toString()], queryFn: ({ signal }) => api.vehicleRealtime(params, signal), enabled: vehicleEnabled && !mapEnabled, placeholderData: workspace.data?.vehicles, refetchInterval: vehicleEnabled && !mapEnabled ? MONITOR_REFRESH.fleet : false, gcTime: QUERY_MEMORY.highVolumeGcTime, ...LIVE_QUERY_POLICY }); const summary = mapEnabled ? projectWorkspaceQuery(workspace, (data) => data.summary) : summaryQuery; const vehicles = mapEnabled ? projectWorkspaceQuery>(workspace, (data) => data.vehicles) : vehiclesQuery; const map = projectWorkspaceQuery(workspace, (data) => data.map); const selectedVehicle = useQuery({ queryKey: ['monitor', 'selected-vehicle', selectedVin], queryFn: ({ signal }) => api.vehicleRealtime(new URLSearchParams({ keyword: selectedVin, limit: '1', offset: '0' }), signal), enabled: Boolean(selectedVin), staleTime: 5_000, refetchInterval: selectedVin ? MONITOR_REFRESH.selected : false, gcTime: QUERY_MEMORY.highVolumeGcTime, ...LIVE_QUERY_POLICY }); return { summary, vehicles, map, selectedVehicle }; } export function useMonitorVehicleCard(vin: string, vehicle?: VehicleRealtimeRow, activelyTracked = false) { const enabled = Boolean(vin); const longitude = vehicle?.longitude; const latitude = vehicle?.latitude; const addressPoint = useMonitorAddressPoint(vin, longitude, latitude); const detail = useQuery({ queryKey: ['monitor', 'vehicle-card', 'detail', vin], queryFn: ({ signal }) => api.vehicleDetail(new URLSearchParams({ keyword: vin }), signal), enabled, staleTime: 30_000, gcTime: QUERY_MEMORY.highVolumeGcTime }); const activeAlerts = useQuery({ queryKey: ['monitor', 'vehicle-card', 'active-alerts', vin], queryFn: ({ signal }) => api.alertEventsV2({ keyword: vin, status: 'active', limit: 20, offset: 0 }, signal), enabled, staleTime: 10_000, refetchInterval: enabled && activelyTracked ? MONITOR_REFRESH.alerts : false, gcTime: QUERY_MEMORY.highVolumeGcTime, ...LIVE_QUERY_POLICY }); const telemetry = useQuery({ queryKey: ['monitor', 'vehicle-card', 'telemetry', vin], queryFn: ({ signal }) => api.latestTelemetry(vin, signal), enabled: enabled && vehicle?.primaryProtocol === 'GB32960', staleTime: 10_000, refetchInterval: enabled && activelyTracked && vehicle?.primaryProtocol === 'GB32960' ? 20_000 : false, gcTime: QUERY_MEMORY.highVolumeGcTime, ...LIVE_QUERY_POLICY }); const address = useQuery({ queryKey: ['monitor', 'vehicle-card', 'address', addressPoint?.vin, addressPoint?.key], queryFn: ({ signal }) => api.reverseGeocode(new URLSearchParams({ longitude: addressPoint!.longitude.toFixed(4), latitude: addressPoint!.latitude.toFixed(4) }), signal), enabled: enabled && addressPoint?.vin === vin, staleTime: 60 * 60_000, gcTime: QUERY_MEMORY.highVolumeGcTime }); return { detail, activeAlerts, telemetry, address }; }