230 lines
8.8 KiB
TypeScript
230 lines
8.8 KiB
TypeScript
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<MonitorAddressPoint>();
|
||
const pendingRef = useRef<MonitorAddressPoint>();
|
||
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<string>();
|
||
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<T>(query: UseQueryResult<MonitorWorkspaceResponse>, select: (workspace: MonitorWorkspaceResponse) => T): UseQueryResult<T> {
|
||
return { ...query, data: query.data ? select(query.data) : undefined } as UseQueryResult<T>;
|
||
}
|
||
|
||
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<MonitorWorkspaceResponse>({
|
||
queryKey: ['monitor', 'workspace', filterScope, mapParams.toString()],
|
||
queryFn: ({ signal }) => api.monitorWorkspace(mapParams, signal),
|
||
enabled: mapEnabled,
|
||
placeholderData: retainPreviousPageWithinScope<MonitorWorkspaceResponse>(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<MonitorSummary>(workspace, (data) => data.summary) : summaryQuery;
|
||
const vehicles = mapEnabled ? projectWorkspaceQuery<Page<VehicleRealtimeRow>>(workspace, (data) => data.vehicles) : vehiclesQuery;
|
||
const map = projectWorkspaceQuery<MonitorMapResponse>(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 };
|
||
}
|