chore(docs): simplify animated layout example (#1747)

* chore(examples): update ts example of layouting

Signed-off-by: braks <78412429+bcakmakoglu@users.noreply.github.com>

* chore(docs): cleanup animated layout example

Signed-off-by: braks <78412429+bcakmakoglu@users.noreply.github.com>

---------

Signed-off-by: braks <78412429+bcakmakoglu@users.noreply.github.com>
This commit is contained in:
Braks
2025-01-13 00:22:13 +01:00
committed by GitHub
parent 25f9dd1dd9
commit e94611a3b0
25 changed files with 1059 additions and 684 deletions

View File

@@ -1,94 +0,0 @@
<script setup lang="ts">
import type { EdgeProps, Node } from '@vue-flow/core'
import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, useNodesData } from '@vue-flow/core'
import type { ProcessNodeData } from './types'
const props = defineProps<EdgeProps>()
const labelRef = ref<HTMLDivElement>()
const sourceNodeData = useNodesData<Node<ProcessNodeData>>(() => props.source)
const isFinished = toRef(() => sourceNodeData.value?.data.isFinished)
const isAnimating = ref(false)
const edgeColor = toRef(() => {
if (sourceNodeData.value?.data.hasError) {
return '#f87171'
}
if (sourceNodeData.value?.data.isFinished) {
return '#10b981'
}
if (sourceNodeData.value?.data.isCancelled) {
return '#fbbf24'
}
if (sourceNodeData.value?.data.isSkipped) {
return '#f59e0b'
}
return '#6b7280'
})
const path = computed(() => getSmoothStepPath(props))
watch(
isFinished,
(isFinished) => {
if (isFinished) {
runAnimation()
}
},
{ immediate: true },
)
function runAnimation() {
isAnimating.value = true
// defer to next tick so the labelRef is available
nextTick(() => {
const keyframes = [{ offsetDistance: '0%' }, { offsetDistance: '100%' }]
const animation = labelRef.value?.animate(keyframes, {
duration: 1500,
direction: 'normal',
easing: 'ease-in-out',
iterations: 1,
})
if (animation) {
animation.onfinish = () => {
isAnimating.value = false
}
}
})
}
</script>
<script lang="ts">
export default {
name: 'AnimationEdge',
inheritAttrs: false,
}
</script>
<template>
<BaseEdge v-bind="$attrs" :id="id" :path="path[0]" :marker-end="markerEnd" :style="{ stroke: edgeColor }" />
<EdgeLabelRenderer v-if="isAnimating">
<div
ref="labelRef"
:style="{
position: 'absolute',
offsetPath: `path('${path[0]}')`,
offsetRotate: '0deg',
offsetAnchor: 'center',
}"
>
📦
</div>
</EdgeLabelRenderer>
</template>

View File

