Files
pikasTech-HWLAB/web/hwlab-cloud-web/src/composables/useWorkbenchNowTicker.ts
T

91 lines
2.8 KiB
TypeScript

// SPEC: PJ2026-010401 Web工作台 draft-2026-06-20-p1-view-local-timing-ticker; PJ2026-0104010803 唯一投影 draft-2026-06-20-p1-view-local-timing-ticker.
// Responsibility: Shared browser-only render ticker for relative Workbench timing labels; it never writes server-state or projection facts.
import { onBeforeUnmount, readonly, ref, watch, type Ref } from "vue";
const TICK_INTERVAL_MS = 1000;
const MAX_VISIBLE_TICK_STEP_MS = TICK_INTERVAL_MS;
const nowMs = ref(Date.now());
const activeSubscribers = new Map<symbol, boolean>();
let timer: number | null = null;
let visibilityListenerAttached = false;
export function useWorkbenchNowTicker(enabled: Ref<boolean>) {
const subscriber = Symbol("workbench-now-ticker");
activeSubscribers.set(subscriber, false);
ensureVisibilityListener();
const stopWatch = watch(() => enabled.value, (value) => {
activeSubscribers.set(subscriber, value === true);
reconcileTimer();
}, { immediate: true });
onBeforeUnmount(() => {
stopWatch();
activeSubscribers.delete(subscriber);
reconcileTimer();
detachVisibilityListenerIfIdle();
});
return { nowMs: readonly(nowMs), refreshNow };
}
function refreshNow(reset = false): void {
const wallNow = Date.now();
if (reset) {
nowMs.value = wallNow;
return;
}
const previous = Number(nowMs.value);
if (!Number.isFinite(previous) || wallNow <= previous) {
nowMs.value = wallNow;
return;
}
nowMs.value = Math.min(wallNow, previous + MAX_VISIBLE_TICK_STEP_MS);
}
function reconcileTimer(): void {
// Passive observer pages may be backgrounded while still being the source of
// web-probe samples. Keep the render ticker alive for active subscribers so
// elapsed/recent labels advance monotonically instead of jumping on resume.
const shouldRun = isBrowser() && hasActiveSubscriber();
if (shouldRun && timer === null) {
refreshNow(true);
timer = window.setInterval(refreshNow, TICK_INTERVAL_MS);
return;
}
if (!shouldRun && timer !== null) {
window.clearInterval(timer);
timer = null;
}
}
function hasActiveSubscriber(): boolean {
for (const active of activeSubscribers.values()) {
if (active) return true;
}
return false;
}
function ensureVisibilityListener(): void {
if (visibilityListenerAttached || typeof document === "undefined") return;
document.addEventListener("visibilitychange", handleVisibilityChange);
visibilityListenerAttached = true;
}
function detachVisibilityListenerIfIdle(): void {
if (activeSubscribers.size > 0 || !visibilityListenerAttached || typeof document === "undefined") return;
document.removeEventListener("visibilitychange", handleVisibilityChange);
visibilityListenerAttached = false;
}
function handleVisibilityChange(): void {
refreshNow();
reconcileTimer();
}
function isBrowser(): boolean {
return typeof window !== "undefined";
}