@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.
- package/CHANGELOG.md +367 -0
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/README.zh-CN.md +261 -0
- package/SKILL.md +115 -0
- package/cordis.patch.yml +9 -0
- package/demo-prompts.md +118 -0
- package/lib/assets/echarts.js +64 -0
- package/lib/assets/mermaid.js +3591 -0
- package/lib/assets/three.js +3844 -0
- package/lib/client.js +5 -0
- package/lib/index.js +1895 -0
- package/lib/invariant.js +19 -0
- package/lib/types/client/EChartNode.d.ts +4 -0
- package/lib/types/client/ErrorBoundary.d.ts +30 -0
- package/lib/types/client/GenuiBlock.d.ts +7 -0
- package/lib/types/client/PlotBlock.d.ts +44 -0
- package/lib/types/client/action-context.d.ts +7 -0
- package/lib/types/client/asset-echarts.d.ts +15 -0
- package/lib/types/client/asset-loader.d.ts +31 -0
- package/lib/types/client/asset-mermaid.d.ts +1 -0
- package/lib/types/client/asset-three.d.ts +1 -0
- package/lib/types/client/blocks/advanced.d.ts +104 -0
- package/lib/types/client/blocks/basic.d.ts +27 -0
- package/lib/types/client/blocks/charts.d.ts +38 -0
- package/lib/types/client/blocks/diagram/geometry.d.ts +73 -0
- package/lib/types/client/blocks/diagram/index.d.ts +5 -0
- package/lib/types/client/blocks/diagram/layout.d.ts +41 -0
- package/lib/types/client/blocks/diagram/theme.d.ts +35 -0
- package/lib/types/client/blocks/forms.d.ts +83 -0
- package/lib/types/client/blocks/render-node.d.ts +10 -0
- package/lib/types/client/blocks/state.d.ts +58 -0
- package/lib/types/client/dom-fence.d.ts +14 -0
- package/lib/types/client/echarts-lazy.d.ts +17 -0
- package/lib/types/client/fence-render.d.ts +58 -0
- package/lib/types/client/gallery.d.ts +9 -0
- package/lib/types/client/guard.d.ts +126 -0
- package/lib/types/client/index.d.ts +36 -0
- package/lib/types/client/interaction-store.d.ts +43 -0
- package/lib/types/client/mermaid-core.d.ts +8 -0
- package/lib/types/client/mermaid-lazy.d.ts +9 -0
- package/lib/types/client/mermaid-safe.d.ts +52 -0
- package/lib/types/client/panel-command.d.ts +29 -0
- package/lib/types/client/panel-store.d.ts +94 -0
- package/lib/types/client/panel.d.ts +15 -0
- package/lib/types/client/parse-partial.d.ts +37 -0
- package/lib/types/client/safe-math.d.ts +43 -0
- package/lib/types/client/scene3d-core.d.ts +21 -0
- package/lib/types/client/scene3d-lazy.d.ts +8 -0
- package/lib/types/client/spec.d.ts +610 -0
- package/lib/types/client/toolview.d.ts +7 -0
- package/lib/types/index.d.ts +1 -0
- package/lib/types/plugin/index.d.ts +25 -0
- package/lib/types/plugin/invariant.d.ts +15 -0
- package/lib/types/plugin/tool.d.ts +27 -0
- package/lib/types/shared/fence-repair.d.ts +67 -0
- package/package.json +121 -0
- package/src/client/EChartNode.tsx +242 -0
- package/src/client/ErrorBoundary.tsx +66 -0
- package/src/client/GenuiBlock.module.css +1263 -0
- package/src/client/GenuiBlock.tsx +166 -0
- package/src/client/PlotBlock.module.css +155 -0
- package/src/client/PlotBlock.tsx +431 -0
- package/src/client/action-context.ts +32 -0
- package/src/client/asset-echarts.ts +25 -0
- package/src/client/asset-loader.ts +79 -0
- package/src/client/asset-mermaid.ts +13 -0
- package/src/client/asset-three.ts +12 -0
- package/src/client/blocks/advanced.tsx +435 -0
- package/src/client/blocks/basic.tsx +116 -0
- package/src/client/blocks/charts.tsx +319 -0
- package/src/client/blocks/diagram/geometry.ts +151 -0
- package/src/client/blocks/diagram/index.tsx +284 -0
- package/src/client/blocks/diagram/layout.ts +199 -0
- package/src/client/blocks/diagram/theme.ts +108 -0
- package/src/client/blocks/forms.tsx +523 -0
- package/src/client/blocks/render-node.tsx +260 -0
- package/src/client/blocks/state.ts +61 -0
- package/src/client/dom-fence.tsx +641 -0
- package/src/client/echarts-lazy.ts +35 -0
- package/src/client/fence-render.tsx +230 -0
- package/src/client/gallery.ts +128 -0
- package/src/client/guard.ts +1450 -0
- package/src/client/index.tsx +195 -0
- package/src/client/interaction-store.ts +122 -0
- package/src/client/mermaid-core.ts +121 -0
- package/src/client/mermaid-lazy.ts +31 -0
- package/src/client/mermaid-safe.ts +122 -0
- package/src/client/panel-command.ts +119 -0
- package/src/client/panel-store.ts +471 -0
- package/src/client/panel.tsx +185 -0
- package/src/client/parse-partial.ts +162 -0
- package/src/client/safe-math.ts +270 -0
- package/src/client/scene3d-core.ts +192 -0
- package/src/client/scene3d-lazy.ts +26 -0
- package/src/client/spec.ts +767 -0
- package/src/client/toolview.tsx +66 -0
- package/src/css-modules.d.ts +5 -0
- package/src/index.ts +12 -0
- package/src/plugin/index.ts +173 -0
- package/src/plugin/invariant.ts +30 -0
- package/src/plugin/node-shims.d.ts +31 -0
- package/src/plugin/tool.ts +330 -0
- package/src/shared/fence-repair.ts +244 -0
- package/tsconfig.json +35 -0
- 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
|
+
}
|