@squadbase/vantage 0.6.1 → 0.6.2-dev1.31accb1

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 (183) hide show
  1. package/dist/add-DY4AXANK.js +6 -0
  2. package/dist/add-DY4AXANK.js.map +1 -0
  3. package/dist/{build-O5TVNH4E.js → build-TVKFIVFQ.js} +8 -8
  4. package/dist/{build-O5TVNH4E.js.map → build-TVKFIVFQ.js.map} +1 -1
  5. package/dist/{check-WFBEDHDR.js → check-3KHZXZMQ.js} +6 -6
  6. package/dist/{check-WFBEDHDR.js.map → check-3KHZXZMQ.js.map} +1 -1
  7. package/dist/{chunk-PLST5Q7T.js → chunk-AG6RZ2RR.js} +1010 -23
  8. package/dist/chunk-AG6RZ2RR.js.map +1 -0
  9. package/dist/{chunk-KLEIVDUV.js → chunk-CEXAQQ5W.js} +150 -11
  10. package/dist/chunk-CEXAQQ5W.js.map +1 -0
  11. package/dist/{chunk-JGRMRXXZ.js → chunk-JKLLFVEU.js} +3 -3
  12. package/dist/{chunk-JGRMRXXZ.js.map → chunk-JKLLFVEU.js.map} +1 -1
  13. package/dist/{chunk-EP6JHXSR.js → chunk-LMUACBMA.js} +3 -3
  14. package/dist/{chunk-EP6JHXSR.js.map → chunk-LMUACBMA.js.map} +1 -1
  15. package/dist/{chunk-MXI3PZFT.js → chunk-OB6UBJXF.js} +156 -200
  16. package/dist/chunk-OB6UBJXF.js.map +1 -0
  17. package/dist/{chunk-BCE22JSM.js → chunk-P7W4LR3Z.js} +32 -13
  18. package/dist/chunk-P7W4LR3Z.js.map +1 -0
  19. package/dist/chunk-PXRX5PEC.js +54 -0
  20. package/dist/chunk-PXRX5PEC.js.map +1 -0
  21. package/dist/{chunk-3G43C6OL.js → chunk-QI3XQKHS.js} +39 -3
  22. package/dist/chunk-QI3XQKHS.js.map +1 -0
  23. package/dist/{chunk-QDZA5JSL.js → chunk-U2M6HGKQ.js} +3 -3
  24. package/dist/{chunk-QDZA5JSL.js.map → chunk-U2M6HGKQ.js.map} +1 -1
  25. package/dist/{chunk-T3FLAGI6.js → chunk-VA776YE5.js} +3 -3
  26. package/dist/{chunk-T3FLAGI6.js.map → chunk-VA776YE5.js.map} +1 -1
  27. package/dist/cli.js +14 -11
  28. package/dist/cli.js.map +1 -1
  29. package/dist/components/index.d.ts +223 -1
  30. package/dist/components/index.js +3 -2
  31. package/dist/{dev-NZRLOQCL.js → dev-GDKATCC7.js} +22 -11
  32. package/dist/dev-GDKATCC7.js.map +1 -0
  33. package/dist/doc/index.d.ts +4 -0
  34. package/dist/doc/index.js +107 -11
  35. package/dist/doc/index.js.map +1 -1
  36. package/dist/{docs-ZGDJUHJI.js → docs-EJJ3BECT.js} +3 -3
  37. package/dist/{docs-ZGDJUHJI.js.map → docs-EJJ3BECT.js.map} +1 -1
  38. package/dist/{doctor-VFKEMR54.js → doctor-WQXPXP6K.js} +7 -7
  39. package/dist/doctor-WQXPXP6K.js.map +1 -0
  40. package/dist/{preview-QDCIVVSR.js → preview-OGMCNQUO.js} +12 -4
  41. package/dist/preview-OGMCNQUO.js.map +1 -0
  42. package/dist/{routes-RHP5FVGD.js → routes-MWBWWOJ2.js} +5 -5
  43. package/dist/{routes-RHP5FVGD.js.map → routes-MWBWWOJ2.js.map} +1 -1
  44. package/dist/{search-RFBELGY7.js → search-6Y422SV6.js} +3 -3
  45. package/dist/{search-RFBELGY7.js.map → search-6Y422SV6.js.map} +1 -1
  46. package/dist/slide/index.js +61 -15
  47. package/dist/slide/index.js.map +1 -1
  48. package/dist/table/index.js +7 -5
  49. package/dist/table/index.js.map +1 -1
  50. package/dist/ui/index.d.ts +140 -10
  51. package/dist/ui/index.js +1406 -63
  52. package/dist/ui/index.js.map +1 -1
  53. package/dist/{upgrade-GF2P67M6.js → upgrade-HCANX4GQ.js} +6 -6
  54. package/dist/{upgrade-GF2P67M6.js.map → upgrade-HCANX4GQ.js.map} +1 -1
  55. package/dist/vite/index.js +3 -3
  56. package/docs/en/changelog.md +24 -0
  57. package/docs/en/cli-reference.md +30 -0
  58. package/docs/en/parts/page-shell.md +1 -1
  59. package/docs/en/routing.md +4 -2
  60. package/docs/index.json +291 -61
  61. package/docs/ja/changelog.md +50 -0
  62. package/docs/ja/cli-reference.md +29 -0
  63. package/docs/ja/components.md +33 -89
  64. package/docs/ja/page-types/csv.md +83 -0
  65. package/docs/ja/page-types/doc.md +83 -0
  66. package/docs/ja/page-types/excel.md +82 -0
  67. package/docs/ja/page-types/slide.md +132 -0
  68. package/docs/ja/page-types.md +47 -0
  69. package/docs/ja/parts/area-chart.md +61 -0
  70. package/docs/ja/parts/bar-chart.md +69 -0
  71. package/docs/ja/parts/bar-list.md +35 -0
  72. package/docs/ja/parts/category-bar.md +36 -0
  73. package/docs/ja/parts/donut-chart.md +47 -0
  74. package/docs/ja/parts/echart.md +3 -0
  75. package/docs/ja/parts/line-chart.md +58 -0
  76. package/docs/ja/parts/page-shell.md +1 -1
  77. package/docs/ja/parts/progress-circle.md +34 -0
  78. package/docs/ja/parts/sparkline.md +1 -1
  79. package/docs/ja/parts/tracker.md +32 -0
  80. package/docs/ja/routing.md +8 -84
  81. package/docs/ja/ui/alert-dialog.md +41 -0
  82. package/docs/ja/ui/avatar.md +31 -0
  83. package/docs/ja/ui/combobox.md +74 -0
  84. package/docs/ja/ui/drawer.md +41 -0
  85. package/docs/ja/ui/field.md +42 -0
  86. package/docs/ja/ui/hover-card.md +21 -0
  87. package/docs/ja/ui/kbd.md +16 -0
  88. package/docs/ja/ui/pagination.md +36 -0
  89. package/docs/ja/ui/radio-group.md +21 -0
  90. package/docs/ja/ui/toast.md +46 -0
  91. package/docs/ja/ui-and-theming.md +14 -7
  92. package/package.json +18 -12
  93. package/registry/ui/accordion.tsx +74 -0
  94. package/registry/ui/alert-dialog.tsx +189 -0
  95. package/registry/ui/alert.tsx +78 -0
  96. package/registry/ui/app-shell.tsx +281 -0
  97. package/registry/ui/area-chart.tsx +1015 -0
  98. package/registry/ui/avatar.tsx +110 -0
  99. package/registry/ui/badge.tsx +54 -0
  100. package/registry/ui/bar-chart.tsx +1013 -0
  101. package/registry/ui/bar-list.tsx +302 -0
  102. package/registry/ui/breadcrumb.tsx +126 -0
  103. package/registry/ui/button.tsx +60 -0
  104. package/registry/ui/calendar.tsx +221 -0
  105. package/registry/ui/card.tsx +105 -0
  106. package/registry/ui/category-bar.tsx +281 -0
  107. package/registry/ui/checkbox.tsx +28 -0
  108. package/registry/ui/collapsible.tsx +21 -0
  109. package/registry/ui/combobox.tsx +310 -0
  110. package/registry/ui/command.tsx +196 -0
  111. package/registry/ui/dashboard-card.tsx +174 -0
  112. package/registry/ui/data-table.tsx +16 -91
  113. package/registry/ui/date-range-picker.tsx +209 -0
  114. package/registry/ui/dialog.tsx +159 -0
  115. package/registry/ui/donut-chart.tsx +1004 -0
  116. package/registry/ui/drawer.tsx +229 -0
  117. package/registry/ui/dropdown-menu.tsx +272 -0
  118. package/registry/ui/echart.tsx +486 -0
  119. package/registry/ui/field.tsx +240 -0
  120. package/registry/ui/filter-bar.tsx +365 -0
  121. package/registry/ui/funnel-steps.tsx +274 -0
  122. package/registry/ui/hover-card.tsx +52 -0
  123. package/registry/ui/input-group.tsx +158 -0
  124. package/registry/ui/input.tsx +22 -0
  125. package/registry/ui/kbd.tsx +28 -0
  126. package/registry/ui/label.tsx +20 -0
  127. package/registry/ui/line-chart.tsx +1007 -0
  128. package/registry/ui/metric-value.tsx +54 -0
  129. package/registry/ui/multi-select.tsx +226 -0
  130. package/registry/ui/page-shell.tsx +227 -0
  131. package/registry/ui/pagination.tsx +138 -0
  132. package/registry/ui/placeholder.tsx +30 -0
  133. package/registry/ui/popover.tsx +90 -0
  134. package/registry/ui/progress-circle.tsx +272 -0
  135. package/registry/ui/progress.tsx +83 -0
  136. package/registry/ui/radio-group.tsx +39 -0
  137. package/registry/ui/scroll-area.tsx +55 -0
  138. package/registry/ui/searchable-select.tsx +209 -0
  139. package/registry/ui/section-header.tsx +41 -0
  140. package/registry/ui/segmented-control.tsx +71 -0
  141. package/registry/ui/select.tsx +253 -0
  142. package/registry/ui/separator.tsx +25 -0
  143. package/registry/ui/sheet.tsx +137 -0
  144. package/registry/ui/sidebar.tsx +723 -0
  145. package/registry/ui/skeleton.tsx +15 -0
  146. package/registry/ui/slider.tsx +54 -0
  147. package/registry/ui/sparkline.tsx +155 -0
  148. package/registry/ui/spinner.tsx +12 -0
  149. package/registry/ui/states.tsx +61 -0
  150. package/registry/ui/status-badge.tsx +79 -0
  151. package/registry/ui/switch.tsx +32 -0
  152. package/registry/ui/table.tsx +116 -0
  153. package/registry/ui/tabs.tsx +82 -0
  154. package/registry/ui/textarea.tsx +20 -0
  155. package/registry/ui/toast.tsx +250 -0
  156. package/registry/ui/toggle-group.tsx +89 -0
  157. package/registry/ui/toggle.tsx +45 -0
  158. package/registry/ui/tooltip.tsx +66 -0
  159. package/registry/ui/tracker.tsx +236 -0
  160. package/registry/ui/trend-indicator.tsx +62 -0
  161. package/skills/vantage-add-feature/SKILL.md +9 -7
  162. package/skills/vantage-design/SKILL.md +3 -1
  163. package/skills/vantage-design/references/format.md +1 -1
  164. package/skills/vantage-pitfalls/SKILL.md +5 -1
  165. package/skills/vantage-slide/SKILL.md +17 -0
  166. package/templates/AGENTS.md +10 -1
  167. package/templates/agents/api.md +29 -0
  168. package/templates/agents/csv.md +2 -1
  169. package/templates/agents/doc.md +6 -3
  170. package/templates/agents/page.md +22 -8
  171. package/templates/agents/slide.md +13 -1
  172. package/templates/agents/xlsx.md +2 -1
  173. package/theme.css +51 -1
  174. package/dist/add-EISGSZAH.js +0 -6
  175. package/dist/add-EISGSZAH.js.map +0 -1
  176. package/dist/chunk-3G43C6OL.js.map +0 -1
  177. package/dist/chunk-BCE22JSM.js.map +0 -1
  178. package/dist/chunk-KLEIVDUV.js.map +0 -1
  179. package/dist/chunk-MXI3PZFT.js.map +0 -1
  180. package/dist/chunk-PLST5Q7T.js.map +0 -1
  181. package/dist/dev-NZRLOQCL.js.map +0 -1
  182. package/dist/doctor-VFKEMR54.js.map +0 -1
  183. package/dist/preview-QDCIVVSR.js.map +0 -1
