@dilipod/ui 0.4.34 → 0.4.36
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.
- package/dist/components/file-preview.d.ts +1 -0
- package/dist/components/file-preview.d.ts.map +1 -1
- package/dist/components/flowchart-diagram.d.ts.map +1 -1
- package/dist/components/sidebar.d.ts +2 -1
- package/dist/components/sidebar.d.ts.map +1 -1
- package/dist/index.js +369 -164
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +370 -165
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/file-preview.tsx +29 -15
- package/src/components/flowchart-diagram.tsx +446 -216
- package/src/components/sidebar.tsx +24 -10
|
@@ -18,255 +18,478 @@ interface FlowEdge {
|
|
|
18
18
|
label?: string
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
interface LayoutNode {
|
|
22
|
+
id: string
|
|
23
|
+
x: number
|
|
24
|
+
y: number
|
|
25
|
+
width: number
|
|
26
|
+
height: number
|
|
27
|
+
layer: number
|
|
28
|
+
node: FlowNode
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ============================================
|
|
32
|
+
// Layout Constants
|
|
33
|
+
// ============================================
|
|
34
|
+
|
|
35
|
+
const NODE_WIDTH = 180
|
|
36
|
+
const NODE_HEIGHT = 44
|
|
37
|
+
const DIAMOND_SIZE = 72
|
|
38
|
+
const X_GAP = 50
|
|
39
|
+
const Y_GAP = 64
|
|
40
|
+
const PADDING = 40
|
|
41
|
+
const FONT_SIZE = 11
|
|
42
|
+
const CHAR_WIDTH = 6.2
|
|
43
|
+
const LINE_HEIGHT = 14
|
|
44
|
+
|
|
21
45
|
// ============================================
|
|
22
46
|
// Parser
|
|
23
47
|
// ============================================
|
|
24
48
|
|
|
49
|
+
function stripQuotes(s: string): string {
|
|
50
|
+
const t = s.trim()
|
|
51
|
+
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
|
|
52
|
+
return t.slice(1, -1)
|
|
53
|
+
}
|
|
54
|
+
return t
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseNodeRef(raw: string): { id: string; label?: string; type?: FlowNode['type'] } {
|
|
58
|
+
const s = raw.trim()
|
|
59
|
+
|
|
60
|
+
// Decision: ID{Label} or ID{"Label"}
|
|
61
|
+
const decision = s.match(/^([A-Za-z0-9_]+)\{(.+)\}$/)
|
|
62
|
+
if (decision) return { id: decision[1], label: stripQuotes(decision[2]), type: 'decision' }
|
|
63
|
+
|
|
64
|
+
// Stadium/terminal: ID([Label])
|
|
65
|
+
const stadium = s.match(/^([A-Za-z0-9_]+)\(\[(.+)\]\)$/)
|
|
66
|
+
if (stadium) return { id: stadium[1], label: stripQuotes(stadium[2]), type: 'terminal' }
|
|
67
|
+
|
|
68
|
+
// Circle: ID((Label))
|
|
69
|
+
const circle = s.match(/^([A-Za-z0-9_]+)\(\((.+)\)\)$/)
|
|
70
|
+
if (circle) return { id: circle[1], label: stripQuotes(circle[2]), type: 'terminal' }
|
|
71
|
+
|
|
72
|
+
// Rounded rect: ID(Label) — but not ID([...]) or ID((...))
|
|
73
|
+
const rounded = s.match(/^([A-Za-z0-9_]+)\(([^[(].+?)\)$/)
|
|
74
|
+
if (rounded) return { id: rounded[1], label: stripQuotes(rounded[2]), type: 'action' }
|
|
75
|
+
|
|
76
|
+
// Rectangle: ID[Label] or ID["Label"] — but not ID([...])
|
|
77
|
+
const rect = s.match(/^([A-Za-z0-9_]+)\[([^(].+?)\]$/)
|
|
78
|
+
if (rect) {
|
|
79
|
+
const label = stripQuotes(rect[2])
|
|
80
|
+
const isTerminal = /^(start|end|begin|finish|done|stop)$/i.test(label)
|
|
81
|
+
return { id: rect[1], label, type: isTerminal ? 'terminal' : 'action' }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Bare ID reference
|
|
85
|
+
const bareId = s.match(/^([A-Za-z0-9_]+)$/)
|
|
86
|
+
if (bareId) return { id: bareId[1] }
|
|
87
|
+
|
|
88
|
+
return { id: s }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function registerNode(
|
|
92
|
+
map: Map<string, FlowNode>,
|
|
93
|
+
ref: { id: string; label?: string; type?: FlowNode['type'] },
|
|
94
|
+
) {
|
|
95
|
+
if (!ref.id) return
|
|
96
|
+
const existing = map.get(ref.id)
|
|
97
|
+
if (existing) {
|
|
98
|
+
// Upgrade label/type if current entry only has ID as label
|
|
99
|
+
if (ref.label && existing.label === existing.id) {
|
|
100
|
+
existing.label = ref.label
|
|
101
|
+
}
|
|
102
|
+
if (ref.type && ref.type !== 'action' && existing.type === 'action') {
|
|
103
|
+
existing.type = ref.type
|
|
104
|
+
}
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
map.set(ref.id, {
|
|
108
|
+
id: ref.id,
|
|
109
|
+
label: ref.label || ref.id,
|
|
110
|
+
type: ref.type || 'action',
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
25
114
|
function parseMermaidFlowchart(mermaid: string): { nodes: FlowNode[]; edges: FlowEdge[] } {
|
|
26
|
-
const
|
|
115
|
+
const nodeMap = new Map<string, FlowNode>()
|
|
27
116
|
const edges: FlowEdge[] = []
|
|
28
117
|
|
|
29
118
|
const lines = mermaid
|
|
30
119
|
.split(/\\n|\n/)
|
|
31
120
|
.map(l => l.trim())
|
|
32
|
-
.filter(l => l && !l.startsWith('
|
|
33
|
-
|
|
34
|
-
function parseNodeDef(str: string): { id: string; label?: string; type?: FlowNode['type'] } {
|
|
35
|
-
// Decision: D{Certificate Found?}
|
|
36
|
-
const decisionMatch = str.match(/^([A-Za-z0-9_]+)\{(.+?)\}$/)
|
|
37
|
-
if (decisionMatch) return { id: decisionMatch[1], label: decisionMatch[2], type: 'decision' }
|
|
38
|
-
// Action / terminal: A[Start] or A(Start) or A([Start])
|
|
39
|
-
const bracketMatch = str.match(/^([A-Za-z0-9_]+)\[?\(?\[?(.+?)\]?\)?\]?$/)
|
|
40
|
-
if (bracketMatch) {
|
|
41
|
-
const label = bracketMatch[2]
|
|
42
|
-
const isTerminal = /^(start|end|begin|finish|done)$/i.test(label)
|
|
43
|
-
return { id: bracketMatch[1], label, type: isTerminal ? 'terminal' : 'action' }
|
|
44
|
-
}
|
|
45
|
-
// Just an ID reference
|
|
46
|
-
return { id: str.trim() }
|
|
47
|
-
}
|
|
121
|
+
.filter(l => l && !l.startsWith('%%'))
|
|
48
122
|
|
|
49
123
|
for (const line of lines) {
|
|
50
|
-
|
|
51
|
-
if (
|
|
124
|
+
// Skip directives
|
|
125
|
+
if (/^(flowchart|graph|subgraph|end|style|classDef|class|click|linkStyle)\b/i.test(line)) continue
|
|
52
126
|
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
127
|
+
// Try edge: A -->|label| B or A --> B or A ==> B or A -.-> B or A --- B
|
|
128
|
+
const edgeMatch = line.match(
|
|
129
|
+
/^(.+?)\s*(?:-->|==>|-\.->|---)\s*(?:\|([^|]*)\|)?\s*(.+)$/,
|
|
130
|
+
)
|
|
56
131
|
|
|
57
|
-
|
|
58
|
-
|
|
132
|
+
if (edgeMatch) {
|
|
133
|
+
const leftNode = parseNodeRef(edgeMatch[1].trim())
|
|
134
|
+
const edgeLabel = edgeMatch[2]?.trim() || undefined
|
|
135
|
+
const rightNode = parseNodeRef(edgeMatch[3].trim())
|
|
59
136
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
nodes.set(right.id, { id: right.id, label: right.label, type: right.type || 'action' })
|
|
65
|
-
}
|
|
66
|
-
if (!nodes.has(left.id)) {
|
|
67
|
-
nodes.set(left.id, { id: left.id, label: left.id, type: 'action' })
|
|
68
|
-
}
|
|
69
|
-
if (!nodes.has(right.id)) {
|
|
70
|
-
nodes.set(right.id, { id: right.id, label: right.id, type: 'action' })
|
|
137
|
+
registerNode(nodeMap, leftNode)
|
|
138
|
+
registerNode(nodeMap, rightNode)
|
|
139
|
+
edges.push({ from: leftNode.id, to: rightNode.id, label: edgeLabel })
|
|
140
|
+
continue
|
|
71
141
|
}
|
|
72
142
|
|
|
73
|
-
|
|
143
|
+
// Standalone node definition
|
|
144
|
+
const nodeDef = parseNodeRef(line)
|
|
145
|
+
if (nodeDef.label) {
|
|
146
|
+
registerNode(nodeMap, nodeDef)
|
|
147
|
+
}
|
|
74
148
|
}
|
|
75
149
|
|
|
76
|
-
return { nodes: Array.from(
|
|
150
|
+
return { nodes: Array.from(nodeMap.values()), edges }
|
|
77
151
|
}
|
|
78
152
|
|
|
79
153
|
// ============================================
|
|
80
|
-
// Layout
|
|
154
|
+
// Layout (BFS Layered / Sugiyama-lite)
|
|
81
155
|
// ============================================
|
|
82
156
|
|
|
83
|
-
function
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
):
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
157
|
+
function computeLayout(
|
|
158
|
+
nodes: FlowNode[],
|
|
159
|
+
edges: FlowEdge[],
|
|
160
|
+
): { layoutNodes: LayoutNode[]; svgWidth: number; svgHeight: number } {
|
|
161
|
+
if (nodes.length === 0) return { layoutNodes: [], svgWidth: 0, svgHeight: 0 }
|
|
162
|
+
|
|
163
|
+
// Build adjacency
|
|
164
|
+
const forward = new Map<string, string[]>()
|
|
165
|
+
const backward = new Map<string, string[]>()
|
|
166
|
+
for (const e of edges) {
|
|
167
|
+
if (!forward.has(e.from)) forward.set(e.from, [])
|
|
168
|
+
forward.get(e.from)!.push(e.to)
|
|
169
|
+
if (!backward.has(e.to)) backward.set(e.to, [])
|
|
170
|
+
backward.get(e.to)!.push(e.from)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Find roots
|
|
174
|
+
const roots = nodes.filter(n => !backward.has(n.id) || backward.get(n.id)!.length === 0)
|
|
175
|
+
if (roots.length === 0) roots.push(nodes[0])
|
|
176
|
+
|
|
177
|
+
// BFS layering (longest-path for better aesthetics)
|
|
178
|
+
const layers = new Map<string, number>()
|
|
179
|
+
const queue: string[] = []
|
|
180
|
+
for (const r of roots) {
|
|
181
|
+
layers.set(r.id, 0)
|
|
182
|
+
queue.push(r.id)
|
|
183
|
+
}
|
|
184
|
+
const visited = new Set<string>()
|
|
185
|
+
while (queue.length > 0) {
|
|
186
|
+
const id = queue.shift()!
|
|
187
|
+
if (visited.has(id)) continue
|
|
188
|
+
visited.add(id)
|
|
189
|
+
const myLayer = layers.get(id) || 0
|
|
190
|
+
for (const child of forward.get(id) || []) {
|
|
191
|
+
const childLayer = layers.get(child)
|
|
192
|
+
if (childLayer === undefined || myLayer + 1 > childLayer) {
|
|
193
|
+
layers.set(child, myLayer + 1)
|
|
194
|
+
}
|
|
195
|
+
if (!visited.has(child)) queue.push(child)
|
|
100
196
|
}
|
|
101
197
|
}
|
|
198
|
+
// Handle disconnected nodes
|
|
199
|
+
const maxLayer = Math.max(0, ...Array.from(layers.values()))
|
|
200
|
+
for (const n of nodes) {
|
|
201
|
+
if (!layers.has(n.id)) layers.set(n.id, maxLayer + 1)
|
|
202
|
+
}
|
|
102
203
|
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
204
|
+
// Group by layer
|
|
205
|
+
const nodesByLayer = new Map<number, FlowNode[]>()
|
|
206
|
+
for (const n of nodes) {
|
|
207
|
+
const layer = layers.get(n.id)!
|
|
208
|
+
if (!nodesByLayer.has(layer)) nodesByLayer.set(layer, [])
|
|
209
|
+
nodesByLayer.get(layer)!.push(n)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Barycenter sort within layers
|
|
213
|
+
const sortedLayers = Array.from(nodesByLayer.keys()).sort((a, b) => a - b)
|
|
214
|
+
const posInLayer = new Map<string, number>()
|
|
215
|
+
|
|
216
|
+
for (let li = 0; li < sortedLayers.length; li++) {
|
|
217
|
+
const layer = sortedLayers[li]
|
|
218
|
+
const nodesInLayer = nodesByLayer.get(layer)!
|
|
219
|
+
|
|
220
|
+
if (li > 0) {
|
|
221
|
+
nodesInLayer.sort((a, b) => {
|
|
222
|
+
const parentsA = backward.get(a.id) || []
|
|
223
|
+
const parentsB = backward.get(b.id) || []
|
|
224
|
+
const avgA = parentsA.length > 0
|
|
225
|
+
? parentsA.reduce((s, p) => s + (posInLayer.get(p) ?? 0), 0) / parentsA.length
|
|
226
|
+
: 0
|
|
227
|
+
const avgB = parentsB.length > 0
|
|
228
|
+
? parentsB.reduce((s, p) => s + (posInLayer.get(p) ?? 0), 0) / parentsB.length
|
|
229
|
+
: 0
|
|
230
|
+
return avgA - avgB
|
|
231
|
+
})
|
|
112
232
|
}
|
|
113
|
-
|
|
114
|
-
|
|
233
|
+
nodesInLayer.forEach((n, i) => posInLayer.set(n.id, i))
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Compute pixel positions
|
|
237
|
+
const layoutNodes: LayoutNode[] = []
|
|
238
|
+
|
|
239
|
+
for (const layer of sortedLayers) {
|
|
240
|
+
const nodesInLayer = nodesByLayer.get(layer)!
|
|
241
|
+
const widths = nodesInLayer.map(n => nodeWidth(n))
|
|
242
|
+
const heights = nodesInLayer.map(n => nodeHeight(n))
|
|
243
|
+
const totalWidth = widths.reduce((s, w) => s + w, 0) + (nodesInLayer.length - 1) * X_GAP
|
|
244
|
+
let startX = -totalWidth / 2
|
|
245
|
+
|
|
246
|
+
nodesInLayer.forEach((n, i) => {
|
|
247
|
+
layoutNodes.push({
|
|
248
|
+
id: n.id,
|
|
249
|
+
x: startX + widths[i] / 2,
|
|
250
|
+
y: layer * (NODE_HEIGHT + Y_GAP),
|
|
251
|
+
width: widths[i],
|
|
252
|
+
height: heights[i],
|
|
253
|
+
layer,
|
|
254
|
+
node: n,
|
|
255
|
+
})
|
|
256
|
+
startX += widths[i] + X_GAP
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Normalize so min x/y = PADDING
|
|
261
|
+
const minX = Math.min(...layoutNodes.map(n => n.x - n.width / 2))
|
|
262
|
+
const minY = Math.min(...layoutNodes.map(n => n.y - n.height / 2))
|
|
263
|
+
const offsetX = PADDING - minX
|
|
264
|
+
const offsetY = PADDING - minY
|
|
265
|
+
for (const n of layoutNodes) {
|
|
266
|
+
n.x += offsetX
|
|
267
|
+
n.y += offsetY
|
|
115
268
|
}
|
|
116
|
-
|
|
269
|
+
|
|
270
|
+
const svgWidth = Math.max(...layoutNodes.map(n => n.x + n.width / 2)) + PADDING
|
|
271
|
+
const svgHeight = Math.max(...layoutNodes.map(n => n.y + n.height / 2)) + PADDING
|
|
272
|
+
|
|
273
|
+
return { layoutNodes, svgWidth, svgHeight }
|
|
117
274
|
}
|
|
118
275
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
} else {
|
|
151
|
-
currentId = null
|
|
152
|
-
}
|
|
276
|
+
function nodeWidth(n: FlowNode): number {
|
|
277
|
+
if (n.type === 'decision') return Math.max(DIAMOND_SIZE * 1.4, 100)
|
|
278
|
+
// Scale width to label length, clamped
|
|
279
|
+
const textWidth = n.label.length * CHAR_WIDTH + 32
|
|
280
|
+
return Math.max(100, Math.min(NODE_WIDTH, textWidth))
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function nodeHeight(n: FlowNode): number {
|
|
284
|
+
if (n.type === 'decision') return DIAMOND_SIZE
|
|
285
|
+
const w = nodeWidth(n)
|
|
286
|
+
const lines = wrapText(n.label, w - 24)
|
|
287
|
+
return Math.max(NODE_HEIGHT, lines.length * LINE_HEIGHT + 16)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ============================================
|
|
291
|
+
// Text Wrapping
|
|
292
|
+
// ============================================
|
|
293
|
+
|
|
294
|
+
function wrapText(text: string, maxPixelWidth: number): string[] {
|
|
295
|
+
const charsPerLine = Math.max(8, Math.floor(maxPixelWidth / CHAR_WIDTH))
|
|
296
|
+
if (text.length <= charsPerLine) return [text]
|
|
297
|
+
|
|
298
|
+
const words = text.split(' ')
|
|
299
|
+
const lines: string[] = []
|
|
300
|
+
let cur = ''
|
|
301
|
+
|
|
302
|
+
for (const word of words) {
|
|
303
|
+
const test = cur ? `${cur} ${word}` : word
|
|
304
|
+
if (test.length > charsPerLine && cur) {
|
|
305
|
+
lines.push(cur)
|
|
306
|
+
cur = word
|
|
153
307
|
} else {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const mergeId = findMergePoint(branchStarts, outgoing)
|
|
159
|
-
|
|
160
|
-
const branches: { label?: string; items: LayoutItem[] }[] = []
|
|
161
|
-
for (const edge of outs) {
|
|
162
|
-
if (visited.has(edge.to) && edge.to !== mergeId) {
|
|
163
|
-
branches.push({ label: edge.label, items: [] })
|
|
164
|
-
continue
|
|
165
|
-
}
|
|
166
|
-
const branchItems = buildLayout(edge.to, outgoing, incoming, nodeMap, visited)
|
|
167
|
-
branches.push({ label: edge.label, items: branchItems })
|
|
168
|
-
}
|
|
308
|
+
cur = test
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (cur) lines.push(cur)
|
|
169
312
|
|
|
170
|
-
|
|
313
|
+
// Max 3 lines
|
|
314
|
+
if (lines.length > 3) {
|
|
315
|
+
lines.length = 3
|
|
316
|
+
lines[2] = lines[2].slice(0, -1) + '\u2026'
|
|
317
|
+
}
|
|
318
|
+
return lines
|
|
319
|
+
}
|
|
171
320
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
321
|
+
// ============================================
|
|
322
|
+
// SVG Edge Path
|
|
323
|
+
// ============================================
|
|
324
|
+
|
|
325
|
+
function computeEdgePath(
|
|
326
|
+
from: LayoutNode,
|
|
327
|
+
to: LayoutNode,
|
|
328
|
+
): string {
|
|
329
|
+
let startX = from.x
|
|
330
|
+
let startY = from.y + from.height / 2
|
|
331
|
+
|
|
332
|
+
// For decisions, exit from side vertex when target is horizontally offset
|
|
333
|
+
if (from.node.type === 'decision') {
|
|
334
|
+
const dx = to.x - from.x
|
|
335
|
+
const halfDiamond = DIAMOND_SIZE / 2
|
|
336
|
+
if (Math.abs(dx) > halfDiamond) {
|
|
337
|
+
startX = from.x + (dx > 0 ? halfDiamond : -halfDiamond)
|
|
338
|
+
startY = from.y
|
|
178
339
|
}
|
|
179
340
|
}
|
|
180
341
|
|
|
181
|
-
|
|
342
|
+
const endX = to.x
|
|
343
|
+
const endY = to.y - to.height / 2
|
|
344
|
+
|
|
345
|
+
// Cubic bezier with vertical control points
|
|
346
|
+
const midY = (startY + endY) / 2
|
|
347
|
+
return `M ${startX} ${startY} C ${startX} ${midY}, ${endX} ${midY}, ${endX} ${endY}`
|
|
182
348
|
}
|
|
183
349
|
|
|
184
350
|
// ============================================
|
|
185
|
-
//
|
|
351
|
+
// SVG Components
|
|
186
352
|
// ============================================
|
|
187
353
|
|
|
188
|
-
function
|
|
354
|
+
function SvgDefs() {
|
|
189
355
|
return (
|
|
190
|
-
<
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
356
|
+
<defs>
|
|
357
|
+
<marker
|
|
358
|
+
id="fc-arrowhead"
|
|
359
|
+
viewBox="0 0 10 7"
|
|
360
|
+
refX="10"
|
|
361
|
+
refY="3.5"
|
|
362
|
+
markerWidth="8"
|
|
363
|
+
markerHeight="6"
|
|
364
|
+
orient="auto-start-reverse"
|
|
365
|
+
>
|
|
366
|
+
<polygon points="0 0, 10 3.5, 0 7" fill="#D1D5DB" />
|
|
367
|
+
</marker>
|
|
368
|
+
<filter id="fc-shadow" x="-4%" y="-4%" width="108%" height="116%">
|
|
369
|
+
<feDropShadow dx="0" dy="1" stdDeviation="1.5" floodOpacity="0.07" />
|
|
370
|
+
</filter>
|
|
371
|
+
</defs>
|
|
199
372
|
)
|
|
200
373
|
}
|
|
201
374
|
|
|
202
|
-
function
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
{node.label}
|
|
211
|
-
</div>
|
|
212
|
-
)
|
|
213
|
-
}
|
|
214
|
-
if (node.type === 'terminal') {
|
|
215
|
-
return (
|
|
216
|
-
<div className="bg-gray-100 border border-gray-200 rounded-full px-5 py-1.5 text-xs font-medium text-gray-500 text-center my-1">
|
|
217
|
-
{node.label}
|
|
218
|
-
</div>
|
|
219
|
-
)
|
|
220
|
-
}
|
|
375
|
+
function TextBlock({ lines, x, y, fill, fontSize }: {
|
|
376
|
+
lines: string[]
|
|
377
|
+
x: number
|
|
378
|
+
y: number
|
|
379
|
+
fill: string
|
|
380
|
+
fontSize: number
|
|
381
|
+
}) {
|
|
382
|
+
const startY = y - ((lines.length - 1) * LINE_HEIGHT) / 2
|
|
221
383
|
return (
|
|
222
|
-
<
|
|
223
|
-
{
|
|
224
|
-
|
|
384
|
+
<text
|
|
385
|
+
x={x}
|
|
386
|
+
textAnchor="middle"
|
|
387
|
+
fill={fill}
|
|
388
|
+
style={{ fontSize: `${fontSize}px`, fontFamily: 'var(--font-outfit, system-ui, sans-serif)', fontWeight: 500 }}
|
|
389
|
+
>
|
|
390
|
+
{lines.map((line, i) => (
|
|
391
|
+
<tspan key={i} x={x} y={startY + i * LINE_HEIGHT}>
|
|
392
|
+
{line}
|
|
393
|
+
</tspan>
|
|
394
|
+
))}
|
|
395
|
+
</text>
|
|
225
396
|
)
|
|
226
397
|
}
|
|
227
398
|
|
|
228
|
-
function
|
|
399
|
+
function ActionNodeSvg({ n }: { n: LayoutNode }) {
|
|
400
|
+
const lines = wrapText(n.node.label, n.width - 24)
|
|
229
401
|
return (
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
402
|
+
<g>
|
|
403
|
+
<rect
|
|
404
|
+
x={n.x - n.width / 2}
|
|
405
|
+
y={n.y - n.height / 2}
|
|
406
|
+
width={n.width}
|
|
407
|
+
height={n.height}
|
|
408
|
+
rx={3}
|
|
409
|
+
fill="white"
|
|
410
|
+
stroke="#E5E7EB"
|
|
411
|
+
strokeWidth={1}
|
|
412
|
+
filter="url(#fc-shadow)"
|
|
413
|
+
/>
|
|
414
|
+
<TextBlock lines={lines} x={n.x} y={n.y} fill="#0A0A0A" fontSize={FONT_SIZE} />
|
|
415
|
+
</g>
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function DecisionNodeSvg({ n }: { n: LayoutNode }) {
|
|
420
|
+
const half = DIAMOND_SIZE / 2
|
|
421
|
+
const points = `${n.x},${n.y - half} ${n.x + half},${n.y} ${n.x},${n.y + half} ${n.x - half},${n.y}`
|
|
422
|
+
const lines = wrapText(n.node.label, DIAMOND_SIZE - 16)
|
|
423
|
+
return (
|
|
424
|
+
<g>
|
|
425
|
+
<polygon
|
|
426
|
+
points={points}
|
|
427
|
+
fill="#FFFBEB"
|
|
428
|
+
stroke="#FCD34D"
|
|
429
|
+
strokeWidth={2}
|
|
430
|
+
/>
|
|
431
|
+
<TextBlock lines={lines} x={n.x} y={n.y} fill="#92400E" fontSize={10} />
|
|
432
|
+
</g>
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function TerminalNodeSvg({ n }: { n: LayoutNode }) {
|
|
437
|
+
return (
|
|
438
|
+
<g>
|
|
439
|
+
<rect
|
|
440
|
+
x={n.x - n.width / 2}
|
|
441
|
+
y={n.y - n.height / 2}
|
|
442
|
+
width={n.width}
|
|
443
|
+
height={n.height}
|
|
444
|
+
rx={n.height / 2}
|
|
445
|
+
fill="#F3F4F6"
|
|
446
|
+
stroke="#E5E7EB"
|
|
447
|
+
strokeWidth={1}
|
|
448
|
+
/>
|
|
449
|
+
<TextBlock lines={[n.node.label]} x={n.x} y={n.y} fill="#6B7280" fontSize={FONT_SIZE} />
|
|
450
|
+
</g>
|
|
451
|
+
)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function EdgeSvg({ from, to, label }: { from: LayoutNode; to: LayoutNode; label?: string }) {
|
|
455
|
+
const path = computeEdgePath(from, to)
|
|
456
|
+
|
|
457
|
+
// Label position at midpoint of the bezier
|
|
458
|
+
const midX = (from.x + to.x) / 2
|
|
459
|
+
const midY = (from.y + from.height / 2 + to.y - to.height / 2) / 2
|
|
460
|
+
|
|
461
|
+
return (
|
|
462
|
+
<g>
|
|
463
|
+
<path
|
|
464
|
+
d={path}
|
|
465
|
+
fill="none"
|
|
466
|
+
stroke="#D1D5DB"
|
|
467
|
+
strokeWidth={1.5}
|
|
468
|
+
markerEnd="url(#fc-arrowhead)"
|
|
469
|
+
/>
|
|
470
|
+
{label && (
|
|
471
|
+
<g>
|
|
472
|
+
<rect
|
|
473
|
+
x={midX - (label.length * CHAR_WIDTH) / 2 - 6}
|
|
474
|
+
y={midY - 9}
|
|
475
|
+
width={label.length * CHAR_WIDTH + 12}
|
|
476
|
+
height={18}
|
|
477
|
+
rx={9}
|
|
478
|
+
fill="#F5F3FF"
|
|
479
|
+
/>
|
|
480
|
+
<text
|
|
481
|
+
x={midX}
|
|
482
|
+
y={midY + 1}
|
|
483
|
+
textAnchor="middle"
|
|
484
|
+
dominantBaseline="central"
|
|
485
|
+
fill="#7C3AED"
|
|
486
|
+
style={{ fontSize: '10px', fontFamily: 'var(--font-outfit, system-ui, sans-serif)', fontWeight: 500 }}
|
|
487
|
+
>
|
|
488
|
+
{label}
|
|
489
|
+
</text>
|
|
490
|
+
</g>
|
|
491
|
+
)}
|
|
492
|
+
</g>
|
|
270
493
|
)
|
|
271
494
|
}
|
|
272
495
|
|
|
@@ -292,27 +515,34 @@ export function FlowchartDiagram({ mermaid, className }: FlowchartDiagramProps)
|
|
|
292
515
|
)
|
|
293
516
|
}
|
|
294
517
|
|
|
295
|
-
const
|
|
296
|
-
const
|
|
297
|
-
for (const edge of edges) {
|
|
298
|
-
if (!outgoing.has(edge.from)) outgoing.set(edge.from, [])
|
|
299
|
-
outgoing.get(edge.from)!.push(edge)
|
|
300
|
-
if (!incoming.has(edge.to)) incoming.set(edge.to, [])
|
|
301
|
-
incoming.get(edge.to)!.push(edge)
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
const nodeMap = new Map(nodes.map(n => [n.id, n]))
|
|
305
|
-
|
|
306
|
-
const roots = nodes.filter(n => !incoming.has(n.id) || incoming.get(n.id)!.length === 0)
|
|
307
|
-
const startId = roots.length > 0 ? roots[0].id : nodes[0].id
|
|
308
|
-
|
|
309
|
-
const visited = new Set<string>()
|
|
310
|
-
const layout = buildLayout(startId, outgoing, incoming, nodeMap, visited)
|
|
518
|
+
const { layoutNodes, svgWidth, svgHeight } = computeLayout(nodes, edges)
|
|
519
|
+
const nodeById = new Map(layoutNodes.map(n => [n.id, n]))
|
|
311
520
|
|
|
312
521
|
return (
|
|
313
522
|
<div className={className}>
|
|
314
|
-
<div className="
|
|
315
|
-
<
|
|
523
|
+
<div className="overflow-x-auto">
|
|
524
|
+
<svg
|
|
525
|
+
width={svgWidth}
|
|
526
|
+
height={svgHeight}
|
|
527
|
+
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
|
|
528
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
529
|
+
className="block"
|
|
530
|
+
>
|
|
531
|
+
<SvgDefs />
|
|
532
|
+
{/* Edges behind nodes */}
|
|
533
|
+
{edges.map((e, i) => {
|
|
534
|
+
const fromNode = nodeById.get(e.from)
|
|
535
|
+
const toNode = nodeById.get(e.to)
|
|
536
|
+
if (!fromNode || !toNode) return null
|
|
537
|
+
return <EdgeSvg key={`e-${i}`} from={fromNode} to={toNode} label={e.label} />
|
|
538
|
+
})}
|
|
539
|
+
{/* Nodes on top */}
|
|
540
|
+
{layoutNodes.map(n => {
|
|
541
|
+
if (n.node.type === 'decision') return <DecisionNodeSvg key={n.id} n={n} />
|
|
542
|
+
if (n.node.type === 'terminal') return <TerminalNodeSvg key={n.id} n={n} />
|
|
543
|
+
return <ActionNodeSvg key={n.id} n={n} />
|
|
544
|
+
})}
|
|
545
|
+
</svg>
|
|
316
546
|
</div>
|
|
317
547
|
</div>
|
|
318
548
|
)
|