@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.
@@ -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 nodes = new Map<string, FlowNode>()
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('flowchart') && !l.startsWith('graph'))
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
- const edgeMatch = line.match(/^(.+?)\s*-->(?:\|(.+?)\|)?\s*(.+)$/)
51
- if (!edgeMatch) continue
124
+ // Skip directives
125
+ if (/^(flowchart|graph|subgraph|end|style|classDef|class|click|linkStyle)\b/i.test(line)) continue
52
126
 
53
- const leftRaw = edgeMatch[1].trim()
54
- const edgeLabel = edgeMatch[2]?.trim()
55
- const rightRaw = edgeMatch[3].trim()
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
- const left = parseNodeDef(leftRaw)
58
- const right = parseNodeDef(rightRaw)
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
- if (left.label && !nodes.has(left.id)) {
61
- nodes.set(left.id, { id: left.id, label: left.label, type: left.type || 'action' })
62
- }
63
- if (right.label && !nodes.has(right.id)) {
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
- edges.push({ from: left.id, to: right.id, label: edgeLabel })
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(nodes.values()), edges }
150
+ return { nodes: Array.from(nodeMap.values()), edges }
77
151
  }
78
152
 
79
153
  // ============================================
80
- // Layout Builder
154
+ // Layout (BFS Layered / Sugiyama-lite)
81
155
  // ============================================
82
156
 
83
- function findMergePoint(
84
- branchStarts: string[],
85
- outgoing: Map<string, FlowEdge[]>,
86
- ): string | null {
87
- const reachable = new Map<string, Set<string>>()
88
-
89
- for (const start of branchStarts) {
90
- const q = [start]
91
- const seen = new Set<string>()
92
- while (q.length > 0) {
93
- const id = q.shift()!
94
- if (seen.has(id)) continue
95
- seen.add(id)
96
- if (!reachable.has(id)) reachable.set(id, new Set())
97
- reachable.get(id)!.add(start)
98
- const outs = outgoing.get(id) || []
99
- for (const e of outs) q.push(e.to)
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
- const allBranches = new Set(branchStarts)
104
- const q = [branchStarts[0]]
105
- const seen = new Set<string>()
106
- while (q.length > 0) {
107
- const id = q.shift()!
108
- if (seen.has(id)) continue
109
- seen.add(id)
110
- if (!allBranches.has(id) && reachable.get(id)?.size === branchStarts.length) {
111
- return id
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
- const outs = outgoing.get(id) || []
114
- for (const e of outs) q.push(e.to)
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
- return null
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
- type LayoutItem =
120
- | { type: 'node'; nodeId: string }
121
- | { type: 'arrow'; label?: string }
122
- | { type: 'branch'; decision: string; branches: { label?: string; items: LayoutItem[] }[]; mergeId: string | null }
123
-
124
- function buildLayout(
125
- startId: string,
126
- outgoing: Map<string, FlowEdge[]>,
127
- incoming: Map<string, FlowEdge[]>,
128
- nodeMap: Map<string, FlowNode>,
129
- visited: Set<string>
130
- ): LayoutItem[] {
131
- const items: LayoutItem[] = []
132
- let currentId: string | null = startId
133
-
134
- while (currentId) {
135
- if (visited.has(currentId)) break
136
-
137
- const node = nodeMap.get(currentId)
138
- if (!node) break
139
-
140
- const outs: FlowEdge[] = outgoing.get(currentId) || []
141
-
142
- if (outs.length <= 1) {
143
- visited.add(currentId)
144
- items.push({ type: 'node', nodeId: currentId })
145
- if (outs.length === 1) {
146
- const nextId: string = outs[0].to
147
- if (visited.has(nextId)) break
148
- items.push({ type: 'arrow', label: outs[0].label })
149
- currentId = nextId
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
- visited.add(currentId)
155
- items.push({ type: 'node', nodeId: currentId })
156
-
157
- const branchStarts: string[] = outs.map((e: FlowEdge) => e.to)
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
- items.push({ type: 'branch', decision: currentId, branches, mergeId })
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
- if (mergeId && !visited.has(mergeId)) {
173
- items.push({ type: 'arrow' })
174
- currentId = mergeId
175
- } else {
176
- currentId = null
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
- return items
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
- // Render Components
351
+ // SVG Components
186
352
  // ============================================
187
353
 
188
- function FlowArrow({ label }: { label?: string }) {
354
+ function SvgDefs() {
189
355
  return (
190
- <div className="flex flex-col items-center">
191
- {label && (
192
- <span className="text-[10px] font-medium text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded mb-0.5">
193
- {label}
194
- </span>
195
- )}
196
- <div className="w-px h-4 bg-gray-300" />
197
- <div className="w-0 h-0 border-l-[4px] border-r-[4px] border-t-[5px] border-l-transparent border-r-transparent border-t-gray-300" />
198
- </div>
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 FlowNodeBox({ node }: { node: FlowNode }) {
203
- if (node.type === 'decision') {
204
- return (
205
- <div
206
- className="bg-amber-50 border-2 border-amber-300 rounded-lg px-4 py-2.5 text-xs font-medium text-amber-800 text-center my-1"
207
- style={{ minWidth: '120px' }}
208
- >
209
- <span className="text-amber-400 mr-1">&#x25C7;</span>
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
- <div className="bg-white border border-gray-200 rounded-sm px-4 py-2 text-xs font-medium text-[var(--black)] text-center shadow-sm my-1 max-w-[220px]">
223
- {node.label}
224
- </div>
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 RenderLayoutItems({ items, nodeMap }: { items: LayoutItem[]; nodeMap: Map<string, FlowNode> }) {
399
+ function ActionNodeSvg({ n }: { n: LayoutNode }) {
400
+ const lines = wrapText(n.node.label, n.width - 24)
229
401
  return (
230
- <>
231
- {items.map((item, i) => {
232
- if (item.type === 'node') {
233
- const node = nodeMap.get(item.nodeId)
234
- if (!node) return null
235
- return <FlowNodeBox key={`node-${item.nodeId}`} node={node} />
236
- }
237
- if (item.type === 'arrow') {
238
- return <FlowArrow key={`arrow-${i}`} label={item.label} />
239
- }
240
- if (item.type === 'branch') {
241
- return (
242
- <div key={`branch-${item.decision}-${i}`} className="flex flex-col items-center w-full">
243
- <div className="w-px h-3 bg-gray-300" />
244
- <div className="flex items-start justify-center gap-6 w-full">
245
- {item.branches.map((branch, j) => (
246
- <div key={j} className="flex flex-col items-center min-w-[100px]">
247
- <div className="flex flex-col items-center">
248
- {branch.label && (
249
- <span className="text-[10px] font-medium text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded mb-1">
250
- {branch.label}
251
- </span>
252
- )}
253
- <div className="w-0 h-0 border-l-[4px] border-r-[4px] border-t-[5px] border-l-transparent border-r-transparent border-t-gray-300" />
254
- </div>
255
- <div className="flex flex-col items-center">
256
- <RenderLayoutItems items={branch.items} nodeMap={nodeMap} />
257
- </div>
258
- </div>
259
- ))}
260
- </div>
261
- {item.mergeId && (
262
- <div className="w-px h-3 bg-gray-300" />
263
- )}
264
- </div>
265
- )
266
- }
267
- return null
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 outgoing = new Map<string, FlowEdge[]>()
296
- const incoming = new Map<string, FlowEdge[]>()
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="flex flex-col items-center py-2">
315
- <RenderLayoutItems items={layout} nodeMap={nodeMap} />
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
  )