@@ -0,0 +1,1004 @@
1
+ // Copied by `vantage add ui donut-chart`. This file is yours to edit.
2
+ // Generated from @squadbase/vantage src/components/donut-chart.tsx by `npm run registry` (in Vantage, edit src/, not registry/).
3
+ /**
4
+ * DonutChart — a typed donut / pie chart over `EChart`: pass rows plus the
5
+ * name column (`category`) and the value column (`value`). The donut prints a
6
+ * label in its hole — the formatted total unless `label` says otherwise —
7
+ * as plain HTML over the canvas, so it takes the page's type and colours.
8
+ */
9
+ import { forwardRef, useMemo, type ReactNode } from "react"
10
+
11
+ import { cn } from "@squadbase/vantage/ui"
12
+ import { EChart } from "@squadbase/vantage/components"
13
+ import type { ChartColor, ChartEventPayload, ValueFormatter } from "@squadbase/vantage/components"
14
+ import { useEffect, useState } from "react"
15
+ import { useRef } from "react"
16
+ import type { EChartsOption } from "@squadbase/vantage/components"
17
+
18
+ /* ---------- inlined from src/components/chart-theme.ts (internal to @squadbase/vantage) ---------- */
19
+
20
+ /** Series palette, in order. Missing tokens are skipped, not defaulted. */
21
+ const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"] as const
22
+
23
+ interface ChartTokens {
24
+ /** Resolved `--chart-1..5`, in order. */
25
+ series: string[]
26
+ /** Body text: titles and the default `textStyle`. */
27
+ foreground: string | null
28
+ /** Secondary text: axis labels, legend, subtitles. */
29
+ mutedForeground: string | null
30
+ /** Axis lines, ticks, split lines, tooltip border. */
31
+ border: string | null
32
+ /** Tooltip surface. */
33
+ popover: string | null
34
+ /** Tooltip text. */
35
+ popoverForeground: string | null
36
+ }
37
+
38
+ let normalizeContext: CanvasRenderingContext2D | null | undefined
39
+
40
+ /** The forms `fillStyle` serializes legacy colours to, and zrender can parse. */
41
+ const ZRENDER_COLOR_RE = /^(?:#[0-9a-f]{6}|rgba?\()/i
42
+
43
+ /**
44
+ * Serialize a CSS colour into a canvas-safe form, or `null` if the browser
45
+ * rejects it. Assigning an invalid value to `fillStyle` is a no-op, so the same
46
+ * input is written over two different known colours: agreeing results mean the
47
+ * value parsed, differing ones mean it was dropped.
48
+ */
49
+ function toCanvasColor(value: string): string | null {
50
+ const raw = value.trim()
51
+ if (!raw) return null
52
+ if (normalizeContext === undefined) {
53
+ const canvas = document.createElement("canvas")
54
+ canvas.width = 1
55
+ canvas.height = 1
56
+ normalizeContext = canvas.getContext("2d", { willReadFrequently: true })
57
+ }
58
+ const ctx = normalizeContext
59
+ if (!ctx) return raw
60
+ ctx.fillStyle = "#000000"
61
+ ctx.fillStyle = raw
62
+ const onBlack = ctx.fillStyle
63
+ ctx.fillStyle = "#ffffff"
64
+ ctx.fillStyle = raw
65
+ if (onBlack !== ctx.fillStyle || typeof onBlack !== "string") return null
66
+ if (ZRENDER_COLOR_RE.test(onBlack)) return onBlack
67
+ // Still `oklch()` / `lab()` / `color(...)`: paint it and read the pixel back.
68
+ // Wide-gamut colours clip to sRGB, which is all zrender can express anyway.
69
+ ctx.clearRect(0, 0, 1, 1)
70
+ ctx.fillRect(0, 0, 1, 1)
71
+ const [r = 0, g = 0, b = 0, a = 0] = ctx.getImageData(0, 0, 1, 1).data
72
+ return a === 255
73
+ ? `rgb(${r}, ${g}, ${b})`
74
+ : `rgba(${r}, ${g}, ${b}, ${Math.round((a / 255) * 1000) / 1000})`
75
+ }
76
+
77
+ /**
78
+ * Read the chart tokens off `el` (the document root by default — tokens live on
79
+ * `:root` and inherit). Returns `null` outside the browser so callers fall back
80
+ * to plain ECharts defaults.
81
+ */
82
+ function readChartTokens(el?: Element | null): ChartTokens | null {
83
+ if (typeof document === "undefined") return null
84
+ const target = el ?? document.documentElement
85
+ if (!target) return null
86
+ const styles = getComputedStyle(target)
87
+ const read = (name: string) => toCanvasColor(styles.getPropertyValue(name))
88
+ const series: string[] = []
89
+ for (const token of SERIES_TOKENS) {
90
+ const color = read(token)
91
+ if (color) series.push(color)
92
+ }
93
+ return {
94
+ series,
95
+ foreground: read("--foreground"),
96
+ mutedForeground: read("--muted-foreground"),
97
+ border: read("--border"),
98
+ popover: read("--popover"),
99
+ popoverForeground: read("--popover-foreground"),
100
+ }
101
+ }
102
+
103
+ /** Structural equality, so a theme toggle that changes nothing doesn't re-init. */
104
+ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean {
105
+ if (a === b) return true
106
+ if (!a || !b) return false
107
+ return (
108
+ a.series.length === b.series.length &&
109
+ a.series.every((color, i) => color === b.series[i]) &&
110
+ a.foreground === b.foreground &&
111
+ a.mutedForeground === b.mutedForeground &&
112
+ a.border === b.border &&
113
+ a.popover === b.popover &&
114
+ a.popoverForeground === b.popoverForeground
115
+ )
116
+ }
117
+
118
+ /**
119
+ * Watch everything that can change the resolved tokens and call `onChange` when
120
+ * something might have. It only ever says "re-read" — the caller compares with
121
+ * `sameChartTokens` and decides whether anything actually moved. Returns a
122
+ * disposer.
123
+ *
124
+ * Two sources, not one:
125
+ * - light/dark flips, which land as `class` / `data-theme` (or an inline
126
+ * `style`) on `<html>`/`<body>`, or as an OS preference change
127
+ * - stylesheet changes. In dev, editing `styles.css` rewrites the injected
128
+ * `<style>` in `<head>` and touches nothing else, so watching the theme
129
+ * attributes alone leaves the chart painted in the previous palette until a
130
+ * reload — the tokens in the DOM are new, the canvas is not.
131
+ */
132
+ function watchChartTokens(onChange: () => void): () => void {
133
+ if (typeof document === "undefined") return () => {}
134
+ // Style edits arrive as bursts of mutations; coalesce them into one read, and
135
+ // take it after the next frame so a swapped stylesheet has been applied.
136
+ let frame = 0
137
+ const schedule = () => {
138
+ if (frame) return
139
+ frame = requestAnimationFrame(() => {
140
+ frame = 0
141
+ onChange()
142
+ })
143
+ }
144
+
145
+ const themeObserver = new MutationObserver(schedule)
146
+ const attributeFilter = ["class", "style", "data-theme"]
147
+ themeObserver.observe(document.documentElement, { attributes: true, attributeFilter })
148
+ if (document.body) themeObserver.observe(document.body, { attributes: true, attributeFilter })
149
+
150
+ const styleObserver = new MutationObserver(schedule)
151
+ if (document.head) {
152
+ styleObserver.observe(document.head, {
153
+ childList: true,
154
+ subtree: true,
155
+ characterData: true,
156
+ attributes: true,
157
+ attributeFilter: ["href", "media", "disabled"],
158
+ })
159
+ }
160
+
161
+ // A swapped `<link>` only applies once the sheet has loaded, after its mutation.
162
+ const onLoad = (event: Event) => {
163
+ if (event.target instanceof HTMLLinkElement) schedule()
164
+ }
165
+ document.addEventListener("load", onLoad, true)
166
+
167
+ const media = window.matchMedia("(prefers-color-scheme: dark)")
168
+ media.addEventListener("change", schedule)
169
+
170
+ return () => {
171
+ if (frame) cancelAnimationFrame(frame)
172
+ themeObserver.disconnect()
173
+ styleObserver.disconnect()
174
+ document.removeEventListener("load", onLoad, true)
175
+ media.removeEventListener("change", schedule)
176
+ }
177
+ }
178
+
179
+ /** Shared shape for the four axis types ECharts themes address separately. */
180
+ function axisTheme(tokens: ChartTokens, splitLine: boolean) {
181
+ const line = tokens.border ?? undefined
182
+ return {
183
+ axisLine: { show: true, lineStyle: { color: line } },
184
+ axisTick: { show: true, lineStyle: { color: line } },
185
+ axisLabel: { show: true, color: tokens.mutedForeground ?? undefined },
186
+ splitLine: { show: splitLine, lineStyle: { color: line ? [line] : undefined } },
187
+ splitArea: { show: false },
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Build the ECharts theme. `option.color` still wins over `theme.color`, so an
193
+ * app that sets its own palette keeps it.
194
+ */
195
+ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
196
+ const text = tokens.foreground ?? undefined
197
+ const muted = tokens.mutedForeground ?? undefined
198
+ const line = tokens.border ?? undefined
199
+ return {
200
+ // Let the page background show through: charts sit on cards, not on a canvas.
201
+ backgroundColor: "transparent",
202
+ ...(tokens.series.length > 0 ? { color: tokens.series } : {}),
203
+ textStyle: { color: text },
204
+ title: { textStyle: { color: text }, subtextStyle: { color: muted } },
205
+ legend: { textStyle: { color: muted } },
206
+ categoryAxis: axisTheme(tokens, false),
207
+ valueAxis: axisTheme(tokens, true),
208
+ logAxis: axisTheme(tokens, true),
209
+ timeAxis: axisTheme(tokens, true),
210
+ tooltip: {
211
+ backgroundColor: tokens.popover ?? undefined,
212
+ borderColor: line,
213
+ borderWidth: 1,
214
+ textStyle: { color: tokens.popoverForeground ?? undefined },
215
+ axisPointer: { lineStyle: { color: line }, crossStyle: { color: line } },
216
+ },
217
+ }
218
+ }
219
+
220
+ /**
221
+ * The resolved tokens, kept current: re-read whenever `watchChartTokens` says
222
+ * they may have moved, and only replaced when a value actually changed. For
223
+ * components that build an ECharts option from token colours themselves (the
224
+ * typed charts), so their palette follows light/dark like `EChart`'s theme.
225
+ */
226
+ function useChartTokens(): ChartTokens | null {
227
+ const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
228
+ useEffect(() => {
229
+ const sync = () => {
230
+ setTokens((prev) => {
231
+ const next = readChartTokens()
232
+ return sameChartTokens(prev, next) ? prev : next
233
+ })
234
+ }
235
+ sync()
236
+ return watchChartTokens(sync)
237
+ }, [])
238
+ return tokens
239
+ }
240
+
241
+ const SERIES_TOKEN_NAME_RE = /^chart-([1-5])$/
242
+ const CSS_VAR_RE = /^var\(\s*(--[\w-]+)\s*(?:,\s*(.+))?\)$/
243
+
244
+ /**
245
+ * Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
246
+ * the resolved series tokens; `var(--token)` is read off the document root; any
247
+ * other CSS colour is normalized like the tokens are. A colour that can't be
248
+ * resolved comes back as `""`, which ECharts treats as "use the palette".
249
+ */
250
+ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | null): string[] {
251
+ return colors.map((color) => {
252
+ const token = SERIES_TOKEN_NAME_RE.exec(color.trim())
253
+ if (token) return tokens?.series[Number(token[1]) - 1] ?? ""
254
+ if (typeof document === "undefined") return color
255
+ let value = color.trim()
256
+ const cssVar = CSS_VAR_RE.exec(value)
257
+ if (cssVar) {
258
+ const styles = getComputedStyle(document.documentElement)
259
+ value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
260
+ }
261
+ return toCanvasColor(value) ?? ""
262
+ })
263
+ }
264
+
265
+ /* ---------- inlined from src/components/chart-utils.ts (internal to @squadbase/vantage) ---------- */
266
+
267
+ /** The series tokens, in palette order — the default `colors` of every typed chart. */
268
+ const DEFAULT_CHART_COLORS: readonly ChartColor[] = [
269
+ "chart-1",
270
+ "chart-2",
271
+ "chart-3",
272
+ "chart-4",
273
+ "chart-5",
274
+ ]
275
+
276
+ const TOKEN_NAME_RE = /^chart-[1-5]$/
277
+
278
+ /**
279
+ * A chart colour as a CSS value: `"chart-2"` → `var(--chart-2)` (so it follows
280
+ * light/dark on its own), anything else passes through unchanged.
281
+ */
282
+ function chartColorToCss(color: ChartColor): string {
283
+ const value = color.trim()
284
+ return TOKEN_NAME_RE.test(value) ? `var(--${value})` : value
285
+ }
286
+
287
+ /** The `i`-th colour, cycling through `colors` (the token palette when empty). */
288
+ function chartColorAt(colors: readonly ChartColor[] | undefined, i: number): ChartColor {
289
+ const palette = colors && colors.length > 0 ? colors : DEFAULT_CHART_COLORS
290
+ return palette[i % palette.length]!
291
+ }
292
+
293
+ /** `colors` extended (by cycling) to exactly `count` entries. */
294
+ function chartColorsFor(colors: readonly ChartColor[] | undefined, count: number): ChartColor[] {
295
+ return Array.from({ length: count }, (_, i) => chartColorAt(colors, i))
296
+ }
297
+
298
+ /** A translucent version of a CSS colour (works for `var(--…)` too). */
299
+ function tintCss(cssColor: string, percent: number): string {
300
+ return `color-mix(in oklab, ${cssColor} ${percent}%, transparent)`
301
+ }
302
+
303
+ const numberFormat = new Intl.NumberFormat("ja-JP", { maximumFractionDigits: 2 })
304
+
305
+ /** Default `valueFormatter`: grouped digits, up to two decimals (`1,234.5`). */
306
+ const defaultValueFormatter: ValueFormatter = (value) => numberFormat.format(value)
307
+
308
+ /**
309
+ * A data cell as a number, or `null` for a gap. Numeric strings count — SQL
310
+ * `DECIMAL` / `BIGINT` columns usually arrive as strings.
311
+ */
312
+ function toChartNumber(value: unknown): number | null {
313
+ if (typeof value === "number") return Number.isFinite(value) ? value : null
314
+ if (typeof value === "bigint") return Number(value)
315
+ if (typeof value === "string" && value.trim() !== "") {
316
+ const n = Number(value)
317
+ return Number.isFinite(n) ? n : null
318
+ }
319
+ return null
320
+ }
321
+
322
+ /** Clamp `value` into `[min, max]`. */
323
+ function clamp(value: number, min: number, max: number): number {
324
+ return Math.min(max, Math.max(min, value))
325
+ }
326
+
327
+ /* ---------------------------------------------------------------- BarList */
328
+
329
+ type BarListSortOrder = "ascending" | "descending" | "none"
330
+
331
+ /** Sort a copy of the rows by value; `"none"` keeps the given order. */
332
+ function sortBarListData<T extends { value: number }>(data: readonly T[], sortOrder: BarListSortOrder): T[] {
333
+ const rows = [...data]
334
+ if (sortOrder === "ascending") rows.sort((a, b) => a.value - b.value)
335
+ else if (sortOrder === "descending") rows.sort((a, b) => b.value - a.value)
336
+ return rows
337
+ }
338
+
339
+ /** Bar widths in percent of the largest value (at least a sliver for non-zero values). */
340
+ function barListWidths(values: readonly number[]): number[] {
341
+ const max = Math.max(0, ...values)
342
+ if (max === 0) return values.map(() => 0)
343
+ return values.map((v) => (v <= 0 ? 0 : Math.max(1, (v / max) * 100)))
344
+ }
345
+
346
+ /* ------------------------------------------------------------ CategoryBar */
347
+
348
+ interface CategoryBarSegment {
349
+ /** Width in percent of the whole bar. */
350
+ width: number
351
+ /** Where the segment starts, in percent. */
352
+ start: number
353
+ /** The running total at the segment's end — what its right-hand label shows. */
354
+ end: number
355
+ }
356
+
357
+ /** Lay the values side by side; negative values count as zero. */
358
+ function categoryBarSegments(values: readonly number[]): CategoryBarSegment[] {
359
+ const clean = values.map((v) => (Number.isFinite(v) && v > 0 ? v : 0))
360
+ const total = clean.reduce((sum, v) => sum + v, 0)
361
+ let running = 0
362
+ return clean.map((v) => {
363
+ const start = total === 0 ? 0 : (running / total) * 100
364
+ running += v
365
+ return { width: total === 0 ? 0 : (v / total) * 100, start, end: running }
366
+ })
367
+ }
368
+
369
+ interface CategoryBarLabel {
370
+ /** Position in percent of the bar. */
371
+ position: number
372
+ /** The running total printed there. */
373
+ value: number
374
+ }
375
+
376
+ /**
377
+ * Tick labels under the bar: `0` at the start, then the running total at each
378
+ * segment's end. A label closer than `minGap` percent to the one before is
379
+ * dropped so narrow segments don't print on top of each other — except the
380
+ * last one (the total), which replaces its neighbour instead.
381
+ */
382
+ function categoryBarLabels(values: readonly number[], minGap = 8): CategoryBarLabel[] {
383
+ const segments = categoryBarSegments(values).filter((s) => s.width > 0)
384
+ if (segments.length === 0) return []
385
+ const labels: CategoryBarLabel[] = [{ position: 0, value: 0 }]
386
+ segments.forEach((s, i) => {
387
+ const label = { position: s.start + s.width, value: s.end }
388
+ const prev = labels[labels.length - 1]!
389
+ if (label.position - prev.position >= minGap) labels.push(label)
390
+ else if (i === segments.length - 1 && labels.length > 1) labels[labels.length - 1] = label
391
+ })
392
+ return labels
393
+ }
394
+
395
+ /** Marker position in percent of the bar, clamped to its ends. */
396
+ function categoryBarMarkerPosition(value: number, values: readonly number[]): number {
397
+ const total = values.reduce((sum, v) => sum + (Number.isFinite(v) && v > 0 ? v : 0), 0)
398
+ if (total === 0) return 0
399
+ return clamp((value / total) * 100, 0, 100)
400
+ }
401
+
402
+ /** Index of the segment the marker sits on (the last one for a value past the end). */
403
+ function categoryBarMarkerSegment(value: number, values: readonly number[]): number {
404
+ const segments = categoryBarSegments(values)
405
+ const index = segments.findIndex((s) => s.width > 0 && value <= s.end)
406
+ if (index !== -1) return index
407
+ for (let i = segments.length - 1; i >= 0; i--) if (segments[i]!.width > 0) return i
408
+ return 0
409
+ }
410
+
411
+ /* --------------------------------------------------------- ProgressCircle */
412
+
413
+ interface ProgressCircleGeometry {
414
+ /** SVG width/height. */
415
+ size: number
416
+ /** Radius of the stroke's centre line. */
417
+ normalizedRadius: number
418
+ circumference: number
419
+ /** `stroke-dashoffset` for the value arc. */
420
+ offset: number
421
+ /** The value as a fraction of `max`, clamped to `[0, 1]`. */
422
+ ratio: number
423
+ }
424
+
425
+ function progressCircleGeometry(
426
+ value: number,
427
+ max: number,
428
+ radius: number,
429
+ strokeWidth: number,
430
+ ): ProgressCircleGeometry {
431
+ const ratio = max > 0 && Number.isFinite(value) ? clamp(value / max, 0, 1) : 0
432
+ const normalizedRadius = Math.max(0, radius - strokeWidth / 2)
433
+ const circumference = 2 * Math.PI * normalizedRadius
434
+ return { size: radius * 2, normalizedRadius, circumference, offset: circumference * (1 - ratio), ratio }
435
+ }
436
+
437
+ /* ---------- inlined from src/components/chart-options.ts (internal to @squadbase/vantage) ---------- */
438
+
439
+ type CartesianChartKind = "area" | "bar" | "line"
440
+ type CartesianStackType = "default" | "stacked" | "percent"
441
+ type CartesianCurveType = "linear" | "smooth" | "step"
442
+ type AreaFill = "gradient" | "solid" | "none"
443
+ type BarLayout = "horizontal" | "vertical"
444
+
445
+ /** Props every cartesian typed chart (Area / Bar / Line) shares. */
446
+ interface CartesianChartBaseProps {
447
+ /** 行の配列。1 行 = x 軸の 1 点。数値の列は number か数値文字列(SQL の DECIMAL など)。 */
448
+ data: Record<string, unknown>[]
449
+ /** x 軸(カテゴリ軸)に使う列名。 */
450
+ index: string
451
+ /** 系列として描く列名。並び順が凡例と配色の順になる。 */
452
+ categories: string[]
453
+ /** 系列色。`"chart-1"`〜`"chart-5"` か CSS カラー。省略時はテーマのトークン順。足りなければ繰り返す。 */
454
+ colors?: ChartColor[]
455
+ /** 軸目盛りとツールチップの数値の書式。 */
456
+ valueFormatter?: ValueFormatter
457
+ /** 凡例を出す。既定 `true`。 */
458
+ showLegend?: boolean
459
+ /** ホバー時のツールチップを出す。既定 `true`。 */
460
+ showTooltip?: boolean
461
+ /** 値軸の目盛り線を出す。既定 `true`。 */
462
+ showGridLines?: boolean
463
+ showXAxis?: boolean
464
+ showYAxis?: boolean
465
+ /** x 軸のラベルを最初と最後だけにする。 */
466
+ startEndOnly?: boolean
467
+ /** y 軸ラベルの幅(px)。並べた複数グラフのプロット領域を揃えたいときに固定する。 */
468
+ yAxisWidth?: number
469
+ minValue?: number
470
+ maxValue?: number
471
+ /** 値軸を 0 からではなくデータの範囲に合わせる。 */
472
+ autoMinValue?: boolean
473
+ /** `false` で値軸の目盛りを整数に限る。 */
474
+ allowDecimals?: boolean
475
+ xAxisLabel?: string
476
+ yAxisLabel?: string
477
+ /**
478
+ * 点(棒)のクリックで呼ばれる。クリックした行に `eventType` / `categoryClicked` を
479
+ * 足して渡し、同じ点をもう一度クリックすると `null`。渡すと選択以外が薄くなる。
480
+ */
481
+ onValueChange?: (value: ChartEventPayload) => void
482
+ /** 高さ。数値は px。省略時は `h-80`(320px)で、`className` の高さクラスが勝つ。 */
483
+ height?: string | number
484
+ loading?: boolean
485
+ ariaLabel?: string
486
+ className?: string
487
+ }
488
+
489
+ /** The point a user clicked, used to dim everything else. */
490
+ interface ChartActivePoint {
491
+ category: string
492
+ index: number
493
+ }
494
+
495
+ interface CartesianChartConfig {
496
+ kind: CartesianChartKind
497
+ data: readonly Record<string, unknown>[]
498
+ index: string
499
+ categories: readonly string[]
500
+ /** Resolved series colours, by category position. `""`/missing → theme palette. */
501
+ palette: readonly string[]
502
+ valueFormatter: (value: number) => string
503
+ type?: CartesianStackType
504
+ /** Bar only. `"vertical"` lays the categories down the y axis (horizontal bars). */
505
+ layout?: BarLayout
506
+ curveType?: CartesianCurveType
507
+ fill?: AreaFill
508
+ connectNulls?: boolean
509
+ showLegend?: boolean
510
+ showTooltip?: boolean
511
+ showGridLines?: boolean
512
+ showXAxis?: boolean
513
+ showYAxis?: boolean
514
+ startEndOnly?: boolean
515
+ yAxisWidth?: number
516
+ minValue?: number
517
+ maxValue?: number
518
+ autoMinValue?: boolean
519
+ allowDecimals?: boolean
520
+ xAxisLabel?: string
521
+ yAxisLabel?: string
522
+ barCategoryGap?: string | number
523
+ /** Always draw the line/area points (needed to click them). */
524
+ showDots?: boolean
525
+ active?: ChartActivePoint | null
526
+ }
527
+
528
+ /** Same coercion as `toChartNumber` in chart-utils (kept local: inlined modules can't import each other). */
529
+ function cellNumber(value: unknown): number | null {
530
+ if (typeof value === "number") return Number.isFinite(value) ? value : null
531
+ if (typeof value === "bigint") return Number(value)
532
+ if (typeof value === "string" && value.trim() !== "") {
533
+ const n = Number(value)
534
+ return Number.isFinite(n) ? n : null
535
+ }
536
+ return null
537
+ }
538
+
539
+ /**
540
+ * Series values per category. `"percent"` turns each row into shares of that
541
+ * row's total (0–100); a row whose total is 0 stays at 0.
542
+ */
543
+ function cartesianSeriesValues(
544
+ data: readonly Record<string, unknown>[],
545
+ categories: readonly string[],
546
+ type: CartesianStackType = "default",
547
+ ): (number | null)[][] {
548
+ const raw = categories.map((category) => data.map((row) => cellNumber(row[category])))
549
+ if (type !== "percent") return raw
550
+ return raw.map((values) =>
551
+ values.map((value, i) => {
552
+ if (value === null) return null
553
+ const total = categories.reduce((sum, _, k) => sum + Math.abs(raw[k]![i] ?? 0), 0)
554
+ return total === 0 ? 0 : (Math.abs(value) / total) * 100
555
+ }),
556
+ )
557
+ }
558
+
559
+ /** A colour with alpha, for gradient stops. Understands `#rgb`, `#rrggbb`, `rgb()`/`rgba()`. */
560
+ function withAlpha(color: string, alpha: number): string {
561
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim())
562
+ if (hex) {
563
+ const h = hex[1]!
564
+ const full = h.length === 3 ? h.replace(/./g, (c) => c + c) : h
565
+ const n = Number.parseInt(full, 16)
566
+ return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`
567
+ }
568
+ const rgb = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(color.trim())
569
+ if (rgb) return `rgba(${rgb[1]}, ${rgb[2]}, ${rgb[3]}, ${alpha})`
570
+ return color
571
+ }
572
+
573
+ const HTML_ESCAPES: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }
574
+
575
+ function escapeHtml(value: string): string {
576
+ return value.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]!)
577
+ }
578
+
579
+ interface TooltipRow {
580
+ name: string
581
+ color: string
582
+ value: number | null
583
+ }
584
+
585
+ /** The tooltip body: a title, then one `● name value` row per series. */
586
+ function tooltipHtml(title: string, rows: readonly TooltipRow[], valueFormatter: (value: number) => string): string {
587
+ const lines = rows.map(
588
+ (row) =>
589
+ `<div style="display:flex;align-items:center;gap:8px;line-height:20px">` +
590
+ `<span style="display:inline-block;width:8px;height:8px;border-radius:9999px;background:${escapeHtml(row.color)}"></span>` +
591
+ `<span style="flex:1;opacity:.8">${escapeHtml(row.name)}</span>` +
592
+ `<span style="font-weight:500;font-variant-numeric:tabular-nums;margin-left:12px">${
593
+ row.value === null ? "–" : escapeHtml(valueFormatter(row.value))
594
+ }</span></div>`,
595
+ )
596
+ return `<div style="font-weight:500;margin-bottom:4px">${escapeHtml(title)}</div>${lines.join("")}`
597
+ }
598
+
599
+ interface AxisTooltipParam {
600
+ seriesName?: string
601
+ dataIndex?: number
602
+ color?: unknown
603
+ }
604
+
605
+ const DIMMED = 0.3
606
+
607
+ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
608
+ const {
609
+ kind,
610
+ data,
611
+ index,
612
+ categories,
613
+ palette,
614
+ valueFormatter,
615
+ type = "default",
616
+ layout = "horizontal",
617
+ curveType = "linear",
618
+ fill = "gradient",
619
+ connectNulls = false,
620
+ showLegend = true,
621
+ showTooltip = true,
622
+ showGridLines = true,
623
+ showXAxis = true,
624
+ showYAxis = true,
625
+ startEndOnly = false,
626
+ yAxisWidth,
627
+ minValue,
628
+ maxValue,
629
+ autoMinValue = false,
630
+ allowDecimals = true,
631
+ xAxisLabel,
632
+ yAxisLabel,
633
+ barCategoryGap,
634
+ showDots = false,
635
+ active = null,
636
+ } = cfg
637
+
638
+ const labels = data.map((row) => String(row[index] ?? ""))
639
+ const values = cartesianSeriesValues(data, categories, kind === "line" ? "default" : type)
640
+ const stacked = kind !== "line" && type !== "default"
641
+ const percent = kind !== "line" && type === "percent"
642
+ const horizontalBars = kind === "bar" && layout === "vertical"
643
+ const last = labels.length - 1
644
+
645
+ const series = categories.map((category, c) => {
646
+ const color = palette[c] || undefined
647
+ const dimSeries = active !== null && active.category !== category
648
+ if (kind === "bar") {
649
+ const radius = stacked ? 0 : horizontalBars ? [0, 3, 3, 0] : [3, 3, 0, 0]
650
+ return {
651
+ type: "bar" as const,
652
+ name: category,
653
+ stack: stacked ? "total" : undefined,
654
+ barCategoryGap,
655
+ itemStyle: { color, borderRadius: radius },
656
+ emphasis: { focus: "series" as const },
657
+ data: values[c]!.map((value, i) =>
658
+ active !== null && !(active.category === category && active.index === i)
659
+ ? { value, itemStyle: { opacity: DIMMED } }
660
+ : value,
661
+ ),
662
+ }
663
+ }
664
+ const areaStyle =
665
+ kind !== "area" || fill === "none"
666
+ ? undefined
667
+ : fill === "solid" || !color
668
+ ? { opacity: fill === "solid" ? 0.3 : 0.2 }
669
+ : {
670
+ color: {
671
+ type: "linear" as const,
672
+ x: 0,
673
+ y: 0,
674
+ x2: 0,
675
+ y2: 1,
676
+ colorStops: [
677
+ { offset: 0, color: withAlpha(color, 0.35) },
678
+ { offset: 1, color: withAlpha(color, 0) },
679
+ ],
680
+ },
681
+ }
682
+ return {
683
+ type: "line" as const,
684
+ name: category,
685
+ stack: stacked ? "total" : undefined,
686
+ smooth: curveType === "smooth",
687
+ step: curveType === "step" ? ("middle" as const) : undefined,
688
+ connectNulls,
689
+ symbol: "circle",
690
+ symbolSize: 6,
691
+ showSymbol: showDots || labels.length === 1,
692
+ lineStyle: { width: 2, color, opacity: dimSeries ? DIMMED : 1 },
693
+ itemStyle: { color, opacity: dimSeries ? DIMMED : 1 },
694
+ areaStyle: areaStyle && dimSeries ? { ...areaStyle, opacity: DIMMED * 0.3 } : areaStyle,
695
+ emphasis: { focus: "series" as const },
696
+ data: values[c]!,
697
+ }
698
+ })
699
+
700
+ const categoryAxis = {
701
+ type: "category" as const,
702
+ data: labels,
703
+ show: horizontalBars ? showYAxis : showXAxis,
704
+ boundaryGap: kind === "bar",
705
+ inverse: horizontalBars,
706
+ axisTick: { show: false },
707
+ axisLabel: {
708
+ hideOverlap: true,
709
+ interval: startEndOnly ? (i: number) => i === 0 || i === last : ("auto" as const),
710
+ ...(horizontalBars && yAxisWidth != null ? { width: yAxisWidth, overflow: "truncate" as const } : {}),
711
+ },
712
+ name: horizontalBars ? yAxisLabel : xAxisLabel,
713
+ nameLocation: "middle" as const,
714
+ nameGap: horizontalBars ? (yAxisWidth ?? 48) + 8 : 28,
715
+ }
716
+ const valueAxis = {
717
+ type: "value" as const,
718
+ show: horizontalBars ? showXAxis : showYAxis,
719
+ min: percent ? 0 : minValue,
720
+ max: percent ? 100 : maxValue,
721
+ scale: autoMinValue && minValue == null,
722
+ minInterval: allowDecimals ? undefined : 1,
723
+ axisLine: { show: false },
724
+ axisTick: { show: false },
725
+ splitLine: { show: showGridLines },
726
+ axisLabel: {
727
+ formatter: percent ? (v: number) => `${v}%` : (v: number) => valueFormatter(v),
728
+ ...(!horizontalBars && yAxisWidth != null ? { width: yAxisWidth, overflow: "truncate" as const } : {}),
729
+ },
730
+ name: horizontalBars ? xAxisLabel : yAxisLabel,
731
+ nameLocation: "middle" as const,
732
+ nameGap: horizontalBars ? 28 : (yAxisWidth ?? 48) + 8,
733
+ }
734
+
735
+ return {
736
+ animationDuration: 400,
737
+ legend: {
738
+ show: showLegend,
739
+ top: 0,
740
+ right: 0,
741
+ type: "scroll",
742
+ icon: "circle",
743
+ itemWidth: 8,
744
+ itemHeight: 8,
745
+ itemGap: 16,
746
+ },
747
+ grid: {
748
+ top: showLegend ? 36 : 12,
749
+ // Lines start and end on the outermost ticks, so the last label hangs past the plot.
750
+ right: kind === "bar" ? 12 : 24,
751
+ bottom: xAxisLabel ? 24 : 4,
752
+ left: yAxisLabel ? 24 : 4,
753
+ containLabel: true,
754
+ },
755
+ tooltip: {
756
+ show: showTooltip,
757
+ trigger: "axis",
758
+ axisPointer: { type: kind === "bar" ? "shadow" : "line" },
759
+ formatter: (params: unknown) => {
760
+ const list = (Array.isArray(params) ? params : [params]) as AxisTooltipParam[]
761
+ const i = list[0]?.dataIndex ?? 0
762
+ const row = data[i] ?? {}
763
+ return tooltipHtml(
764
+ String(row[index] ?? ""),
765
+ list.map((p) => ({
766
+ name: p.seriesName ?? "",
767
+ color: typeof p.color === "string" ? p.color : "currentColor",
768
+ value: cellNumber(row[p.seriesName ?? ""]),
769
+ })),
770
+ valueFormatter,
771
+ )
772
+ },
773
+ },
774
+ xAxis: horizontalBars ? valueAxis : categoryAxis,
775
+ yAxis: horizontalBars ? categoryAxis : valueAxis,
776
+ series,
777
+ } as EChartsOption
778
+ }
779
+
780
+ /* ------------------------------------------------------------------ donut */
781
+
782
+ type DonutVariant = "donut" | "pie"
783
+
784
+ interface DonutChartConfig {
785
+ data: readonly Record<string, unknown>[]
786
+ category: string
787
+ value: string
788
+ /** Resolved slice colours, by row position. `""`/missing → theme palette. */
789
+ palette: readonly string[]
790
+ valueFormatter: (value: number) => string
791
+ variant?: DonutVariant
792
+ showTooltip?: boolean
793
+ active?: string | null
794
+ }
795
+
796
+ /** Sum of the `value` column, ignoring gaps and negative cells. */
797
+ function donutTotal(data: readonly Record<string, unknown>[], value: string): number {
798
+ return data.reduce((sum, row) => {
799
+ const n = cellNumber(row[value])
800
+ return n !== null && n > 0 ? sum + n : sum
801
+ }, 0)
802
+ }
803
+
804
+ interface ItemTooltipParam {
805
+ name?: string
806
+ value?: unknown
807
+ percent?: number
808
+ color?: unknown
809
+ }
810
+
811
+ function buildDonutOption(cfg: DonutChartConfig): EChartsOption {
812
+ const { data, category, value, palette, valueFormatter, variant = "donut", showTooltip = true, active = null } = cfg
813
+ return {
814
+ animationDuration: 400,
815
+ tooltip: {
816
+ show: showTooltip,
817
+ trigger: "item",
818
+ formatter: (params: unknown) => {
819
+ const p = params as ItemTooltipParam
820
+ const n = cellNumber(p.value)
821
+ const share = typeof p.percent === "number" ? ` (${p.percent.toFixed(1)}%)` : ""
822
+ return tooltipHtml(
823
+ `${p.name ?? ""}${share}`,
824
+ [{ name: value, color: typeof p.color === "string" ? p.color : "currentColor", value: n }],
825
+ valueFormatter,
826
+ )
827
+ },
828
+ },
829
+ series: [
830
+ {
831
+ type: "pie",
832
+ radius: variant === "donut" ? ["72%", "100%"] : [0, "100%"],
833
+ center: ["50%", "50%"],
834
+ avoidLabelOverlap: true,
835
+ label: { show: false },
836
+ labelLine: { show: false },
837
+ // A hairline gap between slices instead of a border — no background colour to match.
838
+ padAngle: data.length > 1 ? 1 : 0,
839
+ itemStyle: { borderRadius: variant === "donut" ? 2 : 0 },
840
+ emphasis: { scale: true, scaleSize: 4 },
841
+ data: data.map((row, i) => {
842
+ const name = String(row[category] ?? "")
843
+ const color = palette[i] || undefined
844
+ const n = cellNumber(row[value])
845
+ return {
846
+ name,
847
+ value: n !== null && n > 0 ? n : 0,
848
+ itemStyle: { color, opacity: active !== null && active !== name ? DIMMED : 1 },
849
+ }
850
+ }),
851
+ },
852
+ ],
853
+ } as EChartsOption
854
+ }
855
+
856
+ /* -------------------------------------------------------------- selection */
857
+
858
+ /** Toggle: clicking the active point again clears the selection. */
859
+ function nextChartSelection(
860
+ current: ChartActivePoint | null,
861
+ category: string,
862
+ index: number,
863
+ ): ChartActivePoint | null {
864
+ return current && current.category === category && current.index === index ? null : { category, index }
865
+ }
866
+
867
+ /** What `onValueChange` receives: the clicked row plus what was clicked (ours win over row keys). */
868
+ function chartSelectionPayload(
869
+ selection: ChartActivePoint | null,
870
+ data: readonly Record<string, unknown>[],
871
+ eventType: NonNullable<ChartEventPayload>["eventType"],
872
+ ): ChartEventPayload {
873
+ if (!selection) return null
874
+ return { ...data[selection.index], eventType, categoryClicked: selection.category }
875
+ }
876
+
877
+ interface ChartClickParams {
878
+ componentType?: string
879
+ seriesName?: string
880
+ name?: string
881
+ dataIndex?: number
882
+ }
883
+
884
+ /**
885
+ * Click-to-select for the typed charts: the `onEvents` to hand `EChart`, and
886
+ * the active point to dim the rest with. Inert (no handler, nothing active)
887
+ * without `onValueChange`.
888
+ */
889
+ function useChartSelection(
890
+ data: readonly Record<string, unknown>[],
891
+ onValueChange: ((value: ChartEventPayload) => void) | undefined,
892
+ eventType: NonNullable<ChartEventPayload>["eventType"],
893
+ ): { active: ChartActivePoint | null; onEvents: Record<string, (params: unknown) => void> | undefined } {
894
+ const [active, setActive] = useState<ChartActivePoint | null>(null)
895
+ // Handlers read the latest props without re-subscribing on every render.
896
+ const latest = useRef({ data, onValueChange, active })
897
+ latest.current = { data, onValueChange, active }
898
+ const interactive = onValueChange !== undefined
899
+ const onEvents = useMemo(() => {
900
+ if (!interactive) return undefined
901
+ return {
902
+ click: (raw: unknown) => {
903
+ const p = raw as ChartClickParams
904
+ if (p.componentType !== "series" || typeof p.dataIndex !== "number") return
905
+ const category = eventType === "slice" ? (p.name ?? "") : (p.seriesName ?? "")
906
+ const next = nextChartSelection(latest.current.active, category, p.dataIndex)
907
+ latest.current.active = next
908
+ setActive(next)
909
+ latest.current.onValueChange?.(chartSelectionPayload(next, latest.current.data, eventType))
910
+ },
911
+ }
912
+ }, [interactive, eventType])
913
+ return { active: interactive ? active : null, onEvents }
914
+ }
915
+
916
+ /* ---------- end of inlined code ---------- */
917
+
918
+ export interface DonutChartProps {
919
+ /** 行の配列。1 行 = 1 切片。 */
920
+ data: Record<string, unknown>[]
921
+ /** 切片の名前に使う列名。 */
922
+ category: string
923
+ /** 切片の大きさに使う列名(number か数値文字列)。0 以下と欠損は 0 扱い。 */
924
+ value: string
925
+ /** 切片の色(行の順)。`"chart-1"`〜`"chart-5"` か CSS カラー。省略時はトークン順で、足りなければ繰り返す。 */
926
+ colors?: ChartColor[]
927
+ /** `"donut"`(中央に穴とラベル)か `"pie"`。既定 `"donut"`。 */
928
+ variant?: DonutVariant
929
+ /** ツールチップと中央ラベルの数値の書式。 */
930
+ valueFormatter?: ValueFormatter
931
+ /** 中央ラベル。省略時は合計を `valueFormatter` で書いたもの。donut のみ。 */
932
+ label?: ReactNode
933
+ /** 中央ラベルを出す。既定 `true`(donut のみ)。 */
934
+ showLabel?: boolean
935
+ showTooltip?: boolean
936
+ /** 切片のクリックで呼ばれる。同じ切片をもう一度クリックすると `null`。渡すと選択以外が薄くなる。 */
937
+ onValueChange?: (value: ChartEventPayload) => void
938
+ loading?: boolean
939
+ ariaLabel?: string
940
+ /** 外枠のクラス。既定の大きさは `size-40`(160px 四方)。 */
941
+ className?: string
942
+ }
943
+
944
+ export const DonutChart = forwardRef<HTMLDivElement, DonutChartProps>(function DonutChart(
945
+ {
946
+ data,
947
+ category,
948
+ value,
949
+ colors,
950
+ variant = "donut",
951
+ valueFormatter = defaultValueFormatter,
952
+ label,
953
+ showLabel = true,
954
+ showTooltip = true,
955
+ onValueChange,
956
+ loading,
957
+ ariaLabel,
958
+ className,
959
+ },
960
+ ref,
961
+ ) {
962
+ const tokens = useChartTokens()
963
+ const palette = useMemo(
964
+ () => resolveChartColors(chartColorsFor(colors, data.length), tokens),
965
+ [colors, data.length, tokens],
966
+ )
967
+ const { active, onEvents } = useChartSelection(data, onValueChange, "slice")
968
+ const option = useMemo(
969
+ () =>
970
+ buildDonutOption({
971
+ data,
972
+ category,
973
+ value,
974
+ palette,
975
+ valueFormatter,
976
+ variant,
977
+ showTooltip,
978
+ active: active?.category ?? null,
979
+ }),
980
+ [data, category, value, palette, valueFormatter, variant, showTooltip, active],
981
+ )
982
+ const centerLabel = label ?? valueFormatter(donutTotal(data, value))
983
+
984
+ return (
985
+ <div ref={ref} data-slot="donut-chart" className={cn("relative size-40", className)}>
986
+ <EChart
987
+ option={option}
988
+ onEvents={onEvents}
989
+ loading={loading}
990
+ ariaLabel={ariaLabel ?? `${category}別の${value}`}
991
+ className="h-full"
992
+ />
993
+ {variant === "donut" && showLabel && (
994
+ <div
995
+ data-slot="donut-chart-label"
996
+ className="pointer-events-none absolute inset-0 flex items-center justify-center px-[18%] text-center text-lg font-semibold text-foreground tabular-nums"
997
+ >
998
+ {centerLabel}
999
+ </div>
1000
+ )}
1001
+ </div>
1002
+ )
1003
+ })
1004
+ DonutChart.displayName = "DonutChart"