feat(workbench): expand SCADA monitoring and investigation presentation
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ScadaReplay } from "../api/scada-replay-client";
|
||||
|
||||
export const SCADA_PLAYBACK_SPEED = 180;
|
||||
|
||||
interface PlaybackOptions {
|
||||
active: boolean;
|
||||
enabled: boolean;
|
||||
busy: boolean;
|
||||
start: number;
|
||||
end: number;
|
||||
now: number;
|
||||
intervalSeconds: number | null | undefined;
|
||||
restart: boolean;
|
||||
advance: () => Promise<ScadaReplay | null>;
|
||||
}
|
||||
|
||||
/** Browser-owned demo clock. Each boundary waits for one durable Server step. */
|
||||
export function useScadaPlayback(options: PlaybackOptions) {
|
||||
const latest = useRef(options);
|
||||
latest.current = options;
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [started, setStarted] = useState(false);
|
||||
const [newRound, setNewRound] = useState(false);
|
||||
const [time, setTime] = useState(options.now);
|
||||
const running = useRef(false);
|
||||
const pending = useRef(false);
|
||||
const position = useRef(options.now);
|
||||
const processed = useRef(options.now);
|
||||
const mounted = useRef(true);
|
||||
const restartPrepared = useRef(false);
|
||||
|
||||
const pause = () => { running.current = false; setPlaying(false); };
|
||||
const reset = () => {
|
||||
pause();
|
||||
setStarted(false);
|
||||
setNewRound(false);
|
||||
restartPrepared.current = false;
|
||||
processed.current = position.current = latest.current.start;
|
||||
setTime(latest.current.start);
|
||||
};
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
const hidden = () => { if (document.visibilityState !== "visible") pause(); };
|
||||
document.addEventListener("visibilitychange", hidden);
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
running.current = false;
|
||||
document.removeEventListener("visibilitychange", hidden);
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
pause();
|
||||
setStarted(false);
|
||||
setNewRound(false);
|
||||
position.current = latest.current.now;
|
||||
processed.current = latest.current.now;
|
||||
setTime(latest.current.now);
|
||||
}, [options.start, options.end]);
|
||||
useEffect(() => {
|
||||
processed.current = options.now;
|
||||
position.current = options.now;
|
||||
setTime(options.now);
|
||||
if (options.now >= latest.current.end) pause();
|
||||
}, [options.now]);
|
||||
useEffect(() => {
|
||||
if (!options.active || !options.enabled || options.restart) pause();
|
||||
}, [options.active, options.enabled, options.restart]);
|
||||
useEffect(() => { restartPrepared.current = false; }, [options.restart]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
let previousTick = performance.now();
|
||||
const timer = window.setInterval(() => {
|
||||
const current = latest.current;
|
||||
const tick = performance.now();
|
||||
const elapsed = Math.min(tick - previousTick, 250);
|
||||
previousTick = tick;
|
||||
if (!running.current || !current.active || !current.enabled || document.visibilityState !== "visible"
|
||||
|| pending.current || current.busy || !current.intervalSeconds) return;
|
||||
const interval = current.intervalSeconds * 1000;
|
||||
const target = Math.min(current.end, current.start
|
||||
+ (Math.floor((processed.current - current.start) / interval) + 1) * interval);
|
||||
position.current = Math.min(target, position.current + elapsed * SCADA_PLAYBACK_SPEED);
|
||||
setTime(position.current);
|
||||
if (position.current < target) return;
|
||||
pending.current = true;
|
||||
const requestedFrom = processed.current;
|
||||
void current.advance().then((next) => {
|
||||
if (!mounted.current) return;
|
||||
if (!next || next.status === "failed" || next.step_pending) {
|
||||
pause();
|
||||
return;
|
||||
}
|
||||
const nextTime = Date.parse(next.virtual_now);
|
||||
if (nextTime <= requestedFrom && !current.restart) {
|
||||
pause();
|
||||
return;
|
||||
}
|
||||
processed.current = position.current = nextTime;
|
||||
setNewRound(false);
|
||||
setTime(nextTime);
|
||||
if (next.status === "completed" || nextTime >= current.end) pause();
|
||||
}).catch(() => { if (mounted.current) pause(); }).finally(() => {
|
||||
pending.current = false;
|
||||
previousTick = performance.now();
|
||||
});
|
||||
}, 100);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [playing]);
|
||||
|
||||
const play = () => {
|
||||
if (!options.active || !options.enabled || options.busy || pending.current || !options.intervalSeconds) return;
|
||||
if ((options.restart && !restartPrepared.current) || position.current >= options.end) {
|
||||
processed.current = position.current = options.start;
|
||||
restartPrepared.current = true;
|
||||
setNewRound(true);
|
||||
setTime(options.start);
|
||||
}
|
||||
running.current = true;
|
||||
setStarted(true);
|
||||
setPlaying(true);
|
||||
};
|
||||
return { playing, started, newRound, time, play, pause, reset };
|
||||
}
|
||||
Reference in New Issue
Block a user