@changfenhuang/dsh-genui 0.9.9 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/SKILL.md +128 -6
  5. package/lib/assets/echarts-core.js +43 -0
  6. package/lib/assets/{echarts.js → echarts-full.js} +1 -1
  7. package/lib/assets/mermaid.js +1 -1
  8. package/lib/client.js +5 -3
  9. package/lib/index.js +230 -69
  10. package/lib/types/client/EChartNode.d.ts +9 -0
  11. package/lib/types/client/GenuiBlock.d.ts +3 -4
  12. package/lib/types/client/asset-echarts-core.d.ts +1 -0
  13. package/lib/types/client/asset-echarts.d.ts +3 -2
  14. package/lib/types/client/asset-loader.d.ts +2 -2
  15. package/lib/types/client/blocks/advanced.d.ts +0 -3
  16. package/lib/types/client/blocks/charts.d.ts +23 -12
  17. package/lib/types/client/blocks/state.d.ts +5 -3
  18. package/lib/types/client/echarts-lazy.d.ts +4 -1
  19. package/lib/types/client/genui-runtime/schema.d.ts +11 -1
  20. package/lib/types/client/inline.d.ts +29 -0
  21. package/lib/types/client/spec.d.ts +76 -6
  22. package/lib/types/plugin/index.d.ts +1 -1
  23. package/package.json +4 -3
  24. package/src/client/EChartNode.tsx +233 -19
  25. package/src/client/GenuiBlock.module.css +895 -145
  26. package/src/client/GenuiBlock.tsx +31 -23
  27. package/src/client/PlotBlock.module.css +17 -1
  28. package/src/client/PlotBlock.tsx +33 -1
  29. package/src/client/asset-echarts-core.ts +39 -0
  30. package/src/client/asset-echarts.ts +4 -3
  31. package/src/client/asset-loader.ts +5 -3
  32. package/src/client/blocks/advanced.tsx +60 -16
  33. package/src/client/blocks/charts.tsx +761 -144
  34. package/src/client/blocks/render-node.tsx +252 -11
  35. package/src/client/blocks/state.ts +5 -3
  36. package/src/client/dom-fence.tsx +60 -6
  37. package/src/client/echarts-lazy.ts +11 -2
  38. package/src/client/fence-render.tsx +6 -5
  39. package/src/client/gallery.ts +78 -2
  40. package/src/client/genui-runtime/schema.ts +45 -11
  41. package/src/client/guard.ts +161 -23
  42. package/src/client/index.tsx +14 -3
  43. package/src/client/inline.ts +73 -0
  44. package/src/client/mermaid-core.ts +42 -5
  45. package/src/client/spec.ts +79 -6
  46. package/src/plugin/index.ts +33 -45
  47. package/src/plugin/tool.ts +5 -1
  48. package/tsdown.config.ts +2 -1
@@ -1,9 +1,19 @@
1
1
  /**
2
2
  * Chart family: categorical palette, the sortable table, and the bars / line
3
3
  * / donut renderers. All local-first; no model round trips.
4
+ *
5
+ * Design system v3: the native charts draw in REAL CSS pixels — the plot width
6
+ * is measured from the container and the SVG viewBox matches it 1:1 — instead
7
+ * of a fixed 460×150 viewBox whose axis text scaled with the container (wide
8
+ * screens got oversized labels, narrow ones got unreadable ones). Every chart
9
+ * gets a y-axis with nice 1/2/5 ticks, and single-series bars render against a
10
+ * true zero line so negative values are drawn, not clamped away.
4
11
  * @module @changfenhuang/dsh-genui/client/blocks/charts
5
12
  */
