@changfenhuang/dsh-genui 0.9.1

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 (106) hide show
  1. package/CHANGELOG.md +367 -0
  2. package/LICENSE +21 -0
  3. package/README.md +261 -0
  4. package/README.zh-CN.md +261 -0
  5. package/SKILL.md +115 -0
  6. package/cordis.patch.yml +9 -0
  7. package/demo-prompts.md +118 -0
  8. package/lib/assets/echarts.js +64 -0
  9. package/lib/assets/mermaid.js +3591 -0
  10. package/lib/assets/three.js +3844 -0
  11. package/lib/client.js +5 -0
  12. package/lib/index.js +1895 -0
  13. package/lib/invariant.js +19 -0
  14. package/lib/types/client/EChartNode.d.ts +4 -0
  15. package/lib/types/client/ErrorBoundary.d.ts +30 -0
  16. package/lib/types/client/GenuiBlock.d.ts +7 -0
  17. package/lib/types/client/PlotBlock.d.ts +44 -0
  18. package/lib/types/client/action-context.d.ts +7 -0
  19. package/lib/types/client/asset-echarts.d.ts +15 -0
  20. package/lib/types/client/asset-loader.d.ts +31 -0
  21. package/lib/types/client/asset-mermaid.d.ts +1 -0
  22. package/lib/types/client/asset-three.d.ts +1 -0
  23. package/lib/types/client/blocks/advanced.d.ts +104 -0
  24. package/lib/types/client/blocks/basic.d.ts +27 -0
  25. package/lib/types/client/blocks/charts.d.ts +38 -0
  26. package/lib/types/client/blocks/diagram/geometry.d.ts +73 -0
  27. package/lib/types/client/blocks/diagram/index.d.ts +5 -0
  28. package/lib/types/client/blocks/diagram/layout.d.ts +41 -0
  29. package/lib/types/client/blocks/diagram/theme.d.ts +35 -0
  30. package/lib/types/client/blocks/forms.d.ts +83 -0
  31. package/lib/types/client/blocks/render-node.d.ts +10 -0
  32. package/lib/types/client/blocks/state.d.ts +58 -0
  33. package/lib/types/client/dom-fence.d.ts +14 -0
  34. package/lib/types/client/echarts-lazy.d.ts +17 -0
  35. package/lib/types/client/fence-render.d.ts +58 -0
  36. package/lib/types/client/gallery.d.ts +9 -0
  37. package/lib/types/client/guard.d.ts +126 -0
  38. package/lib/types/client/index.d.ts +36 -0
  39. package/lib/types/client/interaction-store.d.ts +43 -0
  40. package/lib/types/client/mermaid-core.d.ts +8 -0
  41. package/lib/types/client/mermaid-lazy.d.ts +9 -0
  42. package/lib/types/client/mermaid-safe.d.ts +52 -0
  43. package/lib/types/client/panel-command.d.ts +29 -0
  44. package/lib/types/client/panel-store.d.ts +94 -0
  45. package/lib/types/client/panel.d.ts +15 -0
  46. package/lib/types/client/parse-partial.d.ts +37 -0
  47. package/lib/types/client/safe-math.d.ts +43 -0
  48. package/lib/types/client/scene3d-core.d.ts +21 -0
  49. package/lib/types/client/scene3d-lazy.d.ts +8 -0
  50. package/lib/types/client/spec.d.ts +610 -0
  51. package/lib/types/client/toolview.d.ts +7 -0
  52. package/lib/types/index.d.ts +1 -0
  53. package/lib/types/plugin/index.d.ts +25 -0
  54. package/lib/types/plugin/invariant.d.ts +15 -0
  55. package/lib/types/plugin/tool.d.ts +27 -0
  56. package/lib/types/shared/fence-repair.d.ts +67 -0
  57. package/package.json +121 -0
  58. package/src/client/EChartNode.tsx +242 -0
  59. package/src/client/ErrorBoundary.tsx +66 -0
  60. package/src/client/GenuiBlock.module.css +1263 -0
  61. package/src/client/GenuiBlock.tsx +166 -0
  62. package/src/client/PlotBlock.module.css +155 -0
  63. package/src/client/PlotBlock.tsx +431 -0
  64. package/src/client/action-context.ts +32 -0
  65. package/src/client/asset-echarts.ts +25 -0
  66. package/src/client/asset-loader.ts +79 -0
  67. package/src/client/asset-mermaid.ts +13 -0
  68. package/src/client/asset-three.ts +12 -0
  69. package/src/client/blocks/advanced.tsx +435 -0
  70. package/src/client/blocks/basic.tsx +116 -0
  71. package/src/client/blocks/charts.tsx +319 -0
  72. package/src/client/blocks/diagram/geometry.ts +151 -0
  73. package/src/client/blocks/diagram/index.tsx +284 -0
  74. package/src/client/blocks/diagram/layout.ts +199 -0
  75. package/src/client/blocks/diagram/theme.ts +108 -0
  76. package/src/client/blocks/forms.tsx +523 -0
  77. package/src/client/blocks/render-node.tsx +260 -0
  78. package/src/client/blocks/state.ts +61 -0
  79. package/src/client/dom-fence.tsx +641 -0
  80. package/src/client/echarts-lazy.ts +35 -0
  81. package/src/client/fence-render.tsx +230 -0
  82. package/src/client/gallery.ts +128 -0
  83. package/src/client/guard.ts +1450 -0
  84. package/src/client/index.tsx +195 -0
  85. package/src/client/interaction-store.ts +122 -0
  86. package/src/client/mermaid-core.ts +121 -0
  87. package/src/client/mermaid-lazy.ts +31 -0
  88. package/src/client/mermaid-safe.ts +122 -0
  89. package/src/client/panel-command.ts +119 -0
  90. package/src/client/panel-store.ts +471 -0
  91. package/src/client/panel.tsx +185 -0
  92. package/src/client/parse-partial.ts +162 -0
  93. package/src/client/safe-math.ts +270 -0
  94. package/src/client/scene3d-core.ts +192 -0
  95. package/src/client/scene3d-lazy.ts +26 -0
  96. package/src/client/spec.ts +767 -0
  97. package/src/client/toolview.tsx +66 -0
  98. package/src/css-modules.d.ts +5 -0
  99. package/src/index.ts +12 -0
  100. package/src/plugin/index.ts +173 -0
  101. package/src/plugin/invariant.ts +30 -0
  102. package/src/plugin/node-shims.d.ts +31 -0
  103. package/src/plugin/tool.ts +330 -0
  104. package/src/shared/fence-repair.ts +244 -0
  105. package/tsconfig.json +35 -0
  106. package/tsdown.config.ts +193 -0
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Chart family: categorical palette, the sortable table, and the bars / line
3
+ * / donut renderers. All local-first; no model round trips.
4
+ * @module @changfenhuang/dsh-genui/client/blocks/charts
5
+ */
6
+ import { memo, useState } from 'react'
7
+ import css from '../GenuiBlock.module.css'
8
+ import { GENUI_LIMITS } from '../guard.ts'
9
+ import type { GenuiChart, GenuiTable } from '../spec.ts'
10
+
11
+ export const CHART_COLORS = [
12
+ 'var(--dsw-static-deepseek-400)',
13
+ 'var(--dsw-static-green-400)',
14
+ 'var(--dsw-static-amber-400)',
15
+ 'var(--dsw-static-red-400)',
16
+ 'var(--dsw-static-blue-450)',
17
+ 'var(--dsw-static-deepseek-450)',
18
+ 'var(--dsw-static-neutral-bluish-400)',
19
+ 'var(--dsw-static-deepseek-300)',
20
+ ]
21
+
22
+ /** Series color: explicit color wins; multi-series auto-assign from the palette. */
23
+ const seriesColor = (i: number, n: number, c?: string): string | undefined =>
24
+ c ?? (n > 1 ? CHART_COLORS[i % CHART_COLORS.length] : undefined)
25
+
26
+ /**
27
+ * Sortable numeric value of a cell. Human-written table cells are rarely
28
+ * plain numbers, so the sort accepts the usual decorations:
29
+ * `1,234` / `1,234`(千分位)、`1.2k`/`3M`/`5b`、`3.5万`/`2亿`、`0.3%`、
30
+ * `¥99`/`$12`。A cell that cannot be read as a number returns NaN and the
31
+ * row falls back to the text comparison — mixed columns sort deterministically
32
+ * (numbers first, then text).
33
+ */
34
+ export function parseSortableNumber(v: unknown): number {
35
+ if (typeof v === 'number') return Number.isFinite(v) ? v : NaN
36
+ if (typeof v !== 'string') return NaN
37
+ let s = v.trim()
38
+ if (s === '') return NaN
39
+ s = s.replace(/^[¥$€£]/, '')
40
+ const pct = s.endsWith('%')
41
+ if (pct) s = s.slice(0, -1)
42
+ // 中文单位在前:3.5万 → 35000、2亿 → 200000000;再是 k/m/b 后缀。
43
+ let mult = 1
44
+ if (s.endsWith('万')) { mult = 10_000; s = s.slice(0, -1) }
45
+ else if (s.endsWith('亿')) { mult = 100_000_000; s = s.slice(0, -1) }
46
+ else if (/[kmb]$/i.test(s)) {
47
+ const unit = s.slice(-1).toLowerCase()
48
+ mult = unit === 'k' ? 1e3 : unit === 'm' ? 1e6 : 1e9
49
+ s = s.slice(0, -1)
50
+ }
51
+ s = s.replace(/[,,\s]/g, '')
52
+ const n = Number(s)
53
+ if (!Number.isFinite(n)) return NaN
54
+ return n * mult
55
+ }
56
+
57
+ /** A column is numeric when every non-empty cell parses to a finite number —
58
+ * those columns right-align with tabular numerals (the table's data voice). */
59
+ function numericColumns(rows: GenuiTable['rows'], nCols: number): boolean[] {
60
+ return Array.from({ length: nCols }, (_, j) => {
61
+ let any = false
62
+ for (const row of rows) {
63
+ const cell = row[j]
64
+ if (cell === undefined || cell === null || cell === '') continue
65
+ if (!Number.isFinite(parseSortableNumber(cell))) return false
66
+ any = true
67
+ }
68
+ return any
69
+ })
70
+ }
71
+
72
+ export const TableNode = memo(function TableNode({ node }: { node: GenuiTable }) {
73
+ const columns = node.columns.slice(0, GENUI_LIMITS.maxTableCols)
74
+ const rows = node.rows.slice(0, GENUI_LIMITS.maxTableRows)
75
+ const [sort, setSort] = useState<{ col: number; dir: 1 | -1 } | null>(null)
76
+ const sorted = sort === null
77
+ ? rows
78
+ : [...rows].sort((a, b) => {
79
+ const an = parseSortableNumber(a[sort.col])
80
+ const bn = parseSortableNumber(b[sort.col])
81
+ if (Number.isFinite(an) && Number.isFinite(bn) && an !== bn) return (an - bn) * sort.dir
82
+ if (Number.isFinite(an) !== Number.isFinite(bn)) return Number.isFinite(an) ? -sort.dir : sort.dir
83
+ const as = String(a[sort.col] ?? '')
84
+ const bs = String(b[sort.col] ?? '')
85
+ return (as < bs ? -1 : as > bs ? 1 : 0) * sort.dir
86
+ })
87
+ const clickHeader = (i: number): void => {
88
+ setSort(prev => prev !== null && prev.col === i
89
+ ? prev.dir === 1 ? { col: i, dir: -1 } : null
90
+ : { col: i, dir: 1 })
91
+ }
92
+ const numeric = numericColumns(rows, columns.length)
93
+ return (
94
+ <div className={css.tableWrap}>
95
+ <table className={css.table}>
96
+ <thead>
97
+ <tr>
98
+ {columns.map((c, i) => (
99
+ <th
100
+ key={i}
101
+ className={numeric[i] ? css.thNum : undefined}
102
+ aria-sort={sort !== null && sort.col === i ? (sort.dir === 1 ? 'ascending' : 'descending') : 'none'}
103
+ >
104
+ <button type="button" className={css.thSort} onClick={() => clickHeader(i)}>
105
+ {c}
106
+ {sort !== null && sort.col === i && <span className={css.thSortMark} aria-hidden>{sort.dir === 1 ? ' ▲' : ' ▼'}</span>}
107
+ </button>
108
+ </th>
109
+ ))}
110
+ </tr>
111
+ </thead>
112
+ <tbody>
113
+ {sorted.map((row, i) => (
114
+ <tr key={i}>{row.slice(0, columns.length).map((cell, j) => (
115
+ <td key={j} className={numeric[j] ? css.tdNum : undefined}>{String(cell)}</td>
116
+ ))}</tr>
117
+ ))}
118
+ </tbody>
119
+ </table>
120
+ </div>
121
+ )
122
+ })
123
+
124
+ /** Chart: bars (default), line (trend), or donut (share); multi-series bars via `series`.
125
+ * memoized: the spec node is a stable reference, so a keystroke in a sibling
126
+ * field no longer re-renders the whole chart. */
127
+ export const ChartNode = memo(function ChartNode({ chart }: { chart: GenuiChart }) {
128
+ const kind = chart.kind ?? 'bars'
129
+ if (kind === 'donut') return <DonutNode chart={chart} />
130
+ if (kind === 'line') return <LineChartNode chart={chart} />
131
+ return <BarsNode chart={chart} />
132
+ })
133
+
134
+ /** Bars: one column per datum (grouped bars when `series` is present). */
135
+ export const BarsNode = memo(function BarsNode({ chart }: { chart: GenuiChart }) {
136
+ const grouped = chart.series !== undefined ? chart.series.slice(0, GENUI_LIMITS.maxPlotSeries) : undefined
137
+ if (grouped !== undefined && grouped.length > 0) {
138
+ const labels = grouped[0]!.data.map(d => d.label)
139
+ const max = Math.max(...grouped.flatMap(s => s.data.map(d => Number(d.value) || 0)), 1)
140
+ return (
141
+ <div className={css.chart}>
142
+ <div className={css.chartPlot}>
143
+ {[0, 25, 50, 75].map(p => (
144
+ <span key={p} className={p === 0 ? css.baseline : css.gridline} style={{ bottom: `${p}%` }} />
145
+ ))}
146
+ {labels.map((_, i) => (
147
+ <div key={i} className={css.barCol}>
148
+ <div className={css.groupedBars}>
149
+ {grouped.map((s, si) => {
150
+ const d = s.data[i]
151
+ // Cap at 82% so the per-bar value annotation stays inside
152
+ // the plot; negatives clamp to a zero-height bar.
153
+ const v = d === undefined ? 0 : Number(d.value) || 0
154
+ const h = d === undefined ? 0 : Math.min(Math.round((Math.max(0, v) / max) * 100), 82)
155
+ return (
156
+ <div key={si} className={css.groupedBar} title={d === undefined ? s.label : `${s.label}: ${String(d.value)}`}>
157
+ <span className={css.groupValue}>{d === undefined ? '' : String(d.value)}</span>
158
+ <div
159
+ className={css.groupedFill}
160
+ style={{
161
+ height: `${h}%`,
162
+ background: seriesColor(si, grouped.length, s.color) ?? 'var(--dsw-alias-state-business-primary, #4f8ef7)',
163
+ }}
164
+ />
165
+ </div>
166
+ )
167
+ })}
168
+ </div>
169
+ </div>
170
+ ))}
171
+ </div>
172
+ <div className={css.chartLabels}>
173
+ {labels.map(label => <span key={label} className={css.barLabel}>{label}</span>)}
174
+ </div>
175
+ </div>
176
+ )
177
+ }
178
+ const data = chart.data.slice(0, GENUI_LIMITS.maxChartPoints)
179
+ // Negative values clamp to a zero-height bar (the value annotation still
180
+ // shows the real number) — a negative `height` percentage is invalid CSS
181
+ // and used to collapse the bar entirely.
182
+ const max = Math.max(...data.map(d => Number(d.value) || 0), 1)
183
+ return (
184
+ <div className={css.chart}>
185
+ <div className={css.chartPlot}>
186
+ {[0, 25, 50, 75].map(p => (
187
+ <span key={p} className={p === 0 ? css.baseline : css.gridline} style={{ bottom: `${p}%` }} />
188
+ ))}
189
+ {data.map((d, i) => {
190
+ // Cap at 85% so the value annotation always stays inside the plot.
191
+ const v = Number(d.value) || 0
192
+ const h = Math.min(Math.round((Math.max(0, v) / max) * 100), 85)
193
+ return (
194
+ <div key={i} className={css.barCol} title={`${d.label}: ${String(d.value)}`}>
195
+ <span className={css.barValue}>{String(d.value)}</span>
196
+ <div className={css.barFill} style={{ height: `${h}%`, ...(d.color !== undefined ? { background: d.color } : {}) }} />
197
+ </div>
198
+ )
199
+ })}
200
+ </div>
201
+ <div className={css.chartLabels}>
202
+ {data.map(d => <span key={d.label} className={css.barLabel}>{d.label}</span>)}
203
+ </div>
204
+ </div>
205
+ )
206
+ })
207
+
208
+ /** Line: polyline over a fixed-height plot area with a readable Y axis —
209
+ * four evenly spaced gridlines + tick labels (design system v2 skeleton). */
210
+ export const LineChartNode = memo(function LineChartNode({ chart }: { chart: GenuiChart }) {
211
+ const data = chart.data.slice(0, GENUI_LIMITS.maxChartPoints)
212
+ const W = 460
213
+ const H = 150
214
+ const padL = 36
215
+ const padR = 8
216
+ const padT = 10
217
+ const padB = 6
218
+ const max = Math.max(...data.map(d => Number(d.value) || 0), 1)
219
+ const min = Math.min(...data.map(d => Number(d.value) || 0), 0)
220
+ const span = max - min || 1
221
+ const n = Math.max(data.length - 1, 1)
222
+ const pt = (i: number, v: number): [number, number] => [
223
+ padL + (i / n) * (W - padL - padR),
224
+ padT + (1 - (v - min) / span) * (H - padT - padB),
225
+ ]
226
+ const d = data.map((datum, i) => pt(i, Number(datum.value) || 0))
227
+ const path = d.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' ')
228
+ const ticks = [0, 1, 2, 3].map(i => min + (span * i) / 3)
229
+ const formatTick = (t: number): string => {
230
+ const abs = Math.abs(t)
231
+ if (abs >= 1000) return `${(t / 1000).toFixed(abs % 1000 === 0 ? 0 : 1)}k`
232
+ if (Number.isInteger(t)) return String(t)
233
+ return t.toFixed(1)
234
+ }
235
+ return (
236
+ <div className={css.lineChart}>
237
+ <svg width="100%" viewBox={`0 0 ${W} ${H}`}>
238
+ {ticks.map((t, i) => {
239
+ const y = padT + (1 - (t - min) / span) * (H - padT - padB)
240
+ return (
241
+ <g key={i}>
242
+ <line x1={padL} x2={W - padR} y1={y} y2={y} className={i === 0 ? css.lineGridAxis : css.lineGrid} />
243
+ <text x={padL - 6} y={y + 3} textAnchor="end" className={css.lineTick}>{formatTick(t)}</text>
244
+ </g>
245
+ )
246
+ })}
247
+ {data.map((datum, i) => {
248
+ const [x, y] = pt(i, Number(datum.value) || 0)
249
+ return (
250
+ <circle key={i} cx={x} cy={y} r={3} className={css.lineDot} fill={datum.color ?? undefined}>
251
+ <title>{`${datum.label}: ${String(datum.value)}`}</title>
252
+ </circle>
253
+ )
254
+ })}
255
+ <path d={path} className={css.linePath} />
256
+ </svg>
257
+ <div className={css.lineLabels}>
258
+ {data.map((d, i) => <span key={i} className={css.barLabel}>{d.label}</span>)}
259
+ </div>
260
+ </div>
261
+ )
262
+ })
263
+
264
+ /** Donut: share of total with a center total. Negative values contribute
265
+ * zero arc (a negative dasharray segment used to produce an invalid
266
+ * stroke-dasharray and the browser drew the FULL circle instead). */
267
+ export const DonutNode = memo(function DonutNode({ chart }: { chart: GenuiChart }) {
268
+ const data = chart.data.slice(0, GENUI_LIMITS.maxChartPoints)
269
+ const clamped = data.map(d => ({ ...d, v: Math.max(0, Number(d.value) || 0) }))
270
+ const total = clamped.reduce((s, d) => s + d.v, 0) || 1
271
+ // Center total: 1 decimal for fractional sums — a share-of-total figure
272
+ // like 3.3/9.9 used to print the raw float as 6.6000000000000005.
273
+ const totalText = total >= 1000
274
+ ? `${Math.round(total / 100) / 10}k`
275
+ : Number.isInteger(total) ? String(total) : total.toFixed(1)
276
+ const R = 42
277
+ const C = 2 * Math.PI * R
278
+ let offset = 0
279
+ return (
280
+ <div className={css.donut}>
281
+ <svg width="120" height="120" viewBox="0 0 120 120">
282
+ <circle cx="60" cy="60" r={R} fill="none" strokeWidth="14" className={css.donutTrack} />
283
+ {clamped.map((d, i) => {
284
+ const frac = d.v / total
285
+ const len = frac * C
286
+ const el = (
287
+ <circle
288
+ key={i}
289
+ cx="60" cy="60" r={R} fill="none" strokeWidth="14"
290
+ className={css.donutSeg}
291
+ style={{ stroke: seriesColor(i, data.length, d.color) ?? 'var(--dsw-alias-state-business-primary, #4f8ef7)' }}
292
+ strokeDasharray={`${len} ${C - len}`}
293
+ strokeDashoffset={-offset}
294
+ transform="rotate(-90 60 60)"
295
+ >
296
+ <title>{`${d.label}: ${String(d.value)}`}</title>
297
+ </circle>
298
+ )
299
+ offset += len
300
+ return el
301
+ })}
302
+ <text x="60" y="58" textAnchor="middle" className={css.donutTotal}>{totalText}</text>
303
+ <text x="60" y="74" textAnchor="middle" className={css.donutTotalLabel}>合计</text>
304
+ </svg>
305
+ <div className={css.donutLegend}>
306
+ {data.map((d, i) => (
307
+ <span key={i} className={css.legendItem}>
308
+ <span className={css.legendSwatch} style={{ background: seriesColor(i, data.length, d.color) ?? 'var(--dsw-alias-state-business-primary, #4f8ef7)' }} />
309
+ {d.label} · {String(d.value)}
310
+ </span>
311
+ ))}
312
+ </div>
313
+ </div>
314
+ )
315
+ })
316
+
317
+ /** Tab strip with local active-tab state. Keyboard: ArrowLeft/Right to move,
318
+ * Home/End to jump; ids wired via useId so `aria-controls` stays unique
319
+ * across fences and sessions. */
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Editorial diagram geometry: the mandatory orthogonal-connector system from
3
+ * diagram-design §6, ported to pure functions producing SVG path strings.
4
+ *
5
+ * Rules enforced here (the "non-negotiable" list):
6
+ * 1. Rounded right-angle (orthogonal) elbows with r=8 — never diagonal lines
7
+ * between off-axis nodes.
8
+ * 2. Edge labels sit 6–10px off the stroke behind an opaque mask.
9
+ * 3. No two connectors share a path — parallel connectors offset ≥12px.
10
+ * 4. Shared-edge attach points fan ≥12px apart.
11
+ * 5. A connector never passes behind a non-endpoint box (dashed-transit
12
+ * exception only when geometrically unavoidable).
13
+ * 6. Label masks never overlap nodes (nodes paint after labels).
14
+ * @module @changfenhuang/dsh-genui/client/blocks/diagram/geometry
15
+ */
16
+
17
+ /** Axis-aligned box. */
18
+ export interface Box {
19
+ x: number
20
+ y: number
21
+ w: number
22
+ h: number
23
+ }
24
+
25
+ export interface Point {
26
+ x: number
27
+ y: number
28
+ }
29
+
30
+ export type Port = 'left' | 'right' | 'top' | 'bottom'
31
+
32
+ /** Attachment on a box edge. */
33
+ export interface Attach {
34
+ x: number
35
+ y: number
36
+ port: Port
37
+ }
38
+
39
+ const R = 8 // elbow radius (r=6 minimum allowed for tight layouts)
40
+
41
+ function centerOf(b: Box): Point {
42
+ return { x: b.x + b.w / 2, y: b.y + b.h / 2 }
43
+ }
44
+
45
+ /** Center of a box. */
46
+ export function center(b: Box): Point {
47
+ return centerOf(b)
48
+ }
49
+
50
+ /**
51
+ * Pick the attach point on `from` toward `to`. Port choice follows the
52
+ * dominant axis: mostly-horizontal travel uses left/right ports, mostly-
53
+ * vertical uses top/bottom. An explicit `prefer` overrides the heuristic.
54
+ */
55
+ export function attachPoint(box: Box, toward: Box, prefer: Port | undefined): Attach {
56
+ if (prefer === 'left') return { x: box.x, y: box.y + box.h / 2, port: 'left' }
57
+ if (prefer === 'right') return { x: box.x + box.w, y: box.y + box.h / 2, port: 'right' }
58
+ if (prefer === 'top') return { x: box.x + box.w / 2, y: box.y, port: 'top' }
59
+ if (prefer === 'bottom') return { x: box.x + box.w / 2, y: box.y + box.h, port: 'bottom' }
60
+ const c = centerOf(box)
61
+ const t = centerOf(toward)
62
+ const dx = t.x - c.x
63
+ const dy = t.y - c.y
64
+ if (Math.abs(dx) >= Math.abs(dy)) {
65
+ return dx >= 0
66
+ ? { x: box.x + box.w, y: box.y + box.h / 2, port: 'right' }
67
+ : { x: box.x, y: box.y + box.h / 2, port: 'left' }
68
+ }
69
+ return dy >= 0
70
+ ? { x: box.x + box.w / 2, y: box.y + box.h, port: 'bottom' }
71
+ : { x: box.x + box.w / 2, y: box.y, port: 'top' }
72
+ }
73
+
74
+ /**
75
+ * Build an orthogonal path between two attach points. Same-axis endpoints use
76
+ * a straight segment (the one sanctioned case); every bend is a quarter arc
77
+ * of radius R.
78
+ */
79
+ export function orthogonalPath(a: Attach, b: Attach): string {
80
+ if (a.x === b.x) return `M ${a.x} ${a.y} L ${b.x} ${b.y}`
81
+ if (a.y === b.y) return `M ${a.x} ${a.y} L ${b.x} ${b.y}`
82
+
83
+ const horizA = a.port === 'left' || a.port === 'right'
84
+ const horizB = b.port === 'left' || b.port === 'right'
85
+
86
+ // Horizontal exit + horizontal entry: two elbows through the midpoint x.
87
+ if (horizA && horizB) {
88
+ const midX = (a.x + b.x) / 2
89
+ const dirA = a.port === 'right' ? 1 : -1
90
+ return `M ${a.x} ${a.y} H ${midX - dirA * R} Q ${midX} ${a.y} ${midX} ${a.y + R} V ${b.y - R} Q ${midX} ${b.y} ${midX + R} ${b.y} H ${b.x}`
91
+ }
92
+
93
+ // Vertical exit + vertical entry: two elbows through the midpoint y.
94
+ if (!horizA && !horizB) {
95
+ const midY = (a.y + b.y) / 2
96
+ const dirA = a.port === 'bottom' ? 1 : -1
97
+ return `M ${a.x} ${a.y} V ${midY - dirA * R} Q ${a.x} ${midY} ${a.x + R} ${midY} H ${b.x - R} Q ${b.x} ${midY} ${b.x} ${midY + R} V ${b.y}`
98
+ }
99
+
100
+ // Mixed exit/entry: one orthogonal bend, then travel along the entry axis.
101
+ if (horizA) {
102
+ const dirA = a.port === 'right' ? 1 : -1
103
+ return `M ${a.x} ${a.y} H ${a.x + dirA * R * 2} Q ${a.x + dirA * R * 2} ${a.y} ${a.x + dirA * R * 2} ${b.y} V ${b.y} H ${b.x}`
104
+ }
105
+ const dirA = a.port === 'bottom' ? 1 : -1
106
+ return `M ${a.x} ${a.y} V ${a.y + dirA * R * 2} Q ${a.x} ${a.y + dirA * R * 2} ${b.x} ${a.y + dirA * R * 2} H ${b.x} V ${b.y}`
107
+ }
108
+
109
+ /**
110
+ * Route a connector between two boxes with default port selection, returning
111
+ * the path plus both attach points (for label placement and fan offsets).
112
+ */
113
+ export function routeEdge(from: Box, to: Box, opts?: { fromPort?: Port; toPort?: Port }): { d: string; a: Attach; b: Attach } {
114
+ const a = attachPoint(from, to, opts?.fromPort)
115
+ const b = attachPoint(to, from, opts?.toPort)
116
+ return { d: orthogonalPath(a, b), a, b }
117
+ }
118
+
119
+ /** Fan an attach point along a box edge (≥12px apart via the (k+1)/(n+1) rule). */
120
+ export function fanPoint(box: Box, edge: 'top' | 'bottom' | 'left' | 'right', index: number, count: number): Attach {
121
+ const n = Math.max(1, count)
122
+ const t = (index + 1) / (n + 1)
123
+ if (edge === 'top' || edge === 'bottom') {
124
+ const x = box.x + box.w * t
125
+ return edge === 'top' ? { x, y: box.y, port: 'top' } : { x, y: box.y + box.h, port: 'bottom' }
126
+ }
127
+ const y = box.y + box.h * t
128
+ return edge === 'left' ? { x: box.x, y, port: 'left' } : { x: box.x + box.w, y, port: 'right' }
129
+ }
130
+
131
+ /**
132
+ * Edge-label geometry: centered on the dominant segment, offset 6–10px from
133
+ * the stroke so the connector stays visible.
134
+ */
135
+ export function labelGeometry(a: Attach, b: Attach): { cx: number; cy: number; maskY: number; vertical: boolean } {
136
+ const cx = (a.x + b.x) / 2
137
+ const cy = (a.y + b.y) / 2
138
+ const vertical = Math.abs(a.x - b.x) < Math.abs(a.y - b.y)
139
+ if (vertical) {
140
+ const side = b.x >= a.x ? 1 : -1
141
+ return { cx: cx + side * 16, cy, maskY: cy - 6, vertical }
142
+ }
143
+ return { cx, cy, maskY: cy - 20, vertical }
144
+ }
145
+
146
+ /** Hop / bridge arc for a crossing connector (applied to the less important edge). */
147
+ export function bridgePath(cx: number, cy: number, horizontal: boolean): string {
148
+ return horizontal
149
+ ? `M ${cx - 8} ${cy} a 8,8 0 0,1 16,0`
150
+ : `M ${cx} ${cy - 8} a 8,8 0 0,0 0,16`
151
+ }