@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.
- package/CHANGELOG.md +29 -0
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/SKILL.md +128 -6
- package/lib/assets/echarts-core.js +43 -0
- package/lib/assets/{echarts.js → echarts-full.js} +1 -1
- package/lib/assets/mermaid.js +1 -1
- package/lib/client.js +5 -3
- package/lib/index.js +230 -69
- package/lib/types/client/EChartNode.d.ts +9 -0
- package/lib/types/client/GenuiBlock.d.ts +3 -4
- package/lib/types/client/asset-echarts-core.d.ts +1 -0
- package/lib/types/client/asset-echarts.d.ts +3 -2
- package/lib/types/client/asset-loader.d.ts +2 -2
- package/lib/types/client/blocks/advanced.d.ts +0 -3
- package/lib/types/client/blocks/charts.d.ts +23 -12
- package/lib/types/client/blocks/state.d.ts +5 -3
- package/lib/types/client/echarts-lazy.d.ts +4 -1
- package/lib/types/client/genui-runtime/schema.d.ts +11 -1
- package/lib/types/client/inline.d.ts +29 -0
- package/lib/types/client/spec.d.ts +76 -6
- package/lib/types/plugin/index.d.ts +1 -1
- package/package.json +4 -3
- package/src/client/EChartNode.tsx +233 -19
- package/src/client/GenuiBlock.module.css +895 -145
- package/src/client/GenuiBlock.tsx +31 -23
- package/src/client/PlotBlock.module.css +17 -1
- package/src/client/PlotBlock.tsx +33 -1
- package/src/client/asset-echarts-core.ts +39 -0
- package/src/client/asset-echarts.ts +4 -3
- package/src/client/asset-loader.ts +5 -3
- package/src/client/blocks/advanced.tsx +60 -16
- package/src/client/blocks/charts.tsx +761 -144
- package/src/client/blocks/render-node.tsx +252 -11
- package/src/client/blocks/state.ts +5 -3
- package/src/client/dom-fence.tsx +60 -6
- package/src/client/echarts-lazy.ts +11 -2
- package/src/client/fence-render.tsx +6 -5
- package/src/client/gallery.ts +78 -2
- package/src/client/genui-runtime/schema.ts +45 -11
- package/src/client/guard.ts +161 -23
- package/src/client/index.tsx +14 -3
- package/src/client/inline.ts +73 -0
- package/src/client/mermaid-core.ts +42 -5
- package/src/client/spec.ts +79 -6
- package/src/plugin/index.ts +33 -45
- package/src/plugin/tool.ts +5 -1
- 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;
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
/**
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
{
|
|
144
|
-
<span key={
|
|
674
|
+
{ticks.map(t => (
|
|
675
|
+
<span key={t} className={t === 0 ? css.baseline : css.gridline} style={{ bottom: `${pct(t)}%` }} />
|
|
145
676
|
))}
|
|
146
|
-
{labels.map((
|
|
677
|
+
{labels.map((label, i) => (
|
|
147
678
|
<div key={i} className={css.barCol}>
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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.
|
|
743
|
+
className={css.barFill}
|
|
160
744
|
style={{
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
209
|
-
*
|
|
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
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
const
|
|
220
|
-
const
|
|
221
|
-
const
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
const
|
|
228
|
-
const
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
<
|
|
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
|
|
829
|
+
const ty = y(t)
|
|
240
830
|
return (
|
|
241
|
-
<g key={
|
|
242
|
-
<line x1={padL} x2={W - padR} y1={
|
|
243
|
-
<text x={padL -
|
|
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
|
-
{
|
|
248
|
-
|
|
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
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
265
|
-
*
|
|
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 =
|
|
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
|
-
<
|
|
282
|
-
|
|
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=
|
|
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=
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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=
|
|
303
|
-
<text x=
|
|
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
|
-
{
|
|
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}
|
|
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. */
|