6
- import { memo, useState } from 'react'
13
+ import { Fragment, memo, useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
14
+ import type { MouseEvent as ReactMouseEvent, ReactNode, RefObject } from 'react'
15
+ import { writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
16
+ import { renderInline } from '../inline.ts'
7
17
  import css from '../GenuiBlock.module.css'
8
18
  import { GENUI_LIMITS } from '../genui-runtime/index.ts'
9
19
  import type { GenuiChart, GenuiTable } from '../spec.ts'
@@ -19,9 +29,12 @@ export const CHART_COLORS = [
19
29
  'var(--dsw-static-deepseek-300)',
20
30
  ]
21
31
 
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)
32
+ /** Series color: explicit color wins; then an explicit `palette` from the
33
+ * spec; then the host categorical tokens. */
34
+ const seriesColor = (i: number, n: number, c?: string, palette?: readonly string[]): string | undefined =>
35
+ c ?? (palette !== undefined && palette.length > 0
36
+ ? palette[i % palette.length]
37
+ : n > 1 ? CHART_COLORS[i % CHART_COLORS.length] : undefined)
25
38
 
26
39
  /**
27
40
  * Sortable numeric value of a cell. Human-written table cells are rarely
@@ -69,29 +82,254 @@ function numericColumns(rows: GenuiTable['rows'], nCols: number): boolean[] {
69
82
  })
70
83
  }
71
84
 
72
- export const TableNode = memo(function TableNode({ node }: { node: GenuiTable }) {
85
+ /** Signed cell text (`+12.4%`, `-3`, `−2.1k`) reads as a delta without any
86
+ * new spec field — the renderer classifies the string. */
87
+ function deltaTone(value: unknown): 'up' | 'down' | null {
88
+ if (typeof value !== 'string') return null
89
+ const s = value.trim()
90
+ if (!/^[+][\s]*[\d.]/.test(s) && !/^[-−][\s]*[\d.]/.test(s)) return null
91
+ return s.startsWith('+') ? 'up' : 'down'
92
+ }
93
+
94
+ /** `types: ["spark"]` cell: comma/space separated numbers drawn as a micro
95
+ * trend line (area wash + end dot, same geometry as stat.spark). */
96
+ function CellSpark({ cell }: { cell: string | number }) {
97
+ const values = String(cell).split(/[\s,;]+/).map(Number).filter(Number.isFinite)
98
+ if (values.length < 2) return <>{String(cell)}</>
99
+ const W = 88
100
+ const H = 22
101
+ const pad = 2
102
+ const min = Math.min(...values)
103
+ const max = Math.max(...values)
104
+ const span = max - min || 1
105
+ const step = (W - pad * 2) / (values.length - 1)
106
+ const coords = values.map((v, i) => [pad + i * step, H - pad - ((v - min) / span) * (H - pad * 2)] as const)
107
+ const points = coords.map(([px, py]) => `${px.toFixed(1)},${py.toFixed(1)}`).join(' ')
108
+ const last = coords[coords.length - 1]!
109
+ const area = `M ${pad},${H - pad} L ${points.split(' ').join(' L ')} L ${last[0].toFixed(1)},${H - pad} Z`
110
+ return (
111
+ <svg className={css.cellSpark} viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" aria-hidden="true">
112
+ <path d={area} fill="var(--dsl-g-accent)" opacity="0.14" />
113
+ <polyline points={points} fill="none" stroke="var(--dsl-g-accent)" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
114
+ <path d={`M ${last[0].toFixed(1)} ${last[1].toFixed(1)} L ${last[0].toFixed(1)} ${last[1].toFixed(1)}`} stroke="var(--dsl-g-accent)" strokeWidth={4} strokeLinecap="round" vectorEffect="non-scaling-stroke" fill="none" />
115
+ </svg>
116
+ )
117
+ }
118
+
119
+ /** `types: ["ring"]` cell: a 28px ring gauge read as 0-100. */
120
+ function CellRing({ cell }: { cell: string | number }) {
121
+ const n = parseSortableNumber(cell)
122
+ const pct = Number.isFinite(n) ? Math.max(0, Math.min(100, n)) : 0
123
+ const R = 11
124
+ const C = 2 * Math.PI * R
125
+ return (
126
+ <span className={css.cellRing}>
127
+ <svg width={28} height={28} viewBox="0 0 28 28" aria-hidden="true">
128
+ <circle cx={14} cy={14} r={R} fill="none" strokeWidth={4} className={css.ringTrack} />
129
+ <circle
130
+ cx={14} cy={14} r={R} fill="none" strokeWidth={4} strokeLinecap="round" className={css.ringFill}
131
+ strokeDasharray={`${(pct / 100) * C} ${C}`} transform="rotate(-90 14 14)"
132
+ />
133
+ </svg>
134
+ <span className={css.cellRingText}>{String(cell)}</span>
135
+ </span>
136
+ )
137
+ }
138
+
139
+ /** Markdown table text for the 复制 Markdown chip (pipes escaped). */
140
+ export function tableToMarkdown(columns: string[], rows: Array<Array<string | number>>): string {
141
+ const escape = (v: unknown): string => String(v ?? '').replace(/\|/g, '\\|').replace(/\n/g, ' ')
142
+ const head = `| ${columns.map(escape).join(' | ')} |`
143
+ const rule = `| ${columns.map(() => '---').join(' | ')} |`
144
+ const body = rows.map(row => `| ${columns.map((_c, i) => escape(row[i])).join(' | ')} |`)
145
+ return [head, rule, ...body].join('\n')
146
+ }
147
+
148
+ /** RFC 4180 CSV text for the 复制 CSV chip. */
149
+ export function tableToCsv(rows: Array<Array<string | number>>): string {
150
+ const cell = (v: unknown): string => {
151
+ const s = String(v ?? '')
152
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
153
+ }
154
+ return rows.map(row => row.map(cell).join(',')).join('\n')
155
+ }
156
+
157
+ /** Copy chips above a table (`table.export`). Local clipboard only — no action,
158
+ * no round trip; the label confirms for a moment and then resets. */
159
+ function TableExportChips({ columns, rows }: { columns: string[]; rows: Array<Array<string | number>> }) {
160
+ const [copied, setCopied] = useState<'md' | 'csv' | null>(null)
161
+ const copy = (kind: 'md' | 'csv'): void => {
162
+ const text = kind === 'md' ? tableToMarkdown(columns, rows) : tableToCsv(rows)
163
+ void writeClipboard(text).then(ok => {
164
+ if (!ok) return
165
+ setCopied(kind)
166
+ window.setTimeout(() => setCopied(null), 1200)
167
+ })
168
+ }
169
+ return (
170
+ <div className={css.tableTools}>
171
+ <button type="button" className={css.tableTool} onClick={() => copy('md')}>
172
+ {copied === 'md' ? '已复制' : '复制 Markdown'}
173
+ </button>
174
+ <button type="button" className={css.tableTool} onClick={() => copy('csv')}>
175
+ {copied === 'csv' ? '已复制' : '复制 CSV'}
176
+ </button>
177
+ </div>
178
+ )
179
+ }
180
+
181
+ /** `types: ["bar"]` cell: an inline 0-100 track with the value printed on it. */
182
+ function CellBar({ cell }: { cell: string | number }) {
183
+ const n = parseSortableNumber(cell)
184
+ const pct = Number.isFinite(n) ? Math.max(0, Math.min(100, n)) : 0
185
+ return (
186
+ <span className={css.cellBar}>
187
+ <span className={css.cellBarFill} style={{ width: `${pct}%` }} />
188
+ <span className={css.cellBarText}>{String(cell)}</span>
189
+ </span>
190
+ )
191
+ }
192
+
193
+ export const TableNode = memo(function TableNode({ node, renderDetail, filterValue, sortValue }: {
194
+ node: GenuiTable
195
+ /** Live value of `node.filter`: substring-match rows locally. */
196
+ filterValue?: string | undefined
197
+ /** Live value of `node.sortField`: a column header to sort by. */
198
+ sortValue?: string | undefined
199
+ /** Renders a row's detail nodes. Supplied by render-node so the table never
200
+ * has to import the renderer back (import cycle). */
201
+ renderDetail?: ((items: NonNullable<GenuiTable['details']>[number] & object[]) => ReactNode) | undefined
202
+ }) {
73
203
  const columns = node.columns.slice(0, GENUI_LIMITS.maxTableCols)
74
204
  const rows = node.rows.slice(0, GENUI_LIMITS.maxTableRows)
205
+ const types = node.types ?? []
206
+ const groupMode = types[0] === 'group'
75
207
  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
208
+ const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())
209
+ const [expanded, setExpanded] = useState<ReadonlySet<number>>(() => new Set())
210
+
211
+ const compare = (a: GenuiTable['rows'][number], b: GenuiTable['rows'][number], col: number, dir: 1 | -1): number => {
212
+ const an = parseSortableNumber(a[col])
213
+ const bn = parseSortableNumber(b[col])
214
+ if (Number.isFinite(an) && Number.isFinite(bn) && an !== bn) return (an - bn) * dir
215
+ if (Number.isFinite(an) !== Number.isFinite(bn)) return Number.isFinite(an) ? -dir : dir
216
+ const as = String(a[col] ?? '')
217
+ const bs = String(b[col] ?? '')
218
+ return (as < bs ? -1 : as > bs ? 1 : 0) * dir
219
+ }
220
+
221
+ // Group header rows: with `types[0] === 'group'`, a row whose first cell is
222
+ // filled and every other cell empty opens a section. Data rows after it are
223
+ // its CHILDREN — indented, counted, and collapsible — so the relationship is
224
+ // unmistakable instead of "one more row at the same level".
225
+ const isGroupRow = (row: GenuiTable['rows'][number]): boolean =>
226
+ groupMode && String(row[0] ?? '').trim() !== ''
227
+ && row.slice(1).every(cell => String(cell ?? '').trim() === '')
228
+
229
+ // Local filtering (bound control): the model ships the full data set once and
230
+ // the reader narrows it live — no round trip, no re-generation.
231
+ const needle = filterValue?.trim().toLowerCase()
232
+ const filtered = needle === undefined || needle === ''
233
+ ? rows.map((row, index) => ({ row, index }))
234
+ : rows.map((row, index) => ({ row, index })).filter(({ row }) => {
235
+ const cells = node.filterColumn === undefined
236
+ ? row
237
+ : [row[node.filterColumn]]
238
+ return cells.some(cell => String(cell ?? '').toLowerCase().includes(needle))
86
239
  })
240
+ const visibleRows = filtered.map(entry => entry.row)
241
+ const hiddenCount = rows.length - visibleRows.length
242
+
243
+ interface Section { header: { row: GenuiTable['rows'][number]; index: number } | null; children: Array<{ row: GenuiTable['rows'][number]; index: number }> }
244
+ const sections: Section[] = []
245
+ visibleRows.forEach((row, index) => {
246
+ if (isGroupRow(row)) { sections.push({ header: { row, index }, children: [] }); return }
247
+ if (sections.length === 0 || sections[sections.length - 1]!.header === null) {
248
+ if (sections.length === 0) sections.push({ header: null, children: [] })
249
+ }
250
+ sections[sections.length - 1]!.children.push({ row, index })
251
+ })
252
+
253
+ // Sorting keeps sections intact: each section's children sort among
254
+ // themselves, so a grouped table can never scramble its own structure.
255
+ // `sortField` is a select whose value is a column header: apply it as the
256
+ // active sort (a click on the header still overrides it).
257
+ const boundSortCol = sortValue === undefined ? -1 : columns.indexOf(sortValue)
258
+ const effectiveSort = sort !== null ? sort : (boundSortCol >= 0 ? { col: boundSortCol, dir: 1 as const } : null)
259
+
260
+ const sortedSections = sections.map(section => ({
261
+ header: section.header,
262
+ children: effectiveSort === null
263
+ ? section.children
264
+ : [...section.children].sort((a, b) => compare(a.row, b.row, effectiveSort.col, effectiveSort.dir)),
265
+ }))
266
+
267
+ const numeric = numericColumns(visibleRows, columns.length)
268
+ const toggleSection = (index: number): void => {
269
+ setCollapsed(prev => {
270
+ const next = new Set(prev)
271
+ if (next.has(index)) next.delete(index)
272
+ else next.add(index)
273
+ return next
274
+ })
275
+ }
276
+ const toggleDetail = (index: number): void => {
277
+ setExpanded(prev => {
278
+ const next = new Set(prev)
279
+ if (next.has(index)) next.delete(index)
280
+ else next.add(index)
281
+ return next
282
+ })
283
+ }
87
284
  const clickHeader = (i: number): void => {
88
285
  setSort(prev => prev !== null && prev.col === i
89
286
  ? prev.dir === 1 ? { col: i, dir: -1 } : null
90
287
  : { col: i, dir: 1 })
91
288
  }
92
- const numeric = numericColumns(rows, columns.length)
289
+ // Optional 合计 footer: sums every numeric column (section headers excluded).
290
+ const totals = columns.map((_c, j) => {
291
+ if (!numeric[j]) return null
292
+ let sum = 0
293
+ for (const row of visibleRows) {
294
+ if (isGroupRow(row)) continue
295
+ const n = parseSortableNumber(row[j])
296
+ if (Number.isFinite(n)) sum += n
297
+ }
298
+ return sum
299
+ })
300
+ const hasTotals = node.total === true && totals.some(t => t !== null)
301
+ const formatTotal = (n: number): string =>
302
+ Number.isInteger(n) ? n.toLocaleString('en-US') : String(Math.round(n * 100) / 100)
303
+
304
+ const renderCell = (cell: string | number, j: number, rowIndex: number): ReactNode => {
305
+ const type = types[j]
306
+ const tone = type === 'delta'
307
+ ? (String(cell).trim().startsWith('-') ? 'down' : 'up')
308
+ : deltaTone(cell)
309
+ return (
310
+ <td key={j} className={numeric[j] || type === 'num' ? css.tdNum : undefined}>
311
+ {type === 'badge'
312
+ ? <span className={css.cellBadge}>{String(cell)}</span>
313
+ : type === 'bar'
314
+ ? <CellBar cell={cell} />
315
+ : type === 'spark'
316
+ ? <CellSpark cell={cell} />
317
+ : type === 'ring'
318
+ ? <CellRing cell={cell} />
319
+ : type === 'index'
320
+ ? <span className={css.cellIndex}>{rowIndex + 1}</span>
321
+ : tone === null
322
+ // Text cells honour inline markup; numeric/badge/spark
323
+ // cells stay literal (a number has nothing to emphasise).
324
+ ? renderInline(String(cell))
325
+ : <span className={`${css.tdDelta} ${tone === 'up' ? css.tdDeltaUp : css.tdDeltaDown}`}>{String(cell)}</span>}
326
+ </td>
327
+ )
328
+ }
329
+
93
330
  return (
94
331
  <div className={css.tableWrap}>
332
+ {node.export === true && <TableExportChips columns={columns} rows={rows} />}
95
333
  <table className={css.table}>
96
334
  <thead>
97
335
  <tr>
@@ -110,161 +348,535 @@ export const TableNode = memo(function TableNode({ node }: { node: GenuiTable })
110
348
  </tr>
111
349
  </thead>
112
350
  <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
- ))}
351
+ {sortedSections.map((section, si) => {
352
+ const headerIndex = section.header?.index ?? si
353
+ const isCollapsed = collapsed.has(headerIndex)
354
+ return (
355
+ <Fragment key={section.header === null ? `s-${si}` : `g-${section.header.index}`}>
356
+ {section.header !== null && (
357
+ <tr className={css.groupRow}>
358
+ <td colSpan={columns.length}>
359
+ <button
360
+ type="button"
361
+ className={css.groupToggle}
362
+ aria-expanded={!isCollapsed}
363
+ onClick={() => toggleSection(headerIndex)}
364
+ >
365
+ <span className={css.groupChevron} aria-hidden>{isCollapsed ? '▸' : '▾'}</span>
366
+ {String(section.header.row[0])}
367
+ <span className={css.groupCount}>{section.children.length}</span>
368
+ </button>
369
+ </td>
370
+ </tr>
371
+ )}
372
+ {!isCollapsed && section.children.map(child => {
373
+ const detail = node.details?.[child.index] ?? null
374
+ const open = expanded.has(child.index)
375
+ return (
376
+ <Fragment key={child.index}>
377
+ <tr className={section.header === null ? undefined : css.groupChild}>
378
+ {child.row.slice(0, columns.length).map((cell, j) => (
379
+ j === 0 && detail !== null
380
+ ? (
381
+ <td key={j} className={css.detailCell}>
382
+ <button
383
+ type="button"
384
+ className={css.detailToggle}
385
+ aria-expanded={open}
386
+ onClick={() => toggleDetail(child.index)}
387
+ >
388
+ <span className={css.detailChevron} data-open={open} aria-hidden>▸</span>
389
+ {String(cell)}
390
+ </button>
391
+ </td>
392
+ )
393
+ : renderCell(cell, j, child.index)
394
+ ))}
395
+ </tr>
396
+ {open && detail !== null && (
397
+ <tr className={css.detailRow}>
398
+ <td colSpan={columns.length}>
399
+ <div className={css.detailBody}>
400
+ {renderDetail === undefined ? null : renderDetail(detail)}
401
+ </div>
402
+ </td>
403
+ </tr>
404
+ )}
405
+ </Fragment>
406
+ )
407
+ })}
408
+ </Fragment>
409
+ )
410
+ })}
118
411
  </tbody>
412
+ {hiddenCount > 0 && (
413
+ <tfoot>
414
+ <tr className={css.filterRow}>
415
+ <td colSpan={columns.length}>筛选后 {visibleRows.length} / {rows.length} 行</td>
416
+ </tr>
417
+ </tfoot>
418
+ )}
419
+ {hasTotals && (
420
+ <tfoot>
421
+ <tr>
422
+ {columns.map((_c, j) => (
423
+ <td key={j} className={numeric[j] ? css.tdNum : undefined}>
424
+ {j === 0 ? '合计' : totals[j] === null ? '' : formatTotal(totals[j]!)}
425
+ </td>
426
+ ))}
427
+ </tr>
428
+ </tfoot>
429
+ )}
119
430
  </table>
120
431
  </div>
121
432
  )
