@squadbase/vantage 0.6.2-dev1.ac99e02 → 0.6.2-dev1.d7c0911

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 (141) hide show
  1. package/dist/{build-MFWRPYKW.js → build-X5NVCQKT.js} +4 -4
  2. package/dist/{build-MFWRPYKW.js.map → build-X5NVCQKT.js.map} +1 -1
  3. package/dist/{chunk-IBGOCWJB.js → chunk-2ICNHUG6.js} +186 -82
  4. package/dist/chunk-2ICNHUG6.js.map +1 -0
  5. package/dist/{chunk-5ZOP4SQR.js → chunk-2N72FKLB.js} +12 -7
  6. package/dist/chunk-2N72FKLB.js.map +1 -0
  7. package/dist/{chunk-PXRX5PEC.js → chunk-PDOBHMUQ.js} +3 -3
  8. package/dist/chunk-PDOBHMUQ.js.map +1 -0
  9. package/dist/{chunk-A2UUGASH.js → chunk-U736PVHY.js} +2 -2
  10. package/dist/chunk-U736PVHY.js.map +1 -0
  11. package/dist/{chunk-QEWCYW3I.js → chunk-WAMZWZJB.js} +13 -3
  12. package/dist/chunk-WAMZWZJB.js.map +1 -0
  13. package/dist/chunk-WRSX2MDK.js +7866 -0
  14. package/dist/chunk-WRSX2MDK.js.map +1 -0
  15. package/dist/{chunk-OB6UBJXF.js → chunk-XPQSTZUU.js} +125 -35
  16. package/dist/chunk-XPQSTZUU.js.map +1 -0
  17. package/dist/cli.js +3 -3
  18. package/dist/components/index.d.ts +1831 -99
  19. package/dist/components/index.js +4 -4
  20. package/dist/{dev-DCTOHQ6W.js → dev-4XEDTELV.js} +4 -4
  21. package/dist/{dev-DCTOHQ6W.js.map → dev-4XEDTELV.js.map} +1 -1
  22. package/dist/doc/index.js +2 -2
  23. package/dist/doc/index.js.map +1 -1
  24. package/dist/markdown/index.js +1 -1
  25. package/dist/markdown/index.js.map +1 -1
  26. package/dist/table/index.js +5 -5
  27. package/dist/table/index.js.map +1 -1
  28. package/dist/ui/index.d.ts +4 -1
  29. package/dist/ui/index.js +21 -111
  30. package/dist/ui/index.js.map +1 -1
  31. package/dist/{upgrade-DBBE6GIR.js → upgrade-JHXZBGQS.js} +3 -3
  32. package/dist/{upgrade-DBBE6GIR.js.map → upgrade-JHXZBGQS.js.map} +1 -1
  33. package/dist/vite/index.js +2 -2
  34. package/docs/en/components.md +29 -15
  35. package/docs/en/parts/data-table.md +22 -0
  36. package/docs/en/parts/delta.md +50 -0
  37. package/docs/en/parts/format.md +43 -0
  38. package/docs/en/parts/kpi-card.md +57 -0
  39. package/docs/en/parts/metric-value.md +3 -2
  40. package/docs/en/parts/sparkline.md +1 -1
  41. package/docs/en/parts/trend-indicator.md +17 -1
  42. package/docs/en/ui-and-theming.md +1 -1
  43. package/docs/index.json +92 -32
  44. package/docs/ja/changelog.md +5 -0
  45. package/docs/ja/components.md +27 -15
  46. package/docs/ja/parts/data-table.md +22 -0
  47. package/docs/ja/parts/delta.md +50 -0
  48. package/docs/ja/parts/format.md +44 -0
  49. package/docs/ja/parts/kpi-card.md +55 -0
  50. package/docs/ja/parts/metric-value.md +3 -2
  51. package/docs/ja/parts/sparkline.md +1 -1
  52. package/docs/ja/parts/trend-indicator.md +17 -1
  53. package/docs/ja/ui-and-theming.md +2 -2
  54. package/package.json +2 -1
  55. package/registry/ui/accordion.tsx +4 -2
  56. package/registry/ui/alert-dialog.tsx +3 -3
  57. package/registry/ui/alert.tsx +3 -1
  58. package/registry/ui/app-shell.tsx +7 -1
  59. package/registry/ui/area-chart.tsx +2781 -157
  60. package/registry/ui/avatar.tsx +1 -1
  61. package/registry/ui/badge.tsx +3 -1
  62. package/registry/ui/bar-chart.tsx +2780 -157
  63. package/registry/ui/bar-list.tsx +3471 -93
  64. package/registry/ui/breadcrumb.tsx +3 -1
  65. package/registry/ui/button.tsx +3 -1
  66. package/registry/ui/calendar.tsx +3 -1
  67. package/registry/ui/card.tsx +4 -2
  68. package/registry/ui/category-bar.tsx +990 -16
  69. package/registry/ui/chart-frame.tsx +1358 -0
  70. package/registry/ui/checkbox.tsx +3 -1
  71. package/registry/ui/collapsible.tsx +3 -1
  72. package/registry/ui/combobox.tsx +2 -2
  73. package/registry/ui/command.tsx +3 -1
  74. package/registry/ui/dashboard-card.tsx +69 -26
  75. package/registry/ui/data-table.tsx +678 -56
  76. package/registry/ui/date-range-picker.tsx +245 -64
  77. package/registry/ui/delta.tsx +188 -0
  78. package/registry/ui/dialog.tsx +5 -3
  79. package/registry/ui/donut-chart.tsx +2857 -173
  80. package/registry/ui/drawer.tsx +1 -1
  81. package/registry/ui/dropdown-menu.tsx +5 -3
  82. package/registry/ui/echart.tsx +448 -44
  83. package/registry/ui/field.tsx +1 -1
  84. package/registry/ui/filter-bar.tsx +33 -6
  85. package/registry/ui/funnel-steps.tsx +3316 -49
  86. package/registry/ui/heatmap.tsx +1752 -0
  87. package/registry/ui/hover-card.tsx +2 -2
  88. package/registry/ui/input-group.tsx +3 -1
  89. package/registry/ui/input.tsx +3 -1
  90. package/registry/ui/kbd.tsx +3 -1
  91. package/registry/ui/kpi-card.tsx +677 -0
  92. package/registry/ui/label.tsx +3 -1
  93. package/registry/ui/legend.tsx +3500 -0
  94. package/registry/ui/line-chart.tsx +2781 -151
  95. package/registry/ui/metric-value.tsx +9 -1
  96. package/registry/ui/multi-select.tsx +11 -5
  97. package/registry/ui/page-shell.tsx +104 -49
  98. package/registry/ui/pagination.tsx +3 -1
  99. package/registry/ui/placeholder.tsx +8 -2
  100. package/registry/ui/popover.tsx +4 -2
  101. package/registry/ui/progress-circle.tsx +607 -12
  102. package/registry/ui/progress.tsx +3 -1
  103. package/registry/ui/radio-group.tsx +1 -1
  104. package/registry/ui/sample-data.tsx +86 -0
  105. package/registry/ui/scatter-chart.tsx +4150 -0
  106. package/registry/ui/scroll-area.tsx +3 -2
  107. package/registry/ui/searchable-select.tsx +8 -3
  108. package/registry/ui/section-header.tsx +4 -2
  109. package/registry/ui/segmented-control.tsx +7 -2
  110. package/registry/ui/select.tsx +4 -2
  111. package/registry/ui/separator.tsx +3 -1
  112. package/registry/ui/sheet.tsx +4 -2
  113. package/registry/ui/sidebar.tsx +4 -2
  114. package/registry/ui/skeleton.tsx +4 -2
  115. package/registry/ui/slider.tsx +3 -1
  116. package/registry/ui/sparkline.tsx +84 -9
  117. package/registry/ui/spinner.tsx +3 -1
  118. package/registry/ui/states.tsx +34 -5
  119. package/registry/ui/status-badge.tsx +81 -39
  120. package/registry/ui/switch.tsx +3 -1
  121. package/registry/ui/table.tsx +4 -2
  122. package/registry/ui/tabs.tsx +5 -3
  123. package/registry/ui/textarea.tsx +3 -1
  124. package/registry/ui/toast.tsx +2 -2
  125. package/registry/ui/toggle-group.tsx +3 -1
  126. package/registry/ui/toggle.tsx +3 -1
  127. package/registry/ui/tooltip.tsx +4 -2
  128. package/registry/ui/tracker.tsx +624 -17
  129. package/registry/ui/trend-indicator.tsx +13 -1
  130. package/registry/ui/updated-at.tsx +122 -0
  131. package/registry/ui/waterfall-chart.tsx +4186 -0
  132. package/templates/AGENTS.md +13 -1
  133. package/theme.css +237 -136
  134. package/dist/chunk-5ZOP4SQR.js.map +0 -1
  135. package/dist/chunk-A2UUGASH.js.map +0 -1
  136. package/dist/chunk-AG6RZ2RR.js +0 -3208
  137. package/dist/chunk-AG6RZ2RR.js.map +0 -1
  138. package/dist/chunk-IBGOCWJB.js.map +0 -1
  139. package/dist/chunk-OB6UBJXF.js.map +0 -1
  140. package/dist/chunk-PXRX5PEC.js.map +0 -1
  141. package/dist/chunk-QEWCYW3I.js.map +0 -1
