126 lines
4.5 KiB
TypeScript
126 lines
4.5 KiB
TypeScript
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 };
|
|
}
|