122
433
  })
123
434
 
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} />
435
+ /** One tooltip line: label on the left, value on the right. */
436
+ type TipRow = [string, string]
437
+
438
+ interface TipState { x: number; y: number; rows: TipRow[] }
439
+
440
+ /**
441
+ * Instant, self-drawn hover readout. The browser's native `title` takes about
442
+ * a second to appear and cannot show a stacked breakdown, which is exactly
443
+ * what a stacked bar needs ("hover 上去要会显示各自部分的具体数值").
444
+ */
445
+ function useChartTip(): {
446
+ tip: TipState | null
447
+ show: (event: ReactMouseEvent<Element>, rows: TipRow[]) => void
448
+ hide: () => void
449
+ } {
450
+ const [tip, setTip] = useState<TipState | null>(null)
451
+ const show = useCallback((event: ReactMouseEvent<Element>, rows: TipRow[]) => {
452
+ const target = event.currentTarget as Element
453
+ const host = target.closest('[data-genui-chart], [data-genui-line], [data-genui-donut]')
454
+ if (host === null) return
455
+ const hostRect = host.getBoundingClientRect()
456
+ const rect = target.getBoundingClientRect()
457
+ setTip({
458
+ x: rect.left - hostRect.left + rect.width / 2,
459
+ y: rect.top - hostRect.top,
460
+ rows,
461
+ })
462
+ }, [])
463
+ const hide = useCallback(() => setTip(null), [])
464
+ return { tip, show, hide }
465
+ }
466
+
467
+ function ChartTip({ tip }: { tip: TipState | null }) {
468
+ if (tip === null) return null
469
+ return (
470
+ <div className={css.chartTip} style={{ left: `${tip.x}px`, top: `${tip.y}px` }} role="tooltip">
471
+ {tip.rows.map(([label, value], i) => (
472
+ <span key={`${label}-${i}`} className={css.chartTipRow}>
473
+ <span>{label}</span>
474
+ <span className={css.chartTipValue}>{value}</span>
475
+ </span>
476
+ ))}
477
+ </div>
478
+ )
479
+ }
480
+
481
+ /** Measured plot width. Charts draw in CSS pixels: 1 SVG unit = 1px, so axis
482
+ * text keeps its designed size at every container width. */
483
+ function useMeasuredWidth(): [RefObject<HTMLDivElement>, number] {
484
+ const ref = useRef<HTMLDivElement>(null)
485
+ const [width, setWidth] = useState(560)
486
+ useLayoutEffect(() => {
487
+ const el = ref.current
488
+ if (el === null) return
489
+ const measure = (): void => {
490
+ const next = el.clientWidth
491
+ if (next > 0) setWidth(next)
492
+ }
493
+ measure()
494
+ if (typeof ResizeObserver === 'undefined') return
495
+ const observer = new ResizeObserver(measure)
496
+ observer.observe(el)
497
+ return () => observer.disconnect()
498
+ }, [])
499
+ return [ref, width]
500
+ }
501
+
502
+ /** Axis ticks on 1/2/5×10^n steps covering [min, max] inclusively. */
503
+ function niceTicks(min: number, max: number, target = 4): number[] {
504
+ const lo = Math.min(min, 0)
505
+ const hi = Math.max(max, 0)
506
+ if (lo === hi) return [0, 1]
507
+ const raw = (hi - lo) / Math.max(target, 1)
508
+ const magnitude = Math.pow(10, Math.floor(Math.log10(Math.max(raw, Number.MIN_VALUE))))
509
+ const normalized = raw / magnitude
510
+ const step = (normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10) * magnitude
511
+ const start = Math.floor(lo / step) * step
512
+ const end = Math.ceil(hi / step) * step
513
+ const ticks: number[] = []
514
+ for (let t = start; t <= end + step / 2; t += step) ticks.push(Math.abs(t) < step / 1e6 ? 0 : t)
515
+ return ticks
516
+ }
517
+
518
+ /** Compact tick text: 1200 → 1.2k, 0.5 → 0.5, integers bare. */
519
+ function formatTick(t: number): string {
520
+ const abs = Math.abs(t)
521
+ if (abs >= 1e9) return `${(t / 1e9).toFixed(abs % 1e9 === 0 ? 0 : 1)}b`
522
+ if (abs >= 1e6) return `${(t / 1e6).toFixed(abs % 1e6 === 0 ? 0 : 1)}m`
523
+ if (abs >= 1000) return `${(t / 1000).toFixed(abs % 1000 === 0 ? 0 : 1)}k`
524
+ if (Number.isInteger(t)) return String(t)
525
+ return String(Math.round(t * 100) / 100)
526
+ }
527
+
528
+ /** Shared y-axis gutter: ticks positioned against the same percentage scale
529
+ * the plot uses, so labels line up with the gridlines. */
530
+ function YAxis({ ticks, lo, span }: { ticks: number[]; lo: number; span: number }) {
531
+ return (
532
+ <div className={css.chartYAxis} style={{ height: 160 }}>
533
+ {ticks.map(t => (
534
+ <span key={t} className={css.chartYTick} style={{ bottom: `${((t - lo) / span) * 100}%` }}>
535
+ {formatTick(t)}
536
+ </span>
537
+ ))}
538
+ </div>
539
+ )
540
+ }
541
+
542
+ /** Chart: bars (default), line (trend), or donut (share); multi-series bars via `series`. */
543
+ export const ChartNode = memo(function ChartNode({ chart, filterValue }: {
544
+ chart: GenuiChart
545
+ /** Live value of the control bound via `chart.filter`; keeps matching
546
+ * categories only (label substring), so a chart is explorable locally. */
547
+ filterValue?: string | undefined
548
+ }) {
549
+ const filtered = useMemo(() => applyChartFilter(chart, filterValue), [chart, filterValue])
550
+ const kind = filtered.kind ?? 'bars'
551
+ if (kind === 'donut') return <DonutNode chart={filtered} />
552
+ if (kind === 'line') return <LineChartNode chart={filtered} />
553
+ return <BarsNode chart={filtered} />
132
554
  })
