import { useEffect, useMemo, useRef, useState } from "react"; import type { WorkspaceChartInterval, WorkspaceChartPoint, WorkspaceChartTimeRange, } from "../../../components/charts/WorkspaceChart"; const BACKEND_URL = "http://localhost:18450"; type HistorianPoint = { timestamp: string; numericValue: number | null; booleanValue: boolean | null; textValue: string | null; }; export function useTelemetryChartSeries( sensorKeys: string[], timeRange: WorkspaceChartTimeRange, interval: WorkspaceChartInterval, ) { const [seriesByKey, setSeriesByKey] = useState>({}); const [loading, setLoading] = useState(false); const initializedRef = useRef(false); const keySignature = useMemo( () => sensorKeys.slice().sort().join(","), [sensorKeys], ); useEffect(() => { if (sensorKeys.length === 0) { setSeriesByKey({}); setLoading(false); initializedRef.current = false; return; } const controller = new AbortController(); async function loadHistory(showLoading: boolean) { const startedAt = performance.now(); try { const to = new Date(); const from = new Date(to.getTime() - rangeToMs(timeRange)); if (showLoading && !initializedRef.current) { setLoading(true); } console.log("[TelemetryChartSeries REQUEST]", { sensorKeys, timeRange, interval, from: from.toISOString(), to: to.toISOString(), }); const entries = await Promise.all( sensorKeys.map(async (key) => { const params = new URLSearchParams({ key, from: from.toISOString(), to: to.toISOString(), }); const url = `${BACKEND_URL}/api/historian/series?${params.toString()}`; const response = await fetch(url, { signal: controller.signal, cache: "no-store", }); if (!response.ok) { throw new Error(`Failed to load history for ${key}: ${response.status}`); } const payload = (await response.json()) as HistorianPoint[]; const points = payload .filter( (point) => point.numericValue !== null && Number.isFinite(point.numericValue), ) .map((point) => ({ timestamp: point.timestamp, value: point.numericValue as number, })); return [key, aggregatePoints(points, interval)] as const; }), ); setSeriesByKey(Object.fromEntries(entries)); initializedRef.current = true; console.log("[TelemetryChartSeries DONE]", { sensorCount: sensorKeys.length, durationMs: Math.round(performance.now() - startedAt), }); } catch (error) { if (controller.signal.aborted) return; console.error("[TelemetryChartSeries ERROR]", error); if (!initializedRef.current) { setSeriesByKey({}); } } finally { if (!controller.signal.aborted) { setLoading(false); } } } loadHistory(true); const refreshMs = getRefreshMs(timeRange); if (refreshMs === null) { return () => controller.abort(); } const intervalId = window.setInterval(() => { loadHistory(false); }, refreshMs); return () => { controller.abort(); window.clearInterval(intervalId); }; }, [keySignature, timeRange, interval]); return { seriesByKey, loading, }; } function getRefreshMs(range: WorkspaceChartTimeRange): number | null { switch (range) { case "15m": case "1h": return 10000; case "6h": return 30000; case "24h": return 60000; case "7d": case "30d": return null; } } function rangeToMs(range: WorkspaceChartTimeRange) { switch (range) { case "15m": return 15 * 60 * 1000; case "1h": return 60 * 60 * 1000; case "6h": return 6 * 60 * 60 * 1000; case "24h": return 24 * 60 * 60 * 1000; case "7d": return 7 * 24 * 60 * 60 * 1000; case "30d": return 30 * 24 * 60 * 60 * 1000; } } function intervalToMs(interval: WorkspaceChartInterval) { switch (interval) { case "1m": return 60 * 1000; case "5m": return 5 * 60 * 1000; case "15m": return 15 * 60 * 1000; case "1h": return 60 * 60 * 1000; } } function aggregatePoints( points: WorkspaceChartPoint[], interval: WorkspaceChartInterval, ): WorkspaceChartPoint[] { const bucketMs = intervalToMs(interval); const buckets = new Map(); for (const point of points) { const time = new Date(point.timestamp).getTime(); if (!Number.isFinite(time)) continue; const bucketTime = Math.floor(time / bucketMs) * bucketMs; const values = buckets.get(bucketTime) ?? []; const value = point.value; if (value !== null && Number.isFinite(value)) { values.push(value); } buckets.set(bucketTime, values); } return Array.from(buckets.entries()) .sort(([a], [b]) => a - b) .map(([bucketTime, values]) => ({ timestamp: new Date(bucketTime).toISOString(), value: average(values), })) .filter((point) => Number.isFinite(point.value)); } function average(values: number[]) { if (values.length === 0) return 0; return values.reduce((sum, value) => sum + value, 0) / values.length; }