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 { SnapToHandleApp, SnappableConnectionLine } from './connection-radius'
|
||||||
import { NodeResizerApp, ResizableNode } from './node-resizer'
|
import { NodeResizerApp, ResizableNode } from './node-resizer'
|
||||||
import { ToolbarApp, ToolbarNode } from './node-toolbar'
|
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 { SimpleLayoutApp, SimpleLayoutElements, SimpleLayoutIcon, useSimpleLayout } from './layout-simple'
|
||||||
import { LoopbackApp, LoopbackCSS, LoopbackEdge } from './loopback'
|
import { LoopbackApp, LoopbackCSS, LoopbackEdge } from './loopback'
|
||||||
import { MathApp, MathCSS, MathElements, MathIcon, MathOperatorNode, MathResultNode, MathValueNode } from './math'
|
import { MathApp, MathCSS, MathElements, MathIcon, MathOperatorNode, MathResultNode, MathValueNode } from './math'
|
||||||
@@ -136,7 +136,6 @@ export const exampleImports = {
|
|||||||
'ProcessNode.vue': LayoutNode,
|
'ProcessNode.vue': LayoutNode,
|
||||||
'AnimationEdge.vue': LayoutEdge,
|
'AnimationEdge.vue': LayoutEdge,
|
||||||
'useRunProcess.js': useRunProcess,
|
'useRunProcess.js': useRunProcess,
|
||||||
'useShuffle.js': useShuffle,
|
|
||||||
'useLayout.js': useLayout,
|
'useLayout.js': useLayout,
|
||||||
'Icon.vue': LayoutIcon,
|
'Icon.vue': LayoutIcon,
|
||||||
'additionalImports': {
|
'additionalImports': {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<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 { BaseEdge, EdgeLabelRenderer, Position, getSmoothStepPath, useNodesData, useVueFlow } from '@vue-flow/core'
|
||||||
|
import { ProcessStatus } from './useRunProcess'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
id: {
|
id: {
|
||||||
@@ -39,70 +40,85 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: Position.Left,
|
default: Position.Left,
|
||||||
},
|
},
|
||||||
|
data: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const { updateEdgeData } = useVueFlow()
|
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 labelRef = ref()
|
||||||
|
|
||||||
const edgeRef = 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 targetNodeData = computed(() => nodesData.value[0].data)
|
||||||
|
|
||||||
const sourceNodeData = computed(() => nodesData.value[1].data)
|
const sourceNodeData = computed(() => nodesData.value[1].data)
|
||||||
|
|
||||||
const isFinished = toRef(() => sourceNodeData.value.isFinished)
|
const isAnimating = computed({
|
||||||
|
get: () => props.data.isAnimating || false,
|
||||||
const isCancelled = toRef(() => targetNodeData.value.isCancelled)
|
set: (value) => {
|
||||||
|
updateEdgeData(props.id, { isAnimating: value })
|
||||||
const isAnimating = ref(false)
|
},
|
||||||
|
})
|
||||||
|
|
||||||
let animation = null
|
let animation = null
|
||||||
|
|
||||||
const path = computed(() => getSmoothStepPath(props))
|
const path = computed(() => getSmoothStepPath(props))
|
||||||
|
|
||||||
const edgeColor = computed(() => {
|
const edgeColor = computed(() => {
|
||||||
if (targetNodeData.value.hasError) {
|
switch (targetNodeData.value.status) {
|
||||||
return '#f87171'
|
case ProcessStatus.ERROR:
|
||||||
}
|
return '#f87171'
|
||||||
|
case ProcessStatus.FINISHED:
|
||||||
if (targetNodeData.value.isFinished) {
|
return '#42B983'
|
||||||
return '#42B983'
|
case ProcessStatus.CANCELLED:
|
||||||
}
|
case ProcessStatus.SKIPPED:
|
||||||
|
return '#fbbf24'
|
||||||
if (targetNodeData.value.isCancelled || targetNodeData.value.isSkipped) {
|
case ProcessStatus.RUNNING:
|
||||||
return '#fbbf24'
|
return '#2563eb'
|
||||||
}
|
default:
|
||||||
|
return '#6b7280'
|
||||||
if (targetNodeData.value.isRunning || isAnimating.value) {
|
|
||||||
return '#2563eb'
|
|
||||||
}
|
|
||||||
|
|
||||||
return '#6b7280'
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(isCancelled, (isCancelled) => {
|
|
||||||
if (isCancelled) {
|
|
||||||
animation?.cancel()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(isAnimating, (isAnimating) => {
|
// Cancel the animation if the target nodes' process was cancelled
|
||||||
updateEdgeData(props.id, { isAnimating })
|
watch(
|
||||||
})
|
() => targetNodeData.value.status === ProcessStatus.CANCELLED,
|
||||||
|
(isCancelled) => {
|
||||||
|
if (isCancelled) {
|
||||||
|
animation?.cancel()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
watch(isFinished, (isFinished) => {
|
// Run the animation when the source nodes' process is finished
|
||||||
if (isFinished) {
|
watch(
|
||||||
runAnimation()
|
() => sourceNodeData.value.status === ProcessStatus.FINISHED,
|
||||||
}
|
(isFinished) => {
|
||||||
})
|
if (isFinished) {
|
||||||
|
runAnimation()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
function runAnimation() {
|
function runAnimation() {
|
||||||
const pathEl = edgeRef.value?.pathEl
|
const pathEl = edgeRef.value?.pathEl
|
||||||
|
const labelEl = labelRef.value
|
||||||
|
|
||||||
if (!pathEl) {
|
if (!pathEl || !labelEl) {
|
||||||
|
console.warn('Path or label element not found')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,24 +126,38 @@ function runAnimation() {
|
|||||||
|
|
||||||
isAnimating.value = true
|
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
|
// use path length as a possible measure for the animation duration
|
||||||
const pathLengthDuration = totalLength * 10
|
const pathLengthDuration = totalLength * 10
|
||||||
|
|
||||||
animation = labelRef.value.animate(keyframes, {
|
/**
|
||||||
duration: Math.min(Math.max(pathLengthDuration, 1500), 3000), // clamp duration between 1.5s and 3s
|
* We animate the label element along the path of the edge using the `offsetDistance` property and
|
||||||
direction: 'normal',
|
* the Web Animations API.
|
||||||
easing: 'ease-in-out',
|
*
|
||||||
iterations: 1,
|
* 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>
|
</script>
|
||||||
|
|
||||||
@@ -152,7 +182,6 @@ export default {
|
|||||||
offsetRotate: '0deg',
|
offsetRotate: '0deg',
|
||||||
offsetAnchor: 'center',
|
offsetAnchor: 'center',
|
||||||
}"
|
}"
|
||||||
class="animated-edge-label"
|
|
||||||
>
|
>
|
||||||
<span class="truck">
|
<span class="truck">
|
||||||
<span class="box">📦</span>
|
<span class="box">📦</span>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import AnimationEdge from './AnimationEdge.vue'
|
|||||||
|
|
||||||
import { initialEdges, initialNodes } from './initial-elements.js'
|
import { initialEdges, initialNodes } from './initial-elements.js'
|
||||||
import { useRunProcess } from './useRunProcess'
|
import { useRunProcess } from './useRunProcess'
|
||||||
import { useShuffle } from './useShuffle'
|
|
||||||
import { useLayout } from './useLayout'
|
import { useLayout } from './useLayout'
|
||||||
|
|
||||||
const nodes = ref(initialNodes)
|
const nodes = ref(initialNodes)
|
||||||
@@ -17,26 +16,12 @@ const edges = ref(initialEdges)
|
|||||||
|
|
||||||
const cancelOnError = ref(true)
|
const cancelOnError = ref(true)
|
||||||
|
|
||||||
const shuffle = useShuffle()
|
const { graph, layout } = useLayout()
|
||||||
|
|
||||||
const { graph, layout, previousDirection } = useLayout()
|
|
||||||
|
|
||||||
const { run, stop, reset, isRunning } = useRunProcess({ graph, cancelOnError })
|
const { run, stop, reset, isRunning } = useRunProcess({ graph, cancelOnError })
|
||||||
|
|
||||||
const { fitView } = useVueFlow()
|
const { fitView } = useVueFlow()
|
||||||
|
|
||||||
async function shuffleGraph() {
|
|
||||||
await stop()
|
|
||||||
|
|
||||||
reset(nodes.value)
|
|
||||||
|
|
||||||
edges.value = shuffle(nodes.value)
|
|
||||||
|
|
||||||
nextTick(() => {
|
|
||||||
layoutGraph(previousDirection.value)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function layoutGraph(direction) {
|
async function layoutGraph(direction) {
|
||||||
await stop()
|
await stop()
|
||||||
|
|
||||||
@@ -53,8 +38,8 @@ async function layoutGraph(direction) {
|
|||||||
<template>
|
<template>
|
||||||
<div class="layout-flow">
|
<div class="layout-flow">
|
||||||
<VueFlow
|
<VueFlow
|
||||||
:nodes="nodes"
|
v-model:nodes="nodes"
|
||||||
:edges="edges"
|
v-model:edges="edges"
|
||||||
:default-edge-options="{ type: 'animation', animated: true }"
|
:default-edge-options="{ type: 'animation', animated: true }"
|
||||||
@nodes-initialized="layoutGraph('LR')"
|
@nodes-initialized="layoutGraph('LR')"
|
||||||
>
|
>
|
||||||
@@ -73,6 +58,7 @@ async function layoutGraph(direction) {
|
|||||||
:targetY="edgeProps.targetY"
|
:targetY="edgeProps.targetY"
|
||||||
:source-position="edgeProps.sourcePosition"
|
:source-position="edgeProps.sourcePosition"
|
||||||
:target-position="edgeProps.targetPosition"
|
:target-position="edgeProps.targetPosition"
|
||||||
|
:data="edgeProps.data"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -95,10 +81,6 @@ async function layoutGraph(direction) {
|
|||||||
<button title="set vertical layout" @click="layoutGraph('TB')">
|
<button title="set vertical layout" @click="layoutGraph('TB')">
|
||||||
<Icon name="vertical" />
|
<Icon name="vertical" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button title="shuffle graph" @click="shuffleGraph">
|
|
||||||
<Icon name="shuffle" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="checkbox-panel">
|
<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,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" />
|
<path d="M7,17 L12,22 L17,17" stroke="currentColor" stroke-width="2" fill="none" />
|
||||||
</svg>
|
</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>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, toRef } from 'vue'
|
import { computed, toRef } from 'vue'
|
||||||
import { Handle, useNodeConnections } from '@vue-flow/core'
|
import { Handle, useNodeConnections } from '@vue-flow/core'
|
||||||
|
import { ProcessStatus } from './useRunProcess'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
data: {
|
data: {
|
||||||
@@ -23,64 +24,61 @@ const targetConnections = useNodeConnections({
|
|||||||
handleType: 'source',
|
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(() => {
|
const bgColor = computed(() => {
|
||||||
if (isSender.value) {
|
if (isStartNode.value) {
|
||||||
return '#2563eb'
|
return '#2563eb'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.data.hasError) {
|
switch (status.value) {
|
||||||
return '#f87171'
|
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(() => {
|
const processLabel = computed(() => {
|
||||||
if (props.data.hasError) {
|
if (isStartNode.value) {
|
||||||
return '❌'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.data.isSkipped) {
|
|
||||||
return '🚧'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.data.isCancelled) {
|
|
||||||
return '🚫'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSender.value) {
|
|
||||||
return '📦'
|
return '📦'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.data.isFinished) {
|
switch (status.value) {
|
||||||
return '😎'
|
case ProcessStatus.ERROR:
|
||||||
|
return '❌'
|
||||||
|
case ProcessStatus.SKIPPED:
|
||||||
|
return '🚧'
|
||||||
|
case ProcessStatus.CANCELLED:
|
||||||
|
return '🚫'
|
||||||
|
case ProcessStatus.FINISHED:
|
||||||
|
return '😎'
|
||||||
|
default:
|
||||||
|
return '🏠'
|
||||||
}
|
}
|
||||||
|
|
||||||
return '🏠'
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="process-node" :style="{ backgroundColor: bgColor, boxShadow: data.isRunning ? '0 0 10px rgba(0, 0, 0, 0.5)' : '' }">
|
<div
|
||||||
<Handle v-if="!isSender" type="target" :position="targetPosition">
|
class="process-node"
|
||||||
<span v-if="!data.isRunning && !data.isFinished && !data.isCancelled && !data.isSkipped && !data.hasError">📥 </span>
|
: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>
|
||||||
|
|
||||||
<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>
|
<span v-else>
|
||||||
{{ processLabel }}
|
{{ processLabel }}
|
||||||
</span>
|
</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 LayoutNode } from './ProcessNode.vue?raw'
|
||||||
export { default as LayoutEdge } from './AnimationEdge.vue?raw'
|
export { default as LayoutEdge } from './AnimationEdge.vue?raw'
|
||||||
export { default as useRunProcess } from './useRunProcess.js?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 useLayout } from './useLayout.js?raw'
|
||||||
export { default as LayoutIcon } from './Icon.vue?raw'
|
export { default as LayoutIcon } from './Icon.vue?raw'
|
||||||
|
|||||||
@@ -1,61 +1,73 @@
|
|||||||
const position = { x: 0, y: 0 }
|
const initialPos = { x: 0, y: 0 }
|
||||||
const nodeType = 'process'
|
const type = 'process'
|
||||||
|
const data = { status: null }
|
||||||
|
|
||||||
export const initialNodes = [
|
export const initialNodes = [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
position,
|
position: initialPos,
|
||||||
type: nodeType,
|
type,
|
||||||
|
data,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
position,
|
position: initialPos,
|
||||||
type: nodeType,
|
type,
|
||||||
|
data,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2a',
|
id: '2a',
|
||||||
position,
|
position: initialPos,
|
||||||
type: nodeType,
|
type,
|
||||||
|
data,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2b',
|
id: '2b',
|
||||||
position,
|
position: initialPos,
|
||||||
type: nodeType,
|
type,
|
||||||
|
data,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2c',
|
id: '2c',
|
||||||
position,
|
position: initialPos,
|
||||||
type: nodeType,
|
type,
|
||||||
|
data,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2d',
|
id: '2d',
|
||||||
position,
|
position: initialPos,
|
||||||
type: nodeType,
|
type,
|
||||||
|
data,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '3',
|
id: '3',
|
||||||
position,
|
position: initialPos,
|
||||||
type: nodeType,
|
type,
|
||||||
|
data,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '4',
|
id: '4',
|
||||||
position,
|
position: initialPos,
|
||||||
type: nodeType,
|
type,
|
||||||
|
data,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '5',
|
id: '5',
|
||||||
position,
|
position: initialPos,
|
||||||
type: nodeType,
|
type,
|
||||||
|
data,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '6',
|
id: '6',
|
||||||
position,
|
position: initialPos,
|
||||||
type: nodeType,
|
type,
|
||||||
|
data,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '7',
|
id: '7',
|
||||||
position,
|
position: initialPos,
|
||||||
type: nodeType,
|
type,
|
||||||
|
data,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import { ref } from 'vue'
|
|||||||
/**
|
/**
|
||||||
* Composable to run the layout algorithm on the graph.
|
* Composable to run the layout algorithm on the graph.
|
||||||
* It uses the `dagre` library to calculate the layout of the nodes and edges.
|
* It uses the `dagre` library to calculate the layout of the nodes and edges.
|
||||||
|
*
|
||||||
|
* @see https://github.com/dagrejs/dagre/wiki
|
||||||
*/
|
*/
|
||||||
export function useLayout() {
|
export function useLayout() {
|
||||||
const { findNode } = useVueFlow()
|
const { findNode } = useVueFlow()
|
||||||
|
|
||||||
const graph = ref(new dagre.graphlib.Graph())
|
const graph = ref(new dagre.graphlib.Graph())
|
||||||
|
|
||||||
const previousDirection = ref('LR')
|
|
||||||
|
|
||||||
function layout(nodes, edges, direction) {
|
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
|
// 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()
|
const dagreGraph = new dagre.graphlib.Graph()
|
||||||
@@ -24,12 +24,15 @@ export function useLayout() {
|
|||||||
const isHorizontal = direction === 'LR'
|
const isHorizontal = direction === 'LR'
|
||||||
dagreGraph.setGraph({ rankdir: direction })
|
dagreGraph.setGraph({ rankdir: direction })
|
||||||
|
|
||||||
previousDirection.value = direction
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
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)
|
// 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)
|
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 })
|
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 { ref, toRef, toValue } from 'vue'
|
||||||
import { useVueFlow } from '@vue-flow/core'
|
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.
|
* 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 one node finishes, the next one starts.
|
||||||
*
|
*
|
||||||
* When a node has multiple descendants, it will run them in parallel.
|
* 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 }) {
|
export function useRunProcess({ graph: dagreGraph, cancelOnError = true }) {
|
||||||
const { updateNodeData, getConnectedEdges } = useVueFlow()
|
const { updateNodeData, getConnectedEdges } = useVueFlow()
|
||||||
@@ -16,24 +28,35 @@ export function useRunProcess({ graph: dagreGraph, cancelOnError = true }) {
|
|||||||
|
|
||||||
const isRunning = ref(false)
|
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()
|
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()
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// save the upcoming task in case it gets cancelled before we even start it
|
// 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
|
// 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
|
// remove the upcoming task since we are about to start it
|
||||||
upcomingTasks.clear()
|
upcomingTasks.clear()
|
||||||
@@ -44,9 +67,9 @@ export function useRunProcess({ graph: dagreGraph, cancelOnError = true }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// mark the node as executed, so it doesn't run again
|
// 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
|
// simulate an async process with a random timeout between 1-2 seconds
|
||||||
const delay = Math.floor(Math.random() * 2000) + 1000
|
const delay = Math.floor(Math.random() * 2000) + 1000
|
||||||
@@ -54,49 +77,63 @@ export function useRunProcess({ graph: dagreGraph, cancelOnError = true }) {
|
|||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const timeout = setTimeout(
|
const timeout = setTimeout(
|
||||||
async () => {
|
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
|
// randomly decide whether the node will throw an error
|
||||||
const willThrowError = Math.random() < 0.15
|
const willThrowError = Math.random() < 0.15
|
||||||
|
|
||||||
// we avoid throwing an error on the starting node
|
// we avoid throwing an error on the starting node
|
||||||
if (!isStart && willThrowError) {
|
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)) {
|
if (toValue(cancelOnError)) {
|
||||||
await skipDescendants(node.id)
|
await skipDescendants(nodeId)
|
||||||
runningTasks.delete(node.id)
|
runningTasks.delete(nodeId)
|
||||||
|
|
||||||
resolve()
|
resolve(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateNodeData(node.id, { isRunning: false, isFinished: true })
|
updateNodeStatus(nodeId, ProcessStatus.FINISHED)
|
||||||
|
|
||||||
runningTasks.delete(node.id)
|
runningTasks.delete(nodeId)
|
||||||
|
|
||||||
if (children.length > 0) {
|
if (children.length > 0) {
|
||||||
// run the process on the children in parallel
|
// 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
|
// if this is a starting node, we don't want to wait
|
||||||
isStart ? 0 : delay,
|
isStart ? 0 : delay,
|
||||||
)
|
)
|
||||||
|
|
||||||
// save the timeout so we can cancel it if needed
|
// 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) {
|
async function run(nodes) {
|
||||||
|
// if the process is already running, we don't want to start it again
|
||||||
if (isRunning.value) {
|
if (isRunning.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reset all nodes to their initial state
|
||||||
reset(nodes)
|
reset(nodes)
|
||||||
|
|
||||||
isRunning.value = true
|
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)
|
const startingNodes = nodes.filter((node) => graph.value.predecessors(node.id)?.length === 0)
|
||||||
|
|
||||||
// run the process on all starting nodes in parallel
|
// 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()
|
clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all nodes to their initial state.
|
||||||
|
*
|
||||||
|
* @param nodes The nodes to reset.
|
||||||
|
*/
|
||||||
function reset(nodes) {
|
function reset(nodes) {
|
||||||
clear()
|
clear()
|
||||||
|
|
||||||
for (const node of nodes) {
|
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) {
|
async function skipDescendants(nodeId) {
|
||||||
const children = graph.value.successors(nodeId)
|
const children = graph.value.successors(nodeId) || []
|
||||||
|
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
updateNodeData(child, { isRunning: false, isSkipped: true })
|
updateNodeStatus(child, ProcessStatus.SKIPPED)
|
||||||
await skipDescendants(child)
|
await skipDescendants(child)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the process.
|
||||||
|
*
|
||||||
|
* It will mark all running nodes as cancelled and skip all upcoming tasks.
|
||||||
|
*/
|
||||||
async function stop() {
|
async function stop() {
|
||||||
isRunning.value = false
|
isRunning.value = false
|
||||||
|
|
||||||
for (const nodeId of upcomingTasks) {
|
for (const nodeId of upcomingTasks) {
|
||||||
clearTimeout(runningTasks.get(nodeId))
|
clearTimeout(runningTasks.get(nodeId))
|
||||||
runningTasks.delete(nodeId)
|
runningTasks.delete(nodeId)
|
||||||
updateNodeData(nodeId, { isRunning: false, isFinished: false, hasError: false, isSkipped: false, isCancelled: true })
|
updateNodeStatus(nodeId, ProcessStatus.CANCELLED)
|
||||||
await skipDescendants(nodeId)
|
await skipDescendants(nodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [nodeId, task] of runningTasks) {
|
for (const [nodeId, task] of runningTasks) {
|
||||||
clearTimeout(task)
|
clearTimeout(task)
|
||||||
runningTasks.delete(nodeId)
|
runningTasks.delete(nodeId)
|
||||||
updateNodeData(nodeId, { isRunning: false, isFinished: false, hasError: false, isSkipped: false, isCancelled: true })
|
updateNodeStatus(nodeId, ProcessStatus.CANCELLED)
|
||||||
await skipDescendants(nodeId)
|
await skipDescendants(nodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,12 +200,25 @@ export function useRunProcess({ graph: dagreGraph, cancelOnError = true }) {
|
|||||||
upcomingTasks.clear()
|
upcomingTasks.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all running tasks and executed nodes.
|
||||||
|
*/
|
||||||
function clear() {
|
function clear() {
|
||||||
isRunning.value = false
|
isRunning.value = false
|
||||||
executedNodes.clear()
|
executedNodes.clear()
|
||||||
runningTasks.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 }
|
return { run, stop, reset, isRunning }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +227,7 @@ async function until(condition) {
|
|||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
if (condition()) {
|
if (condition()) {
|
||||||
clearInterval(interval)
|
clearInterval(interval)
|
||||||
resolve()
|
resolve(true)
|
||||||
}
|
}
|
||||||
}, 100)
|
}, 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,57 +1,41 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import dagre from '@dagrejs/dagre'
|
import { nextTick, ref } from 'vue'
|
||||||
import { ConnectionMode, Panel, Position, VueFlow, useVueFlow } from '@vue-flow/core'
|
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 { initialEdges, initialNodes } from './initial-elements'
|
||||||
import { useRunProcess } from './useRunProcess'
|
import { useRunProcess } from './composables/useRunProcess'
|
||||||
import AnimationEdge from './AnimationEdge.vue'
|
import type { Direction } from './composables/useLayout'
|
||||||
import ProcessNode from './ProcessNode.vue'
|
import { useLayout } from './composables/useLayout'
|
||||||
|
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
const nodes = ref(initialNodes)
|
const nodes = ref(initialNodes)
|
||||||
|
|
||||||
const edges = ref(initialEdges)
|
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 cancelOnError = ref(true)
|
||||||
const dagreGraph = ref(new dagre.graphlib.Graph())
|
|
||||||
|
|
||||||
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') {
|
async function layoutGraph(direction: Direction) {
|
||||||
dagreGraph.value = new dagre.graphlib.Graph()
|
// Stop the current execution process
|
||||||
dagreGraph.value.setDefaultEdgeLabel(() => ({}))
|
await stop()
|
||||||
|
|
||||||
const isHorizontal = direction === 'LR'
|
// Reset the nodes to their initial status
|
||||||
dagreGraph.value.setGraph({ rankdir: direction })
|
reset(nodes.value)
|
||||||
|
|
||||||
for (const node of nodes.value) {
|
// Layout the graph
|
||||||
// if you need width+height of nodes for your layout, you can use the dimensions property of the internal node (`GraphNode` type)
|
nodes.value = layout(nodes.value, edges.value, direction)
|
||||||
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 },
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
// Fit the view to the graph
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
fitView()
|
fitView()
|
||||||
})
|
})
|
||||||
@@ -59,67 +43,47 @@ function handleLayout(direction: 'TB' | 'LR') {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="layoutflow">
|
<div class="layout-flow">
|
||||||
<VueFlow :nodes="nodes" :edges="edges" :connection-mode="ConnectionMode.Loose" @nodes-initialized="handleLayout('LR')">
|
<VueFlow
|
||||||
<template #node-process="props">
|
v-model:nodes="nodes"
|
||||||
<ProcessNode v-bind="props" />
|
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>
|
||||||
|
|
||||||
<template #edge-animation="props">
|
<template #edge-process="processEdgeProps">
|
||||||
<AnimationEdge v-bind="props" />
|
<ProcessEdge v-bind="processEdgeProps" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<Panel class="layout-panel" position="top-left">
|
<Background />
|
||||||
<button @click="handleLayout('TB')">vertical</button>
|
|
||||||
<button @click="handleLayout('LR')">horizontal</button>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<Panel class="process-panel" position="top-right">
|
<Panel class="process-panel" position="top-right">
|
||||||
<button v-if="isRunning" @click="stop">🛑</button>
|
<div class="layout-panel">
|
||||||
<button v-else @click="run(nodes)">▶️</button>
|
<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>
|
</Panel>
|
||||||
</VueFlow>
|
</VueFlow>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { Edge } from '@vue-flow/core'
|
||||||
|
|
||||||
|
export interface ProcessEdgeData {
|
||||||
|
isAnimating?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProcessEdge = Edge<ProcessEdgeData, any, 'process'>
|
||||||
@@ -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 initialPos = { x: 0, y: 0 }
|
||||||
const type: string = 'process'
|
const type = 'process'
|
||||||
|
const data = { status: null }
|
||||||
|
|
||||||
export const initialNodes: Node[] = [
|
export const initialNodes: ProcessNode[] = [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
position,
|
position: initialPos,
|
||||||
type,
|
type,
|
||||||
|
data,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
position,
|
position: initialPos,
|
||||||
type,
|
type,
|
||||||
|
data,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2a',
|
id: '2a',
|
||||||
position,
|
position: initialPos,
|
||||||
type,
|
type,
|
||||||
|
data,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2b',
|
id: '2b',
|
||||||
position,
|
position: initialPos,
|
||||||
type,
|
type,
|
||||||
|
data,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2c',
|
id: '2c',
|
||||||
position,
|
position: initialPos,
|
||||||
type,
|
type,
|
||||||
|
data,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2d',
|
id: '2d',
|
||||||
position,
|
position: initialPos,
|
||||||
type,
|
type,
|
||||||
|
data,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '3',
|
id: '3',
|
||||||
position,
|
position: initialPos,
|
||||||
type,
|
type,
|
||||||
|
data,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '4',
|
id: '4',
|
||||||
position,
|
position: initialPos,
|
||||||
type,
|
type,
|
||||||
|
data,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '5',
|
id: '5',
|
||||||
position,
|
position: initialPos,
|
||||||
type,
|
type,
|
||||||
|
data,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '6',
|
id: '6',
|
||||||
position,
|
position: initialPos,
|
||||||
type,
|
type,
|
||||||
|
data,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '7',
|
id: '7',
|
||||||
position,
|
position: initialPos,
|
||||||
type,
|
type,
|
||||||
|
data,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const initialEdges: Edge[] = [
|
export const initialEdges: ProcessEdge[] = [
|
||||||
{ id: 'e1-2', source: '1', target: '2', type: 'animation', animated: true },
|
{ id: 'e1-2', source: '1', target: '2', type, animated: true },
|
||||||
{ id: 'e1-3', source: '1', target: '3', type: 'animation', animated: true },
|
{ id: 'e1-3', source: '1', target: '3', type, animated: true },
|
||||||
{ id: 'e2-2a', source: '2', target: '2a', type: 'animation', animated: true },
|
{ id: 'e2-2a', source: '2', target: '2a', type, animated: true },
|
||||||
{ id: 'e2-2b', source: '2', target: '2b', type: 'animation', animated: true },
|
{ id: 'e2-2b', source: '2', target: '2b', type, animated: true },
|
||||||
{ id: 'e2-2c', source: '2', target: '2c', type: 'animation', animated: true },
|
{ id: 'e2-2c', source: '2', target: '2c', type, animated: true },
|
||||||
{ id: 'e2c-2d', source: '2c', target: '2d', type: 'animation', animated: true },
|
{ id: 'e2c-2d', source: '2c', target: '2d', type, animated: true },
|
||||||
{ id: 'e3-7', source: '3', target: '4', type: 'animation', animated: true },
|
{ id: 'e3-7', source: '3', target: '4', type, animated: true },
|
||||||
{ id: 'e4-5', source: '4', target: '5', type: 'animation', animated: true },
|
{ id: 'e4-5', source: '4', target: '5', type, animated: true },
|
||||||
{ id: 'e5-6', source: '5', target: '6', type: 'animation', animated: true },
|
{ id: 'e5-6', source: '5', target: '6', type, animated: true },
|
||||||
{ id: 'e5-7', source: '5', target: '7', type: 'animation', animated: true },
|
{ id: 'e5-7', source: '5', target: '7', type, animated: true },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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'>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export interface ProcessNodeData {
|
|
||||||
hasError: boolean
|
|
||||||
isSkipped: boolean
|
|
||||||
isCancelled: boolean
|
|
||||||
isFinished: boolean
|
|
||||||
isRunning: boolean
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
Generated
+12
-6
@@ -95,13 +95,13 @@ importers:
|
|||||||
version: 6.0.3(typedoc@0.26.11(typescript@5.4.5))
|
version: 6.0.3(typedoc@0.26.11(typescript@5.4.5))
|
||||||
unplugin-auto-import:
|
unplugin-auto-import:
|
||||||
specifier: ^0.18.3
|
specifier: ^0.18.3
|
||||||
version: 0.18.3(@nuxt/kit@3.13.2(rollup@4.21.0)(webpack-sources@3.2.3))(@vueuse/core@11.2.0(vue@3.5.12(typescript@5.4.5)))(rollup@4.21.0)(webpack-sources@3.2.3)
|
version: 0.18.3(@nuxt/kit@3.13.2(magicast@0.3.5)(rollup@4.21.0)(webpack-sources@3.2.3))(@vueuse/core@11.2.0(vue@3.5.12(typescript@5.4.5)))(rollup@4.21.0)(webpack-sources@3.2.3)
|
||||||
unplugin-icons:
|
unplugin-icons:
|
||||||
specifier: ^0.20.0
|
specifier: ^0.20.0
|
||||||
version: 0.20.0(@vue/compiler-sfc@3.5.12)(vue-template-compiler@2.7.14)(webpack-sources@3.2.3)
|
version: 0.20.0(@vue/compiler-sfc@3.5.12)(vue-template-compiler@2.7.14)(webpack-sources@3.2.3)
|
||||||
unplugin-vue-components:
|
unplugin-vue-components:
|
||||||
specifier: ^0.27.4
|
specifier: ^0.27.4
|
||||||
version: 0.27.4(@babel/parser@7.25.7)(@nuxt/kit@3.13.2(rollup@4.21.0)(webpack-sources@3.2.3))(rollup@4.21.0)(vue@3.5.12(typescript@5.4.5))
|
version: 0.27.4(@babel/parser@7.25.7)(@nuxt/kit@3.13.2(magicast@0.3.5)(rollup@4.21.0)(webpack-sources@3.2.3))(rollup@4.21.0)(vue@3.5.12(typescript@5.4.5))
|
||||||
vite-plugin-windicss:
|
vite-plugin-windicss:
|
||||||
specifier: ^1.9.3
|
specifier: ^1.9.3
|
||||||
version: 1.9.3(vite@5.4.10(@types/node@20.14.2)(sass@1.79.4)(terser@5.21.0))
|
version: 1.9.3(vite@5.4.10(@types/node@20.14.2)(sass@1.79.4)(terser@5.21.0))
|
||||||
@@ -221,7 +221,7 @@ importers:
|
|||||||
version: 5.1.4(vite@5.4.8(@types/node@20.14.2)(sass@1.79.4)(terser@5.21.0))(vue@3.5.11(typescript@5.4.5))
|
version: 5.1.4(vite@5.4.8(@types/node@20.14.2)(sass@1.79.4)(terser@5.21.0))(vue@3.5.11(typescript@5.4.5))
|
||||||
unplugin-auto-import:
|
unplugin-auto-import:
|
||||||
specifier: ^0.18.3
|
specifier: ^0.18.3
|
||||||
version: 0.18.3(@nuxt/kit@3.13.2(rollup@4.21.0)(webpack-sources@3.2.3))(@vueuse/core@11.2.0(vue@3.5.11(typescript@5.4.5)))(rollup@4.21.0)(webpack-sources@3.2.3)
|
version: 0.18.3(@nuxt/kit@3.13.2(magicast@0.3.5)(rollup@4.21.0)(webpack-sources@3.2.3))(@vueuse/core@11.2.0(vue@3.5.11(typescript@5.4.5)))(rollup@4.21.0)(webpack-sources@3.2.3)
|
||||||
vite:
|
vite:
|
||||||
specifier: ^5.4.8
|
specifier: ^5.4.8
|
||||||
version: 5.4.8(@types/node@20.14.2)(sass@1.79.4)(terser@5.21.0)
|
version: 5.4.8(@types/node@20.14.2)(sass@1.79.4)(terser@5.21.0)
|
||||||
@@ -1667,6 +1667,7 @@ packages:
|
|||||||
'@humanwhocodes/config-array@0.11.11':
|
'@humanwhocodes/config-array@0.11.11':
|
||||||
resolution: {integrity: sha512-N2brEuAadi0CcdeMXUkhbZB84eskAc8MEX1By6qEchoVywSgXPIjou4rYsl0V3Hj0ZnuGycGCjdNgockbzeWNA==}
|
resolution: {integrity: sha512-N2brEuAadi0CcdeMXUkhbZB84eskAc8MEX1By6qEchoVywSgXPIjou4rYsl0V3Hj0ZnuGycGCjdNgockbzeWNA==}
|
||||||
engines: {node: '>=10.10.0'}
|
engines: {node: '>=10.10.0'}
|
||||||
|
deprecated: Use @eslint/config-array instead
|
||||||
|
|
||||||
'@humanwhocodes/module-importer@1.0.1':
|
'@humanwhocodes/module-importer@1.0.1':
|
||||||
resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
|
resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
|
||||||
@@ -1674,6 +1675,7 @@ packages:
|
|||||||
|
|
||||||
'@humanwhocodes/object-schema@1.2.1':
|
'@humanwhocodes/object-schema@1.2.1':
|
||||||
resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==}
|
resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==}
|
||||||
|
deprecated: Use @eslint/object-schema instead
|
||||||
|
|
||||||
'@iconify-json/simple-icons@1.2.11':
|
'@iconify-json/simple-icons@1.2.11':
|
||||||
resolution: {integrity: sha512-AHCGDtBRqP+JzAbBzgO8uN/08CXxEmuaC6lQQZ3b5burKhRU12AJnJczwbUw2K5Mb/U85EpSUNhYMG3F28b8NA==}
|
resolution: {integrity: sha512-AHCGDtBRqP+JzAbBzgO8uN/08CXxEmuaC6lQQZ3b5burKhRU12AJnJczwbUw2K5Mb/U85EpSUNhYMG3F28b8NA==}
|
||||||
@@ -4184,6 +4186,7 @@ packages:
|
|||||||
eslint@8.51.0:
|
eslint@8.51.0:
|
||||||
resolution: {integrity: sha512-2WuxRZBrlwnXi+/vFSJyjMqrNjtJqiasMzehF0shoLaW7DzS3/9Yvrmq5JiT66+pNjiX4UBnLDiKHcWAr/OInA==}
|
resolution: {integrity: sha512-2WuxRZBrlwnXi+/vFSJyjMqrNjtJqiasMzehF0shoLaW7DzS3/9Yvrmq5JiT66+pNjiX4UBnLDiKHcWAr/OInA==}
|
||||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||||
|
deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
espree@9.6.1:
|
espree@9.6.1:
|
||||||
@@ -4518,6 +4521,7 @@ packages:
|
|||||||
|
|
||||||
glob@7.2.3:
|
glob@7.2.3:
|
||||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||||
|
deprecated: Glob versions prior to v9 are no longer supported
|
||||||
|
|
||||||
glob@8.1.0:
|
glob@8.1.0:
|
||||||
resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==}
|
resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==}
|
||||||
@@ -4739,6 +4743,7 @@ packages:
|
|||||||
|
|
||||||
inflight@1.0.6:
|
inflight@1.0.6:
|
||||||
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
|
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
|
||||||
|
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
|
||||||
|
|
||||||
inherits@2.0.4:
|
inherits@2.0.4:
|
||||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||||
@@ -6300,6 +6305,7 @@ packages:
|
|||||||
|
|
||||||
rimraf@3.0.2:
|
rimraf@3.0.2:
|
||||||
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
|
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
|
||||||
|
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
rollup-plugin-visualizer@5.12.0:
|
rollup-plugin-visualizer@5.12.0:
|
||||||
@@ -15067,7 +15073,7 @@ snapshots:
|
|||||||
|
|
||||||
unpipe@1.0.0: {}
|
unpipe@1.0.0: {}
|
||||||
|
|
||||||
unplugin-auto-import@0.18.3(@nuxt/kit@3.13.2(rollup@4.21.0)(webpack-sources@3.2.3))(@vueuse/core@11.2.0(vue@3.5.11(typescript@5.4.5)))(rollup@4.21.0)(webpack-sources@3.2.3):
|
unplugin-auto-import@0.18.3(@nuxt/kit@3.13.2(magicast@0.3.5)(rollup@4.21.0)(webpack-sources@3.2.3))(@vueuse/core@11.2.0(vue@3.5.11(typescript@5.4.5)))(rollup@4.21.0)(webpack-sources@3.2.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@antfu/utils': 0.7.10
|
'@antfu/utils': 0.7.10
|
||||||
'@rollup/pluginutils': 5.1.0(rollup@4.21.0)
|
'@rollup/pluginutils': 5.1.0(rollup@4.21.0)
|
||||||
@@ -15084,7 +15090,7 @@ snapshots:
|
|||||||
- rollup
|
- rollup
|
||||||
- webpack-sources
|
- webpack-sources
|
||||||
|
|
||||||
unplugin-auto-import@0.18.3(@nuxt/kit@3.13.2(rollup@4.21.0)(webpack-sources@3.2.3))(@vueuse/core@11.2.0(vue@3.5.12(typescript@5.4.5)))(rollup@4.21.0)(webpack-sources@3.2.3):
|
unplugin-auto-import@0.18.3(@nuxt/kit@3.13.2(magicast@0.3.5)(rollup@4.21.0)(webpack-sources@3.2.3))(@vueuse/core@11.2.0(vue@3.5.12(typescript@5.4.5)))(rollup@4.21.0)(webpack-sources@3.2.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@antfu/utils': 0.7.10
|
'@antfu/utils': 0.7.10
|
||||||
'@rollup/pluginutils': 5.1.0(rollup@4.21.0)
|
'@rollup/pluginutils': 5.1.0(rollup@4.21.0)
|
||||||
@@ -15117,7 +15123,7 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- webpack-sources
|
- webpack-sources
|
||||||
|
|
||||||
unplugin-vue-components@0.27.4(@babel/parser@7.25.7)(@nuxt/kit@3.13.2(rollup@4.21.0)(webpack-sources@3.2.3))(rollup@4.21.0)(vue@3.5.12(typescript@5.4.5)):
|
unplugin-vue-components@0.27.4(@babel/parser@7.25.7)(@nuxt/kit@3.13.2(magicast@0.3.5)(rollup@4.21.0)(webpack-sources@3.2.3))(rollup@4.21.0)(vue@3.5.12(typescript@5.4.5)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@antfu/utils': 0.7.10
|
'@antfu/utils': 0.7.10
|
||||||
'@rollup/pluginutils': 5.1.0(rollup@4.21.0)
|
'@rollup/pluginutils': 5.1.0(rollup@4.21.0)
|
||||||
|
|||||||
Reference in New Issue
Block a user