133
555
 
134
- /** Bars: one column per datum (grouped bars when `series` is present). */
556
+ /** Keep the categories whose label matches the bound filter (case-insensitive).
557
+ * Bars/donut filter `data`, line filters each series' points. */
558
+ function applyChartFilter(chart: GenuiChart, filterValue: string | undefined): GenuiChart {
559
+ const needle = filterValue?.trim().toLowerCase()
560
+ if (needle === undefined || needle === '') return chart
561
+ const keep = (datum: { label: string }): boolean => datum.label.toLowerCase().includes(needle)
562
+ const data = chart.data.filter(keep)
563
+ if (chart.series === undefined) return data.length === chart.data.length ? chart : { ...chart, data }
564
+ const series = chart.series.map(entry => ({ ...entry, data: entry.data.filter(keep) }))
565
+ return { ...chart, data, series }
566
+ }
567
+
568
+ /** Bars: one column per datum (grouped bars when `series` is present).
569
+ * Single-series bars render against a true zero line, so negative values
570
+ * draw downward instead of clamping to zero height. */
135
571
  export const BarsNode = memo(function BarsNode({ chart }: { chart: GenuiChart }) {
572
+ const { tip, show, hide } = useChartTip()
136
573
  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)
574
+ const isGrouped = grouped !== undefined && grouped.length > 0
575
+ const data = chart.data.slice(0, GENUI_LIMITS.maxChartPoints)
576
+ const labels = isGrouped ? grouped[0]!.data.map(d => d.label) : data.map(d => d.label)
577
+ const seriesValues = isGrouped
578
+ ? grouped.map(s => s.data.map(d => Number(d.value) || 0))
579
+ : [data.map(d => Number(d.value) || 0)]
580
+ const colors = seriesValues.map((_values, si) =>
581
+ seriesColor(si, seriesValues.length, isGrouped ? grouped[si]?.color : undefined, chart.palette) ?? 'var(--dsw-alias-state-business-primary, #4f8ef7)')
582
+ const flat = seriesValues.flat()
583
+ const showValues = labels.length <= 12
584
+ const stacked = chart.stacked === true && isGrouped
585
+ const categoryTotals = labels.map((_l, i) => seriesValues.reduce((sum, values) => sum + Math.max(0, values[i] ?? 0), 0))
586
+
587
+ // Horizontal: label column + one track per series. The axis is always
588
+ // 0..max (a horizontal track has no zero line to cross).
589
+ if (chart.horizontal === true) {
590
+ const scale = Math.max(Math.max(...flat, 0), 1)
591
+ const legend = isGrouped
592
+ ? (
593
+ <div className={css.chartLegend}>
594
+ {grouped.map((entry, si) => (
595
+ <span key={si} className={css.legendItem}>
596
+ <span className={css.legendSwatch} style={{ background: colors[si] }} />
597
+ {entry.label}
598
+ </span>
599
+ ))}
600
+ </div>
601
+ )
602
+ : null
140
603
  return (
141
- <div className={css.chart} data-genui-chart="bars">
604
+ <div className={css.chart} data-genui-chart="bars" role="img" aria-label={`横向柱状图,${labels.length} 组`} onMouseLeave={hide}>
605
+ <div className={css.hbars}>
606
+ {labels.map((label, i) => (
607
+ <div key={i} className={css.hbarRow}>
608
+ <span className={css.hbarLabel} title={label}>{label}</span>
609
+ <div className={css.hbarTracks}>
610
+ {stacked
611
+ ? (
612
+ <div className={css.hbarTrack} title={`${label}: ${categoryTotals[i] ?? 0}`}>
613
+ {seriesValues.map((values, si) => {
614
+ const v = Math.max(0, values[i] ?? 0)
615
+ const total = categoryTotals[i] ?? 0
616
+ const width = total === 0 ? 0 : (v / total) * Math.max(0, Math.min(100, (total / scale) * 100))
617
+ return (
618
+ <div
619
+ key={si}
620
+ className={css.hbarSeg}
621
+ style={{ width: `${width}%`, background: colors[si] }}
622
+ onMouseEnter={event => show(event, [[grouped[si]!.label, String(v)], ['合计', String(total)]])}
623
+ onMouseMove={event => show(event, [[grouped[si]!.label, String(v)], ['合计', String(total)]])}
624
+ />
625
+ )
626
+ })}
627
+ </div>
628
+ )
629
+ : seriesValues.map((values, si) => {
630
+ const v = values[i] ?? 0
631
+ const width = Math.max(0, Math.min(100, (Math.max(0, v) / scale) * 100))
632
+ return (
633
+ <div key={si} className={css.hbarTrack}>
634
+ <div
635
+ className={css.hbarFill}
636
+ style={{ width: `${width}%`, background: colors[si] }}
637
+ onMouseEnter={event => show(event, isGrouped ? [[grouped[si]!.label, String(v)], ['合计', String(categoryTotals[i] ?? 0)]] : [[label, String(v)]])}
638
+ onMouseMove={event => show(event, isGrouped ? [[grouped[si]!.label, String(v)], ['合计', String(categoryTotals[i] ?? 0)]] : [[label, String(v)]])}
639
+ />
640
+ </div>
641
+ )
642
+ })}
643
+ </div>
644
+ {showValues && (
645
+ <span className={css.hbarValue}>
646
+ {isGrouped ? categoryTotals[i] ?? 0 : String(data[i]?.value ?? '')}
647
+ </span>
648
+ )}
649
+ </div>
650
+ ))}
651
+ </div>
652
+ {legend}
653
+ <ChartTip tip={tip} />
654
+ </div>
655
+ )
656
+ }
657
+
658
+ // Vertical: grouped bars clamp negatives (the flex layout stacks upward), so
659
+ // the axis starts at zero for that shape; single-series bars render against
660
+ // a true zero line and draw negatives downward.
661
+ const ticks = niceTicks(isGrouped ? 0 : Math.min(...flat, 0), Math.max(...flat, 0), 4)
662
+ const lo = ticks[0]!
663
+ const hi = ticks[ticks.length - 1]!
664
+ const span = hi - lo || 1
665
+ const pct = (v: number): number => ((v - lo) / span) * 100
666
+ const zero = pct(0)
667
+ const summary = `柱状图,${labels.length} 组,最大 ${formatTick(Math.max(...flat, 0))}`
668
+ return (
669
+ <div className={css.chart} data-genui-chart="bars" role="img" aria-label={summary} onMouseLeave={hide}>
670
+ <ChartTip tip={tip} />
671
+ <div className={css.chartBody}>
672
+ <YAxis ticks={ticks} lo={lo} span={span} />
142
673
  <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}%` }} />
674
+ {ticks.map(t => (
675
+ <span key={t} className={t === 0 ? css.baseline : css.gridline} style={{ bottom: `${pct(t)}%` }} />
145
676
  ))}
146
- {labels.map((_, i) => (
677
+ {labels.map((label, i) => (
147
678
  <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)
679
+ {stacked
680
+ ? (
681
+ <>
682
+ {showValues && (
683
+ <span className={css.barValue} style={{ bottom: `calc(${pct(categoryTotals[i] ?? 0)}% + 4px)` }}>
684
+ {String(categoryTotals[i] ?? 0)}
685
+ </span>
686
+ )}
687
+ <div className={css.stack} style={{ height: `${Math.max(0, pct(categoryTotals[i] ?? 0))}%` }}>
688
+ {grouped.map((entry, si) => {
689
+ const datum = entry.data[i]
690
+ const raw = datum === undefined ? 0 : Number(datum.value) || 0
691
+ const v = Math.max(0, raw)
692
+ const total = categoryTotals[i] ?? 0
693
+ const segHeight = total === 0 ? 0 : (v / total) * pct(total)
694
+ const rows: TipRow[] = [[entry.label, String(raw)], ['合计', String(total)]]
695
+ return (
696
+ <div
697
+ key={si}
698
+ className={css.stackSeg}
699
+ style={{ height: total === 0 ? '0%' : `${(v / total) * 100}%`, background: colors[si] }}
700
+ onMouseEnter={event => show(event, rows)}
701
+ onMouseMove={event => show(event, rows)}
702
+ >
703
+ {segHeight >= 11 && <span className={css.stackValue}>{raw}</span>}
704
+ </div>
705
+ )
706
+ })}
707
+ </div>
708
+ </>
709
+ )
710
+ : isGrouped
711
+ ? (
712
+ <div className={css.groupedBars}>
713
+ {grouped.map((entry, si) => {
714
+ const datum = entry.data[i]
715
+ const v = datum === undefined ? 0 : Number(datum.value) || 0
716
+ return (
717
+ <div key={si} className={css.groupedBar}>
718
+ {showValues && <span className={css.groupValue}>{datum === undefined ? '' : String(datum.value)}</span>}
719
+ <div
720
+ className={css.groupedFill}
721
+ style={{
722
+ height: `${Math.max(0, pct(Math.max(0, v)))}%`,
723
+ background: colors[si],
724
+ }}
725
+ onMouseEnter={event => show(event, [[entry.label, String(datum?.value ?? '')], ['合计', String(categoryTotals[i] ?? 0)]])}
726
+ onMouseMove={event => show(event, [[entry.label, String(datum?.value ?? '')], ['合计', String(categoryTotals[i] ?? 0)]])}
727
+ />
728
+ </div>
729
+ )
730
+ })}
731
+ </div>
732
+ )
733
+ : (() => {
734
+ const v = seriesValues[0]![i] ?? 0
735
+ const top = Math.max(pct(v), zero)
736
+ const bottom = Math.min(pct(v), zero)
155
737
  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>
738
+ <>
739
+ {showValues && (
740
+ <span className={css.barValue} style={{ bottom: `calc(${top}% + 4px)` }}>{String(data[i]?.value ?? '')}</span>
741
+ )}
158
742
  <div
159
- className={css.groupedFill}
743
+ className={css.barFill}
160
744
  style={{
161
- height: `${h}%`,
162
- background: seriesColor(si, grouped.length, s.color) ?? 'var(--dsw-alias-state-business-primary, #4f8ef7)',
745
+ bottom: `${bottom}%`,
746
+ height: `${Math.max(top - bottom, 0.6)}%`,
747
+ ...(v < 0 ? { borderRadius: '0 0 5px 5px' } : {}),
748
+ ...(data[i]?.color !== undefined ? { background: data[i]!.color } : {}),
163
749
  }}
750
+ onMouseEnter={event => show(event, [[label, String(data[i]?.value ?? '')]])}
751
+ onMouseMove={event => show(event, [[label, String(data[i]?.value ?? '')]])}
164
752
  />
165
- </div>
753
+ </>
166
754
  )
167
- })}
168
- </div>
755
+ })()}
169
756
  </div>
170
757
  ))}
171
758
  </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} data-genui-chart="bars">
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
759
  </div>
201
760
  <div className={css.chartLabels}>
202
- {data.map(d => <span key={d.label} className={css.barLabel}>{d.label}</span>)}
761
+ {labels.map((label, i) => <span key={`${label}-${i}`} className={css.barLabel}>{label}</span>)}
203
762
  </div>
763
+ {isGrouped && (
764
+ <div className={css.chartLegend}>
765
+ {grouped.map((entry, si) => (
766
+ <span key={si} className={css.legendItem}>
767
+ <span className={css.legendSwatch} style={{ background: colors[si] }} />
768
+ {entry.label}
769
+ </span>
770
+ ))}
771
+ </div>
772
+ )}
204
773
  </div>
205
774
  )
206
775
  })
207
776
 
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). */
777
+ /** Line: responsive polyline with an area wash, nice y ticks and sampled x
778
+ * labels drawn in SVG at their real size. */
210
779
  export const LineChartNode = memo(function LineChartNode({ chart }: { chart: GenuiChart }) {
780
+ const [ref, measured] = useMeasuredWidth()
781
+ const { tip, show, hide } = useChartTip()
782
+ const gradientId = `genui-line-${useId().replace(/:/g, '')}`
783
+ const grouped = chart.series !== undefined && chart.series.length > 0
784
+ ? chart.series.slice(0, GENUI_LIMITS.maxPlotSeries)
785
+ : undefined
211
786
  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
- }
787
+ const labels = grouped !== undefined ? grouped[0]!.data.map(d => d.label) : data.map(d => d.label)
788
+ const seriesValues = grouped !== undefined
789
+ ? grouped.map(entry => entry.data.map(d => Number(d.value) || 0))
790
+ : [data.map(d => Number(d.value) || 0)]
791
+ const colors = seriesValues.map((_values, si) =>
792
+ seriesColor(si, seriesValues.length, grouped !== undefined ? grouped[si]?.color : undefined, chart.palette) ?? 'var(--dsl-g-accent)')
793
+ const multi = seriesValues.length > 1
794
+ const flat = seriesValues.flat()
795
+ const pointCount = Math.max(...seriesValues.map(values => values.length), 0)
796
+ const W = Math.max(measured, 260)
797
+ const H = 176
798
+ const padL = 44
799
+ const padR = 12
800
+ const padT = 14
801
+ const padB = 26
802
+ const ticks = niceTicks(Math.min(...flat, 0), Math.max(...flat, 0), 4)
803
+ const lo = ticks[0]!
804
+ const hi = ticks[ticks.length - 1]!
805
+ const span = hi - lo || 1
806
+ const innerW = W - padL - padR
807
+ const innerH = H - padT - padB
808
+ const x = (i: number): number => padL + (pointCount <= 1 ? innerW / 2 : (i / (pointCount - 1)) * innerW)
809
+ const y = (v: number): number => padT + (1 - (v - lo) / span) * innerH
810
+ const paths = seriesValues.map(values =>
811
+ values.map((v, i) => `${i === 0 ? 'M' : 'L'} ${x(i).toFixed(1)} ${y(v).toFixed(1)}`).join(' '))
812
+ const area = !multi && pointCount > 1
813
+ ? `${paths[0]} L ${x(pointCount - 1).toFixed(1)} ${y(lo).toFixed(1)} L ${x(0).toFixed(1)} ${y(lo).toFixed(1)} Z`
814
+ : null
815
+ // Keep x labels readable: at most one per ~64px of plot width.
816
+ const labelStep = Math.max(1, Math.ceil(pointCount / Math.max(1, Math.floor(innerW / 64))))
817
+ const summary = `折线图,${multi ? `${seriesValues.length} 条序列` : `${pointCount} 个点`},范围 ${formatTick(Math.min(...flat, 0))} 到 ${formatTick(Math.max(...flat, 0))}`
235
818
  return (
236
- <div className={css.lineChart} data-genui-chart="line">
237
- <svg width="100%" viewBox={`0 0 ${W} ${H}`}>
819
+ <div className={css.lineChart} data-genui-chart="line" data-genui-line ref={ref} role="img" aria-label={summary} onMouseLeave={hide}>
820
+ <ChartTip tip={tip} />
821
+ <svg width="100%" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none">
822
+ <defs>
823
+ <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
824
+ <stop offset="0%" stopColor="var(--dsl-g-accent)" stopOpacity="0.22" />
825
+ <stop offset="100%" stopColor="var(--dsl-g-accent)" stopOpacity="0.02" />
826
+ </linearGradient>
827
+ </defs>
238
828
  {ticks.map((t, i) => {
239
- const y = padT + (1 - (t - min) / span) * (H - padT - padB)
829
+ const ty = y(t)
240
830
  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>
831
+ <g key={t}>
832
+ <line x1={padL} x2={W - padR} y1={ty} y2={ty} className={i === 0 ? css.lineGridAxis : css.lineGrid} />
833
+ <text x={padL - 8} y={ty + 4} textAnchor="end" className={css.lineTick}>{formatTick(t)}</text>
244
834
  </g>
245
835
  )
246
836
  })}
247
- {data.map((datum, i) => {
248
- const [x, y] = pt(i, Number(datum.value) || 0)
837
+ {area !== null && <path d={area} fill={`url(#${gradientId})`} />}
838
+ {paths.map((d, si) => <path key={si} d={d} className={css.linePath} style={{ stroke: colors[si] }} />)}
839
+ {seriesValues.map((values, si) => values.map((v, i) => {
840
+ const rows: TipRow[] = multi
841
+ ? [[grouped![si]!.label, String(v)], ['节点', labels[i] ?? '']]
842
+ : [[labels[i] ?? '', String(v)]]
249
843
  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>
844
+ <circle
845
+ key={`${si}-${i}`}
846
+ cx={x(i)}
847
+ cy={y(v)}
848
+ r={multi ? 3 : 3.5}
849
+ className={css.lineDot}
850
+ style={{ fill: colors[si] }}
851
+ onMouseEnter={event => show(event, rows)}
852
+ onMouseMove={event => show(event, rows)}
853
+ />
253
854
  )
254
- })}
255
- <path d={path} className={css.linePath} />
855
+ }))}
856
+ {labels.map((label, i) => (
857
+ i % labelStep === 0
858
+ ? <text key={`l-${i}`} x={x(i)} y={H - 8} textAnchor="middle" className={css.lineLabel}>{label}</text>
859
+ : null
860
+ ))}
256
861
  </svg>