@@ -1,5 +1,7 @@
1
1
  // Copied by `vantage add ui progress-circle`. This file is yours to edit.
2
- // Generated from @squadbase/vantage src/components/progress-circle.tsx by `npm run registry` (in Vantage, edit src/, not registry/).
2
+ // Generated from @squadbase/squadkit registry/charts/progress-circle.tsx by `npm run registry` (in Vantage, edit packages/squadkit, not registry/).
3
+ "use client"
4
+
3
5
  /**
4
6
  * ProgressCircle — a ring that fills to `value / max`, with anything you pass
5
7
  * as children in the middle (usually the percentage). Plain SVG, coloured by
@@ -9,9 +11,26 @@ import { forwardRef, useEffect, useState, type ComponentProps } from "react"
9
11
 
10
12
  import { cn } from "@squadbase/vantage/ui"
11
13
  import type { ChartColor } from "@squadbase/vantage/components"
12
- import type { ValueFormatter } from "@squadbase/vantage/components"
14
+ import { DEFAULT_LOCALE, EMPTY_VALUE, formatValue, toValueFormatter, type ValueFormat } from "@squadbase/vantage/components"
15
+ import type { ChartColors, ChartSeriesColor, ChartTone, ValueFormatter } from "@squadbase/vantage/components"
16
+
17
+ /* ---------- inlined from @squadbase/squadkit registry/lib/chart-utils.ts (internal to @squadbase/vantage) ---------- */
13
18
 
14
- /* ---------- inlined from src/components/chart-utils.ts (internal to @squadbase/vantage) ---------- */
19
+ const ROOT_PROP_KEYS = new Set(["id", "style", "role", "tabIndex", "lang", "hidden"])
20
+ const ROOT_PROP_PREFIX_RE = /^(data|aria)-/
21
+
22
+ /**
23
+ * The attributes of a typed chart's leftover props that belong on its root
24
+ * element: `id` / `style` / `role` / `tabIndex` / `lang` / `hidden`, `data-*` and `aria-*`.
25
+ * An allowlist, so a chart's own (or deprecated, ignored) props never reach the DOM.
26
+ */
27
+ function chartRootProps(rest: Record<string, unknown>): Record<string, unknown> {
28
+ const out: Record<string, unknown> = {}
29
+ for (const [key, value] of Object.entries(rest)) {
30
+ if (value !== undefined && (ROOT_PROP_KEYS.has(key) || ROOT_PROP_PREFIX_RE.test(key))) out[key] = value
31
+ }
32
+ return out
33
+ }
15
34
 