@@ -1,57 +1,41 @@
<script lang="ts" setup>
import dagre from '@dagrejs/dagre'
import { ConnectionMode, Panel, Position, VueFlow, useVueFlow } from '@vue-flow/core'
import { nextTick, ref } from 'vue'
import { Panel, VueFlow, useVueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import Icon from './components/Icon.vue'
import ProcessNode from './nodes/ProcessNode.vue'
import ProcessEdge from './edges/ProcessEdge.vue'
import { initialEdges, initialNodes } from './initial-elements'
import { useRunProcess } from './useRunProcess'
import AnimationEdge from './AnimationEdge.vue'
import ProcessNode from './ProcessNode.vue'
import { useRunProcess } from './composables/useRunProcess'
import type { Direction } from './composables/useLayout'
import { useLayout } from './composables/useLayout'
import './styles.css'
const nodes = ref(initialNodes)
const edges = ref(initialEdges)
// we create a new graph instance, in case some nodes/edges were removed, otherwise dagre would act as if they were still there
const dagreGraph = ref(new dagre.graphlib.Graph())
const cancelOnError = ref(true)
dagreGraph.value.setDefaultEdgeLabel(() => ({}))
const { graph, layout } = useLayout()
const { run, stop, isRunning } = useRunProcess(dagreGraph)
const { run, stop, reset, isRunning } = useRunProcess({ graph, cancelOnError })
const { findNode, fitView } = useVueFlow()
const { fitView } = useVueFlow()
function handleLayout(direction: 'TB' | 'LR') {
dagreGraph.value = new dagre.graphlib.Graph()
dagreGraph.value.setDefaultEdgeLabel(() => ({}))
async function layoutGraph(direction: Direction) {
// Stop the current execution process
await stop()
const isHorizontal = direction === 'LR'
dagreGraph.value.setGraph({ rankdir: direction })
// Reset the nodes to their initial status
reset(nodes.value)
for (const node of nodes.value) {
// if you need width+height of nodes for your layout, you can use the dimensions property of the internal node (`GraphNode` type)
const graphNode = findNode(node.id)!
dagreGraph.value.setNode(node.id, { width: graphNode.dimensions.width || 150, height: graphNode.dimensions.height || 50 })
}
for (const edge of edges.value) {
dagreGraph.value.setEdge(edge.source, edge.target)
}
dagre.layout(dagreGraph.value)
// set nodes with updated positions
nodes.value = nodes.value.map((node) => {
const nodeWithPosition = dagreGraph.value.node(node.id)
return {
...node,
targetPosition: isHorizontal ? Position.Left : Position.Top,
sourcePosition: isHorizontal ? Position.Right : Position.Bottom,
position: { x: nodeWithPosition.x, y: nodeWithPosition.y },
}
})
// Layout the graph
nodes.value = layout(nodes.value, edges.value, direction)
// Fit the view to the graph
nextTick(() => {
fitView()
})
@@ -59,67 +43,47 @@ function handleLayout(direction: 'TB' | 'LR') {
</script>
<template>
<div class="layoutflow">
<VueFlow :nodes="nodes" :edges="edges" :connection-mode="ConnectionMode.Loose" @nodes-initialized="handleLayout('LR')">
<template #node-process="props">
<ProcessNode v-bind="props" />
<div class="layout-flow">
<VueFlow
v-model:nodes="nodes"
v-model:edges="edges"
:default-edge-options="{ type: 'process', animated: true }"
@nodes-initialized="layoutGraph('LR')"
>
<template #node-process="processNodeProps">
<ProcessNode v-bind="processNodeProps" />
</template>
<template #edge-animation="props">
<AnimationEdge v-bind="props" />
<template #edge-process="processEdgeProps">
<ProcessEdge v-bind="processEdgeProps" />
</template>
<Panel class="layout-panel" position="top-left">
<button @click="handleLayout('TB')">vertical</button>
<button @click="handleLayout('LR')">horizontal</button>
</Panel>
<Background />
<Panel class="process-panel" position="top-right">
<button v-if="isRunning" @click="stop">🛑</button>
<button v-else @click="run(nodes)"></button>
<div class="layout-panel">
<button v-if="isRunning" class="stop-btn" title="stop" @click="stop">
<Icon name="stop" />
<span class="spinner" />
</button>
<button v-else title="start" @click="run(nodes)">
<Icon name="play" />
</button>
<button title="set horizontal layout" @click="layoutGraph('LR')">
<Icon name="horizontal" />
</button>
<button title="set vertical layout" @click="layoutGraph('TB')">
<Icon name="vertical" />
</button>
</div>
<div class="checkbox-panel">
<label>Cancel on error</label>
<input v-model="cancelOnError" type="checkbox" />
</div>
</Panel>
</VueFlow>
</div>
</template>
<style>
.layoutflow {
background-color: #1a192b;
height: 100%;
width: 100%;
}
.process-panel,
.layout-panel {
display: flex;
gap: 10px;
}
.process-panel button,
.layout-panel button {
border: none;
padding: 10px;
cursor: pointer;
background-color: #2d3748;
border-radius: 8px;
color: white;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
}
.process-panel button {
font-size: 24px;
width: 50px;
height: 50px;
}
.process-panel button:hover,
.layout-panel button:hover {
background-color: #4b5563;
transition: background-color 0.2s;
}
.process-panel button:disabled {
background-color: #4b5563;
cursor: not-allowed;
}
</style>

View File

@@ -1,108 +0,0 @@
<script setup lang="ts">
import type { NodeProps } from '@vue-flow/core'
import { Handle, useNodeConnections } from '@vue-flow/core'
import type { ProcessNodeData } from './types'
const props = defineProps<NodeProps<ProcessNodeData>>()
const sourceConnections = useNodeConnections({
handleType: 'target',
})
const targetConnections = useNodeConnections({
handleType: 'source',
})
const isSender = toRef(() => sourceConnections.value.length <= 0)
const isReceiver = toRef(() => targetConnections.value.length <= 0)
const bgColor = toRef(() => {
if (isSender.value) {
return '#4b5563'
}
if (props.data.hasError) {
return '#f87171'
}
if (props.data.isFinished) {
return '#10b981'
}
if (props.data.isCancelled) {
return '#fbbf24'
}
return '#4b5563'
})
const processLabel = toRef(() => {
if (props.data.hasError) {
return '❌'
}
if (props.data.isSkipped) {
return '🚧'
}
if (props.data.isCancelled) {
return '🚫'
}
if (isSender.value) {
return '📦'
}
if (props.data.isFinished) {
return '😎'
}
return '📥'
})
</script>
<template>
<div class="process-node" :style="{ backgroundColor: bgColor }">
<Handle v-if="!isSender" type="target" :position="targetPosition" />
<Handle v-if="!isReceiver" type="source" :position="sourcePosition" />
<div v-if="data.isRunning" class="spinner" />
<span v-else>
{{ processLabel }}
</span>
</div>
</template>
<style scoped>
.process-node {
padding: 10px;
color: white;
border: 1px solid #4b5563;
border-radius: 99px;
font-size: 12px;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #10b981;
border-radius: 50%;
width: 10px;
height: 10px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
defineProps<{ name: 'play' | 'stop' | 'horizontal' | 'vertical' }>()
</script>
<template>
<svg v-if="name === 'play'" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M8 5v14l11-7z" fill="currentColor" />
</svg>
<svg v-else-if="name === 'stop'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M8 16h8V8H8zm4 6q-2.075 0-3.9-.788t-3.175-2.137q-1.35-1.35-2.137-3.175T2 12q0-2.075.788-3.9t2.137-3.175q1.35-1.35 3.175-2.137T12 2q2.075 0 3.9.788t3.175 2.137q1.35 1.35 2.138 3.175T22 12q0 2.075-.788 3.9t-2.137 3.175q-1.35 1.35-3.175 2.138T12 22"
/>
</svg>
<svg v-else-if="name === 'horizontal'" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M2,12 L22,12" stroke="currentColor" stroke-width="2" />
<path d="M7,7 L2,12 L7,17" stroke="currentColor" stroke-width="2" fill="none" />
<path d="M17,7 L22,12 L17,17" stroke="currentColor" stroke-width="2" fill="none" />
</svg>
<svg v-else-if="name === 'vertical'" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12,2 L12,22" stroke="currentColor" stroke-width="2" />
<path d="M7,7 L12,2 L17,7" stroke="currentColor" stroke-width="2" fill="none" />
<path d="M7,17 L12,22 L17,17" stroke="currentColor" stroke-width="2" fill="none" />
</svg>
</template>

View File

@@ -0,0 +1,60 @@
import dagre from '@dagrejs/dagre'
import type { Edge, Node } from '@vue-flow/core'
import { Position, useVueFlow } from '@vue-flow/core'
import { ref } from 'vue'
export type Direction = 'LR' | 'TB'
/**
* Composable to run the layout algorithm on the graph.
* It uses the `dagre` library to calculate the layout of the nodes and edges.
*/
export function useLayout() {
const { findNode } = useVueFlow()
const graph = ref(new dagre.graphlib.Graph<Node>())
function layout<NodeType extends Node, EdgeType extends Edge>(nodes: NodeType[], edges: EdgeType[], direction: Direction) {
// we create a new graph instance, in case some nodes/edges were removed, otherwise dagre would act as if they were still there
const dagreGraph = new dagre.graphlib.Graph<Node>()
graph.value = dagreGraph
dagreGraph.setDefaultEdgeLabel(() => ({}))
const isHorizontal = direction === 'LR'
dagreGraph.setGraph({ rankdir: direction })
for (const node of nodes) {
// if you need width+height of nodes for your layout, you can use the dimensions property of the internal node (`GraphNode` type)
const graphNode = findNode(node.id)
if (!graphNode) {
console.error(`Node with id ${node.id} not found in the graph`)
continue
}
dagreGraph.setNode(node.id, { width: graphNode.dimensions.width || 150, height: graphNode.dimensions.height || 50 })
}
for (const edge of edges) {
dagreGraph.setEdge(edge.source, edge.target)
}
dagre.layout(dagreGraph)
// set nodes with updated positions
return nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id)
return {
...node,
targetPosition: isHorizontal ? Position.Left : Position.Top,
sourcePosition: isHorizontal ? Position.Right : Position.Bottom,
position: { x: nodeWithPosition.x, y: nodeWithPosition.y },
}
})
}
return { graph, layout }
}

View File

@@ -0,0 +1,234 @@
import { ref, toRef, toValue } from 'vue'
import type { Node } from '@vue-flow/core'
import { useVueFlow } from '@vue-flow/core'
import type { MaybeRefOrGetter } from 'vue'
import type dagre from '@dagrejs/dagre'
import type { ProcessData, ProcessNode } from '../nodes'
import { ProcessStatus } from '../nodes'
import type { ProcessEdge } from '../edges'
interface UseRunProcessOptions {
graph: MaybeRefOrGetter<dagre.graphlib.Graph<Node>>
cancelOnError?: MaybeRefOrGetter<boolean>
}
/**
* Composable to simulate running a process tree.
*
* It loops through each node, pretends to run an async process, and updates the node's data indicating whether the process has finished.
* When one node finishes, the next one starts.
*
* When a node has multiple descendants, it will run them in parallel.
*
* @param options
* @param options.graph The graph object containing the nodes and edges.
* @param options.cancelOnError Whether to cancel the process if an error occurs.
*/
export function useRunProcess({ graph: dagreGraph, cancelOnError = true }: UseRunProcessOptions) {
const { updateNodeData, getConnectedEdges } = useVueFlow()
const graph = toRef(() => toValue(dagreGraph))
const isRunning = ref(false)
const runningTasks = new Map<string, NodeJS.Timeout>()
const executedNodes = new Set<string>()
const upcomingTasks = new Set<string>()
/**
* Run the process on a node.
* It will mark the node as running, simulate an async process, and then mark the node as finished or errored.
*
* @param nodeId The ID of the node to run.
* @param isStart Whether this is a starting node.
*/
async function runNode(nodeId: string, isStart = false) {
if (executedNodes.has(nodeId)) {
return
}
// save the upcoming task in case it gets cancelled before we even start it
upcomingTasks.add(nodeId)
// get all incoming edges to this node
const incomers = (getConnectedEdges(nodeId) as ProcessEdge[]).filter((connection) => connection.target === nodeId)
// wait for edge animations to finish before starting the process
await Promise.all(incomers.map((incomer) => until(() => !incomer.data?.isAnimating)))
// remove the upcoming task since we are about to start it
upcomingTasks.clear()
if (!isRunning.value) {
// The process was stopped
return
}
// mark the node as executed, so it doesn't run again
executedNodes.add(nodeId)
updateNodeStatus(nodeId, ProcessStatus.RUNNING)
// simulate an async process with a random timeout between 1-2 seconds
const delay = Math.floor(Math.random() * 2000) + 1000
return new Promise((resolve) => {
const timeout = setTimeout(
async () => {
// get all children of this node
const children = graph.value.successors(nodeId) || []
// randomly decide whether the node will throw an error
const willThrowError = Math.random() < 0.15
// we avoid throwing an error on the starting node
if (!isStart && willThrowError) {
updateNodeStatus(nodeId, ProcessStatus.ERROR)
// if cancelOnError is true, we stop the process and mark all descendants as skipped
if (toValue(cancelOnError)) {
await skipDescendants(nodeId)
runningTasks.delete(nodeId)
resolve(true)
return
}
}
updateNodeStatus(nodeId, ProcessStatus.FINISHED)
runningTasks.delete(nodeId)
if (children.length > 0) {
// run the process on the children in parallel
await Promise.all(children.map((child) => runNode(child)))
}
resolve(true)
},
// if this is a starting node, we don't want to wait
isStart ? 0 : delay,
)
// save the timeout so we can cancel it if needed
runningTasks.set(nodeId, timeout)
})
}
/**
* Run a sequence of nodes.
* It will start with the nodes that have no predecessors and then run the process on each node in sequence.
* If a node has multiple descendants, it will run them in parallel.
* If an error occurs, it will stop the process and mark all descendants as skipped.
* If cancelOnError is true, it will stop the process if an error occurs.
* If the process is stopped, it will mark all running nodes as cancelled.
*
* @param nodes The nodes to run.
*/
async function run(nodes: ProcessNode[]) {
// if the process is already running, we don't want to start it again
if (isRunning.value) {
return
}
// reset all nodes to their initial state
reset(nodes)
isRunning.value = true
// get all starting nodes (nodes with no predecessors)
const startingNodes = nodes.filter((node) => graph.value.predecessors(node.id)?.length === 0)
// run the process on all starting nodes in parallel
await Promise.all(startingNodes.map((node) => runNode(node.id, true)))
clear()
}
/**
* Reset all nodes to their initial state.
*
* @param nodes The nodes to reset.
*/
function reset(nodes: ProcessNode[]) {
clear()
for (const node of nodes) {
updateNodeStatus(node.id, null)
}
}
/**
* Skip all descendants of a node.
*
* @param nodeId The ID of the node to skip descendants for.
*/
async function skipDescendants(nodeId: string) {
const children = graph.value.successors(nodeId) || []
for (const child of children) {
updateNodeStatus(child, ProcessStatus.SKIPPED)
await skipDescendants(child)
}
}
/**
* Stop the process.
*
* It will mark all running nodes as cancelled and skip all upcoming tasks.
*/
async function stop() {
isRunning.value = false
for (const nodeId of upcomingTasks) {
clearTimeout(runningTasks.get(nodeId))
runningTasks.delete(nodeId)
updateNodeStatus(nodeId, ProcessStatus.CANCELLED)
await skipDescendants(nodeId)
}
for (const [nodeId, task] of runningTasks) {
clearTimeout(task)
runningTasks.delete(nodeId)
updateNodeStatus(nodeId, ProcessStatus.CANCELLED)
await skipDescendants(nodeId)
}
executedNodes.clear()
upcomingTasks.clear()
}
/**
* Clear all running tasks and executed nodes.
*/
function clear() {
isRunning.value = false
executedNodes.clear()
runningTasks.clear()
}
/**
* Update the status of a node.
*
* @param nodeId The ID of the node to update.
* @param status The new status of the node.
*/
function updateNodeStatus(nodeId: string, status: ProcessData['status']) {
updateNodeData<ProcessData>(nodeId, { status })
}
return { run, stop, reset, isRunning }
}
async function until(condition: () => boolean) {
return new Promise((resolve) => {
const interval = setInterval(() => {
if (condition()) {
clearInterval(interval)
resolve(true)
}
}, 100)
})
}

View File

@@ -0,0 +1,167 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import type { EdgeProps } from '@vue-flow/core'
import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, useNodesData, useVueFlow } from '@vue-flow/core'
import type { ProcessNode } from '../nodes'
import { ProcessStatus } from '../nodes'
import type { ProcessEdgeData } from '.'
const props = defineProps<EdgeProps<ProcessEdgeData>>()
const { updateEdgeData } = useVueFlow()
/**
* We call `useNodesData` to get the data of the source and target nodes, which
* contain the information about the status of each nodes' process.
*/
const nodesData = useNodesData<ProcessNode>(() => [props.target, props.source])
const labelRef = ref<HTMLDivElement | null>(null)
const edgeRef = ref()
/**
* We extract the source and target node data from the nodes data.
* We only need the first element of the array since we are only connecting two nodes.
*/
const targetNodeData = computed(() => nodesData.value[0].data)
const sourceNodeData = computed(() => nodesData.value[1].data)
const isAnimating = computed({
get: () => props.data.isAnimating || false,
set: (value) => {
updateEdgeData(props.id, { isAnimating: value })
},
})
let animation: Animation | null = null
const path = computed(() => getSmoothStepPath(props))
const edgeColor = computed(() => {
switch (targetNodeData.value.status) {
case ProcessStatus.ERROR:
return '#f87171'
case ProcessStatus.FINISHED:
return '#42B983'
case ProcessStatus.CANCELLED:
case ProcessStatus.SKIPPED:
return '#fbbf24'
case ProcessStatus.RUNNING:
return '#2563eb'
default:
return '#6b7280'
}
})
// Cancel the animation if the target nodes' process was cancelled
watch(
() => targetNodeData.value.status === ProcessStatus.CANCELLED,
(isCancelled) => {
if (isCancelled) {
animation?.cancel()
}
},
)
// Run the animation when the source nodes' process is finished
watch(
() => sourceNodeData.value.status === ProcessStatus.FINISHED,
(isFinished) => {
if (isFinished) {
runAnimation()
}
},
)
function runAnimation() {
const pathEl = edgeRef.value?.pathEl
const labelEl = labelRef.value
if (!pathEl || !labelEl) {
console.warn('Path or label element not found')
return
}
const totalLength = pathEl.getTotalLength()
isAnimating.value = true
// We need to wait for the next tick to ensure that the label element is rendered
nextTick(() => {
const keyframes = [{ offsetDistance: '0%' }, { offsetDistance: '100%' }]
// use path length as a possible measure for the animation duration
const pathLengthDuration = totalLength * 10
/**
* We animate the label element along the path of the edge using the `offsetDistance` property and
* the Web Animations API.
*
* The `animate` method returns an `Animation` object that we can use to listen to events like `finish` or `cancel`.
*
* The animation duration is calculated based on the total length of the path and clamped between 1.5s and 3s.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Element/animate
*/
const labelAnimation = labelEl.animate(keyframes, {
duration: Math.min(Math.max(pathLengthDuration, 1500), 3000), // clamp duration between 1.5s and 3s
direction: 'normal',
easing: 'ease-in-out',
iterations: 1,
})
const handleAnimationEnd = () => {
isAnimating.value = false
}
labelAnimation.onfinish = handleAnimationEnd
labelAnimation.oncancel = handleAnimationEnd
animation = labelAnimation
})
}
</script>
<script lang="ts">
export default {
name: 'AnimationEdge',
inheritAttrs: false,
}
</script>
<template>
<BaseEdge :id="id" ref="edgeRef" :path="path[0]" :style="{ stroke: edgeColor }" />
<EdgeLabelRenderer>
<div
ref="labelRef"
:style="{
visibility: isAnimating ? 'visible' : 'hidden',
position: 'absolute',
zIndex: 1,
offsetPath: `path('${path[0]}')`,
offsetRotate: '0deg',
offsetAnchor: 'center',
}"
>
<span class="truck">
<span class="box">📦</span>
🚚
</span>
</div>
</EdgeLabelRenderer>
</template>
<style scoped>
.truck {
position: relative;
display: inline-block;
transform: scaleX(-1);
}
.box {
position: absolute;
top: -10px;
}
</style>

