w-flow-vue 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/.editorconfig +9 -0
  2. package/.eslintignore +3 -0
  3. package/.eslintrc.js +55 -0
  4. package/.jsdoc +25 -0
  5. package/AGENT.md +223 -0
  6. package/LICENSE +21 -0
  7. package/README.md +37 -0
  8. package/SECURITY.md +5 -0
  9. package/babel.config.js +16 -0
  10. package/dist/w-flow-vue.umd.js +15 -0
  11. package/dist/w-flow-vue.umd.js.map +1 -0
  12. package/docs/components_WFlowVue.vue.html +1214 -0
  13. package/docs/examples/app.html +62 -0
  14. package/docs/examples/app.umd.js +20 -0
  15. package/docs/examples/app.umd.js.map +1 -0
  16. package/docs/examples/ex-AppBasic.html +440 -0
  17. package/docs/examples/ex-AppConnectivity.html +131 -0
  18. package/docs/fonts/Montserrat/Montserrat-Bold.eot +0 -0
  19. package/docs/fonts/Montserrat/Montserrat-Bold.ttf +0 -0
  20. package/docs/fonts/Montserrat/Montserrat-Bold.woff +0 -0
  21. package/docs/fonts/Montserrat/Montserrat-Bold.woff2 +0 -0
  22. package/docs/fonts/Montserrat/Montserrat-Regular.eot +0 -0
  23. package/docs/fonts/Montserrat/Montserrat-Regular.ttf +0 -0
  24. package/docs/fonts/Montserrat/Montserrat-Regular.woff +0 -0
  25. package/docs/fonts/Montserrat/Montserrat-Regular.woff2 +0 -0
  26. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.eot +0 -0
  27. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.svg +978 -0
  28. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.ttf +0 -0
  29. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff +0 -0
  30. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff2 +0 -0
  31. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.eot +0 -0
  32. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.svg +1049 -0
  33. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.ttf +0 -0
  34. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff +0 -0
  35. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff2 +0 -0
  36. package/docs/global.html +1919 -0
  37. package/docs/index.html +84 -0
  38. package/docs/js_defaults.mjs.html +105 -0
  39. package/docs/js_edge-path.mjs.html +237 -0
  40. package/docs/js_geometry.mjs.html +298 -0
  41. package/docs/js_graph.mjs.html +103 -0
  42. package/docs/js_step-routing.mjs.html +346 -0
  43. package/docs/module-WFlowVue.html +2790 -0
  44. package/docs/scripts/collapse.js +39 -0
  45. package/docs/scripts/commonNav.js +28 -0
  46. package/docs/scripts/linenumber.js +25 -0
  47. package/docs/scripts/nav.js +12 -0
  48. package/docs/scripts/polyfill.js +4 -0
  49. package/docs/scripts/prettify/Apache-License-2.0.txt +202 -0
  50. package/docs/scripts/prettify/lang-css.js +2 -0
  51. package/docs/scripts/prettify/prettify.js +28 -0
  52. package/docs/scripts/search.js +99 -0
  53. package/docs/styles/jsdoc.css +776 -0
  54. package/docs/styles/prettify.css +80 -0
  55. package/jest.config.js +20 -0
  56. package/package.json +80 -0
  57. package/public/index.html +38 -0
  58. package/script.txt +22 -0
  59. package/src/App.vue +326 -0
  60. package/src/AppBasic.vue +125 -0
  61. package/src/AppConnectivity.vue +186 -0
  62. package/src/components/WFlowVue.vue +1142 -0
  63. package/src/components/canvas/BackgroundLayer.vue +78 -0
  64. package/src/components/canvas/FlowCanvas.vue +64 -0
  65. package/src/components/canvas/SelectionBox.vue +36 -0
  66. package/src/components/canvas/ViewportTransform.vue +35 -0
  67. package/src/components/edges/ConnectionLine.vue +65 -0
  68. package/src/components/edges/EdgeMarkerDefs.vue +76 -0
  69. package/src/components/edges/EdgeRenderer.vue +120 -0
  70. package/src/components/edges/EdgeWrapper.vue +379 -0
  71. package/src/components/nodes/DefaultNode.vue +276 -0
  72. package/src/components/nodes/Handle.vue +101 -0
  73. package/src/components/nodes/InputNode.vue +47 -0
  74. package/src/components/nodes/NodeBody.vue +103 -0
  75. package/src/components/nodes/NodeFace.vue +128 -0
  76. package/src/components/nodes/NodeRenderer.vue +95 -0
  77. package/src/components/nodes/NodeWrapper.vue +475 -0
  78. package/src/components/nodes/OutputNode.vue +47 -0
  79. package/src/components/ui/ConnSettingsForm.vue +158 -0
  80. package/src/components/ui/Controls.vue +83 -0
  81. package/src/components/ui/NodeSettingsForm.vue +185 -0
  82. package/src/js/defaults.mjs +33 -0
  83. package/src/js/edge-path.mjs +165 -0
  84. package/src/js/geometry.mjs +226 -0
  85. package/src/js/graph.mjs +31 -0
  86. package/src/js/step-routing.mjs +274 -0
  87. package/src/main.js +22 -0
  88. package/test/WFlowVue-features.test.mjs +760 -0
  89. package/test/WFlowVue.test.mjs +421 -0
  90. package/test/components-canvas.test.mjs +102 -0
  91. package/test/components-edge.test.mjs +147 -0
  92. package/test/components-node.test.mjs +174 -0
  93. package/test/components-ui.test.mjs +69 -0
  94. package/test/defaults.test.mjs +86 -0
  95. package/test/edge-path.test.mjs +102 -0
  96. package/test/generate-routing-snapshots.mjs +77 -0
  97. package/test/generate-visual-baselines.mjs +206 -0
  98. package/test/geometry.test.mjs +236 -0
  99. package/test/graph.test.mjs +72 -0
  100. package/test/jsons/routing-snapshots.json +24994 -0
  101. package/test/pics/_check2.png +0 -0
  102. package/test/pics/_check3.png +0 -0
  103. package/test/pics/_check4.png +0 -0
  104. package/test/pics/_check5.png +0 -0
  105. package/test/pics/_v1.png +0 -0
  106. package/test/pics/_v2.png +0 -0
  107. package/test/pics/_v3.png +0 -0
  108. package/test/pics/_v4.png +0 -0
  109. package/test/pics/_v5.png +0 -0
  110. package/test/pics/_v6.png +0 -0
  111. package/test/pics/_v7.png +0 -0
  112. package/test/pics/vb-edge-hovered.png +0 -0
  113. package/test/pics/vb-edges-normal.png +0 -0
  114. package/test/pics/vb-locked-edge-hovered.png +0 -0
  115. package/test/pics/vb-locked-node-hovered.png +0 -0
  116. package/test/pics/vb-locked-node-selected.png +0 -0
  117. package/test/pics/vb-locked-overview.png +0 -0
  118. package/test/pics/vb-node-1.png +0 -0
  119. package/test/pics/vb-node-10.png +0 -0
  120. package/test/pics/vb-node-11.png +0 -0
  121. package/test/pics/vb-node-12.png +0 -0
  122. package/test/pics/vb-node-2.png +0 -0
  123. package/test/pics/vb-node-3.png +0 -0
  124. package/test/pics/vb-node-4.png +0 -0
  125. package/test/pics/vb-node-5.png +0 -0
  126. package/test/pics/vb-node-6.png +0 -0
  127. package/test/pics/vb-node-7.png +0 -0
  128. package/test/pics/vb-node-8.png +0 -0
  129. package/test/pics/vb-node-9.png +0 -0
  130. package/test/pics/vb-node-hovered.png +0 -0
  131. package/test/pics/vb-node-selected.png +0 -0
  132. package/test/pics/vb-overview.png +0 -0
  133. package/test/step-routing-connectivity.test.mjs +78 -0
  134. package/test/step-routing.test.mjs +88 -0
  135. package/test/visual-regression.test.mjs +274 -0
  136. package/toolg/addVersion.mjs +4 -0
  137. package/toolg/cleanFolder.mjs +4 -0
  138. package/toolg/gDistApp.mjs +34 -0
  139. package/toolg/gDistRollupComps.mjs +22 -0
  140. package/toolg/gDocExams.mjs +47 -0
  141. package/toolg/gExtractHtml.mjs +179 -0
  142. package/toolg/modifyReadme.mjs +4 -0
  143. package/vue.config.js +9 -0
  144. package/vue2/344/271/213foreignObject/345/205/247/346/270/262/346/237/223/345/225/217/351/241/214/350/210/207/344/277/256/346/255/243.md +151 -0
