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

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