257
- <div className={css.lineLabels}>
258
- {data.map((d, i) => <span key={i} className={css.barLabel}>{d.label}</span>)}
259
- </div>
862
+ {multi && (
863
+ <div className={css.chartLegend}>
864
+ {grouped!.map((entry, si) => (
865
+ <span key={si} className={css.legendItem}>
866
+ <span className={css.legendSwatch} style={{ background: colors[si] }} />
867
+ {entry.label}
868
+ </span>
869
+ ))}
870
+ </div>
871
+ )}
260
872
  </div>
261
873
  )
262
874
  })
263
875
 
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). */
876
+ /** Donut: share of total with a center total and a legend that shows each
877
+ * slice's value AND percentage (the old legend was unstyled text). */
267
878
  export const DonutNode = memo(function DonutNode({ chart }: { chart: GenuiChart }) {
879
+ const { tip, show, hide } = useChartTip()
268
880
  const data = chart.data.slice(0, GENUI_LIMITS.maxChartPoints)
269
881
  const clamped = data.map(d => ({ ...d, v: Math.max(0, Number(d.value) || 0) }))
270
882
  const total = clamped.reduce((s, d) => s + d.v, 0) || 1
@@ -273,47 +885,52 @@ export const DonutNode = memo(function DonutNode({ chart }: { chart: GenuiChart
273
885
  const totalText = total >= 1000
274
886
  ? `${Math.round(total / 100) / 10}k`
275
887
  : Number.isInteger(total) ? String(total) : total.toFixed(1)
276
- const R = 42
888
+ const R = 62
889
+ const STROKE = 18
890
+ const SIZE = 160
277
891
  const C = 2 * Math.PI * R
278
892
  let offset = 0
893
+ const summary = `环形图,${clamped.length} 项,合计 ${totalText}`
279
894
  return (
280
- <div className={css.donut} data-genui-chart="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} />
895
+ <div className={css.donut} data-genui-chart="donut" data-genui-donut role="img" aria-label={summary} onMouseLeave={hide}>
896
+ <ChartTip tip={tip} />
897
+ <svg width={SIZE} height={SIZE} viewBox={`0 0 ${SIZE} ${SIZE}`}>
898
+ <circle cx={SIZE / 2} cy={SIZE / 2} r={R} fill="none" strokeWidth={STROKE} className={css.donutTrack} />
283
899
  {clamped.map((d, i) => {
284
900
  const frac = d.v / total
285
901
  const len = frac * C
286
902
  const el = (
287
903
  <circle
288
904
  key={i}
289
- cx="60" cy="60" r={R} fill="none" strokeWidth="14"
905
+ cx={SIZE / 2}
906
+ cy={SIZE / 2}
907
+ r={R}
908
+ fill="none"
909
+ strokeWidth={STROKE}
290
910
  className={css.donutSeg}
291
- style={{ stroke: seriesColor(i, data.length, d.color) ?? 'var(--dsw-alias-state-business-primary, #4f8ef7)' }}
911
+ style={{ stroke: seriesColor(i, data.length, d.color, chart.palette) ?? 'var(--dsw-alias-state-business-primary, #4f8ef7)' }}
292
912
  strokeDasharray={`${len} ${C - len}`}
293
913
  strokeDashoffset={-offset}
294
- transform="rotate(-90 60 60)"
295
- >
296
- <title>{`${d.label}: ${String(d.value)}`}</title>
297
- </circle>
914
+ transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`}
915
+ onMouseEnter={event => show(event, [[d.label, `${String(d.value)} · ${(frac * 100).toFixed(1)}%`]])}
916
+ onMouseMove={event => show(event, [[d.label, `${String(d.value)} · ${(frac * 100).toFixed(1)}%`]])}
917
+ />
298
918
  )
299
919
  offset += len
300
920
  return el
301
921
  })}
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>
922
+ <text x={SIZE / 2} y={SIZE / 2 - 2} textAnchor="middle" className={css.donutTotal}>{totalText}</text>
923
+ <text x={SIZE / 2} y={SIZE / 2 + 16} textAnchor="middle" className={css.donutTotalLabel}>合计</text>
304
924
  </svg>
305
925
  <div className={css.donutLegend}>
306
- {data.map((d, i) => (
926
+ {clamped.map((d, i) => (
307
927
  <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)}
928
+ <span className={css.legendSwatch} style={{ background: seriesColor(i, data.length, d.color, chart.palette) ?? 'var(--dsw-alias-state-business-primary, #4f8ef7)' }} />
929
+ <span>{d.label}</span>
930
+ <span className={css.donutPct}>{String(d.value)} · {(d.v / total * 100).toFixed(1)}%</span>
310
931
  </span>
311
932
  ))}
312
933
  </div>
313
934
  </div>
314
935
  )
315
936
  })
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. */