@@ -0,0 +1,83 @@
1
+ <template>
2
+ <div :class="['vue-flow__controls', 'vue-flow__panel', `vue-flow__panel--${position}`]">
3
+ <template v-if="showZoom">
4
+ <button
5
+ title="Zoom In"
6
+ @click="$emit('zoom-in')"
7
+ >
8
+ <svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" fill="none"/></svg>
9
+ </button>
10
+ <button
11
+ title="Zoom Out"
12
+ @click="$emit('zoom-out')"
13
+ >
14
+ <svg viewBox="0 0 24 24"><path d="M5 12h14" stroke="currentColor" stroke-width="2" fill="none"/></svg>
15
+ </button>
16
+ </template>
17
+ <button
18
+ v-if="showFitView"
19
+ title="Fit View"
20
+ @click="$emit('fit-view')"
21
+ >
22
+ <svg viewBox="0 0 24 24"><path d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>
23
+ </button>
24
+ <button
25
+ v-if="showInteractive"
26
+ :title="locked ? 'Unlock' : 'Lock'"
27
+ @click="$emit('toggle-interactive')"
28
+ >
29
+ <svg v-if="locked" viewBox="0 0 24 24"><path d="M17 11H7a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2zM12 17a1.5 1.5 0 110-3 1.5 1.5 0 010 3zM8 11V7a4 4 0 118 0v4" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>
30
+ <svg v-else viewBox="0 0 24 24"><path d="M17 11H7a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2zM12 17a1.5 1.5 0 110-3 1.5 1.5 0 010 3zM16 11V7a4 4 0 10-8 0" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>
31
+ </button>
32
+ <slot />
33
+ </div>
34
+ </template>
35
+
36
+ <script>
37
+ export default {
38
+ name: 'FlowControls',
39
+ props: {
40
+ showZoom: { type: Boolean, default: true },
41
+ showFitView: { type: Boolean, default: true },
42
+ showInteractive: { type: Boolean, default: true },
43
+ locked: { type: Boolean, default: false },
44
+ position: { type: String, default: 'top-left' },
45
+ },
46
+ }
47
+ </script>
48
+
49
+ <style scoped>
50
+ .vue-flow__controls {
51
+ display: flex;
52
+ flex-direction: column;
53
+ gap: 4px;
54
+ box-shadow: 0 0 2px 1px rgba(0, 0, 0, 0.08);
55
+ background: #fefefe;
56
+ border-radius: 4px;
57
+ overflow: hidden;
58
+ }
59
+ .vue-flow__controls button {
60
+ display: flex;
61
+ align-items: center;
62
+ justify-content: center;
63
+ width: 26px;
64
+ height: 26px;
65
+ border: none;
66
+ background: #fefefe;
67
+ cursor: pointer;
68
+ padding: 4px;
69
+ font-size: 14px;
70
+ color: #333;
71
+ }
72
+ .vue-flow__controls button:hover {
73
+ background: #f0f0f0;
74
+ }
75
+ .vue-flow__panel {
76
+ position: absolute;
77
+ z-index: 5;
78
+ }
79
+ .vue-flow__panel--top-left { top: 10px; left: 10px; }
80
+ .vue-flow__panel--top-right { top: 10px; right: 10px; }
81
+ .vue-flow__panel--bottom-left { bottom: 10px; left: 10px; }
82
+ .vue-flow__panel--bottom-right { bottom: 10px; right: 10px; }
83
+ </style>
@@ -0,0 +1,185 @@
1
+ <template>
2
+ <div class="vue-flow__settings-form" :style="formStyle">
3
+ <label>Name
4
+ <input type="text" :value="node.name || ''" @input="$emit('update', 'name', $event.target.value)">
5
+ </label>
6
+ <label>Description
7
+ <input type="text" :value="node.description || ''" @input="$emit('update', 'description', $event.target.value)">
8
+ </label>
9
+ <label>Type
10
+ <select :value="node.type || defNode.type" @input="$emit('update', 'type', $event.target.value)">
11
+ <option value="input">Input</option>
12
+ <option value="basic">Basic</option>
13
+ <option value="output">Output</option>
14
+ </select>
15
+ </label>
16
+ <label>Shape
17
+ <select :value="node.shape || defNode.shape" @input="$emit('update', 'shape', $event.target.value)">
18
+ <option value="rectangle">Rectangle</option>
19
+ <option value="diamond">Diamond</option>
20
+ <option value="ellipse">Ellipse</option>
21
+ <option value="triangle">Triangle ▲</option>
22
+ <option value="triangle-right">Triangle ▶</option>
23
+ <option value="triangle-down">Triangle ▼</option>
24
+ <option value="triangle-left">Triangle ◀</option>
25
+ </select>
26
+ </label>
27
+ <label>Popup Direction
28
+ <select :value="node.popupDirection || defNode.popupDirection" @input="$emit('update', 'popupDirection', $event.target.value)">
29
+ <option value="top">Top</option>
30
+ <option value="right">Right</option>
31
+ <option value="bottom">Bottom</option>
32
+ <option value="left">Left</option>
33
+ </select>
34
+ </label>
35
+ <label>Font Size
36
+ <input type="number" :value="node.fontSize || defNode.fontSize" :min="defNode.fontSizeMin" :max="defNode.fontSizeMax" @input="onFontSizeInput($event.target.value)">
37
+ </label>
38
+ <label>Font Color
39
+ <WColorSelect :value="node.fontColor || defNode.fontColor" :size="160" :colorBlockSize="16" :showColorText="false" @input="$emit('update', 'fontColor', $event)" />
40
+ </label>
41
+ <label>Face Color
42
+ <WColorSelect :value="node.faceColor || defNode.faceColor" :size="160" :colorBlockSize="16" :showColorText="false" @input="$emit('update', 'faceColor', $event)" />
43
+ </label>
44
+ <label>Edge Color
45
+ <WColorSelect :value="node.edgeColor || defNode.edgeColor" :size="160" :colorBlockSize="16" :showColorText="false" @input="$emit('update', 'edgeColor', $event)" />
46
+ </label>
47
+ <label>Edge Width
48
+ <input type="number" :value="node.edgeWidth !== undefined ? node.edgeWidth : defNode.edgeWidth" min="1" max="24" @input="onEdgeWidthInput($event.target.value)">
49
+ </label>
50
+ <label v-if="node.type === 'output' || node.type === 'basic'">From Handle
51
+ <select :value="node.fromPosition || defNode.fromPosition" @input="$emit('update', 'fromPosition', $event.target.value)">
52
+ <option value="top">Top</option>
53
+ <option value="right">Right</option>
54
+ <option value="bottom">Bottom</option>
55
+ <option value="left">Left</option>
56
+ </select>
57
+ </label>
58
+ <label v-if="node.type === 'input' || node.type === 'basic'">To Handle
59
+ <select :value="node.toPosition || defNode.toPosition" @input="$emit('update', 'toPosition', $event.target.value)">
60
+ <option value="top">Top</option>
61
+ <option value="right">Right</option>
62
+ <option value="bottom">Bottom</option>
63
+ <option value="left">Left</option>
64
+ </select>
65
+ </label>
66
+ <div class="vue-flow__delete-area">
67
+ <button v-if="!confirmDelete" class="vue-flow__delete-btn" @click="confirmDelete = true">刪除節點</button>
68
+ <template v-else>
69
+ <span class="vue-flow__delete-warn">確定刪除?相關連線也會一併刪除</span>
70
+ <div class="vue-flow__delete-confirm-row">
71
+ <button class="vue-flow__delete-btn vue-flow__delete-btn--confirm" @click="$emit('delete')">確認刪除</button>
72
+ <button class="vue-flow__delete-btn vue-flow__delete-btn--cancel" @click="confirmDelete = false">取消</button>
73
+ </div>
74
+ </template>
75
+ </div>
76
+ </div>
77
+ </template>
78
+
79
+ <script>
80
+ import WColorSelect from 'w-component-vue/src/components/WColorSelect.vue'
81
+
82
+ export default {
83
+ components: { WColorSelect },
84
+ props: {
85
+ node: { type: Object, required: true },
86
+ defNode: { type: Object, required: true },
87
+ textFontSize: { type: String, default: '' },
88
+ },
89
+ data() {
90
+ return { confirmDelete: false }
91
+ },
92
+ computed: {
93
+ formStyle() {
94
+ let s = {}
95
+ if (this.textFontSize) s.fontSize = this.textFontSize
96
+ return s
97
+ },
98
+ },
99
+ methods: {
100
+ onFontSizeInput(val) {
101
+ let n = Number(val)
102
+ let d = this.defNode
103
+ if (!val || isNaN(n) || n < d.fontSizeMin) return
104
+ if (n > d.fontSizeMax) n = d.fontSizeMax
105
+ this.$emit('update', 'fontSize', n)
106
+ },
107
+ onEdgeWidthInput(val) {
108
+ let n = Number(val)
109
+ if (!val || isNaN(n) || n < 1) return
110
+ if (n > 24) n = 24
111
+ this.$emit('update', 'edgeWidth', n)
112
+ },
113
+ },
114
+ }
115
+ </script>
116
+
117
+ <style>
118
+ .vue-flow__settings-form {
119
+ display: flex;
120
+ flex-direction: column;
121
+ gap: 8px;
122
+ min-width: 180px;
123
+ }
124
+ .vue-flow__settings-form label {
125
+ display: flex;
126
+ justify-content: space-between;
127
+ align-items: center;
128
+ gap: 8px;
129
+ font-size: 12px;
130
+ }
131
+ .vue-flow__settings-form select,
132
+ .vue-flow__settings-form input[type="number"],
133
+ .vue-flow__settings-form input[type="text"] {
134
+ width: 100px;
135
+ font-size: 12px;
136
+ padding: 1px 4px;
137
+ border: 1px solid #ccc;
138
+ border-radius: 3px;
139
+ }
140
+ .vue-flow__settings-form input[type="color"] {
141
+ width: 32px;
142
+ height: 24px;
143
+ padding: 0;
144
+ border: 1px solid #ccc;
145
+ cursor: pointer;
146
+ flex-shrink: 0;
147
+ }
148
+ .vue-flow__delete-area {
149
+ margin-top: 4px;
150
+ padding-top: 8px;
151
+ border-top: 1px solid #eee;
152
+ }
153
+ .vue-flow__delete-warn {
154
+ display: block;
155
+ font-size: 11px;
156
+ color: #c00;
157
+ margin-bottom: 4px;
158
+ }
159
+ .vue-flow__delete-confirm-row {
160
+ display: flex;
161
+ gap: 6px;
162
+ }
163
+ .vue-flow__delete-btn {
164
+ padding: 3px 10px;
165
+ font-size: 11px;
166
+ border: 1px solid #ccc;
167
+ border-radius: 3px;
168
+ background: #fff;
169
+ cursor: pointer;
170
+ }
171
+ .vue-flow__delete-btn:hover {
172
+ background: #f5f5f5;
173
+ }
174
+ .vue-flow__delete-btn--confirm {
175
+ color: #fff;
176
+ background: #dc2626;
177
+ border-color: #dc2626;
178
+ }
179
+ .vue-flow__delete-btn--confirm:hover {
180
+ background: #b91c1c;
181
+ }
182
+ .vue-flow__delete-btn--cancel {
183
+ color: #666;
184
+ }
185
+ </style>
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Default values for node and connection properties.
3
+ */
4
+
5
+ export const NODE_DEFAULTS = {
6
+ type: 'basic',
7
+ shape: 'rectangle',
8
+ width: 100,
9
+ height: 40,
10
+ fontSize: 12,
11
+ fontSizeMin: 1,
12
+ fontSizeMax: 72,
13
+ fontColor: '#333333',
14
+ faceColor: '#ffffff',
15
+ edgeColor: '#bbbbbb',
16
+ edgeWidth: 1,
17
+ toPosition: 'bottom',
18
+ fromPosition: 'top',
19
+ popupDirection: 'right',
20
+ }
21
+
22
+ export const CONN_DEFAULTS = {
23
+ type: 'bezier',
24
+ fontSize: 10,
25
+ fontSizeMin: 1,
26
+ fontSizeMax: 72,
27
+ fontColor: '#333333',
28
+ edgeColor: '#b1b1b7',
29
+ edgeWidth: 1,
30
+ markerEnd: '',
31
+ animated: false,
32
+ defOffset: 24,
33
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Edge path generators for different edge types.
3
+ */
4
+
5
+ import { calculateStepPoints, clearStepCache } from './step-routing.mjs'
6
+
7
+ export { clearStepCache }
8
+
9
+ /**
10
+ * Calculate the control point offset for bezier curves based on handle position.
11
+ */
12
+ function getControlOffset(distance, position) {
13
+ switch (position) {
14
+ case 'top': return { x: 0, y: -distance }
15
+ case 'bottom': return { x: 0, y: distance }
16
+ case 'left': return { x: -distance, y: 0 }
17
+ case 'right': return { x: distance, y: 0 }
18
+ default: return { x: 0, y: 0 }
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Get bezier curve path.
24
+ * @returns {{ path: string, labelX: number, labelY: number }}
25
+ */
26
+ export function getBezierPath({
27
+ sourceX, sourceY, sourcePosition = 'bottom',
28
+ targetX, targetY, targetPosition = 'top',
29
+ curvature = 0.25,
30
+ }) {
31
+ const dist = Math.sqrt(Math.pow(targetX - sourceX, 2) + Math.pow(targetY - sourceY, 2))
32
+ const offset = Math.max(dist * curvature, 25)
33
+
34
+ const s = getControlOffset(offset, sourcePosition)
35
+ const t = getControlOffset(offset, targetPosition)
36
+
37
+ const controlX1 = sourceX + s.x
38
+ const controlY1 = sourceY + s.y
39
+ const controlX2 = targetX + t.x
40
+ const controlY2 = targetY + t.y
41
+
42
+ const path = `M ${sourceX},${sourceY} C ${controlX1},${controlY1} ${controlX2},${controlY2} ${targetX},${targetY}`
43
+
44
+ const labelX = (sourceX + controlX1 + controlX2 + targetX) / 4
45
+ const labelY = (sourceY + controlY1 + controlY2 + targetY) / 4
46
+
47
+ return { path, labelX, labelY }
48
+ }
49
+
50
+ /**
51
+ * Get straight line path.
52
+ * @returns {{ path: string, labelX: number, labelY: number }}
53
+ */
54
+ export function getStraightPath({ sourceX, sourceY, targetX, targetY }) {
55
+ const path = `M ${sourceX},${sourceY} L ${targetX},${targetY}`
56
+ const labelX = (sourceX + targetX) / 2
57
+ const labelY = (sourceY + targetY) / 2
58
+ return { path, labelX, labelY }
59
+ }
60
+
61
+ /**
62
+ * Get step (right-angle) path.
63
+ * @returns {{ path: string, labelX: number, labelY: number }}
64
+ */
65
+ export function getStepPath({
66
+ sourceX, sourceY, sourcePosition = 'bottom',
67
+ targetX, targetY, targetPosition = 'top',
68
+ offset = 20,
69
+ allNodes, nodeInternals, connFromId, connToId,
70
+ }) {
71
+ const points = calculateStepPoints(
72
+ sourceX, sourceY, sourcePosition,
73
+ targetX, targetY, targetPosition,
74
+ offset, allNodes, nodeInternals, connFromId, connToId
75
+ )
76
+ const path = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x},${p.y}`).join(' ')
77
+ const label = labelAtHalfLength(points)
78
+ return { path, labelX: label.x, labelY: label.y }
79
+ }
80
+
81
+ /**
82
+ * Get smooth step (rounded right-angle) path.
83
+ * @returns {{ path: string, labelX: number, labelY: number }}
84
+ */
85
+ export function getSmoothStepPath({
86
+ sourceX, sourceY, sourcePosition = 'bottom',
87
+ targetX, targetY, targetPosition = 'top',
88
+ borderRadius = 5,
89
+ offset = 20,
90
+ allNodes, nodeInternals, connFromId, connToId,
91
+ }) {
92
+ const points = calculateStepPoints(
93
+ sourceX, sourceY, sourcePosition,
94
+ targetX, targetY, targetPosition,
95
+ offset, allNodes, nodeInternals, connFromId, connToId
96
+ )
97
+
98
+ if (points.length <= 2) {
99
+ const path = `M ${points[0].x},${points[0].y} L ${points[1].x},${points[1].y}`
100
+ const labelX = (points[0].x + points[1].x) / 2
101
+ const labelY = (points[0].y + points[1].y) / 2
102
+ return { path, labelX, labelY }
103
+ }
104
+
105
+ let path = `M ${points[0].x},${points[0].y}`
106
+
107
+ for (let i = 1; i < points.length - 1; i++) {
108
+ const prev = points[i - 1]
109
+ const curr = points[i]
110
+ const next = points[i + 1]
111
+
112
+ const dx1 = curr.x - prev.x
113
+ const dy1 = curr.y - prev.y
114
+ const dx2 = next.x - curr.x
115
+ const dy2 = next.y - curr.y
116
+
117
+ const len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1)
118
+ const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2)
119
+
120
+ if (len1 === 0 || len2 === 0) {
121
+ // Zero-length segment — skip rounding, just draw straight line
122
+ path += ` L ${curr.x},${curr.y}`
123
+ continue
124
+ }
125
+
126
+ const r = Math.min(borderRadius, len1 / 2, len2 / 2)
127
+
128
+ const beforeX = curr.x - (dx1 / len1) * r
129
+ const beforeY = curr.y - (dy1 / len1) * r
130
+ const afterX = curr.x + (dx2 / len2) * r
131
+ const afterY = curr.y + (dy2 / len2) * r
132
+
133
+ path += ` L ${beforeX},${beforeY} Q ${curr.x},${curr.y} ${afterX},${afterY}`
134
+ }
135
+
136
+ path += ` L ${points[points.length - 1].x},${points[points.length - 1].y}`
137
+
138
+ const label = labelAtHalfLength(points)
139
+ return { path, labelX: label.x, labelY: label.y }
140
+ }
141
+
142
+ /**
143
+ * Find the point at exactly half the total Manhattan path length.
144
+ */
145
+ function labelAtHalfLength(pts) {
146
+ if (pts.length < 2) return { x: pts[0].x, y: pts[0].y }
147
+ let totalLen = 0
148
+ for (let i = 0; i < pts.length - 1; i++) {
149
+ totalLen += Math.abs(pts[i + 1].x - pts[i].x) + Math.abs(pts[i + 1].y - pts[i].y)
150
+ }
151
+ let half = totalLen / 2
152
+ let acc = 0
153
+ for (let i = 0; i < pts.length - 1; i++) {
154
+ let segLen = Math.abs(pts[i + 1].x - pts[i].x) + Math.abs(pts[i + 1].y - pts[i].y)
155
+ if (acc + segLen >= half) {
156
+ let ratio = segLen > 0 ? (half - acc) / segLen : 0
157
+ return {
158
+ x: pts[i].x + (pts[i + 1].x - pts[i].x) * ratio,
159
+ y: pts[i].y + (pts[i + 1].y - pts[i].y) * ratio,
160
+ }
161
+ }
162
+ acc += segLen
163
+ }
164
+ return { x: pts[pts.length - 1].x, y: pts[pts.length - 1].y }
165
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Calculate the absolute position of a handle on the canvas.
3
+ */
4
+ export function getHandlePosition(node, handlePosition, nodeInternals, handleType) {
5
+ const internals = nodeInternals || {}
6
+ const w = (internals.width) || node.width || 150
7
+ const h = (internals.height) || node.height || 40
8
+ const x = node.position.x
9
+ const y = node.position.y
10
+ const isDiamond = node.shape === 'diamond'
11
+ const isEllipse = node.shape === 'ellipse'
12
+ const ns = node.shape
13
+ const isTriangle = ns === 'triangle' || ns === 'triangle-right' || ns === 'triangle-down' || ns === 'triangle-left'
14
+
15
+ // Check if this is a default node with source and target on the same side
16
+ const sameSide = node.type === 'basic' &&
17
+ (node.toPosition || 'bottom') === (node.fromPosition || 'top')
18
+ let ratio = 0.5
19
+ if (sameSide && handleType === 'target') ratio = 0.33
20
+ if (sameSide && handleType === 'source') ratio = 0.67
21
+
22
+ // Diamond same-side: position along diamond edges
23
+ if (isDiamond && sameSide) {
24
+ return getDiamondEdgePoint(x, y, w, h, handlePosition, ratio)
25
+ }
26
+
27
+ // Ellipse: position on the ellipse border
28
+ if (isEllipse) {
29
+ return getEllipseEdgePoint(x, y, w, h, handlePosition, ratio)
30
+ }
31
+
32
+ // Triangle: position on the triangle edges
33
+ if (isTriangle) {
34
+ return getTriangleEdgePoint(x, y, w, h, handlePosition, ratio, ns)
35
+ }
36
+
37
+ switch (handlePosition) {
38
+ case 'top': return { x: x + w * ratio, y }
39
+ case 'bottom': return { x: x + w * ratio, y: y + h }
40
+ case 'left': return { x, y: y + h * ratio }
41
+ case 'right': return { x: x + w, y: y + h * ratio }
42
+ default: return { x: x + w / 2, y: y + h }
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Get a point on the diamond edge for same-side handles.
48
+ * Each "side" of the diamond is split into two edges meeting at the vertex.
49
+ * ratio < 0.5: on the first edge, ratio >= 0.5: on the second edge.
50
+ */
51
+ function getDiamondEdgePoint(x, y, w, h, side, ratio) {
52
+ let halfW = w / 2
53
+ let halfH = h / 2
54
+
55
+ switch (side) {
56
+ case 'top':
57
+ if (ratio <= 0.5) {
58
+ let t = ratio * 2
59
+ return { x: x + t * halfW, y: y + halfH - t * halfH }
60
+ }
61
+ else {
62
+ let t = (ratio - 0.5) * 2
63
+ return { x: x + halfW + t * halfW, y: y + t * halfH }
64
+ }
65
+ case 'bottom':
66
+ if (ratio <= 0.5) {
67
+ let t = ratio * 2
68
+ return { x: x + t * halfW, y: y + halfH + t * halfH }
69
+ }
70
+ else {
71
+ let t = (ratio - 0.5) * 2
72
+ return { x: x + halfW + t * halfW, y: y + h - t * halfH }
73
+ }
74
+ case 'left':
75
+ if (ratio <= 0.5) {
76
+ let t = ratio * 2
77
+ return { x: x + halfW - t * halfW, y: y + t * halfH }
78
+ }
79
+ else {
80
+ let t = (ratio - 0.5) * 2
81
+ return { x: x + t * halfW, y: y + halfH + t * halfH }
82
+ }
83
+ case 'right':
84
+ if (ratio <= 0.5) {
85
+ let t = ratio * 2
86
+ return { x: x + halfW + t * halfW, y: y + t * halfH }
87
+ }
88
+ else {
89
+ let t = (ratio - 0.5) * 2
90
+ return { x: x + w - t * halfW, y: y + halfH + t * halfH }
91
+ }
92
+ default:
93
+ return { x: x + halfW, y: y + h }
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Get a point on the ellipse edge for handle positioning.
99
+ * Maps ratio (0..1) to a parametric angle based on the side.
100
+ */
101
+ function getEllipseEdgePoint(x, y, w, h, side, ratio) {
102
+ let cx = x + w / 2
103
+ let cy = y + h / 2
104
+ let rx = w / 2
105
+ let ry = h / 2
106
+ let angle
107
+
108
+ switch (side) {
109
+ case 'top':
110
+ angle = Math.PI * (1 - ratio); break
111
+ case 'bottom':
112
+ angle = Math.PI * (ratio - 1); break
113
+ case 'left':
114
+ angle = Math.PI * (0.5 + ratio); break
115
+ case 'right':
116
+ angle = Math.PI * (0.5 - ratio); break
117
+ default:
118
+ angle = 0
119
+ }
120
+
121
+ return {
122
+ x: cx + rx * Math.cos(angle),
123
+ y: cy - ry * Math.sin(angle)
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Get a point on the triangle edge for handle positioning.
129
+ * Supports 4 directions: triangle (up), triangle-right, triangle-down, triangle-left.
130
+ */
131
+ function getTriangleEdgePoint(x, y, w, h, side, ratio, shape) {
132
+ // Get absolute vertices based on direction
133
+ let apex, baseA, baseB, apexSide, baseSide, edgeA, edgeB
134
+ if (shape === 'triangle-right') {
135
+ apex = { x: x + w, y: y + h / 2 }; baseA = { x, y }; baseB = { x, y: y + h }
136
+ apexSide = 'right'; baseSide = 'left'; edgeA = 'top'; edgeB = 'bottom'
137
+ }
138
+ else if (shape === 'triangle-down') {
139
+ apex = { x: x + w / 2, y: y + h }; baseA = { x, y }; baseB = { x: x + w, y }
140
+ apexSide = 'bottom'; baseSide = 'top'; edgeA = 'left'; edgeB = 'right'
141
+ }
142
+ else if (shape === 'triangle-left') {
143
+ apex = { x, y: y + h / 2 }; baseA = { x: x + w, y }; baseB = { x: x + w, y: y + h }
144
+ apexSide = 'left'; baseSide = 'right'; edgeA = 'top'; edgeB = 'bottom'
145
+ }
146
+ else {
147
+ // triangle (up)
148
+ apex = { x: x + w / 2, y }; baseA = { x, y: y + h }; baseB = { x: x + w, y: y + h }
149
+ apexSide = 'top'; baseSide = 'bottom'; edgeA = 'left'; edgeB = 'right'
150
+ }
151
+
152
+ if (side === apexSide) {
153
+ if (ratio <= 0.5) {
154
+ let t = ratio * 2
155
+ return { x: baseA.x + (apex.x - baseA.x) * t, y: baseA.y + (apex.y - baseA.y) * t }
156
+ }
157
+ else {
158
+ let t2 = (ratio - 0.5) * 2
159
+ return { x: apex.x + (baseB.x - apex.x) * t2, y: apex.y + (baseB.y - apex.y) * t2 }
160
+ }
161
+ }
162
+ if (side === baseSide) {
163
+ return { x: baseA.x + (baseB.x - baseA.x) * ratio, y: baseA.y + (baseB.y - baseA.y) * ratio }
164
+ }
165
+ if (side === edgeA) {
166
+ return { x: apex.x + (baseA.x - apex.x) * ratio, y: apex.y + (baseA.y - apex.y) * ratio }
167
+ }
168
+ if (side === edgeB) {
169
+ return { x: apex.x + (baseB.x - apex.x) * ratio, y: apex.y + (baseB.y - apex.y) * ratio }
170
+ }
171
+ return { x: x + w / 2, y: y + h / 2 }
172
+ }
173
+
174
+ /**
175
+ * Get all nodes that overlap with a given rectangle.
176
+ */
177
+ export function getOverlappingNodes(rect, nodes, nodeInternals) {
178
+ return nodes.filter(node => {
179
+ const internals = (nodeInternals && nodeInternals[node.id]) || {}
180
+ const w = internals.width || node.width || 150
181
+ const h = internals.height || node.height || 40
182
+ const nodeRect = {
183
+ x: node.position.x,
184
+ y: node.position.y,
185
+ width: w,
186
+ height: h,
187
+ }
188
+ return rectsOverlap(rect, nodeRect)
189
+ })
190
+ }
191
+
192
+ /**
193
+ * Check if two rectangles overlap.
194
+ */
195
+ function rectsOverlap(a, b) {
196
+ return (
197
+ a.x < b.x + b.width &&
198
+ a.x + a.width > b.x &&
199
+ a.y < b.y + b.height &&
200
+ a.y + a.height > b.y
201
+ )
202
+ }
203
+
204
+ /**
205
+ * Clamp a position within a coordinate extent.
206
+ */
207
+ export function clampPosition(position, extent) {
208
+ if (!extent) return position
209
+ return {
210
+ x: Math.max(extent[0][0], Math.min(extent[1][0], position.x)),
211
+ y: Math.max(extent[0][1], Math.min(extent[1][1], position.y)),
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Snap a position to the nearest grid point.
217
+ * @param {{ x: number, y: number }} position
218
+ * @param {number|null} gridSize - Grid cell size (single number for both axes)
219
+ */
220
+ export function snapPosition(position, gridSize) {
221
+ if (!gridSize) return position
222
+ return {
223
+ x: Math.round(position.x / gridSize) * gridSize,
224
+ y: Math.round(position.y / gridSize) * gridSize,
225
+ }
226
+ }