Forráskód Böngészése

feat: 添加`vue-flow`流程图示例 (#1001)

* feat: 添加`vue-flow`流程图示例
xiaoming 1 éve
szülő
commit
f0a80c680e

+ 4 - 0
package.json

@@ -55,6 +55,8 @@
     "@pureadmin/descriptions": "^1.2.1",
     "@pureadmin/table": "^3.1.2",
     "@pureadmin/utils": "^2.4.7",
+    "@vue-flow/background": "^1.3.0",
+    "@vue-flow/core": "^1.33.4",
     "@vueuse/core": "^10.9.0",
     "@vueuse/motion": "^2.1.0",
     "@wangeditor/editor": "^5.1.23",
@@ -114,6 +116,7 @@
     "@iconify/vue": "^4.1.1",
     "@intlify/unplugin-vue-i18n": "^2.0.0",
     "@pureadmin/theme": "^3.2.0",
+    "@types/dagre": "^0.7.52",
     "@types/gradient-string": "^1.1.5",
     "@types/intro.js": "^5.1.5",
     "@types/js-cookie": "^3.0.6",
@@ -130,6 +133,7 @@
     "boxen": "^7.1.1",
     "cloc": "^2.11.0",
     "cssnano": "^6.1.0",
+    "dagre": "^0.8.5",
     "eslint": "^8.57.0",
     "eslint-config-prettier": "^9.1.0",
     "eslint-define-config": "^2.1.0",

+ 118 - 0
pnpm-lock.yaml

@@ -26,6 +26,12 @@ dependencies:
   '@pureadmin/utils':
     specifier: ^2.4.7
     version: 2.4.7(echarts@5.5.0)(vue@3.4.21)
+  '@vue-flow/background':
+    specifier: ^1.3.0
+    version: 1.3.0(@vue-flow/core@1.33.4)(vue@3.4.21)
+  '@vue-flow/core':
+    specifier: ^1.33.4
+    version: 1.33.4(vue@3.4.21)
   '@vueuse/core':
     specifier: ^10.9.0
     version: 10.9.0(vue@3.4.21)
@@ -199,6 +205,9 @@ devDependencies:
   '@pureadmin/theme':
     specifier: ^3.2.0
     version: 3.2.0
+  '@types/dagre':
+    specifier: ^0.7.52
+    version: 0.7.52
   '@types/gradient-string':
     specifier: ^1.1.5
     version: 1.1.5
@@ -247,6 +256,9 @@ devDependencies:
   cssnano:
     specifier: ^6.1.0
     version: 6.1.0(postcss@8.4.35)
+  dagre:
+    specifier: ^0.8.5
+    version: 0.8.5
   eslint:
     specifier: ^8.57.0
     version: 8.57.0
@@ -2003,6 +2015,10 @@ packages:
       '@types/node': 20.11.27
     dev: true
 
+  /@types/dagre@0.7.52:
+    resolution: {integrity: sha512-XKJdy+OClLk3hketHi9Qg6gTfe1F3y+UFnHxKA2rn9Dw+oXa4Gb378Ztz9HlMgZKSxpPmn4BNVh9wgkpvrK1uw==}
+    dev: true
+
   /@types/estree@1.0.5:
     resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
 
@@ -2352,6 +2368,30 @@ packages:
       path-browserify: 1.0.1
     dev: true
 
+  /@vue-flow/background@1.3.0(@vue-flow/core@1.33.4)(vue@3.4.21):
+    resolution: {integrity: sha512-fu/8s9wzSOQIitnSTI10XT3bzTtagh4h8EF2SWwtlDklOZjAaKy75lqv4htHa3wigy/r4LGCOGwLw3Pk88/AxA==}
+    peerDependencies:
+      '@vue-flow/core': ^1.23.0
+      vue: ^3.3.0
+    dependencies:
+      '@vue-flow/core': 1.33.4(vue@3.4.21)
+      vue: 3.4.21(typescript@5.4.2)
+    dev: false
+
+  /@vue-flow/core@1.33.4(vue@3.4.21):
+    resolution: {integrity: sha512-ryoamKfQ5pgtdv//Gjpyc4nsawMOwfI2jVzOPvZ92VQs78L4lidiWD7UybqeEkrGw6UPue1CGlzoy/4KlOWcSg==}
+    peerDependencies:
+      vue: ^3.3.0
+    dependencies:
+      '@vueuse/core': 10.9.0(vue@3.4.21)
+      d3-drag: 3.0.0
+      d3-selection: 3.0.0
+      d3-zoom: 3.0.0
+      vue: 3.4.21(typescript@5.4.2)
+    transitivePeerDependencies:
+      - '@vue/composition-api'
+    dev: false
+
   /@vue/babel-helper-vue-transform-on@1.2.1:
     resolution: {integrity: sha512-jtEXim+pfyHWwvheYwUwSXm43KwQo8nhOBDyjrUITV6X2tB7lJm6n/+4sqR8137UVZZul5hBzWHdZ2uStYpyRQ==}
     dev: true
@@ -3851,6 +3891,71 @@ packages:
   /csstype@3.1.3:
     resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
 
+  /d3-color@3.1.0:
+    resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
+    engines: {node: '>=12'}
+    dev: false
+
+  /d3-dispatch@3.0.1:
+    resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
+    engines: {node: '>=12'}
+    dev: false
+
+  /d3-drag@3.0.0:
+    resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
+    engines: {node: '>=12'}
+    dependencies:
+      d3-dispatch: 3.0.1
+      d3-selection: 3.0.0
+    dev: false
+
+  /d3-ease@3.0.1:
+    resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
+    engines: {node: '>=12'}
+    dev: false
+
+  /d3-interpolate@3.0.1:
+    resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
+    engines: {node: '>=12'}
+    dependencies:
+      d3-color: 3.1.0
+    dev: false
+
+  /d3-selection@3.0.0:
+    resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
+    engines: {node: '>=12'}
+    dev: false
+
+  /d3-timer@3.0.1:
+    resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
+    engines: {node: '>=12'}
+    dev: false
+
+  /d3-transition@3.0.1(d3-selection@3.0.0):
+    resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
+    engines: {node: '>=12'}
+    peerDependencies:
+      d3-selection: 2 - 3
+    dependencies:
+      d3-color: 3.1.0
+      d3-dispatch: 3.0.1
+      d3-ease: 3.0.1
+      d3-interpolate: 3.0.1
+      d3-selection: 3.0.0
+      d3-timer: 3.0.1
+    dev: false
+
+  /d3-zoom@3.0.0:
+    resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
+    engines: {node: '>=12'}
+    dependencies:
+      d3-dispatch: 3.0.1
+      d3-drag: 3.0.0
+      d3-interpolate: 3.0.1
+      d3-selection: 3.0.0
+      d3-transition: 3.0.1(d3-selection@3.0.0)
+    dev: false
+
   /d@1.0.2:
     resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==}
     engines: {node: '>=0.12'}
@@ -3859,6 +3964,13 @@ packages:
       type: 2.7.2
     dev: false
 
+  /dagre@0.8.5:
+    resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==}
+    dependencies:
+      graphlib: 2.1.8
+      lodash: 4.17.21
+    dev: true
+
   /danmu.js@1.1.13:
     resolution: {integrity: sha512-knFd0/cB2HA4FFWiA7eB2suc5vCvoHdqio33FyyCSfP7C+1A+zQcTvnvwfxaZhrxsGj4qaQI2I8XiTqedRaVmg==}
     dependencies:
