109 lines
3.5 KiB
TypeScript
109 lines
3.5 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
|
|
type ProgressStatus = "queued" | "running" | "submitting" | "completed" | "failed" | "success" | "error";
|
|
|
|
interface SmoothedProgressOptions {
|
|
creepSpeed?: number;
|
|
chaseRate?: number;
|
|
creepCeiling?: number;
|
|
creepAhead?: number;
|
|
completionDuration?: number;
|
|
}
|
|
|
|
const DEFAULT_CREEP_SPEED = 0.5;
|
|
const DEFAULT_CHASE_RATE = 0.09;
|
|
const DEFAULT_CREEP_CEILING = 97;
|
|
const DEFAULT_CREEP_AHEAD = 25;
|
|
const DEFAULT_COMPLETION_DURATION = 450;
|
|
|
|
function isTerminal(status: ProgressStatus): boolean {
|
|
return status === "completed" || status === "success" || status === "failed" || status === "error";
|
|
}
|
|
|
|
function isSuccess(status: ProgressStatus): boolean {
|
|
return status === "completed" || status === "success";
|
|
}
|
|
|
|
/**
|
|
* Smoothly interpolates between coarse server-reported progress values.
|
|
* On completion, animates quickly to 100% instead of jumping.
|
|
*/
|
|
export function useSmoothedProgress(
|
|
realProgress: number,
|
|
status: ProgressStatus,
|
|
options?: SmoothedProgressOptions,
|
|
): number {
|
|
const creepSpeed = options?.creepSpeed ?? DEFAULT_CREEP_SPEED;
|
|
const chaseRate = options?.chaseRate ?? DEFAULT_CHASE_RATE;
|
|
const creepCeiling = options?.creepCeiling ?? DEFAULT_CREEP_CEILING;
|
|
const creepAhead = options?.creepAhead ?? DEFAULT_CREEP_AHEAD;
|
|
const completionDuration = options?.completionDuration ?? DEFAULT_COMPLETION_DURATION;
|
|
|
|
const [displayed, setDisplayed] = useState(0);
|
|
const rafRef = useRef(0);
|
|
const targetRef = useRef(realProgress);
|
|
const statusRef = useRef(status);
|
|
const completionStartRef = useRef<number | null>(null);
|
|
const completionBaseRef = useRef(0);
|
|
|
|
useEffect(() => {
|
|
targetRef.current = realProgress;
|
|
statusRef.current = status;
|
|
|
|
if (isSuccess(status) && completionStartRef.current === null) {
|
|
completionStartRef.current = performance.now();
|
|
completionBaseRef.current = displayed;
|
|
}
|
|
}, [realProgress, status]);
|
|
|
|
useEffect(() => {
|
|
if (status === "failed" || status === "error") {
|
|
cancelAnimationFrame(rafRef.current);
|
|
return;
|
|
}
|
|
|
|
let lastFrame = performance.now();
|
|
|
|
const tick = (now: number) => {
|
|
const dt = Math.min((now - lastFrame) / 1000, 0.1);
|
|
lastFrame = now;
|
|
|
|
setDisplayed((current) => {
|
|
const currentStatus = statusRef.current;
|
|
|
|
if (isSuccess(currentStatus)) {
|
|
const start = completionStartRef.current ?? now;
|
|
const elapsed = now - start;
|
|
const t = Math.min(elapsed / completionDuration, 1);
|
|
const eased = 1 - (1 - t) * (1 - t);
|
|
const base = completionBaseRef.current;
|
|
return base + (100 - base) * eased;
|
|
}
|
|
|
|
if (isTerminal(currentStatus)) return current;
|
|
|
|
const target = targetRef.current;
|
|
|
|
if (current >= target) {
|
|
const ceiling = Math.min(target + creepAhead, creepCeiling);
|
|
if (current >= ceiling) return current;
|
|
return current + creepSpeed * dt;
|
|
}
|
|
|
|
const gap = target - current;
|
|
const step = (gap * chaseRate + 0.3) * dt * 60;
|
|
return Math.min(current + step, target);
|
|
});
|
|
|
|
rafRef.current = requestAnimationFrame(tick);
|
|
};
|
|
|
|
rafRef.current = requestAnimationFrame(tick);
|
|
return () => cancelAnimationFrame(rafRef.current);
|
|
}, [status, chaseRate, creepSpeed, creepCeiling, creepAhead, completionDuration]);
|
|
|
|
if (status === "failed" || status === "error") return Math.round(displayed);
|
|
if (isSuccess(status) && displayed >= 99.5) return 100;
|
|
return Math.round(Math.max(0, Math.min(99, displayed)));
|
|
}
|