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:
@@ -18,7 +18,7 @@ import { IntersectionApp, IntersectionCSS } from './intersection'
|
||||
import { SnapToHandleApp, SnappableConnectionLine } from './connection-radius'
|
||||
import { NodeResizerApp, ResizableNode } from './node-resizer'
|
||||
import { ToolbarApp, ToolbarNode } from './node-toolbar'
|
||||
import { LayoutApp, LayoutEdge, LayoutElements, LayoutIcon, LayoutNode, useLayout, useRunProcess, useShuffle } from './layout'
|
||||
import { LayoutApp, LayoutEdge, LayoutElements, LayoutIcon, LayoutNode, useLayout, useRunProcess } from './layout'
|
||||
import { SimpleLayoutApp, SimpleLayoutElements, SimpleLayoutIcon, useSimpleLayout } from './layout-simple'
|
||||
import { LoopbackApp, LoopbackCSS, LoopbackEdge } from './loopback'
|
||||
import { MathApp, MathCSS, MathElements, MathIcon, MathOperatorNode, MathResultNode, MathValueNode } from './math'
|
||||
@@ -136,7 +136,6 @@ export const exampleImports = {
|
||||
'ProcessNode.vue': LayoutNode,
|
||||
'AnimationEdge.vue': LayoutEdge,
|
||||
'useRunProcess.js': useRunProcess,
|
||||
'useShuffle.js': useShuffle,
|
||||
'useLayout.js': useLayout,
|
||||
'Icon.vue': LayoutIcon,
|
||||
'additionalImports': {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, ref, toRef, watch } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { BaseEdge, EdgeLabelRenderer, Position, getSmoothStepPath, useNodesData, useVueFlow } from '@vue-flow/core'
|
||||
import { ProcessStatus } from './useRunProcess'
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -39,70 +40,85 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: Position.Left,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
|
||||
const { updateEdgeData } = useVueFlow()
|
||||
|
||||
const nodesData = useNodesData([props.target, props.source])
|
||||
/**
|
||||
* 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(() => [props.target, props.source])
|
||||
|
||||
const labelRef = ref()
|
||||
|
||||
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 isFinished = toRef(() => sourceNodeData.value.isFinished)
|
||||
|
||||
const isCancelled = toRef(() => targetNodeData.value.isCancelled)
|
||||
|
||||
const isAnimating = ref(false)
|
||||
const isAnimating = computed({
|
||||
get: () => props.data.isAnimating || false,
|
||||
set: (value) => {
|
||||
updateEdgeData(props.id, { isAnimating: value })
|
||||
},
|
||||
})
|
||||
|
||||
let animation = null
|
||||
|
||||
const path = computed(() => getSmoothStepPath(props))
|
||||
|
||||
const edgeColor = computed(() => {
|
||||
if (targetNodeData.value.hasError) {
|
||||
return '#f87171'
|
||||
}
|
||||
|
||||
if (targetNodeData.value.isFinished) {
|
||||
return '#42B983'
|
||||
}
|
||||
|
||||
if (targetNodeData.value.isCancelled || targetNodeData.value.isSkipped) {
|
||||
return '#fbbf24'
|
||||
}
|
||||
|
||||
if (targetNodeData.value.isRunning || isAnimating.value) {
|
||||
return '#2563eb'
|
||||
}
|
||||
|
||||
return '#6b7280'
|
||||
})
|
||||
|
||||
watch(isCancelled, (isCancelled) => {
|
||||
if (isCancelled) {
|
||||
animation?.cancel()
|
||||
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'
|
||||
}
|
||||
})
|
||||
|
||||
watch(isAnimating, (isAnimating) => {
|
||||
updateEdgeData(props.id, { isAnimating })
|
||||
})
|
||||
// Cancel the animation if the target nodes' process was cancelled
|
||||
watch(
|
||||
() => targetNodeData.value.status === ProcessStatus.CANCELLED,
|
||||
(isCancelled) => {
|
||||
if (isCancelled) {
|
||||
animation?.cancel()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(isFinished, (isFinished) => {
|
||||
if (isFinished) {
|
||||
runAnimation()
|
||||
}
|
||||
})
|
||||
// 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) {
|
||||
if (!pathEl || !labelEl) {
|
||||
console.warn('Path or label element not found')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -110,24 +126,38 @@ function runAnimation() {
|
||||
|
||||
isAnimating.value = true
|
||||
|
||||
const keyframes = [{ offsetDistance: '0%' }, { offsetDistance: '100%' }]
|
||||
// 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
|
||||
// use path length as a possible measure for the animation duration
|
||||
const pathLengthDuration = totalLength * 10
|
||||
|
||||
animation = labelRef.value.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,
|
||||
/**
|
||||
* 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
|
||||
})
|
||||
|
||||
animation.onfinish = handleAnimationEnd
|
||||
animation.oncancel = handleAnimationEnd
|
||||
}
|
||||
|
||||
function handleAnimationEnd() {
|
||||
isAnimating.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -152,7 +182,6 @@ export default {
|
||||
offsetRotate: '0deg',
|
||||
offsetAnchor: 'center',
|
||||
}"
|
||||
class="animated-edge-label"
|
||||
>
|
||||
<span class="truck">
|
||||
<span class="box">📦</span>
|
||||
|
||||
@@ -8,7 +8,6 @@ import AnimationEdge from './AnimationEdge.vue'
|
||||
|
||||
import { initialEdges, initialNodes } from './initial-elements.js'
|
||||
import { useRunProcess } from './useRunProcess'
|
||||
import { useShuffle } from './useShuffle'
|
||||
import { useLayout } from './useLayout'
|
||||
|
||||
const nodes = ref(initialNodes)
|
||||
@@ -17,26 +16,12 @@ const edges = ref(initialEdges)
|
||||
|
||||
const cancelOnError = ref(true)
|
||||
|
||||
const shuffle = useShuffle()
|
||||
|
||||
const { graph, layout, previousDirection } = useLayout()
|
||||
const { graph, layout } = useLayout()
|
||||
|
||||
const { run, stop, reset, isRunning } = useRunProcess({ graph, cancelOnError })
|
||||
|
||||
const { fitView } = useVueFlow()
|
||||
|
||||
async function shuffleGraph() {
|
||||
await stop()
|
||||
|
||||
reset(nodes.value)
|
||||
|
||||
edges.value = shuffle(nodes.value)
|
||||
|
||||
nextTick(() => {
|
||||
layoutGraph(previousDirection.value)
|
||||
})
|
||||
}
|
||||
|
||||
async function layoutGraph(direction) {
|
||||
await stop()
|
||||
|
||||
@@ -53,8 +38,8 @@ async function layoutGraph(direction) {
|
||||
<template>
|
||||
<div class="layout-flow">
|
||||
<VueFlow
|
||||
:nodes="nodes"
|
||||
:edges="edges"
|
||||
v-model:nodes="nodes"
|
||||
v-model:edges="edges"
|
||||
:default-edge-options="{ type: 'animation', animated: true }"
|
||||
@nodes-initialized="layoutGraph('LR')"
|
||||
>
|
||||
@@ -73,6 +58,7 @@ async function layoutGraph(direction) {
|
||||
:targetY="edgeProps.targetY"
|
||||
:source-position="edgeProps.sourcePosition"
|
||||
:target-position="edgeProps.targetPosition"
|
||||
:data="edgeProps.data"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -95,10 +81,6 @@ async function layoutGraph(direction) {
|
||||
<button title="set vertical layout" @click="layoutGraph('TB')">
|
||||
<Icon name="vertical" />
|
||||
</button>
|
||||
|
||||
<button title="shuffle graph" @click="shuffleGraph">
|
||||
<Icon name="shuffle" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-panel">
|
||||
|
||||
@@ -30,11 +30,4 @@ defineProps({
|
||||
<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>
|
||||
|
||||
<svg v-else-if="name === 'shuffle'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M14 20v-2h2.6l-3.175-3.175L14.85 13.4L18 16.55V14h2v6zm-8.6 0L4 18.6L16.6 6H14V4h6v6h-2V7.4zm3.775-9.425L4 5.4L5.4 4l5.175 5.175z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, toRef } from 'vue'
|
||||
import { Handle, useNodeConnections } from '@vue-flow/core'
|
||||
import { ProcessStatus } from './useRunProcess'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
@@ -23,64 +24,61 @@ const targetConnections = useNodeConnections({
|
||||
handleType: 'source',
|
||||
})
|
||||
|
||||
const isSender = toRef(() => sourceConnections.value.length <= 0)
|
||||
const isStartNode = toRef(() => sourceConnections.value.length <= 0)
|
||||
|
||||
const isReceiver = toRef(() => targetConnections.value.length <= 0)
|
||||
const isEndNode = toRef(() => targetConnections.value.length <= 0)
|
||||
|
||||
const status = toRef(() => props.data.status)
|
||||
|
||||
const bgColor = computed(() => {
|
||||
if (isSender.value) {
|
||||
if (isStartNode.value) {
|
||||
return '#2563eb'
|
||||
}
|
||||
|
||||
if (props.data.hasError) {
|
||||
return '#f87171'
|
||||
switch (status.value) {
|
||||
case ProcessStatus.ERROR:
|
||||
return '#f87171'
|
||||
case ProcessStatus.FINISHED:
|
||||
return '#42B983'
|
||||
case ProcessStatus.CANCELLED:
|
||||
return '#fbbf24'
|
||||
default:
|
||||
return '#4b5563'
|
||||
}
|
||||
|
||||
if (props.data.isFinished) {
|
||||
return '#42B983'
|
||||
}
|
||||
|
||||
if (props.data.isCancelled) {
|
||||
return '#fbbf24'
|
||||
}
|
||||
|
||||
return '#4b5563'
|
||||
})
|
||||
|
||||
const processLabel = computed(() => {
|
||||
if (props.data.hasError) {
|
||||
return '❌'
|
||||
}
|
||||
|
||||
if (props.data.isSkipped) {
|
||||
return '🚧'
|
||||
}
|
||||
|
||||
if (props.data.isCancelled) {
|
||||
return '🚫'
|
||||
}
|
||||
|
||||
if (isSender.value) {
|
||||
if (isStartNode.value) {
|
||||
return '📦'
|
||||
}
|
||||
|
||||
if (props.data.isFinished) {
|
||||
return '😎'
|
||||
switch (status.value) {
|
||||
case ProcessStatus.ERROR:
|
||||
return '❌'
|
||||
case ProcessStatus.SKIPPED:
|
||||
return '🚧'
|
||||
case ProcessStatus.CANCELLED:
|
||||
return '🚫'
|
||||
case ProcessStatus.FINISHED:
|
||||
return '😎'
|
||||
default:
|
||||
return '🏠'
|
||||
}
|
||||
|
||||
return '🏠'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="process-node" :style="{ backgroundColor: bgColor, boxShadow: data.isRunning ? '0 0 10px rgba(0, 0, 0, 0.5)' : '' }">
|
||||
<Handle v-if="!isSender" type="target" :position="targetPosition">
|
||||
<span v-if="!data.isRunning && !data.isFinished && !data.isCancelled && !data.isSkipped && !data.hasError">📥 </span>
|
||||
<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="!isReceiver" type="source" :position="sourcePosition" />
|
||||
<Handle v-if="!isEndNode" type="source" :position="sourcePosition" />
|
||||
|
||||
<div v-if="!isSender && data.isRunning" class="spinner" />
|
||||
<div v-if="status === ProcessStatus.RUNNING" class="spinner" />
|
||||
<span v-else>
|
||||
{{ processLabel }}
|
||||
</span>
|
||||
|
||||
@@ -3,6 +3,5 @@ export { default as LayoutElements } from './initial-elements.js?raw'
|
||||
export { default as LayoutNode } from './ProcessNode.vue?raw'
|
||||
export { default as LayoutEdge } from './AnimationEdge.vue?raw'
|
||||
export { default as useRunProcess } from './useRunProcess.js?raw'
|
||||
export { default as useShuffle } from './useShuffle.js?raw'
|
||||
export { default as useLayout } from './useLayout.js?raw'
|
||||
export { default as LayoutIcon } from './Icon.vue?raw'
|
||||
|
||||
@@ -1,61 +1,73 @@
|
||||
const position = { x: 0, y: 0 }
|
||||
const nodeType = 'process'
|
||||
const initialPos = { x: 0, y: 0 }
|
||||
const type = 'process'
|
||||
const data = { status: null }
|
||||
|
||||
export const initialNodes = [
|
||||
{
|
||||
id: '1',
|
||||
position,
|
||||
type: nodeType,
|
||||
position: initialPos,
|
||||
type,
|
||||
data,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
position,
|
||||
type: nodeType,
|
||||
position: initialPos,
|
||||
type,
|
||||
data,
|
||||
},
|
||||
{
|
||||
id: '2a',
|
||||
position,
|
||||
type: nodeType,
|
||||
position: initialPos,
|
||||
type,
|
||||
data,
|
||||
},
|
||||
{
|
||||
id: '2b',
|
||||
position,
|
||||
type: nodeType,
|
||||
position: initialPos,
|
||||
type,
|
||||
data,
|
||||
},
|
||||
{
|
||||
id: '2c',
|
||||
position,
|
||||
type: nodeType,
|
||||
position: initialPos,
|
||||
type,
|
||||
data,
|
||||
},
|
||||
{
|
||||
id: '2d',
|
||||
position,
|
||||
type: nodeType,
|
||||
position: initialPos,
|
||||
type,
|
||||
data,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
position,
|
||||
type: nodeType,
|
||||
position: initialPos,
|
||||
type,
|
||||
data,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
position,
|
||||
type: nodeType,
|
||||
position: initialPos,
|
||||
type,
|
||||
data,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
position,
|
||||
type: nodeType,
|
||||
position: initialPos,
|
||||
type,
|
||||
data,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
position,
|
||||
type: nodeType,
|
||||
position: initialPos,
|
||||
type,
|
||||
data,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
position,
|
||||
type: nodeType,
|
||||
position: initialPos,
|
||||
type,
|
||||
data,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -5,14 +5,14 @@ import { ref } from 'vue'
|
||||
/**
|
||||
* Composable to run the layout algorithm on the graph.
|
||||
* It uses the `dagre` library to calculate the layout of the nodes and edges.
|
||||
*
|
||||
* @see https://github.com/dagrejs/dagre/wiki
|
||||
*/
|
||||
export function useLayout() {
|
||||
const { findNode } = useVueFlow()
|
||||
|
||||
const graph = ref(new dagre.graphlib.Graph())
|
||||
|
||||
const previousDirection = ref('LR')
|
||||
|
||||
function layout(nodes, edges, 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()
|
||||
@@ -24,12 +24,15 @@ export function useLayout() {
|
||||
const isHorizontal = direction === 'LR'
|
||||
dagreGraph.setGraph({ rankdir: direction })
|
||||
|
||||
previousDirection.value = 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 })
|
||||
}
|
||||
|
||||
@@ -52,5 +55,5 @@ export function useLayout() {
|
||||
})
|
||||
}
|
||||
|
||||
return { graph, layout, previousDirection }
|
||||
return { graph, layout }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { ref, toRef, toValue } from 'vue'
|
||||
import { useVueFlow } from '@vue-flow/core'
|
||||
|
||||
export const ProcessStatus = {
|
||||
ERROR: 'error',
|
||||
SKIPPED: 'skipped',
|
||||
CANCELLED: 'cancelled',
|
||||
FINISHED: 'finished',
|
||||
RUNNING: 'running',
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable to simulate running a process tree.
|
||||
*
|
||||
@@ -8,6 +16,10 @@ import { useVueFlow } from '@vue-flow/core'
|
||||
* 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 }) {
|
||||
const { updateNodeData, getConnectedEdges } = useVueFlow()
|
||||
@@ -16,24 +28,35 @@ export function useRunProcess({ graph: dagreGraph, cancelOnError = true }) {
|
||||
|
||||
const isRunning = ref(false)
|
||||
|
||||
const executedNodes = new Set()
|
||||
|
||||
/** Map of running tasks with the node ID as the key and the timeout as the value. */
|
||||
const runningTasks = new Map()
|
||||
|
||||
/** Set of node ids of nodes that have been executed */
|
||||
const executedNodes = new Set()
|
||||
|
||||
/** Set of node ids yet to be executed */
|
||||
const upcomingTasks = new Set()
|
||||
|
||||
async function runNode(node, isStart = false) {
|
||||
if (executedNodes.has(node.id)) {
|
||||
/**
|
||||
* 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, isStart = false) {
|
||||
if (executedNodes.has(nodeId)) {
|
||||
return
|
||||
}
|
||||
|
||||
// save the upcoming task in case it gets cancelled before we even start it
|
||||
upcomingTasks.add(node.id)
|
||||
upcomingTasks.add(nodeId)
|
||||
|
||||
const incomers = getConnectedEdges(node.id).filter((connection) => connection.target === node.id)
|
||||
// get all incoming edges to this node
|
||||
const incomers = getConnectedEdges(nodeId).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)))
|
||||
await Promise.all(incomers.map((incomer) => until(() => !incomer.data?.isAnimating)))
|
||||
|
||||
// remove the upcoming task since we are about to start it
|
||||
upcomingTasks.clear()
|
||||
@@ -44,9 +67,9 @@ export function useRunProcess({ graph: dagreGraph, cancelOnError = true }) {
|
||||
}
|
||||
|
||||
// mark the node as executed, so it doesn't run again
|
||||
executedNodes.add(node.id)
|
||||
executedNodes.add(nodeId)
|
||||
|
||||
updateNodeData(node.id, { isRunning: true, isFinished: false, hasError: false, isCancelled: false })
|
||||
updateNodeStatus(nodeId, ProcessStatus.RUNNING)
|
||||
|
||||
// simulate an async process with a random timeout between 1-2 seconds
|
||||
const delay = Math.floor(Math.random() * 2000) + 1000
|
||||
@@ -54,49 +77,63 @@ export function useRunProcess({ graph: dagreGraph, cancelOnError = true }) {
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(
|
||||
async () => {
|
||||
const children = graph.value.successors(node.id)
|
||||
// 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) {
|
||||
updateNodeData(node.id, { isRunning: false, hasError: true })
|
||||
updateNodeStatus(nodeId, ProcessStatus.ERROR)
|
||||
|
||||
// if cancelOnError is true, we stop the process and mark all descendants as skipped
|
||||
if (toValue(cancelOnError)) {
|
||||
await skipDescendants(node.id)
|
||||
runningTasks.delete(node.id)
|
||||
await skipDescendants(nodeId)
|
||||
runningTasks.delete(nodeId)
|
||||
|
||||
resolve()
|
||||
resolve(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
updateNodeData(node.id, { isRunning: false, isFinished: true })
|
||||
updateNodeStatus(nodeId, ProcessStatus.FINISHED)
|
||||
|
||||
runningTasks.delete(node.id)
|
||||
runningTasks.delete(nodeId)
|
||||
|
||||
if (children.length > 0) {
|
||||
// run the process on the children in parallel
|
||||
await Promise.all(children.map((id) => runNode({ id })))
|
||||
await Promise.all(children.map((child) => runNode(child)))
|
||||
}
|
||||
|
||||
resolve()
|
||||
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(node.id, timeout)
|
||||
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) {
|
||||
// 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
|
||||
@@ -105,42 +142,57 @@ export function useRunProcess({ graph: dagreGraph, cancelOnError = true }) {
|
||||
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)))
|
||||
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) {
|
||||
clear()
|
||||
|
||||
for (const node of nodes) {
|
||||
updateNodeData(node.id, { isRunning: false, isFinished: false, hasError: false, isSkipped: false, isCancelled: false })
|
||||
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) {
|
||||
const children = graph.value.successors(nodeId)
|
||||
const children = graph.value.successors(nodeId) || []
|
||||
|
||||
for (const child of children) {
|
||||
updateNodeData(child, { isRunning: false, isSkipped: true })
|
||||
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)
|
||||
updateNodeData(nodeId, { isRunning: false, isFinished: false, hasError: false, isSkipped: false, isCancelled: true })
|
||||
updateNodeStatus(nodeId, ProcessStatus.CANCELLED)
|
||||
await skipDescendants(nodeId)
|
||||
}
|
||||
|
||||
for (const [nodeId, task] of runningTasks) {
|
||||
clearTimeout(task)
|
||||
runningTasks.delete(nodeId)
|
||||
updateNodeData(nodeId, { isRunning: false, isFinished: false, hasError: false, isSkipped: false, isCancelled: true })
|
||||
updateNodeStatus(nodeId, ProcessStatus.CANCELLED)
|
||||
await skipDescendants(nodeId)
|
||||
}
|
||||
|
||||
@@ -148,12 +200,25 @@ export function useRunProcess({ graph: dagreGraph, cancelOnError = true }) {
|
||||
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, status) {
|
||||
updateNodeData(nodeId, { status })
|
||||
}
|
||||
|
||||
return { run, stop, reset, isRunning }
|
||||
}
|
||||
|
||||
@@ -162,7 +227,7 @@ async function until(condition) {
|
||||
const interval = setInterval(() => {
|
||||
if (condition()) {
|
||||
clearInterval(interval)
|
||||
resolve()
|
||||
resolve(true)
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
function shuffleArray(array) {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[array[i], array[j]] = [array[j], array[i]]
|
||||
}
|
||||
}
|
||||
|
||||
// generate a list of all possible edges, excluding duplicates and self-references
|
||||
function generatePossibleEdges(nodes) {
|
||||
const possibleEdges = []
|
||||
|
||||
for (const sourceNode of nodes) {
|
||||
for (const targetNode of nodes) {
|
||||
if (sourceNode.id !== targetNode.id) {
|
||||
const edgeId = `e${sourceNode.id}-${targetNode.id}`
|
||||
possibleEdges.push({ id: edgeId, source: sourceNode.id, target: targetNode.id, type: 'animation', animated: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return possibleEdges
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable that returns a function to shuffle the edges of a graph
|
||||
*
|
||||
* @returns {Function} A function that takes a list of nodes and returns a list of edges
|
||||
*/
|
||||
export function useShuffle() {
|
||||
return (nodes) => {
|
||||
const possibleEdges = generatePossibleEdges(nodes)
|
||||
shuffleArray(possibleEdges)
|
||||
|
||||
const usedNodes = new Set()
|
||||
const newEdges = []
|
||||
|
||||
// ensure every node is used, maintaining a single starting node and no duplicate connections
|
||||
for (const edge of possibleEdges) {
|
||||
if (!usedNodes.has(edge.target) && (usedNodes.size === 0 || usedNodes.has(edge.source))) {
|
||||
newEdges.push(edge)
|
||||
usedNodes.add(edge.source)
|
||||
usedNodes.add(edge.target)
|
||||
}
|
||||
}
|
||||
|
||||
return newEdges
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user