@@ -4962,6 +5074,12 @@ packages:
     resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
     dev: true
 
+  /graphlib@2.1.8:
+    resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==}
+    dependencies:
+      lodash: 4.17.21
+    dev: true
+
   /has-flag@3.0.0:
     resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
     engines: {node: '>=4'}

+ 22 - 20
src/router/enums.ts

@@ -1,29 +1,31 @@
 // 完整版菜单比较多,将 rank 抽离出来,在此方便维护
 
 const home = 0, // 平台规定只有 home 路由的 rank 才能为 0 ,所以后端在返回 rank 的时候需要从非 0 开始
-  components = 1,
-  able = 2,
-  table = 3,
-  list = 4,
-  result = 5,
-  error = 6,
-  frame = 7,
-  nested = 8,
-  permission = 9,
-  system = 10,
-  monitor = 11,
-  tabs = 12,
-  about = 13,
-  editor = 14,
-  flowchart = 15,
-  formdesign = 16,
-  board = 17,
-  ppt = 18,
-  guide = 19,
-  menuoverflow = 20;
+  vueflow = 1,
+  components = 2,
+  able = 3,
+  table = 4,
+  list = 5,
+  result = 6,
+  error = 7,
+  frame = 8,
+  nested = 9,
+  permission = 10,
+  system = 11,
+  monitor = 12,
+  tabs = 13,
+  about = 14,
+  editor = 15,
+  flowchart = 16,
+  formdesign = 17,
+  board = 18,
+  ppt = 19,
+  guide = 20,
+  menuoverflow = 21;
 
 export {
   home,
+  vueflow,
   components,
   able,
   table,

+ 22 - 0
src/router/modules/vueflow.ts

@@ -0,0 +1,22 @@
+import { vueflow } from "@/router/enums";
+
+export default {
+  path: "/vue-flow",
+  redirect: "/vue-flow/index",
+  meta: {
+    icon: "ep:set-up",
+    title: "vue-flow",
+    rank: vueflow
+  },
+  children: [
+    {
+      path: "/vue-flow/index",
+      name: "VueFlow",
+      component: () => import("@/views/vue-flow/layouting/index.vue"),
+      meta: {
+        title: "vue-flow",
+        extraIcon: "IF-pure-iconfont-new svg"
+      }
+    }
+  ]
+} satisfies RouteConfigsTable;

+ 214 - 0
src/views/vue-flow/layouting/animationEdge.vue

@@ -0,0 +1,214 @@
+<script lang="ts" setup>
+import { computed, nextTick, ref, toRef, watch } from "vue";
+import { TransitionPresets, executeTransition } from "@vueuse/core";
+import {
+  Position,
+  BaseEdge,
+  useVueFlow,
+  useNodesData,
+  getSmoothStepPath,
+  EdgeLabelRenderer
+} from "@vue-flow/core";
+
+const props = defineProps({
+  id: {
+    type: String,
+    required: true
+  },
+  source: {
+    type: String,
+    required: true
+  },
+  target: {
+    type: String,
+    required: true
+  },
+  sourceX: {
+    type: Number,
+    required: true
+  },
+  sourceY: {
+    type: Number,
+    required: true
+  },
+  targetX: {
+    type: Number,
+    required: true
+  },
+  targetY: {
+    type: Number,
+    required: true
+  },
+  sourcePosition: {
+    type: String,
+    default: Position.Right
+  },
+  targetPosition: {
+    type: String,
+    default: Position.Left
+  }
+});
+
+const { findEdge } = useVueFlow();
+
+const nodesData = useNodesData([props.target, props.source]);
+
+const edgePoint = ref(0);
+
+const edgeRef = ref();
+
+const labelPosition = ref({ x: 0, y: 0 });
+
+const currentLength = ref(0);
+
+const targetNodeData = toRef(() => nodesData.value[0].data);
+
+const sourceNodeData = toRef(() => nodesData.value[1].data);
+
+const isFinished = toRef(() => sourceNodeData.value.isFinished);
+
+const isCancelled = toRef(() => targetNodeData.value.isCancelled);
+
+const isAnimating = ref(false);
+
+const edgeColor = toRef(() => {
+  if (targetNodeData.value.hasError) {
+    return "#f87171";
+  }
+
+  if (targetNodeData.value.isFinished) {
+    return "#42B983";
+  }
+
+  if (targetNodeData.value.isCancelled || targetNodeData.value.isSkipped) {
+    return "#fbbf24";
+  }
+
+  if (targetNodeData.value.isRunning || isAnimating.value) {
+    return "#2563eb";
+  }
+
+  return "#6b7280";
+});
+
+// @ts-expect-error
+const path = computed(() => getSmoothStepPath(props));
+
+watch(isCancelled, isCancelled => {
+  if (isCancelled) {
+    reset();
+  }
+});
+
+watch(isAnimating, isAnimating => {
+  const edge = findEdge(props.id);
+
+  if (edge) {
+    edge.data = {
+      ...edge.data,
+      isAnimating
+    };
+  }
+});
+
+watch(edgePoint, point => {
+  const pathEl = edgeRef.value?.pathEl;
+
+  if (!pathEl || point === 0 || !isAnimating.value) {
+    return;
+  }
+
+  const nextLength = pathEl.getTotalLength();
+
+  if (currentLength.value !== nextLength) {
+    runAnimation();
+    return;
+  }
+
+  labelPosition.value = pathEl.getPointAtLength(point);
+});
+
+watch(isFinished, isFinished => {
+  if (isFinished) {
+    runAnimation();
+  }
+});
+
+async function runAnimation() {
+  const pathEl = edgeRef.value?.pathEl;
+
+  if (!pathEl) {
+    return;
+  }
+
+  const totalLength = pathEl.getTotalLength();
+
+  const from = edgePoint.value || 0;
+
+  labelPosition.value = pathEl.getPointAtLength(from);
+
+  isAnimating.value = true;
+
+  if (currentLength.value !== totalLength) {
+    currentLength.value = totalLength;
+  }
+
+  await executeTransition(edgePoint, from, totalLength, {
+    transition: TransitionPresets.easeInOutCubic,
+    duration: Math.max(1500, totalLength / 2),
+    abort: () => !isAnimating.value
+  });
+
+  reset();
+}
+
+function reset() {
+  nextTick(() => {
+    edgePoint.value = 0;
+    currentLength.value = 0;
+    labelPosition.value = { x: 0, y: 0 };
+    isAnimating.value = false;
+  });
+}
+</script>
+
+<template>
+  <BaseEdge
+    :id="id"
+    ref="edgeRef"
+    :path="path[0]"
+    :style="{ stroke: edgeColor }"
+  />
+
+  <EdgeLabelRenderer v-if="isAnimating">
+    <div
+      :style="{
+        transform: `translate(-50%, -50%) translate(${labelPosition.x}px,${labelPosition.y}px)`
+      }"
+      class="nodrag nopan animated-edge-label"
+    >
+      <span class="truck">
+        <span class="box">📦</span>
+        🚚
+      </span>
+    </div>
+  </EdgeLabelRenderer>
+</template>
+
+<style scoped>
+.animated-edge-label {
+  position: absolute;
+  z-index: 100;
+}
+
+.truck {
+  position: relative;
+  display: inline-block;
+  transform: scaleX(-1);
+}
+
+.box {
+  position: absolute;
+  top: -10px;
+}
+</style>

