Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/hooks/useMonitorData.ts

230 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}