Layout Animation
import type { MaybeRefOrGetter } from 'vue';
import type { Node } from '@xyflow/vue';
import type { DagreGraph } from './useLayout';
import { shallowRef, toValue } from 'vue';
import { getConnectedEdges, useVueFlow } from '@xyflow/vue';
export enum ProcessStatus {
ERROR = 'error',
SKIPPED = 'skipped',
CANCELLED = 'cancelled',
FINISHED = 'finished',
RUNNING = 'running',
}
export type ProcessNodeData = { status: ProcessStatus | null };
export type ProcessNodeType = Node<ProcessNodeData, 'process'>;
interface RunProcessOptions {
graph: MaybeRefOrGetter<DagreGraph>;
cancelOnError?: MaybeRefOrGetter<boolean>;
}
/**
* Simulates running a process tree: it walks the graph, pretends to run an async task on each node and
* updates the node's `status`. When a node finishes it starts its children (in parallel), optionally
* cancelling everything downstream on error.
*/
export function useRunProcess({ graph, cancelOnError = true }: RunProcessOptions) {
const { updateNodeData, getNode, getEdge, getEdges } = useVueFlow<ProcessNodeType>();
const isRunning = shallowRef(false);
/** node id → its scheduled timeout */
const runningTasks = new Map<string, ReturnType<typeof setTimeout>>();
/** node ids that have already run */
const executedNodes = new Set<string>();
/** node ids queued but not yet started */
const upcomingTasks = new Set<string>();
function updateNodeStatus(nodeId: string, status: ProcessStatus | null) {
updateNodeData(nodeId, { status });
}
async function runNode(nodeId: string, isStart = false): Promise<boolean | void> {
if (executedNodes.has(nodeId)) {
return;
}
upcomingTasks.add(nodeId);
const node = getNode(nodeId);
const connectedEdges = node ? getConnectedEdges([node], [...getEdges.value]) : [];
const incomers = connectedEdges.filter((edge) => edge.target === nodeId);
// wait for incoming edge animations to finish; re-read the edge each poll since updates replace it
await Promise.all(incomers.map((incomer) => until(() => !getEdge(incomer.id)?.data?.isAnimating)));
upcomingTasks.clear();
if (!isRunning.value) {
return;
}
executedNodes.add(nodeId);
updateNodeStatus(nodeId, ProcessStatus.RUNNING);
const delay = Math.floor(Math.random() * 2000) + 1000;
return new Promise<boolean>((resolve) => {
const timeout = setTimeout(
async () => {
const children = toValue(graph).successors(nodeId) ?? [];
// 15% chance of failure (never on the starting node)
const willThrowError = Math.random() < 0.15;
if (!isStart && willThrowError) {
updateNodeStatus(nodeId, ProcessStatus.ERROR);
if (toValue(cancelOnError)) {
await skipDescendants(nodeId);
runningTasks.delete(nodeId);
resolve(true);
return;
}
}
updateNodeStatus(nodeId, ProcessStatus.FINISHED);
runningTasks.delete(nodeId);
if (children.length > 0) {
await Promise.all(children.map((child) => runNode(child)));
}
resolve(true);
},
isStart ? 0 : delay,
);
runningTasks.set(nodeId, timeout);
});
}
async function run(nodes: Node[]) {
if (isRunning.value) {
return;
}
reset(nodes);
isRunning.value = true;
const startingNodes = nodes.filter((node) => (toValue(graph).predecessors(node.id) ?? []).length === 0);
await Promise.all(startingNodes.map((node) => runNode(node.id, true)));
clear();
}
function reset(nodes: Node[]) {
clear();
for (const node of nodes) {
updateNodeStatus(node.id, null);
}
}
async function skipDescendants(nodeId: string) {
const children = toValue(graph).successors(nodeId) ?? [];
for (const child of children) {
updateNodeStatus(child, ProcessStatus.SKIPPED);
await skipDescendants(child);
}
}
async function stop() {
isRunning.value = false;
for (const nodeId of [...upcomingTasks, ...runningTasks.keys()]) {
clearTimeout(runningTasks.get(nodeId));
runningTasks.delete(nodeId);
updateNodeStatus(nodeId, ProcessStatus.CANCELLED);
await skipDescendants(nodeId);
}
executedNodes.clear();
upcomingTasks.clear();
}
function clear() {
isRunning.value = false;
executedNodes.clear();
runningTasks.clear();
}
return { run, stop, reset, isRunning };
}
function until(condition: () => boolean) {
return new Promise<void>((resolve) => {
const interval = setInterval(() => {
if (condition()) {
clearInterval(interval);
resolve();
}
}, 100);
});
}Last updated on