+ 85 - 0
src/views/vue-flow/layouting/icon.vue

@@ -0,0 +1,85 @@
+<script lang="ts" setup>
+defineProps({
+  name: {
+    type: String,
+    required: true
+  }
+});
+</script>
+
+<template>
+  <svg
+    v-if="name === 'play'"
+    viewBox="0 0 24 24"
+    height="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"
+    height="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"
+    height="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"
+    height="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>
+
+  <svg
+    v-else-if="name === 'shuffle'"
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 24 24"
+    height="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>

+ 214 - 0
src/views/vue-flow/layouting/index.vue

@@ -0,0 +1,214 @@
+<script lang="ts" setup>
+import "@vue-flow/core/dist/style.css";
+import "@vue-flow/core/dist/theme-default.css";
+import Icon from "./icon.vue";
+import { nextTick, ref } from "vue";
+import { useLayout } from "./useLayout";
+import { useShuffle } from "./useShuffle";
+import ProcessNode from "./processNode.vue";
+import { useRunProcess } from "./useRunProcess";
+import AnimationEdge from "./animationEdge.vue";
+import { Background } from "@vue-flow/background";
+import { Panel, VueFlow, useVueFlow } from "@vue-flow/core";
+import { initialEdges, initialNodes } from "./initialElements";
+
+const nodes = ref(initialNodes);
+
+const edges = ref(initialEdges);
+
+const cancelOnError = ref(true);
+
+const shuffle = useShuffle();
+
+const { graph, layout, previousDirection } = useLayout();
+
+// @ts-expect-error
+const { run, stop, reset, isRunning } = useRunProcess({ graph, cancelOnError });
+
+const { fitView } = useVueFlow();
+
+async function shuffleGraph() {
+  await stop();
+
+  reset(nodes.value);
+
+  edges.value = shuffle(nodes.value);
+
+  nextTick(() => {
+    layoutGraph(previousDirection.value);
+  });
+}
+
+async function layoutGraph(direction) {
+  await stop();
+
+  reset(nodes.value);
+
+  nodes.value = layout(nodes.value, edges.value, direction);
+
+  nextTick(() => {
+    fitView();
+    run(nodes.value);
+  });
+}
+</script>
+
+<template>
+  <div class="layout-flow">
+    <VueFlow
+      :nodes="nodes"
+      :edges="edges"
+      @nodes-initialized="layoutGraph('LR')"
+    >
+      <template #node-process="props">
+        <ProcessNode
+          :data="props.data"
+          :source-position="props.sourcePosition"
+          :target-position="props.targetPosition"
+        />
+      </template>
+
+      <template #edge-animation="edgeProps">
+        <AnimationEdge
+          :id="edgeProps.id"
+          :source="edgeProps.source"
+          :target="edgeProps.target"
+          :source-x="edgeProps.sourceX"
+          :source-y="edgeProps.sourceY"
+          :targetX="edgeProps.targetX"
+          :targetY="edgeProps.targetY"
+          :source-position="edgeProps.sourcePosition"
+          :target-position="edgeProps.targetPosition"
+        />
+      </template>
+
+      <Background />
+
+      <Panel class="process-panel" position="top-left">
+        <div class="layout-panel">
+          <button v-if="isRunning" class="stop-btn" title="stop" @click="stop">
+            <Icon name="stop" />
+            <span class="spinner" />
+          </button>
+          <button v-else title="start" @click="run(nodes)">
+            <Icon name="play" />
+          </button>
+
+          <button title="set horizontal layout" @click="layoutGraph('LR')">
+            <Icon name="horizontal" />
+          </button>
+
+          <button title="set vertical layout" @click="layoutGraph('TB')">
+            <Icon name="vertical" />
+          </button>
+
+          <button title="shuffle graph" @click="shuffleGraph">
+            <Icon name="shuffle" />
+          </button>
+        </div>
+
+        <!-- <div class="checkbox-panel">
+          <label>Cancel on error</label>
+          <input v-model="cancelOnError" type="checkbox" />
+        </div> -->
+      </Panel>
+    </VueFlow>
+  </div>
+</template>
+
+<style scoped>
+
+
+@keyframes spin {
+  0% {
+    transform: rotate(0deg);
+  }
+
+  100% {
+    transform: rotate(360deg);
+  }
+}
+
+*,
+::before,
+::after {
+  box-sizing: content-box;
+}
+
+.main-content {
+  margin: 0 !important;
+}
+
+.layout-flow {
+  width: 100%;
+  height: 100%;
+}
+
+.process-panel,
+.layout-panel {
+  display: flex;
+  gap: 10px;
+}
+
+.process-panel {
+  display: flex;
+  flex-direction: column;
+  padding: 10px;
+  background-color: #2d3748;
+  border-radius: 8px;
+  box-shadow: 0 0 10px rgb(0 0 0 / 50%);
+}
+
+.process-panel button {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 40px;
+  height: 40px;
+  font-size: 16px;
+  color: white;
+  cursor: pointer;
+  background-color: #4a5568;
+  border: none;
+  border-radius: 8px;
+  box-shadow: 0 0 10px rgb(0 0 0 / 50%);
+}
+
+/* .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 {
+  font-size: 12px;
+  color: white;
+}
+
+.stop-btn svg {
+  display: none;
+}
+
+.stop-btn:hover svg {
+  display: block;
+}
+
+.stop-btn:hover .spinner {
+  display: none;
+}
+
+.spinner {
+  width: 20px;
+  height: 20px;
+  border: 3px solid #f3f3f3;
+  border-top: 3px solid #2563eb;
+  border-radius: 50%;
+  animation: spin 1s linear infinite;
+}
+</style>

+ 76 - 0
src/views/vue-flow/layouting/initialElements.ts

@@ -0,0 +1,76 @@
+import type { Edge, Node } from "@vue-flow/core";
+
+const position = { x: 0, y: 0 };
+const nodeType = "process";
+const edgeType = "animation";
+
+export const initialNodes: Node[] = [
+  {
+    id: "1",
+    position,
+    type: nodeType
+  },
+  {
+    id: "2",
+    position,
+    type: nodeType
+  },
+  {
+    id: "2a",
+    position,
+    type: nodeType
+  },
+  {
+    id: "2b",
+    position,
+    type: nodeType
+  },
+  {
+    id: "2c",
+    position,
+    type: nodeType
+  },
+  {
+    id: "2d",
+    position,
+    type: nodeType
+  },
+  {
+    id: "3",
+    position,
+    type: nodeType
+  },
+  {
+    id: "4",
+    position,
+    type: nodeType
+  },
+  {
+    id: "5",
+    position,
+    type: nodeType
+  },
+  {
+    id: "6",
+    position,
+    type: nodeType
+  },
+  {
+    id: "7",
+    position,
+    type: nodeType
+  }
+];
+
+export const initialEdges: Edge[] = [
+  { id: "e1-2", source: "1", target: "2", type: edgeType, animated: true },
+  { id: "e1-3", source: "1", target: "3", type: edgeType, animated: true },
+  { id: "e2-2a", source: "2", target: "2a", type: edgeType, animated: true },
+  { id: "e2-2b", source: "2", target: "2b", type: edgeType, animated: true },
+  { id: "e2-2c", source: "2", target: "2c", type: edgeType, animated: true },
+  { id: "e2c-2d", source: "2c", target: "2d", type: edgeType, animated: true },
+  { id: "e3-7", source: "3", target: "4", type: edgeType, animated: true },
+  { id: "e4-5", source: "4", target: "5", type: edgeType, animated: true },
+  { id: "e5-6", source: "5", target: "6", type: edgeType, animated: true },
+  { id: "e5-7", source: "5", target: "7", type: edgeType, animated: true }
+];

+ 145 - 0
src/views/vue-flow/layouting/processNode.vue

@@ -0,0 +1,145 @@
+<script lang="ts" setup>
+import { toRef } from "vue";
+import { Handle, useHandleConnections } from "@vue-flow/core";
+
+const props = defineProps({
+  data: {
+    type: Object,
+    required: true
+  },
+  sourcePosition: {
+    type: String
+  },
+  targetPosition: {
+    type: String
+  }
+});
+
+const sourceConnections = useHandleConnections({
+  type: "target"
+});
+
+const targetConnections = useHandleConnections({
+  type: "source"
+});
+
+const isSender = toRef(() => sourceConnections.value.length <= 0);
+
+const isReceiver = toRef(() => targetConnections.value.length <= 0);
+
+const bgColor = toRef(() => {
+  if (isSender.value) {
+    return "#2563eb";
+  }
+
+  if (props.data.hasError) {
+    return "#f87171";
+  }
+
+  if (props.data.isFinished) {
+    return "#42B983";
+  }
+
+  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,
+      boxShadow: data.isRunning ? '0 0 10px rgba(0, 0, 0, 0.5)' : ''
+    }"
+  >
+    <Handle v-if="!isSender" type="target" :position="targetPosition as any">
+      <span
+        v-if="
+          !data.isRunning &&
+          !data.isFinished &&
+          !data.isCancelled &&
+          !data.isSkipped &&
+          !data.hasError
+        "
+        >📥
+      </span>
+    </Handle>
+    <Handle
+      v-if="!isReceiver"
+      type="source"
+      :position="sourcePosition as any"
+    />
+
+    <div v-if="!isSender && data.isRunning" class="spinner" />
+    <span v-else>
+      {{ processLabel }}
+    </span>
+  </div>
+</template>
+
+<style scoped>
+@keyframes spin {
+  0% {
+    transform: rotate(0deg);
+  }
+
+  100% {
+    transform: rotate(360deg);
+  }
+}
+
+.process-node {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 24px;
+  height: 24px;
+  padding: 10px;
+  border-radius: 99px;
+}
+
+.process-node .vue-flow__handle {
+  width: unset;
+  height: unset;
+  font-size: 12px;
+  background: transparent;
+  border: none;
+}
+
+.spinner {
+  width: 20px;
+  height: 20px;
+  border: 1px solid #f3f3f3;
+  border-top: 1px solid #2563eb;
+  border-radius: 50%;
+  animation: spin 1s linear infinite;
+}
+</style>

+ 52 - 0
src/views/vue-flow/layouting/useLayout.ts

@@ -0,0 +1,52 @@
+import dagre from "dagre";
+import { ref } from "vue";
+import { Position, useVueFlow } from "@vue-flow/core";
+
+export function useLayout() {
+  const { findNode } = useVueFlow();
+
+  const graph = ref(new dagre.graphlib.Graph());
+
+  const previousDirection = ref("LR");
+
+  function layout(nodes, edges, direction) {
+    const dagreGraph = new dagre.graphlib.Graph();
+
+    graph.value = dagreGraph;
+
+    dagreGraph.setDefaultEdgeLabel(() => ({}));
+
+    const isHorizontal = direction === "LR";
+    dagreGraph.setGraph({ rankdir: direction });
+
+    previousDirection.value = direction;
+
+    for (const node of nodes) {
+      const graphNode = findNode(node.id);
+
+      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);
+
+    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, previousDirection };
+}

+ 181 - 0
src/views/vue-flow/layouting/useRunProcess.ts

@@ -0,0 +1,181 @@
+import { ref, toRef, toValue } from "vue";
+import { useVueFlow } from "@vue-flow/core";
+
+export function useRunProcess({ graph: dagreGraph, cancelOnError = true }) {
+  const { updateNodeData, getConnectedEdges } = useVueFlow();
+
+  const graph = toRef(() => toValue(dagreGraph));
+
+  const isRunning = ref(false);
+
+  const executedNodes = new Set();
+
+  const runningTasks = new Map();
+
+  const upcomingTasks = new Set();
+
+  async function runNode(node, isStart = false) {
+    if (executedNodes.has(node.id)) {
+      return;
+    }
+
+    upcomingTasks.add(node.id);
+
+    const incomers = getConnectedEdges(node.id).filter(
+      connection => connection.target === node.id
+    );
+
+    await Promise.all(
+      incomers.map(incomer => until(() => !incomer.data.isAnimating))
+    );
+
+    upcomingTasks.clear();
+
+    if (!isRunning.value) {
+      return;
+    }
+
+    executedNodes.add(node.id);
+
+    updateNodeData(node.id, {
+      isRunning: true,
+      isFinished: false,
+      hasError: false,
+      isCancelled: false
+    });
+
+    const delay = Math.floor(Math.random() * 2000) + 1000;
+
+    return new Promise(resolve => {
+      const timeout = setTimeout(
+        async () => {
+          const children = graph.value.successors(node.id);
+
+          const willThrowError = Math.random() < 0.15;
+
+          if (!isStart && willThrowError) {
+            updateNodeData(node.id, { isRunning: false, hasError: true });
+
+            if (toValue(cancelOnError)) {
+              await skipDescendants(node.id);
+              runningTasks.delete(node.id);
+
+              // @ts-expect-error
+              resolve();
+              return;
+            }
+          }
+
+          updateNodeData(node.id, { isRunning: false, isFinished: true });
+
+          runningTasks.delete(node.id);
+
+          if (children.length > 0) {
+            await Promise.all(children.map(id => runNode({ id })));
+          }
+
+          // @ts-expect-error
+          resolve();
+        },
+        isStart ? 0 : delay
+      );
+
+      runningTasks.set(node.id, timeout);
+    });
+  }
+
+  async function run(nodes) {
+    if (isRunning.value) {
+      return;
+    }
+
+    reset(nodes);
+
+    isRunning.value = true;
+
+    const startingNodes = nodes.filter(
+      node => graph.value.predecessors(node.id)?.length === 0
+    );
+
+    await Promise.all(startingNodes.map(node => runNode(node, true)));
+
+    clear();
+  }
+
+  function reset(nodes) {
+    clear();
+
+    for (const node of nodes) {
+      updateNodeData(node.id, {
+        isRunning: false,
+        isFinished: false,
+        hasError: false,
+        isSkipped: false,
+        isCancelled: false
+      });
+    }
+  }
+
+  async function skipDescendants(nodeId) {
+    const children = graph.value.successors(nodeId);
+
+    for (const child of children) {
+      updateNodeData(child, { isRunning: false, isSkipped: true });
+      await skipDescendants(child);
+    }
+  }
+
+  async function stop() {
+    isRunning.value = false;
+
+    for (const nodeId of upcomingTasks) {
+      clearTimeout(runningTasks.get(nodeId));
+      runningTasks.delete(nodeId);
+      // @ts-expect-error
+      updateNodeData(nodeId, {
+        isRunning: false,
+        isFinished: false,
+        hasError: false,
+        isSkipped: false,
+        isCancelled: true
+      });
+      await skipDescendants(nodeId);
+    }
+
+    for (const [nodeId, task] of runningTasks) {
+      clearTimeout(task);
+      runningTasks.delete(nodeId);
+      updateNodeData(nodeId, {
+        isRunning: false,
+        isFinished: false,
+        hasError: false,
+        isSkipped: false,
+        isCancelled: true
+      });
+      await skipDescendants(nodeId);
+    }
+
+    executedNodes.clear();
+    upcomingTasks.clear();
+  }
+
+  function clear() {
+    isRunning.value = false;
+    executedNodes.clear();
+    runningTasks.clear();
+  }
+
+  return { run, stop, reset, isRunning };
+}
+
+async function until(condition) {
+  return new Promise(resolve => {
+    const interval = setInterval(() => {
+      if (condition()) {
+        clearInterval(interval);
+        // @ts-expect-error
+        resolve();
+      }
+    }, 100);
+  });
+}

+ 50 - 0
src/views/vue-flow/layouting/useShuffle.ts

@@ -0,0 +1,50 @@
+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]];
+  }
+}
+
+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;
+}
+
+export function useShuffle() {
+  return nodes => {
+    const possibleEdges = generatePossibleEdges(nodes);
+    shuffleArray(possibleEdges);
+
+    const usedNodes = new Set();
+    const newEdges = [];
+
+    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;
+  };
+}