16
35
  /** The series tokens, in palette order — the default `colors` of every typed chart. */
17
36
  const DEFAULT_CHART_COLORS: readonly ChartColor[] = [
@@ -24,13 +43,37 @@ const DEFAULT_CHART_COLORS: readonly ChartColor[] = [
24
43
 
25
44
  const TOKEN_NAME_RE = /^chart-[1-5]$/
26
45
 
46
+ /** The meaning colours; each is a theme token of the same name (`--positive`, …). */
47
+ const CHART_TONES: readonly ChartTone[] = ["positive", "warning", "negative", "neutral"]
48
+
49
+ /** Whether `color` is a meaning colour (`"positive"` / `"warning"` / `"negative"` / `"neutral"`). */
50
+ function isChartTone(color: string): color is ChartTone {
51
+ return (CHART_TONES as readonly string[]).includes(color.trim())
52
+ }
53
+
27
54
  /**
28
55
  * A chart colour as a CSS value: `"chart-2"` → `var(--chart-2)` (so it follows
29
56
  * light/dark on its own), anything else passes through unchanged.
30
57
  */
31
58
  function chartColorToCss(color: ChartColor): string {
32
59
  const value = color.trim()
33
- return TOKEN_NAME_RE.test(value) ? `var(--${value})` : value
60
+ return TOKEN_NAME_RE.test(value) || isChartTone(value) ? `var(--${value})` : value
61
+ }
62
+
63
+ /**
64
+ * A chart colour for an AREA (bar, slice, segment, ring): like `chartColorToCss`, but a meaning
65
+ * colour resolves to its area token (`"positive"` → `var(--positive-fill)`), which keeps the same
66
+ * lightness step from the page in light and dark. `--positive` etc. are for text and icons.
67
+ */
68
+ function chartFillToCss(color: ChartColor): string {
69
+ const value = color.trim()
70
+ return isChartTone(value) ? `var(--${value}-fill)` : chartColorToCss(color)
71
+ }
72
+
73
+ /** The colour name to resolve for an area: a meaning colour becomes its `-fill` token (`"positive"` → `"positive-fill"`), the rest is unchanged. */
74
+ function chartFillName(color: ChartColor): ChartColor {
75
+ const value = color.trim()
76
+ return isChartTone(value) ? `${value}-fill` : color
34
77
  }
35
78
 
36
79
  /** The `i`-th colour, cycling through `colors` (the token palette when empty). */
@@ -54,6 +97,79 @@ const numberFormat = new Intl.NumberFormat("ja-JP", { maximumFractionDigits: 2 }
54
97
  /** Default `valueFormatter`: grouped digits, up to two decimals (`1,234.5`). */
55
98
  const defaultValueFormatter: ValueFormatter = (value) => numberFormat.format(value)
56
99
 
100
+ /**
101
+ * The formatter a chart actually uses: an explicit `valueFormatter` wins, else
102
+ * `format` (default `"compact"`, so ¥1億 / 9.2万 on axes) via `toValueFormatter`.
103
+ */
104
+ function resolveValueFormatter(
105
+ valueFormatter: ValueFormatter | undefined,
106
+ format: ValueFormat | undefined,
107
+ locale?: string,
108
+ ): ValueFormatter {
109
+ return valueFormatter ?? toValueFormatter(format, locale)
110
+ }
111
+
112
+ /**
113
+ * The formatter of value-axis ticks and on-plot labels: an explicit
114
+ * `valueFormatter` wins; else `"compact"` / `"number"` / `"currency"` read in
115
+ * the short form (`1億`, `¥1億`, `¥4,520万` — a tick of `¥100,000,000` is
116
+ * too wide to read), and `"percent"` / `"multiple"` as `format` says. The short
117
+ * form goes through `formatValue` (compact notation, `digits` / `unit` dropped),
118
+ * so an axis tick and a KPI / tooltip of the same `format` always print the same
119
+ * currency sign and grouping.
120
+ */
121
+ function resolveAxisFormatter(
122
+ valueFormatter: ValueFormatter | undefined,
123
+ format: ValueFormat | undefined,
124
+ locale: string = DEFAULT_LOCALE,
125
+ ): ValueFormatter {
126
+ if (valueFormatter) return valueFormatter
127
+ const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
128
+ if (spec.type !== "compact" && spec.type !== "number" && spec.type !== "currency") return toValueFormatter(format, locale)
129
+ const currency = "currency" in spec ? spec.currency : undefined
130
+ const short: ValueFormat =
131
+ spec.type === "currency" ? { type: "currency", currency, notation: "compact" } : { type: "compact" }
132
+ return (value) => formatValue(value, short, locale)
133
+ }
134
+
135
+ /** The unit words of a ja number (`1.2万` / `3.4億` / `1兆`) and their sizes, largest first. */
136
+ const JA_UNITS = [
137
+ { char: "兆", size: 1e12 },
138
+ { char: "億", size: 1e8 },
139
+ { char: "万", size: 1e4 },
140
+ ] as const
141
+
142
+ const JA_NUMBER_TOKEN_RE = /\d[\d,]*(?:\.\d+)?(?:兆|億|万)?/
143
+
144
+ /**
145
+ * The value-axis tick formatter for an axis whose largest tick is `top`: every tick reads in the unit of
146
+ * that largest tick, so 万 and 億 never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`).
147
+ * Only the ja short forms (`compact` / `number` / `currency`, no `valueFormatter`) have units to align;
148
+ * any other format — and an axis whose largest tick is under 1万 — keeps `resolveAxisFormatter`'s output as it is.
149
+ */
150
+ function resolveAxisTickFormatter(
151
+ valueFormatter: ValueFormatter | undefined,
152
+ format: ValueFormat | undefined,
153
+ locale: string = DEFAULT_LOCALE,
154
+ ): (top: number) => ValueFormatter {
155
+ const base = resolveAxisFormatter(valueFormatter, format, locale)
156
+ const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
157
+ const aligns =
158
+ !valueFormatter && locale.toLowerCase().startsWith("ja") && (spec.type === "compact" || spec.type === "number" || spec.type === "currency")
159
+ if (!aligns) return () => base
160
+ const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 3 })
161
+ return (top) => {
162
+ const unit = JA_UNITS.find((u) => Math.abs(top) >= u.size)
163
+ if (!unit) return base
164
+ return (value) => {
165
+ const text = base(value)
166
+ if (value === 0 || !Number.isFinite(value)) return text
167
+ const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.char}`
168
+ return text.replace(JA_NUMBER_TOKEN_RE, () => scaled)
169
+ }
170
+ }
171
+ }
172
+
57
173
  /**
58
174
  * A data cell as a number, or `null` for a gap. Numeric strings count — SQL
59
175
  * `DECIMAL` / `BIGINT` columns usually arrive as strings.
@@ -73,6 +189,222 @@ function clamp(value: number, min: number, max: number): number {
73
189
  return Math.min(max, Math.max(min, value))
74
190
  }
75
191
 
192
+ /* ------------------------------------------------------------ aggregation */
193
+
194
+ /** How many categories a chart colours individually; the rest fold into「その他」. */
195
+ const MAX_CHART_SERIES = 5
196
+
197
+ /** The series name the folded categories get. */
198
+ const OTHER_SERIES_LABEL = "その他"
199
+
200
+ interface AggregatedItems<Item extends { name: string; value: number }> {
201
+ /** The `n` largest items, in their given order. All items when there are `n` or fewer. */
202
+ top: Item[]
203
+ /** The items folded into `other`, in their given order. */
204
+ rest: Item[]
205
+ /** The folded remainder, or `null` when nothing was folded. */
206
+ other: { name: string; value: number } | null
207
+ }
208
+
209
+ /**
210
+ * Keep the `n` largest items (by value; ties keep the earlier one) in their
211
+ * given order and fold the rest into one `otherLabel` item. Shared by every
212
+ * chart that caps its categories (Area / Line / Bar series, Donut slices,
213
+ * BarList rows).
214
+ */
215
+ function aggregateTopN<Item extends { name: string; value: number }>(
216
+ items: readonly Item[],
217
+ n: number = MAX_CHART_SERIES,
218
+ otherLabel: string = OTHER_SERIES_LABEL,
219
+ ): AggregatedItems<Item> {
220
+ if (items.length <= n) return { top: [...items], rest: [], other: null }
221
+ const ranked = items
222
+ .map((item, i) => ({ item, i }))
223
+ .sort((a, b) => b.item.value - a.item.value || a.i - b.i)
224
+ const keep = new Set(ranked.slice(0, n).map((r) => r.i))
225
+ const top = items.filter((_, i) => keep.has(i))
226
+ const rest = items.filter((_, i) => !keep.has(i))
227
+ return { top, rest, other: { name: otherLabel, value: rest.reduce((sum, item) => sum + item.value, 0) } }
228
+ }
229
+
230
+ interface AggregatedSeries {
231
+ /** Rows to draw: the input rows, plus an `otherCategory` column when categories were folded. */
232
+ data: Record<string, unknown>[]
233
+ /** Series to draw: the kept categories in their given order, then `otherCategory`. */
234
+ categories: string[]
235
+ /** The folded series' name, or `null`. */
236
+ otherCategory: string | null
237
+ }
238
+
239
+ /**
240
+ * Cap a cartesian chart at `n` series: rank the categories by the sum of their
241
+ * values, keep the top `n` and add a per-row sum of the rest as `otherLabel`.
242
+ */
243
+ function aggregateSeries(
244
+ data: readonly Record<string, unknown>[],
245
+ categories: readonly string[],
246
+ n: number = MAX_CHART_SERIES,
247
+ otherLabel: string = OTHER_SERIES_LABEL,
248
+ ): AggregatedSeries {
249
+ const totals = categories.map((name) => ({
250
+ name,
251
+ value: data.reduce((sum, row) => sum + (toChartNumber(row[name]) ?? 0), 0),
252
+ }))
253
+ const { top, rest, other } = aggregateTopN(totals, n, otherLabel)
254
+ if (!other) return { data: [...data], categories: [...categories], otherCategory: null }
255
+ const folded = rest.map((item) => item.name)
256
+ return {
257
+ data: data.map((row) => {
258
+ let sum: number | null = null
259
+ for (const name of folded) {
260
+ const v = toChartNumber(row[name])
261
+ if (v !== null) sum = (sum ?? 0) + v
262
+ }
263
+ return { ...row, [other.name]: sum }
264
+ }),
265
+ categories: [...top.map((item) => item.name), other.name],
266
+ otherCategory: other.name,
267
+ }
268
+ }
269
+
270
+ /**
271
+ * `aggregateSeries` that never folds `keep` (a comparison series) into「その他」: the others are capped
272
+ * at `n` as usual and `keep` is appended last, so it is drawn on top of the cap and never takes the first slot.
273
+ */
274
+ function aggregateSeriesKeeping(
275
+ data: readonly Record<string, unknown>[],
276
+ categories: readonly string[],
277
+ keep: string | undefined,
278
+ n: number = MAX_CHART_SERIES,
279
+ ): AggregatedSeries {
280
+ if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n)
281
+ const aggregated = aggregateSeries(
282
+ data,
283
+ categories.filter((c) => c !== keep),
284
+ n,
285
+ )
286
+ return { ...aggregated, categories: [...aggregated.categories, keep] }
287
+ }
288
+
289
+ /**
290
+ * `seriesColorsFor` for a chart with a comparison series: the comparison takes no `--chart-*` token (the
291
+ * builder paints it in muted grey; its slot here is `""`), and the others are assigned as if it were absent,
292
+ * so a main series keeps its colour whether or not a comparison is drawn.
293
+ */
294
+ function seriesColorsWithComparison(
295
+ categories: readonly string[],
296
+ colors: ChartColors | undefined,
297
+ otherColor: string,
298
+ otherCategory: string | null,
299
+ allCategories: readonly string[],
300
+ comparison: string | undefined,
301
+ ): string[] {
302
+ if (comparison === undefined || !categories.includes(comparison)) {
303
+ return seriesColorsFor(categories, colors, otherColor, otherCategory, allCategories)
304
+ }
305
+ const main = categories.filter((c) => c !== comparison)
306
+ const mainColors = seriesColorsFor(
307
+ main,
308
+ colors,
309
+ otherColor,
310
+ otherCategory,
311
+ allCategories.filter((c) => c !== comparison),
312
+ )
313
+ return categories.map((c) => (c === comparison ? "" : (mainColors[main.indexOf(c)] ?? "")))
314
+ }
315
+
316
+ /**
317
+ * Colour per drawn series: the record's token for that category, else the
318
+ * array's token at the category's position in `allCategories` (cycled), else
319
+ * the token palette by drawn position. `otherCategory` — and a category the
320
+ * data itself names「その他」(`OTHER_SERIES_LABEL`) — always gets `otherColor`.
321
+ */
322
+ function seriesColorsFor(
323
+ categories: readonly string[],
324
+ colors: ChartColors | undefined,
325
+ otherColor: string,
326
+ otherCategory: string | null = null,
327
+ allCategories: readonly string[] = categories,
328
+ ): string[] {
329
+ const list = Array.isArray(colors) ? (colors as ChartSeriesColor[]) : undefined
330
+ const record = !Array.isArray(colors) && colors ? (colors as Partial<Record<string, ChartSeriesColor>>) : undefined
331
+ return categories.map((category, i) => {
332
+ if ((otherCategory !== null && category === otherCategory) || category === OTHER_SERIES_LABEL) return otherColor
333
+ const fromRecord = record?.[category]
334
+ if (fromRecord) return fromRecord
335
+ if (list && list.length > 0) {
336
+ const at = allCategories.indexOf(category)
337
+ return list[(at === -1 ? i : at) % list.length]!
338
+ }
339
+ return DEFAULT_CHART_COLORS[i % DEFAULT_CHART_COLORS.length]!
340
+ })
341
+ }
342
+
343
+ /* ------------------------------------------------------------------ Donut */
344
+
345
+ /** One drawn donut slice, largest first. */
346
+ interface DonutSlice {
347
+ name: string
348
+ /** Negative and missing values count as 0. */
349
+ value: number
350
+ /** A chart token (`"chart-1"`…), or `otherColor` for the folded slice. */
351
+ color: string
352
+ /** The folded「その他」slice. */
353
+ isOther: boolean
354
+ /** The input row, or a synthesized `{ [index]: "その他", [value]: sum }` for the folded slice. */
355
+ row: Record<string, unknown>
356
+ }
357
+
358
+ interface DonutSlicesConfig {
359
+ /** Label column. */
360
+ index: string
361
+ /** Value column. */
362
+ value: string
363
+ /** Slice colours by rank (cycled). Default: the token palette in order. */
364
+ colors?: readonly ChartSeriesColor[]
365
+ /** How many slices are drawn individually; the rest fold into「その他」. Default 5. */
366
+ maxSlices?: number
367
+ /** Colour of the folded slice (pass `CHART_OTHER_COLOR`). */
368
+ otherColor: string
369
+ otherLabel?: string
370
+ }
371
+
372
+ /**
373
+ * Donut slices: sorted by value (largest first, ties keep input order), the
374
+ * `maxSlices` largest kept and coloured by rank in fixed token order, the rest
375
+ * summed into one「その他」slice in `otherColor`.
376
+ */
377
+ function donutSlices(
378
+ data: readonly Record<string, unknown>[],
379
+ { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = OTHER_SERIES_LABEL }: DonutSlicesConfig,
380
+ ): DonutSlice[] {
381
+ const items = data
382
+ .map((row, i) => {
383
+ const n = toChartNumber(row[value])
384
+ return { name: String(row[index] ?? ""), value: n !== null && n > 0 ? n : 0, row, i }
385
+ })
386
+ .sort((a, b) => b.value - a.value || a.i - b.i)
387
+ const { top, other } = aggregateTopN(items, Math.max(1, maxSlices), otherLabel)
388
+ const palette = colors && colors.length > 0 ? colors : DEFAULT_CHART_COLORS
389
+ const slices: DonutSlice[] = top.map((item, rank) => ({
390
+ name: item.name,
391
+ value: item.value,
392
+ color: palette[rank % palette.length]!,
393
+ isOther: false,
394
+ row: item.row,
395
+ }))
396
+ if (other) {
397
+ slices.push({
398
+ name: other.name,
399
+ value: other.value,
400
+ color: otherColor,
401
+ isOther: true,
402
+ row: { [index]: other.name, [value]: other.value },
403
+ })
404
+ }
405
+ return slices
406
+ }
407
+
76
408
  /* ---------------------------------------------------------------- BarList */
77
409
 
78
410
  type BarListSortOrder = "ascending" | "descending" | "none"
@@ -85,6 +417,78 @@ function sortBarListData<T extends { value: number }>(data: readonly T[], sortOr
85
417
  return rows
86
418
  }
87
419
 
420
+ /** BarList-only settings. */
421
+ interface BarListOptions {
422
+ /** 並べ順。既定 `"descending"`(大きい順)。`"none"` は渡した順。「その他」は常に最後。 */
423
+ sortOrder?: BarListSortOrder
424
+ /** 個別に出す行の数。超えた分は合計して「その他」の 1 行にまとめる。既定 5。 */
425
+ maxItems?: number
426
+ /** まとめた行のラベル。既定「その他」。 */
427
+ otherLabel?: string
428
+ /** 順位に入れず、常に「その他」へ足す行の名前。例 `["(not set)", "Unassigned"]`。 */
429
+ exclude?: readonly string[]
430
+ /** 値の横に構成比(「その他」も含めた全行の合計に対する割合。整数 %)を添える。例「¥8,240万 · 43%」。既定 `false`。 */
431
+ showShare?: boolean
432
+ }
433
+
434
+ /** One drawn BarList row. */
435
+ interface BarListRow<Row = Record<string, unknown>> {
436
+ /** React key: the row's `key`, else its name (`"__other"` for the folded row). */
437
+ key: string
438
+ name: string
439
+ value: number
440
+ /** Only for real rows; the folded row never links. */
441
+ href?: string
442
+ /** Legacy per-row colour (`BarListItem.color`). */
443
+ color?: ChartColor
444
+ /** The folded「その他」row. */
445
+ isOther: boolean
446
+ /** The input row (`null` for the folded row). */
447
+ row: Row | null
448
+ }
449
+
450
+ interface BarListRowsConfig extends BarListOptions {
451
+ /** Name column. Default `"name"`. */
452
+ index?: string
453
+ /** Value column. Default `"value"`. */
454
+ category?: string
455
+ }
456
+
457
+ /**
458
+ * The rows a BarList draws: read name / value (numeric strings count, gaps are
459
+ * 0), keep the `maxItems` largest (via `aggregateTopN`), sort them, and append
460
+ * the folded remainder as one「その他」row. Rows named in `exclude` skip the
461
+ * ranking and are always added to「その他」.
462
+ */
463
+ function barListRows<Row extends object>(
464
+ data: readonly Row[],
465
+ { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = OTHER_SERIES_LABEL, exclude }: BarListRowsConfig = {},
466
+ ): BarListRow<Row>[] {
467
+ const items = data.map((row): BarListRow<Row> => {
468
+ const cells = row as Record<string, unknown>
469
+ const name = String(cells[index] ?? "")
470
+ return {
471
+ key: typeof cells.key === "string" ? cells.key : name,
472
+ name,
473
+ value: toChartNumber(cells[category]) ?? 0,
474
+ href: typeof cells.href === "string" ? cells.href : undefined,
475
+ color: typeof cells.color === "string" ? cells.color : undefined,
476
+ isOther: false,
477
+ row,
478
+ }
479
+ })
480
+ const excluded = new Set(exclude ?? [])
481
+ const ranked = excluded.size > 0 ? items.filter((item) => !excluded.has(item.name)) : items
482
+ const outside = excluded.size > 0 ? items.filter((item) => excluded.has(item.name)) : []
483
+ const { top, other } = aggregateTopN(ranked, Math.max(0, maxItems), otherLabel)
484
+ const rows = sortBarListData(top, sortOrder)
485
+ if (other || outside.length > 0) {
486
+ const value = (other?.value ?? 0) + outside.reduce((sum, item) => sum + item.value, 0)
487
+ rows.push({ key: "__other", name: otherLabel, value, isOther: true, row: null })
488
+ }
489
+ return rows
490
+ }
491
+
88
492
  /** Bar widths in percent of the largest value (at least a sliver for non-zero values). */
89
493
  function barListWidths(values: readonly number[]): number[] {
90
494
  const max = Math.max(0, ...values)
@@ -92,6 +496,100 @@ function barListWidths(values: readonly number[]): number[] {
92
496
  return values.map((v) => (v <= 0 ? 0 : Math.max(1, (v / max) * 100)))
93
497
  }
94
498
 
499
+ /**
500
+ * How strongly the BarList bars are painted: every row the same tint of the bar
501
+ * colour (rank is the bar's length, not its weight). A tint over transparent, so
502
+ * it sits on the card in light and dark; it must stay visible against the card
503
+ * while the label printed over the bar keeps its contrast (see `tests/contrast.test.ts`).
504
+ */
505
+ const BAR_LIST_TINT = 45
506
+ /** 「その他」: a tint of the other colour, so it is the palest fill in the list (light and dark). */
507
+ const BAR_LIST_OTHER_TINT = 30
508
+
509
+ /**
510
+ * The fill of each BarList row: all real rows in the same tint of `color`
511
+ * (`BAR_LIST_TINT`), the folded「その他」row in a tint of `otherColor`
512
+ * (`BAR_LIST_OTHER_TINT`) — the palest fill, a remainder never outweighs a
513
+ * ranked row. A legacy per-row `color` (or a threshold tone) replaces `color`
514
+ * for that row. A tint, not the solid token, so the label printed over the bar
515
+ * keeps its contrast.
516
+ */
517
+ function barListFills(
518
+ rows: readonly Pick<BarListRow, "isOther" | "color">[],
519
+ color: ChartColor,
520
+ otherColor: string,
521
+ ): string[] {
522
+ return rows.map((row) =>
523
+ row.isOther ? tintCss(otherColor, BAR_LIST_OTHER_TINT) : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
524
+ )
525
+ }
526
+
527
+ /** A row's share of `total` as text: whole percent (fixed, no decimals), `<1%` for a sliver, `0%` for nothing. */
528
+ function barListShareText(value: number, total: number): string {
529
+ const v = Number.isFinite(value) && value > 0 ? value : 0
530
+ if (total <= 0 || v === 0) return "0%"
531
+ const pct = (v / total) * 100
532
+ return pct < 1 ? "<1%" : `${Math.round(pct)}%`
533
+ }
534
+
535
+ /**
536
+ * The text at the end of each BarList row: the formatted value and, with
537
+ * `showShare`, its share of the total of all drawn rows (including「その他」,
538
+ * so the shares are of the whole list): `value` and `share` apart (the share is
539
+ * drawn muted), `text` as one string (「¥8,240万 · 43%」).
540
+ */
541
+ function barListValueTexts(
542
+ values: readonly number[],
543
+ formatter: ValueFormatter,
544
+ showShare = false,
545
+ ): { value: string; share?: string; text: string }[] {
546
+ const total = values.reduce((sum, v) => sum + (Number.isFinite(v) && v > 0 ? v : 0), 0)
547
+ return values.map((v) => {
548
+ const value = formatter(v)
549
+ if (!showShare) return { value, text: value }
550
+ const share = barListShareText(v, total)
551
+ return { value, share, text: `${value} · ${share}` }
552
+ })
553
+ }
554
+
555
+ /* -------------------------------------------------------------- DataTable */
556
+
557
+ /** The visually-hidden table `ChartFrame` renders next to the plot (`dataTable`). */
558
+ interface ChartDataTable {
559
+ /** 表の見出し(`<caption>`)。 */
560
+ caption?: string
561
+ /** 列見出し。先頭がラベル列。 */
562
+ columns: string[]
563
+ /** 行。セルは書式済みの文字列。 */
564
+ rows: string[][]
565
+ }
566
+
567
+ /**
568
+ * The chart's numbers as a table, for screen readers: one row per data row, the
569
+ * `index` cell then each category through `formatter` (gaps → `—`). `labels`
570
+ * renames column keys for the header (`{ sessions: "セッション" }`); `indexName` is the first column's header when `labels` has none for `index` (e.g. 「項目」, instead of the raw key).
571
+ */
572
+ function chartDataTable(
573
+ data: readonly Record<string, unknown>[],
574
+ index: string,
575
+ categories: readonly string[],
576
+ formatter: ValueFormatter,
577
+ labels?: Readonly<Record<string, string>>,
578
+ indexName?: string,
579
+ ): ChartDataTable {
580
+ const label = (key: string) => labels?.[key] ?? key
581
+ return {
582
+ columns: [labels?.[index] ?? indexName ?? index, ...categories.map(label)],
583
+ rows: data.map((row) => [
584
+ String(row[index] ?? ""),
585
+ ...categories.map((key) => {
586
+ const n = toChartNumber(row[key])
587
+ return n === null ? EMPTY_VALUE : formatter(n)
588
+ }),
589
+ ]),
590
+ }
591
+ }
592
+
95
593
  /* ------------------------------------------------------------ CategoryBar */
96
594
 
97
595
  interface CategoryBarSegment {
@@ -126,9 +624,11 @@ interface CategoryBarLabel {
126
624
  * Tick labels under the bar: `0` at the start, then the running total at each
127
625
  * segment's end. A label closer than `minGap` percent to the one before is
128
626
  * dropped so narrow segments don't print on top of each other — except the
129
- * last one (the total), which replaces its neighbour instead.
627
+ * last one (the total), which replaces its neighbour instead. With a
628
+ * `markerPosition`, an inner label closer than `minGap` to the marker is dropped
629
+ * too — the marker sits right under it and the two read as one crowded spot.
130
630
  */
131
- function categoryBarLabels(values: readonly number[], minGap = 8): CategoryBarLabel[] {
631
+ function categoryBarLabels(values: readonly number[], minGap = 8, markerPosition?: number): CategoryBarLabel[] {
132
632
  const segments = categoryBarSegments(values).filter((s) => s.width > 0)
133
633
  if (segments.length === 0) return []
134
634
  const labels: CategoryBarLabel[] = [{ position: 0, value: 0 }]
@@ -138,7 +638,46 @@ function categoryBarLabels(values: readonly number[], minGap = 8): CategoryBarLa
138
638
  if (label.position - prev.position >= minGap) labels.push(label)
139
639
  else if (i === segments.length - 1 && labels.length > 1) labels[labels.length - 1] = label
140
640
  })
141
- return labels
641
+ if (markerPosition === undefined || !Number.isFinite(markerPosition)) return labels
642
+ return labels.filter(
643
+ (label, i) => i === 0 || i === labels.length - 1 || Math.abs(label.position - markerPosition) >= minGap,
644
+ )
645
+ }
646
+
647
+ /**
648
+ * `labels` thinned to what fits side by side on a bar `width` px wide, given each label's printed text
649
+ * (`texts`, same order): a label that would touch the one before is dropped — except the last (the
650
+ * total), which replaces its neighbour instead. The first sits at the left edge, the last at the right
651
+ * edge, the rest centred on their position. The width is a rough estimate (full-width characters 12 px,
652
+ * the rest 7 px at 12 px type).
653
+ */
654
+ function categoryBarFitLabels(
655
+ labels: readonly CategoryBarLabel[],
656
+ texts: readonly string[],
657
+ width: number,
658
+ gap = 8,
659
+ ): CategoryBarLabel[] {
660
+ const textWidth = (text: string) => {
661
+ let w = 0
662
+ for (const ch of text) w += /[\u3000-\u9fff\uff00-\uffef]/.test(ch) ? 12 : 7
663
+ return w
664
+ }
665
+ const extent = (label: CategoryBarLabel, text: string): [number, number] => {
666
+ const w = textWidth(text)
667
+ if (label.position <= 0) return [0, w]
668
+ if (label.position >= 100) return [width - w, width]
669
+ const center = (label.position / 100) * width
670
+ return [center - w / 2, center + w / 2]
671
+ }
672
+ const kept: { label: CategoryBarLabel; extent: [number, number] }[] = []
673
+ labels.forEach((label, i) => {
674
+ const here = extent(label, texts[i] ?? "")
675
+ const touches = () => kept.length > 0 && kept[kept.length - 1]!.extent[1] + gap > here[0]
676
+ if (i === labels.length - 1) while (kept.length > 1 && touches()) kept.pop()
677
+ else if (touches()) return
678
+ kept.push({ label, extent: here })
679
+ })
680
+ return kept.map((k) => k.label)
142
681
  }
143
682
 
144
683
  /** Marker position in percent of the bar, clamped to its ends. */
@@ -183,6 +722,62 @@ function progressCircleGeometry(
183
722
  return { size: radius * 2, normalizedRadius, circumference, offset: circumference * (1 - ratio), ratio }
184
723
  }
185
724
 
725
+ /* ---------------------------------------------------------------- summary */
726
+
727
+ interface ChartSummaryInput {
728
+ data: readonly Record<string, unknown>[]
729
+ /** The x-axis key. */
730
+ index: string
731
+ /** Series keys; the first one is described. */
732
+ categories: readonly string[]
733
+ /** Title of the chart, put in front when given. */
734
+ title?: string
735
+ /** Same as the chart's `format` (default `"compact"`). */
736
+ format?: ValueFormat
737
+ /** Wins over `format`. */
738
+ valueFormatter?: ValueFormatter
739
+ }
740
+
741
+ /**
742
+ * One-sentence summary built from the rows, for `summary="auto"`:
743
+ * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」. The direction is
744
+ * computed from the last two non-empty points of the first series — never
745
+ * written by hand, so it cannot contradict the data. `undefined` when there is
746
+ * no usable point.
747
+ */
748
+ function buildChartSummary(input: ChartSummaryInput): string | undefined {
749
+ const key = input.categories[0]
750
+ if (key === undefined) return undefined
751
+ const points: { label: string; value: number }[] = []
752
+ for (const row of input.data) {
753
+ const raw = row[key]
754
+ const value = raw === null || raw === undefined || raw === "" ? NaN : Number(raw)
755
+ if (Number.isFinite(value)) points.push({ label: String(row[input.index] ?? ""), value })
756
+ }
757
+ const latest = points[points.length - 1]
758
+ if (!latest) return undefined
759
+ const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact")
760
+ const prev = points[points.length - 2]
761
+ const direction = !prev ? "" : latest.value > prev.value ? "、前の点より増加" : latest.value < prev.value ? "、前の点より減少" : "、前の点と横ばい"
762
+ const head = input.title ? `${input.title}: ` : ""
763
+ return `${head}最新 ${latest.label} は ${text(latest.value)}${direction}`
764
+ }
765
+
766
+ /**
767
+ * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」 from the slices
768
+ * (largest first). `undefined` when there is nothing to describe.
769
+ */
770
+ function buildShareSummary(
771
+ slices: readonly { name: string; value: number }[],
772
+ title?: string,
773
+ ): string | undefined {
774
+ const total = slices.reduce((sum, s) => sum + s.value, 0)
775
+ const top = slices[0]
776
+ if (!top || total <= 0) return undefined
777
+ const share = `${Math.round((top.value / total) * 1000) / 10}%`
778
+ return `${title ? `${title}: ` : ""}最大は ${top.name}(${share})`
779
+ }
780
+
186
781
  /* ---------- end of inlined code ---------- */
187
782
 
188
783
  export interface ProgressCircleProps extends ComponentProps<"div"> {
@@ -194,9 +789,9 @@ export interface ProgressCircleProps extends ComponentProps<"div"> {
194
789
  radius?: number
195
790
  /** 線の太さ(px)。既定 `6`。 */
196
791
  strokeWidth?: number
197
- /** 色。`"chart-1"`〜`"chart-5"` か CSS カラー。既定 `"chart-1"`。地のリングは同じ色を淡くしたもの。 */
792
+ /** 色。`"chart-1"`〜`"chart-5"`・意味色(`"positive"` など。面用の `--<tone>-fill` で塗る)・CSS カラー。既定 `"chart-1"`。地のリングは同じ色を淡くしたもの。 */
198
793
  color?: ChartColor
199
- /** マウント時と値の変化をアニメーションする。既定 `true`。 */
794
+ /** マウント時と値の変化をアニメーションする。既定 `false`(`prefers-reduced-motion` では常に無効)。 */
200
795
  showAnimation?: boolean
201
796
  }
202
797
 
@@ -207,7 +802,7 @@ export const ProgressCircle = forwardRef<HTMLDivElement, ProgressCircleProps>(fu
207
802
  radius = 32,
208
803
  strokeWidth = 6,
209
804
  color = "chart-1",
210
- showAnimation = true,
805
+ showAnimation = false,
211
806
  className,
212
807
  children,
213
808
  ...props
@@ -222,7 +817,7 @@ export const ProgressCircle = forwardRef<HTMLDivElement, ProgressCircleProps>(fu
222
817
  }, [showAnimation])
223
818
 
224
819
  const { size, normalizedRadius, circumference, offset, ratio } = progressCircleGeometry(value, max, radius, strokeWidth)
225
- const css = chartColorToCss(color)
820
+ const css = chartFillToCss(color)
226
821
 
227
822
  return (
228
823
  <div
@@ -256,7 +851,7 @@ export const ProgressCircle = forwardRef<HTMLDivElement, ProgressCircleProps>(fu
256
851
  strokeLinecap="round"
257
852
  strokeDasharray={`${circumference} ${circumference}`}
258
853
  strokeDashoffset={mounted ? offset : circumference}
259
- className={cn(showAnimation && "transition-[stroke-dashoffset] duration-700 ease-out")}
854
+ className={cn(showAnimation && "transition-[stroke-dashoffset] duration-700 ease-out motion-reduce:transition-none")}
260
855
  style={{ stroke: css }}
261
856
  />
262
857
  )}