View File

@@ -0,0 +1,7 @@
import type { Edge } from '@vue-flow/core'
export interface ProcessEdgeData {
isAnimating?: boolean
}
export type ProcessEdge = Edge<ProcessEdgeData, any, 'process'>

View File

@@ -1,75 +1,88 @@
import type { Edge, Node } from '@vue-flow/core'
import type { ProcessNode } from './nodes'
import type { ProcessEdge } from './edges'
const position = { x: 0, y: 0 }
const type: string = 'process'
const initialPos = { x: 0, y: 0 }
const type = 'process'
const data = { status: null }
export const initialNodes: Node[] = [
export const initialNodes: ProcessNode[] = [
{
id: '1',
position,
position: initialPos,
type,
data,
},
{
id: '2',
position,
position: initialPos,
type,
data,
},
{
id: '2a',
position,
position: initialPos,
type,
data,
},
{
id: '2b',
position,
position: initialPos,
type,
data,
},
{
id: '2c',
position,
position: initialPos,
type,
data,
},
{
id: '2d',
position,
position: initialPos,
type,
data,
},
{
id: '3',
position,
position: initialPos,
type,
data,
},
{
id: '4',
position,
position: initialPos,
type,
data,
},
{
id: '5',
position,
position: initialPos,
type,
data,
},
{
id: '6',
position,
position: initialPos,
type,
data,
},
{
id: '7',
position,
position: initialPos,
type,
data,
},
]
export const initialEdges: Edge[] = [
{ id: 'e1-2', source: '1', target: '2', type: 'animation', animated: true },
{ id: 'e1-3', source: '1', target: '3', type: 'animation', animated: true },
{ id: 'e2-2a', source: '2', target: '2a', type: 'animation', animated: true },
{ id: 'e2-2b', source: '2', target: '2b', type: 'animation', animated: true },
{ id: 'e2-2c', source: '2', target: '2c', type: 'animation', animated: true },
{ id: 'e2c-2d', source: '2c', target: '2d', type: 'animation', animated: true },
{ id: 'e3-7', source: '3', target: '4', type: 'animation', animated: true },
{ id: 'e4-5', source: '4', target: '5', type: 'animation', animated: true },
{ id: 'e5-6', source: '5', target: '6', type: 'animation', animated: true },
{ id: 'e5-7', source: '5', target: '7', type: 'animation', animated: true },
export const initialEdges: ProcessEdge[] = [
{ id: 'e1-2', source: '1', target: '2', type, animated: true },
{ id: 'e1-3', source: '1', target: '3', type, animated: true },
{ id: 'e2-2a', source: '2', target: '2a', type, animated: true },
{ id: 'e2-2b', source: '2', target: '2b', type, animated: true },
{ id: 'e2-2c', source: '2', target: '2c', type, animated: true },
{ id: 'e2c-2d', source: '2c', target: '2d', type, animated: true },
{ id: 'e3-7', source: '3', target: '4', type, animated: true },
{ id: 'e4-5', source: '4', target: '5', type, animated: true },
{ id: 'e5-6', source: '5', target: '6', type, animated: true },
{ id: 'e5-7', source: '5', target: '7', type, animated: true },
]

View File

@@ -0,0 +1,97 @@
<script lang="ts" setup>
import { computed, toRef } from 'vue'
import type { NodeProps } from '@vue-flow/core'
import { Handle, useNodeConnections } from '@vue-flow/core'
import type { ProcessData } from '.'
import { ProcessStatus } from '.'
const props = defineProps<NodeProps<ProcessData>>()
const sourceConnections = useNodeConnections({
handleType: 'target',
})
const targetConnections = useNodeConnections({
handleType: 'source',
})
const isStartNode = toRef(() => sourceConnections.value.length <= 0)
const isEndNode = toRef(() => targetConnections.value.length <= 0)
const status = toRef(() => props.data.status)
const bgColor = computed(() => {
if (isStartNode.value) {
return '#2563eb'
}
switch (status.value) {
case ProcessStatus.ERROR:
return '#f87171'
case ProcessStatus.FINISHED:
return '#42B983'
case ProcessStatus.CANCELLED:
return '#fbbf24'
default:
return '#4b5563'
}
})
const processLabel = computed(() => {
if (isStartNode.value) {
return '📦'
}
switch (status.value) {
case ProcessStatus.ERROR:
return '❌'
case ProcessStatus.SKIPPED:
return '🚧'
case ProcessStatus.CANCELLED:
return '🚫'
case ProcessStatus.FINISHED:
return '😎'
default:
return '🏠'
}
})
</script>
<template>
<div
class="process-node"
:style="{ backgroundColor: bgColor, boxShadow: status === ProcessStatus.RUNNING ? '0 0 10px rgba(0, 0, 0, 0.5)' : '' }"
>
<Handle v-if="!isStartNode" type="target" :position="targetPosition">
<span v-if="status === null">📥 </span>
</Handle>
<Handle v-if="!isEndNode" type="source" :position="sourcePosition" />
<div v-if="status === ProcessStatus.RUNNING" class="spinner" />
<span v-else>
{{ processLabel }}
</span>
</div>
</template>
<style scoped>
.process-node {
padding: 10px;
border-radius: 99px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.process-node .vue-flow__handle {
border: none;
height: unset;
width: unset;
background: transparent;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,15 @@
import type { Node } from '@vue-flow/core'
export enum ProcessStatus {
ERROR = 'error',
SKIPPED = 'skipped',
CANCELLED = 'cancelled',
FINISHED = 'finished',
RUNNING = 'running',
}
export interface ProcessData {
status: ProcessStatus | null
}
export type ProcessNode = Node<ProcessData, any, 'process'>

View File

@@ -0,0 +1,85 @@
.layout-flow {
background-color: #1a192b;
height: 100%;
width: 100%;
}
.process-panel,
.layout-panel {
display: flex;
gap: 10px;
}
.process-panel {
background-color: #2d3748;
padding: 10px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
}
.process-panel button {
border: none;
cursor: pointer;
background-color: #4a5568;
border-radius: 8px;
color: white;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
}
.process-panel button {
font-size: 16px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.checkbox-panel {
display: flex;
align-items: center;
gap: 10px;
}
.process-panel button:hover,
.layout-panel button:hover {
background-color: #2563eb;
transition: background-color 0.2s;
}
.process-panel label {
color: white;
font-size: 12px;
}
.stop-btn svg {
display: none;
}
.stop-btn:hover svg {
display: block;
}
.stop-btn:hover .spinner {
display: none;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #2563eb;
border-radius: 50%;
width: 10px;
height: 10px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -1,7 +0,0 @@
export interface ProcessNodeData {
hasError: boolean
isSkipped: boolean
isCancelled: boolean
isFinished: boolean
isRunning: boolean
}

View File

@@ -1,124 +0,0 @@
import type dagre from '@dagrejs/dagre'
import type { Node } from '@vue-flow/core'
import { useVueFlow } from '@vue-flow/core'
import type { MaybeRefOrGetter } from 'vue'
/**
* Composable to simulate running a process tree.
*
* It loops through each node, pretends to run an async process, and updates the node's data indicating whether the process has finished.
* When one node finishes, the next one starts.
*
* When a node has multiple descendants, it will run them in parallel.
*/
export function useRunProcess(dagreGraph: MaybeRefOrGetter<dagre.graphlib.Graph>) {
const { updateNodeData } = useVueFlow()
const graph = toRef(() => toValue(dagreGraph))
const isRunning = ref(false)
const executedNodes = new Set<string>()
const runningTasks = new Map<string, NodeJS.Timeout>()
async function runNode(node: { id: string }, isStart = false) {
if (executedNodes.has(node.id)) {
return
}
executedNodes.add(node.id)
updateNodeData(node.id, { isRunning: true, isFinished: false, hasError: false, isCancelled: false })
// Simulate an async process with a random timeout between 1 and 3 seconds
const delay = Math.floor(Math.random() * 2000) + 1000
return new Promise<void>((resolve) => {
const timeout = setTimeout(
async () => {
const children = graph.value.successors(node.id) as unknown as string[]
// Randomly decide whether the node will throw an error
const willThrowError = Math.random() < 0.15
if (willThrowError) {
updateNodeData(node.id, { isRunning: false, hasError: true })
await skipDescendants(node.id)
runningTasks.delete(node.id)
resolve()
return
}
updateNodeData(node.id, { isRunning: false, isFinished: true })
runningTasks.delete(node.id)
if (children.length > 0) {
// Run the process on the children in parallel
await Promise.all(children.map((id) => runNode({ id })))
}
resolve()
},
isStart ? 0 : delay,
)
runningTasks.set(node.id, timeout)
})
}
async function run(nodes: Node[]) {
if (isRunning.value) {
return
}
reset(nodes)
isRunning.value = true
// Get all starting nodes (nodes with no predecessors)
const startingNodes = nodes.filter((node) => graph.value.predecessors(node.id)?.length === 0)
// Run the process on all starting nodes in parallel
await Promise.all(startingNodes.map((node) => runNode(node, true)))
clear()
}
function reset(nodes: Node[]) {
clear()
for (const node of nodes) {
updateNodeData(node.id, { isRunning: false, isFinished: false, hasError: false, isSkipped: false, isCancelled: false })
}
}
async function skipDescendants(nodeId: string) {
const children = graph.value.successors(nodeId) as unknown as string[]
for (const child of children) {
updateNodeData(child, { isRunning: false, isSkipped: true })
await skipDescendants(child)
}
}
function stop() {
isRunning.value = false
for (const [nodeId, task] of runningTasks) {
clearTimeout(task)
runningTasks.delete(nodeId)
updateNodeData(nodeId, { isRunning: false, isFinished: false, hasError: false, isSkipped: false, isCancelled: true })
skipDescendants(nodeId)
}
executedNodes.clear()
}
function clear() {
isRunning.value = false
executedNodes.clear()
runningTasks.clear()
}
return { run, stop, reset, isRunning }
}