@squadbase/vantage 0.6.1 → 0.6.2-dev1.25d59a0

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 (237) hide show
  1. package/dist/add-4GHF2TX2.js +6 -0
  2. package/dist/add-4GHF2TX2.js.map +1 -0
  3. package/dist/{build-O5TVNH4E.js → build-X5NVCQKT.js} +9 -9
  4. package/dist/{build-O5TVNH4E.js.map → build-X5NVCQKT.js.map} +1 -1
  5. package/dist/{check-WFBEDHDR.js → check-ZBIH422T.js} +7 -7
  6. package/dist/{check-WFBEDHDR.js.map → check-ZBIH422T.js.map} +1 -1
  7. package/dist/chunk-2ICNHUG6.js +1820 -0
  8. package/dist/chunk-2ICNHUG6.js.map +1 -0
  9. package/dist/{chunk-5ZOP4SQR.js → chunk-2N72FKLB.js} +12 -7
  10. package/dist/chunk-2N72FKLB.js.map +1 -0
  11. package/dist/{chunk-BCE22JSM.js → chunk-7DZGJQ3P.js} +33 -14
  12. package/dist/chunk-7DZGJQ3P.js.map +1 -0
  13. package/dist/{chunk-QDZA5JSL.js → chunk-AABMKHEU.js} +4 -4
  14. package/dist/{chunk-QDZA5JSL.js.map → chunk-AABMKHEU.js.map} +1 -1
  15. package/dist/{chunk-KLEIVDUV.js → chunk-CEXAQQ5W.js} +150 -11
  16. package/dist/chunk-CEXAQQ5W.js.map +1 -0
  17. package/dist/{chunk-3G43C6OL.js → chunk-GNXCX4WE.js} +46 -3
  18. package/dist/chunk-GNXCX4WE.js.map +1 -0
  19. package/dist/chunk-HKSVYMYJ.js +7919 -0
  20. package/dist/chunk-HKSVYMYJ.js.map +1 -0
  21. package/dist/{chunk-JGRMRXXZ.js → chunk-JKLLFVEU.js} +3 -3
  22. package/dist/{chunk-JGRMRXXZ.js.map → chunk-JKLLFVEU.js.map} +1 -1
  23. package/dist/chunk-PDOBHMUQ.js +54 -0
  24. package/dist/chunk-PDOBHMUQ.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-A2UUGASH.js → chunk-U736PVHY.js} +2 -2
  28. package/dist/chunk-U736PVHY.js.map +1 -0
  29. package/dist/{chunk-PVTHHV7X.js → chunk-V3LF7BE7.js} +27 -5
  30. package/dist/chunk-V3LF7BE7.js.map +1 -0
  31. package/dist/{chunk-EP6JHXSR.js → chunk-WAMZWZJB.js} +54 -9
  32. package/dist/chunk-WAMZWZJB.js.map +1 -0
  33. package/dist/{chunk-MXI3PZFT.js → chunk-XPQSTZUU.js} +255 -209
  34. package/dist/chunk-XPQSTZUU.js.map +1 -0
  35. package/dist/{chunk-DTDVSFRY.js → chunk-XQUSGIDG.js} +17 -3
  36. package/dist/chunk-XQUSGIDG.js.map +1 -0
  37. package/dist/cli.js +15 -11
  38. package/dist/cli.js.map +1 -1
  39. package/dist/client/index.d.ts +32 -3
  40. package/dist/client/index.js +205 -12
  41. package/dist/client/index.js.map +1 -1
  42. package/dist/components/index.d.ts +1983 -29
  43. package/dist/components/index.js +4 -3
  44. package/dist/{dev-NZRLOQCL.js → dev-4XEDTELV.js} +24 -13
  45. package/dist/dev-4XEDTELV.js.map +1 -0
  46. package/dist/doc/index.d.ts +4 -0
  47. package/dist/doc/index.js +107 -11
  48. package/dist/doc/index.js.map +1 -1
  49. package/dist/{docs-ZGDJUHJI.js → docs-JVP4RPKT.js} +3 -3
  50. package/dist/{docs-ZGDJUHJI.js.map → docs-JVP4RPKT.js.map} +1 -1
  51. package/dist/{doctor-VFKEMR54.js → doctor-5E2VEZPK.js} +8 -8
  52. package/dist/doctor-5E2VEZPK.js.map +1 -0
  53. package/dist/markdown/index.js +1 -1
  54. package/dist/markdown/index.js.map +1 -1
  55. package/dist/{preview-QDCIVVSR.js → preview-T6CLSQVX.js} +12 -4
  56. package/dist/preview-T6CLSQVX.js.map +1 -0
  57. package/dist/query/index.d.ts +0 -11
  58. package/dist/query/index.js +2 -2
  59. package/dist/{routes-RHP5FVGD.js → routes-3SGAFDMC.js} +6 -6
  60. package/dist/{routes-RHP5FVGD.js.map → routes-3SGAFDMC.js.map} +1 -1
  61. package/dist/{search-RFBELGY7.js → search-5LC4XPB5.js} +3 -3
  62. package/dist/{search-RFBELGY7.js.map → search-5LC4XPB5.js.map} +1 -1
  63. package/dist/slide/index.js +63 -17
  64. package/dist/slide/index.js.map +1 -1
  65. package/dist/table/index.js +11 -9
  66. package/dist/table/index.js.map +1 -1
  67. package/dist/ui/index.d.ts +144 -11
  68. package/dist/ui/index.js +1339 -86
  69. package/dist/ui/index.js.map +1 -1
  70. package/dist/{upgrade-GF2P67M6.js → upgrade-JHXZBGQS.js} +7 -7
  71. package/dist/{upgrade-GF2P67M6.js.map → upgrade-JHXZBGQS.js.map} +1 -1
  72. package/dist/vite/index.d.ts +10 -0
  73. package/dist/vite/index.js +4 -4
  74. package/docs/en/changelog.md +24 -0
  75. package/docs/en/cli-reference.md +30 -0
  76. package/docs/en/components.md +29 -15
  77. package/docs/en/parts/data-table.md +22 -0
  78. package/docs/en/parts/delta.md +50 -0
  79. package/docs/en/parts/format.md +43 -0
  80. package/docs/en/parts/kpi-card.md +57 -0
  81. package/docs/en/parts/metric-value.md +3 -2
  82. package/docs/en/parts/page-shell.md +1 -1
  83. package/docs/en/parts/sparkline.md +1 -1
  84. package/docs/en/parts/trend-indicator.md +17 -1
  85. package/docs/en/routing.md +4 -2
  86. package/docs/en/ui-and-theming.md +1 -1
  87. package/docs/index.json +366 -76
  88. package/docs/ja/changelog.md +55 -0
  89. package/docs/ja/cli-reference.md +29 -0
  90. package/docs/ja/components.md +57 -101
  91. package/docs/ja/page-types/csv.md +83 -0
  92. package/docs/ja/page-types/doc.md +83 -0
  93. package/docs/ja/page-types/excel.md +82 -0
  94. package/docs/ja/page-types/slide.md +132 -0
  95. package/docs/ja/page-types.md +47 -0
  96. package/docs/ja/parts/area-chart.md +61 -0
  97. package/docs/ja/parts/bar-chart.md +69 -0
  98. package/docs/ja/parts/bar-list.md +35 -0
  99. package/docs/ja/parts/category-bar.md +36 -0
  100. package/docs/ja/parts/data-table.md +22 -0
  101. package/docs/ja/parts/delta.md +50 -0
  102. package/docs/ja/parts/donut-chart.md +47 -0
  103. package/docs/ja/parts/echart.md +3 -0
  104. package/docs/ja/parts/format.md +44 -0
  105. package/docs/ja/parts/kpi-card.md +55 -0
  106. package/docs/ja/parts/line-chart.md +58 -0
  107. package/docs/ja/parts/metric-value.md +3 -2
  108. package/docs/ja/parts/page-shell.md +1 -1
  109. package/docs/ja/parts/progress-circle.md +34 -0
  110. package/docs/ja/parts/sparkline.md +2 -2
  111. package/docs/ja/parts/tracker.md +32 -0
  112. package/docs/ja/parts/trend-indicator.md +17 -1
  113. package/docs/ja/routing.md +8 -84
  114. package/docs/ja/ui/alert-dialog.md +41 -0
  115. package/docs/ja/ui/avatar.md +31 -0
  116. package/docs/ja/ui/combobox.md +74 -0
  117. package/docs/ja/ui/drawer.md +41 -0
  118. package/docs/ja/ui/field.md +42 -0
  119. package/docs/ja/ui/hover-card.md +21 -0
  120. package/docs/ja/ui/kbd.md +16 -0
  121. package/docs/ja/ui/pagination.md +36 -0
  122. package/docs/ja/ui/radio-group.md +21 -0
  123. package/docs/ja/ui/toast.md +46 -0
  124. package/docs/ja/ui-and-theming.md +14 -7
  125. package/package.json +43 -29
  126. package/registry/ui/accordion.tsx +76 -0
  127. package/registry/ui/alert-dialog.tsx +189 -0
  128. package/registry/ui/alert.tsx +80 -0
  129. package/registry/ui/app-shell.tsx +287 -0
  130. package/registry/ui/area-chart.tsx +3675 -0
  131. package/registry/ui/attention-list.tsx +303 -0
  132. package/registry/ui/avatar.tsx +110 -0
  133. package/registry/ui/badge.tsx +56 -0
  134. package/registry/ui/bar-chart.tsx +3672 -0
  135. package/registry/ui/bar-list.tsx +3716 -0
  136. package/registry/ui/box-plot-chart.tsx +3880 -0
  137. package/registry/ui/breadcrumb.tsx +128 -0
  138. package/registry/ui/button.tsx +62 -0
  139. package/registry/ui/calendar.tsx +223 -0
  140. package/registry/ui/card.tsx +107 -0
  141. package/registry/ui/category-bar.tsx +1263 -0
  142. package/registry/ui/chart-frame.tsx +1366 -0
  143. package/registry/ui/checkbox.tsx +30 -0
  144. package/registry/ui/collapsible.tsx +23 -0
  145. package/registry/ui/combobox.tsx +310 -0
  146. package/registry/ui/command.tsx +198 -0
  147. package/registry/ui/dashboard-card.tsx +217 -0
  148. package/registry/ui/data-table.tsx +693 -146
  149. package/registry/ui/date-range-picker.tsx +390 -0
  150. package/registry/ui/delta.tsx +188 -0
  151. package/registry/ui/dialog.tsx +161 -0
  152. package/registry/ui/donut-chart.tsx +3724 -0
  153. package/registry/ui/drawer.tsx +229 -0
  154. package/registry/ui/dropdown-menu.tsx +274 -0
  155. package/registry/ui/echart.tsx +923 -0
  156. package/registry/ui/field.tsx +240 -0
  157. package/registry/ui/filter-bar.tsx +392 -0
  158. package/registry/ui/funnel-steps.tsx +3569 -0
  159. package/registry/ui/heatmap.tsx +1760 -0
  160. package/registry/ui/histogram-chart.tsx +3960 -0
  161. package/registry/ui/hover-card.tsx +52 -0
  162. package/registry/ui/input-group.tsx +160 -0
  163. package/registry/ui/input.tsx +24 -0
  164. package/registry/ui/kbd.tsx +30 -0
  165. package/registry/ui/kpi-card.tsx +699 -0
  166. package/registry/ui/label.tsx +22 -0
  167. package/registry/ui/legend.tsx +3536 -0
  168. package/registry/ui/line-chart.tsx +3673 -0
  169. package/registry/ui/metric-value.tsx +62 -0
  170. package/registry/ui/multi-select.tsx +232 -0
  171. package/registry/ui/page-shell.tsx +282 -0
  172. package/registry/ui/pagination.tsx +140 -0
  173. package/registry/ui/paired-chart.tsx +3801 -0
  174. package/registry/ui/placeholder.tsx +36 -0
  175. package/registry/ui/popover.tsx +92 -0
  176. package/registry/ui/progress-circle.tsx +867 -0
  177. package/registry/ui/progress.tsx +85 -0
  178. package/registry/ui/radio-group.tsx +39 -0
  179. package/registry/ui/sample-data.tsx +86 -0
  180. package/registry/ui/scatter-chart.tsx +4186 -0
  181. package/registry/ui/scroll-area.tsx +56 -0
  182. package/registry/ui/searchable-select.tsx +214 -0
  183. package/registry/ui/section-header.tsx +43 -0
  184. package/registry/ui/segmented-control.tsx +76 -0
  185. package/registry/ui/select.tsx +255 -0
  186. package/registry/ui/separator.tsx +27 -0
  187. package/registry/ui/sheet.tsx +139 -0
  188. package/registry/ui/sidebar.tsx +725 -0
  189. package/registry/ui/skeleton.tsx +17 -0
  190. package/registry/ui/slider.tsx +56 -0
  191. package/registry/ui/sparkline.tsx +230 -0
  192. package/registry/ui/spinner.tsx +14 -0
  193. package/registry/ui/states.tsx +90 -0
  194. package/registry/ui/status-badge.tsx +121 -0
  195. package/registry/ui/switch.tsx +34 -0
  196. package/registry/ui/table.tsx +118 -0
  197. package/registry/ui/tabs.tsx +84 -0
  198. package/registry/ui/textarea.tsx +22 -0
  199. package/registry/ui/toast.tsx +250 -0
  200. package/registry/ui/toggle-group.tsx +91 -0
  201. package/registry/ui/toggle.tsx +47 -0
  202. package/registry/ui/tooltip.tsx +68 -0
  203. package/registry/ui/tracker.tsx +843 -0
  204. package/registry/ui/trend-indicator.tsx +74 -0
  205. package/registry/ui/updated-at.tsx +122 -0
  206. package/registry/ui/waterfall-chart.tsx +4222 -0
  207. package/skills/vantage-add-feature/SKILL.md +9 -7
  208. package/skills/vantage-design/SKILL.md +12 -2
  209. package/skills/vantage-design/references/format.md +1 -1
  210. package/skills/vantage-pitfalls/SKILL.md +5 -1
  211. package/skills/vantage-slide/SKILL.md +17 -0
  212. package/templates/AGENTS.md +22 -1
  213. package/templates/agents/api.md +29 -0
  214. package/templates/agents/csv.md +2 -1
  215. package/templates/agents/doc.md +6 -3
  216. package/templates/agents/page.md +32 -8
  217. package/templates/agents/slide.md +13 -1
  218. package/templates/agents/xlsx.md +2 -1
  219. package/theme.css +281 -126
  220. package/dist/add-EISGSZAH.js +0 -6
  221. package/dist/add-EISGSZAH.js.map +0 -1
  222. package/dist/chunk-3G43C6OL.js.map +0 -1
  223. package/dist/chunk-5ZOP4SQR.js.map +0 -1
  224. package/dist/chunk-A2UUGASH.js.map +0 -1
  225. package/dist/chunk-BCE22JSM.js.map +0 -1
  226. package/dist/chunk-DTDVSFRY.js.map +0 -1
  227. package/dist/chunk-EP6JHXSR.js.map +0 -1
  228. package/dist/chunk-KLEIVDUV.js.map +0 -1
  229. package/dist/chunk-MXI3PZFT.js.map +0 -1
  230. package/dist/chunk-PLST5Q7T.js +0 -2221
  231. package/dist/chunk-PLST5Q7T.js.map +0 -1
  232. package/dist/chunk-PVTHHV7X.js.map +0 -1
  233. package/dist/chunk-T3FLAGI6.js +0 -316
  234. package/dist/chunk-T3FLAGI6.js.map +0 -1
  235. package/dist/dev-NZRLOQCL.js.map +0 -1
  236. package/dist/doctor-VFKEMR54.js.map +0 -1
  237. package/dist/preview-QDCIVVSR.js.map +0 -1
@@ -0,0 +1,923 @@
1
+ // Copied by `vantage add ui echart`. This file is yours to edit.
2
+ // Generated from @squadbase/squadkit registry/charts/echart.tsx by `pnpm run registry` (in Vantage, edit packages/squadkit, not registry/).
3
+ "use client"
4
+
5
+ /**
6
+ * EChart — a thin Apache ECharts wrapper. It owns the canvas lifecycle (init,
7
+ * resize, dispose), forwards `option` and event handlers, drives the loading
8
+ * spinner, and can copy/download the chart as a PNG.
9
+ *
10
+ * Colours follow the design tokens: `--chart-1..5` become the series palette,
11
+ * and the text/border/popover tokens style axes, legend and tooltip. They're
12
+ * resolved once at init (see `chart-theme.ts`) and re-read whenever they can
13
+ * have changed — a light/dark flip, or a stylesheet swap such as editing
14
+ * `styles.css` while the dev server is running. `theme` opts out entirely;
15
+ * `option.color` overrides just the palette and keeps the rest.
16
+ *
17
+ * Pitfall: the canvas can't read CSS. A colour written into the option as
18
+ * `"var(--chart-1)"`, `"chart-1"` or `"oklch(…)"` paints black (or nothing).
19
+ * Leave colours out and the tokens apply; when you must set one yourself, pass
20
+ * it through `useResolvedChartColors` and use the result:
21
+ *
22
+ * @example
23
+ * const [accent] = useResolvedChartColors(["chart-2"])
24
+ * const option = { series: [{ type: "line", data, lineStyle: { color: accent } }] }
25
+ * return <EChart option={option} />
26
+ *
27
+ * Updates merge instead of replacing (`replaceMerge` on every component type in
28
+ * play), so series with a stable `id` transition to their new values while
29
+ * anything dropped from the option is still removed. With
30
+ * `prefers-reduced-motion: reduce` every animation is off.
31
+ *
32
+ * ECharts is loaded modularly (`echarts/core`): only the bar / line / pie series (and `custom`, which draws
33
+ * the per-bar target marker),
34
+ * the components the typed charts emit and the canvas renderer are registered
35
+ * below. To draw anything else with `<EChart>` (scatter, funnel, dataZoom, …),
36
+ * register it once in your own code:
37
+ *
38
+ * @example
39
+ * import { use } from "echarts/core"
40
+ * import { ScatterChart } from "echarts/charts"
41
+ * use([ScatterChart])
42
+ */
43
+ import { forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"
44
+ import { init, use } from "echarts/core"
45
+ import { BarChart, CustomChart, LineChart, PieChart } from "echarts/charts"
46
+ import {
47
+ GridComponent,
48
+ LegendComponent,
49
+ MarkAreaComponent,
50
+ MarkLineComponent,
51
+ MarkPointComponent,
52
+ TooltipComponent,
53
+ } from "echarts/components"
54
+ import { CanvasRenderer } from "echarts/renderers"
55
+ import type { ECharts, EChartsOption } from "echarts"
56
+ import { Check, Copy, Download } from "lucide-react"
57
+
58
+ import { cn } from "@squadbase/vantage/ui"
59
+ import { Button } from "@squadbase/vantage/ui"
60
+ import type { ChartActionsOptions } from "@squadbase/vantage/components"
61
+ import { createContext, type ForwardedRef } from "react"
62
+ import type { ChartTone } from "@squadbase/vantage/components"
63
+
64
+ /* ---------- inlined from @squadbase/squadkit registry/lib/chart-theme.ts (internal to @squadbase/vantage) ---------- */
65
+
66
+ /**
67
+ * The colour of the folded「その他」series / slice / row. The ONLY place this
68
+ * grey is defined — every chart takes it from here. It is the most subdued
69
+ * colour in a chart, not the darkest: the theme's `--neutral` mixed halfway into
70
+ * `--background`, so it recedes in light and dark alike (and brand themes follow
71
+ * it). `--neutral-subtle` alone sits too close to the background to read as a
72
+ * slice. Canvas charts resolve it through `resolveChartColors`.
73
+ */
74
+ const CHART_OTHER_COLOR = "color-mix(in oklab, var(--neutral) 50%, var(--background))"
75
+
76
+ /**
77
+ * True inside `ChartFrame`. The frame's plot area carries the chart's single
78
+ * `role="img"` and label, so `EChart` drops its own when it sees this.
79
+ */
80
+ const ChartFrameContext = createContext(false)
81
+
82
+ /** Series palette, in order. Missing tokens are skipped, not defaulted. */
83
+ const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"] as const
84
+
85
+ interface ChartTokens {
86
+ /** Resolved `--chart-1..5`, in order. */
87
+ series: string[]
88
+ /** Computed `font-family` of the container, so canvas text matches the DOM. Optional. */
89
+ fontFamily?: string
90
+ /** Body text: titles and the default `textStyle`. */
91
+ foreground: string | null
92
+ /** Secondary text: axis labels, legend, subtitles. */
93
+ mutedForeground: string | null
94
+ /** Axis lines, ticks, split lines, tooltip border. */
95
+ border: string | null
96
+ /** Tooltip surface. */
97
+ popover: string | null
98
+ /** Tooltip text. */
99
+ popoverForeground: string | null
100
+ }
101
+
102
+ let normalizeContext: CanvasRenderingContext2D | null | undefined
103
+
104
+ /** The forms `fillStyle` serializes legacy colours to, and zrender can parse. */
105
+ const ZRENDER_COLOR_RE = /^(?:#[0-9a-f]{6}|rgba?\()/i
106
+
107
+ /**
108
+ * Serialize a CSS colour into a canvas-safe form, or `null` if the browser
109
+ * rejects it. Assigning an invalid value to `fillStyle` is a no-op, so the same
110
+ * input is written over two different known colours: agreeing results mean the
111
+ * value parsed, differing ones mean it was dropped.
112
+ */
113
+ function toCanvasColor(value: string): string | null {
114
+ const raw = value.trim()
115
+ if (!raw) return null
116
+ if (normalizeContext === undefined) {
117
+ const canvas = document.createElement("canvas")
118
+ canvas.width = 1
119
+ canvas.height = 1
120
+ normalizeContext = canvas.getContext("2d", { willReadFrequently: true })
121
+ }
122
+ const ctx = normalizeContext
123
+ if (!ctx) return raw
124
+ ctx.fillStyle = "#000000"
125
+ ctx.fillStyle = raw
126
+ const onBlack = ctx.fillStyle
127
+ ctx.fillStyle = "#ffffff"
128
+ ctx.fillStyle = raw
129
+ if (onBlack !== ctx.fillStyle || typeof onBlack !== "string") return null
130
+ if (ZRENDER_COLOR_RE.test(onBlack)) return onBlack
131
+ // Still `oklch()` / `lab()` / `color(...)`: paint it and read the pixel back.
132
+ // Wide-gamut colours clip to sRGB, which is all zrender can express anyway.
133
+ ctx.clearRect(0, 0, 1, 1)
134
+ ctx.fillRect(0, 0, 1, 1)
135
+ const [r = 0, g = 0, b = 0, a = 0] = ctx.getImageData(0, 0, 1, 1).data
136
+ return a === 255
137
+ ? `rgb(${r}, ${g}, ${b})`
138
+ : `rgba(${r}, ${g}, ${b}, ${Math.round((a / 255) * 1000) / 1000})`
139
+ }
140
+
141
+ /**
142
+ * Read the chart tokens off `el` (the document root by default — tokens live on
143
+ * `:root` and inherit). Returns `null` outside the browser so callers fall back
144
+ * to plain ECharts defaults.
145
+ */
146
+ function readChartTokens(el?: Element | null): ChartTokens | null {
147
+ if (typeof document === "undefined") return null
148
+ const target = el ?? document.documentElement
149
+ if (!target) return null
150
+ const styles = getComputedStyle(target)
151
+ const read = (name: string) => toCanvasColor(styles.getPropertyValue(name))
152
+ const series: string[] = []
153
+ for (const token of SERIES_TOKENS) {
154
+ const color = read(token)
155
+ if (color) series.push(color)
156
+ }
157
+ return {
158
+ series,
159
+ ...(styles.fontFamily ? { fontFamily: styles.fontFamily } : {}),
160
+ foreground: read("--foreground"),
161
+ mutedForeground: read("--muted-foreground"),
162
+ border: read("--border"),
163
+ popover: read("--popover"),
164
+ popoverForeground: read("--popover-foreground"),
165
+ }
166
+ }
167
+
168
+ /** Structural equality, so a theme toggle that changes nothing doesn't re-init. */
169
+ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean {
170
+ if (a === b) return true
171
+ if (!a || !b) return false
172
+ return (
173
+ a.series.length === b.series.length &&
174
+ a.series.every((color, i) => color === b.series[i]) &&
175
+ a.fontFamily === b.fontFamily &&
176
+ a.foreground === b.foreground &&
177
+ a.mutedForeground === b.mutedForeground &&
178
+ a.border === b.border &&
179
+ a.popover === b.popover &&
180
+ a.popoverForeground === b.popoverForeground
181
+ )
182
+ }
183
+
184
+ /**
185
+ * Watch everything that can change the resolved tokens and call `onChange` when
186
+ * something might have. It only ever says "re-read" — the caller compares with
187
+ * `sameChartTokens` and decides whether anything actually moved. Returns a
188
+ * disposer.
189
+ *
190
+ * Two sources, not one:
191
+ * - light/dark flips, which land as `class` / `data-theme` (or an inline
192
+ * `style`) on `<html>`/`<body>`, or as an OS preference change
193
+ * - stylesheet changes. In dev, editing `styles.css` rewrites the injected
194
+ * `<style>` in `<head>` and touches nothing else, so watching the theme
195
+ * attributes alone leaves the chart painted in the previous palette until a
196
+ * reload — the tokens in the DOM are new, the canvas is not.
197
+ */
198
+ function watchChartTokens(onChange: () => void): () => void {
199
+ if (typeof document === "undefined") return () => {}
200
+ // Style edits arrive as bursts of mutations; coalesce them into one read, and
201
+ // take it after the next frame so a swapped stylesheet has been applied.
202
+ let frame = 0
203
+ const schedule = () => {
204
+ if (frame) return
205
+ frame = requestAnimationFrame(() => {
206
+ frame = 0
207
+ onChange()
208
+ })
209
+ }
210
+
211
+ const themeObserver = new MutationObserver(schedule)
212
+ const attributeFilter = ["class", "style", "data-theme"]
213
+ themeObserver.observe(document.documentElement, { attributes: true, attributeFilter })
214
+ if (document.body) themeObserver.observe(document.body, { attributes: true, attributeFilter })
215
+
216
+ const styleObserver = new MutationObserver(schedule)
217
+ if (document.head) {
218
+ styleObserver.observe(document.head, {
219
+ childList: true,
220
+ subtree: true,
221
+ characterData: true,
222
+ attributes: true,
223
+ attributeFilter: ["href", "media", "disabled"],
224
+ })
225
+ }
226
+
227
+ // A swapped `<link>` only applies once the sheet has loaded, after its mutation.
228
+ const onLoad = (event: Event) => {
229
+ if (event.target instanceof HTMLLinkElement) schedule()
230
+ }
231
+ document.addEventListener("load", onLoad, true)
232
+
233
+ const media = window.matchMedia("(prefers-color-scheme: dark)")
234
+ media.addEventListener("change", schedule)
235
+
236
+ return () => {
237
+ if (frame) cancelAnimationFrame(frame)
238
+ themeObserver.disconnect()
239
+ styleObserver.disconnect()
240
+ document.removeEventListener("load", onLoad, true)
241
+ media.removeEventListener("change", schedule)
242
+ }
243
+ }
244
+
245
+ /** Shared shape for the four axis types ECharts themes address separately. */
246
+ function axisTheme(tokens: ChartTokens, splitLine: boolean) {
247
+ const line = tokens.border ?? undefined
248
+ return {
249
+ axisLine: { show: false },
250
+ axisTick: { show: false },
251
+ axisLabel: { show: true, color: tokens.mutedForeground ?? undefined, fontSize: 12 },
252
+ // Faint horizontal rules only: the border colour at low opacity.
253
+ splitLine: { show: splitLine, lineStyle: { color: line ? [line] : undefined, opacity: 0.5, width: 1 } },
254
+ splitArea: { show: false },
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Build the ECharts theme. `option.color` still wins over `theme.color`, so an
260
+ * app that sets its own palette keeps it.
261
+ */
262
+ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
263
+ const text = tokens.foreground ?? undefined
264
+ const muted = tokens.mutedForeground ?? undefined
265
+ const line = tokens.border ?? undefined
266
+ return {
267
+ // Let the page background show through: charts sit on cards, not on a canvas.
268
+ backgroundColor: "transparent",
269
+ ...(tokens.series.length > 0 ? { color: tokens.series } : {}),
270
+ textStyle: { color: text, ...(tokens.fontFamily ? { fontFamily: tokens.fontFamily } : {}) },
271
+ title: { textStyle: { color: text }, subtextStyle: { color: muted } },
272
+ legend: { textStyle: { color: muted } },
273
+ categoryAxis: axisTheme(tokens, false),
274
+ valueAxis: axisTheme(tokens, true),
275
+ logAxis: axisTheme(tokens, true),
276
+ timeAxis: axisTheme(tokens, true),
277
+ tooltip: {
278
+ // ECharts' default 0.4s transform lags the tooltip ~300ms behind the cursor.
279
+ transitionDuration: 0,
280
+ backgroundColor: tokens.popover ?? undefined,
281
+ borderColor: line,
282
+ borderWidth: 1,
283
+ padding: [8, 12],
284
+ // Rounded card with a soft shadow; in dark mode the shadow barely shows, so the border carries the edge.
285
+ extraCssText: "border-radius:8px;box-shadow:var(--shadow-card, 0 4px 12px rgb(0 0 0 / 0.08));",
286
+ textStyle: {
287
+ color: tokens.popoverForeground ?? undefined,
288
+ fontSize: 12,
289
+ ...(tokens.fontFamily ? { fontFamily: tokens.fontFamily } : {}),
290
+ },
291
+ axisPointer: { lineStyle: { color: line }, crossStyle: { color: line } },
292
+ },
293
+ }
294
+ }
295
+
296
+ /**
297
+ * The resolved tokens, kept current: re-read whenever `watchChartTokens` says
298
+ * they may have moved, and only replaced when a value actually changed. For
299
+ * components that build an ECharts option from token colours themselves (the
300
+ * typed charts), so their palette follows light/dark like `EChart`'s theme.
301
+ */
302
+ function useChartTokens(): ChartTokens | null {
303
+ const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
304
+ // Compared here, not inside a `setTokens` updater: an updater that reads the DOM is
305
+ // replayed by React on every rebase and returns a fresh object each time, so the state
306
+ // never settles ("Maximum update depth exceeded" once the tokens change after mount).
307
+ const tokensRef = useRef(tokens)
308
+ useEffect(() => {
309
+ const sync = () => {
310
+ const next = readChartTokens()
311
+ if (sameChartTokens(tokensRef.current, next)) return
312
+ tokensRef.current = next
313
+ setTokens(next)
314
+ }
315
+ sync()
316
+ return watchChartTokens(sync)
317
+ }, [])
318
+ return tokens
319
+ }
320
+
321
+ const SERIES_TOKEN_NAME_RE = /^chart-([1-5])$/
322
+ const TONE_NAME_RE = /^(?:positive|warning|negative|neutral|info)(?:-fill)?$/
323
+ const CSS_VAR_RE = /^var\(\s*(--[\w-]+)\s*(?:,\s*(.+))?\)$/
324
+ const EMBEDDED_VAR_RE = /var\(\s*(--[\w-]+)\s*\)/g
325
+
326
+ /**
327
+ * Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
328
+ * the resolved series tokens; `"positive"` / `"warning"` / `"negative"` / `"neutral"` (and the area
329
+ * variants `"positive-fill"`, …) are read off the document root (`--positive`, …); `var(--token)` is read off the document root; any
330
+ * other CSS colour is normalized like the tokens are. A colour that can't be
331
+ * resolved comes back as `""`, which ECharts treats as "use the palette".
332
+ */
333
+ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | null): string[] {
334
+ return colors.map((color) => {
335
+ const token = SERIES_TOKEN_NAME_RE.exec(color.trim())
336
+ if (token) return tokens?.series[Number(token[1]) - 1] ?? ""
337
+ // Meaning colours are the theme tokens of the same name: same path as `var(--positive)`.
338
+ if (TONE_NAME_RE.test(color.trim())) {
339
+ if (typeof document === "undefined") return ""
340
+ return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue(`--${color.trim()}`)) ?? ""
341
+ }
342
+ if (typeof document === "undefined") return color
343
+ let value = color.trim()
344
+ const cssVar = CSS_VAR_RE.exec(value)
345
+ const styles = getComputedStyle(document.documentElement)
346
+ if (cssVar) {
347
+ value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
348
+ } else if (value.includes("var(")) {
349
+ // A colour function over tokens (`color-mix(… var(--muted-foreground) …)`):
350
+ // substitute the tokens, then let the canvas resolve the rest.
351
+ value = value.replace(EMBEDDED_VAR_RE, (_, name: string) => styles.getPropertyValue(name).trim())
352
+ }
353
+ return toCanvasColor(value) ?? ""
354
+ })
355
+ }
356
+
357
+ /** Every meaning colour resolved for the canvas (`""` when it can't be resolved). */
358
+ function resolveToneColors(tokens: ChartTokens | null): Record<ChartTone, string> {
359
+ const [positive = "", warning = "", negative = "", neutral = ""] = resolveChartColors(
360
+ ["positive", "warning", "negative", "neutral"],
361
+ tokens,
362
+ )
363
+ return { positive, warning, negative, neutral }
364
+ }
365
+
366
+ /** The area (`--<tone>-fill`) colours of every meaning tone, resolved for the canvas (`""` when it can't be resolved). */
367
+ function resolveFillColors(tokens: ChartTokens | null): Record<ChartTone, string> {
368
+ const [positive = "", warning = "", negative = "", neutral = ""] = resolveChartColors(
369
+ ["positive-fill", "warning-fill", "negative-fill", "neutral-fill"],
370
+ tokens,
371
+ )
372
+ return { positive, warning, negative, neutral }
373
+ }
374
+
375
+ const TRANSPARENT_RE = /^(?:transparent|rgba\([^)]*,\s*0(?:\.0+)?\))$/i
376
+
377
+ /**
378
+ * The colour the chart is painted on: the first opaque `background-color` up
379
+ * from `el` (a card, the page), else `--background`. Canvas-safe, or `null`
380
+ * outside the browser.
381
+ */
382
+ function readChartSurface(el: Element | null): string | null {
383
+ if (typeof document === "undefined") return null
384
+ for (let node: Element | null = el; node; node = node.parentElement) {
385
+ const bg = getComputedStyle(node).backgroundColor.trim()
386
+ if (bg && !TRANSPARENT_RE.test(bg)) {
387
+ const color = toCanvasColor(bg)
388
+ if (color) return color
389
+ }
390
+ }
391
+ return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue("--background"))
392
+ }
393
+
394
+ /** Widths are bucketed so a resize only rebuilds the option when the label budget can change. */
395
+ const WIDTH_BUCKET = 40
396
+
397
+ /**
398
+ * The surface colour behind a chart (see `readChartSurface`), re-read when the
399
+ * tokens move (light/dark), and the plot's width (rounded down to 40px;
400
+ * `undefined` until measured). Returns a ref for the plot that also feeds the
401
+ * component's forwarded ref.
402
+ */
403
+ function useChartSurface<E extends Element>(
404
+ forwarded: ForwardedRef<E>,
405
+ tokens: ChartTokens | null,
406
+ ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined } {
407
+ const [node, setNode] = useState<E | null>(null)
408
+ const [surface, setSurface] = useState<string | undefined>(undefined)
409
+ const [width, setWidth] = useState<number | undefined>(undefined)
410
+ const ref = useCallback(
411
+ (el: E | null) => {
412
+ setNode(el)
413
+ if (typeof forwarded === "function") forwarded(el)
414
+ else if (forwarded) forwarded.current = el
415
+ },
416
+ [forwarded],
417
+ )
418
+ useEffect(() => {
419
+ if (!node) return
420
+ setSurface(readChartSurface(node) ?? undefined)
421
+ }, [node, tokens])
422
+ useEffect(() => {
423
+ if (!node || typeof ResizeObserver === "undefined") return
424
+ const update = (w: number) => setWidth(Math.floor(w / WIDTH_BUCKET) * WIDTH_BUCKET)
425
+ update(node.getBoundingClientRect().width)
426
+ const observer = new ResizeObserver((entries) => update(entries[0]?.contentRect.width ?? 0))
427
+ observer.observe(node)
428
+ return () => observer.disconnect()
429
+ }, [node])
430
+ return { ref, surface, width }
431
+ }
432
+
433
+ /* ---------- end of inlined code ---------- */
434
+
435
+ // What the typed charts (Bar / Line / Area / Donut) emit. Other series and
436
+ // components are the consumer's to `use()` — see the note at the top.
437
+ use([
438
+ BarChart,
439
+ CustomChart,
440
+ LineChart,
441
+ PieChart,
442
+ GridComponent,
443
+ LegendComponent,
444
+ MarkAreaComponent,
445
+ MarkLineComponent,
446
+ MarkPointComponent,
447
+ TooltipComponent,
448
+ CanvasRenderer,
449
+ ])
450
+
451
+ export interface EChartProps {
452
+ /**
453
+ * ECharts のオプション。
454
+ *
455
+ * 型推論を効かせるため、必ず `EChartsOption` を annotation するか
456
+ * `satisfies EChartsOption` を付けること。付けないと `type: "category"` などの
457
+ * literal が `string` に拡張され、ビルドが失敗する。
458
+ *
459
+ * @example
460
+ * import type { EChartsOption } from "@/components/echart"
461
+ *
462
+ * const option: EChartsOption = {
463
+ * xAxis: { type: "category", data: ["A", "B"] },
464
+ * yAxis: { type: "value" },
465
+ * series: [{ type: "bar", data: [1, 2] }],
466
+ * }
467
+ * <EChart option={option} />
468
+ */
469
+ option: EChartsOption
470
+ /**
471
+ * チャートの高さ。数値は px、文字列は CSS 値(例: `"50vh"`)として扱う。
472
+ *
473
+ * 省略した場合は外側ラッパーに `h-[400px]` がデフォルトで当たるが、`className` に
474
+ * `h-full` や `h-[360px]` などの高さクラスを渡せばそちらが優先される
475
+ * (`h-full` を使う場合は親要素に確定した高さが必要)。
476
+ * `height` と `className` の高さを両方指定した場合は `height`(インライン style)が勝つ。
477
+ */
478
+ height?: string | number
479
+ loading?: boolean
480
+ /**
481
+ * `echarts.init` にそのまま渡すテーマ(名前または登録済みテーマオブジェクト)。
482
+ *
483
+ * 渡すとデザイントークン追従は**完全に無効**になり、明暗の切り替えにも追従しなく
484
+ * なる。系列色だけ変えたいなら `option.color` を使う ― option はテーマより優先
485
+ * されるので、軸・凡例・ツールチップのトークン追従は残る。
486
+ */
487
+ theme?: string | object
488
+ onEvents?: Record<string, (params: unknown) => void>
489
+ ariaLabel?: string
490
+ className?: string
491
+ /**
492
+ * true にするとチャート右上に PNG コピー / ダウンロードボタンを表示する。
493
+ * `exportHeader` があれば PNG の上にその見出しを含める(`{ includeTitle: false }` で含めない)。
494
+ */
495
+ actions?: boolean | ChartActionsOptions
496
+ /** ダウンロード時のファイル名(拡張子含む)。省略時は "chart.png" */
497
+ fileName?: string
498
+ /**
499
+ * PNG の上に含める見出しと要約。型付きグラフは自分の `title` / `summary` を渡す。
500
+ * 自分で `ChartFrame` を組むときも同じものを渡すと、保存した画像だけで何のグラフか分かる。
501
+ */
502
+ exportHeader?: ChartExportHeader
503
+ }
504
+
505
+ /** The heading a saved PNG carries above the plot. */
506
+ export interface ChartExportHeader {
507
+ title?: string
508
+ summary?: string
509
+ }
510
+
511
+ const HEADER_PAD_X = 16
512
+ const HEADER_PAD_TOP = 16
513
+ const HEADER_PAD_BOTTOM = 12
514
+ const HEADER_LINE_HEIGHT = 20
515
+ const HEADER_GAP = 4
516
+ const HEADER_FONT_SIZE = 14
517
+
518
+ export interface ChartExportHeaderLine {
519
+ text: string
520
+ kind: "title" | "summary"
521
+ }
522
+
523
+ /**
524
+ * Break `text` into lines no wider than `maxWidth` (px, via `measure`), character
525
+ * by character so Japanese wraps without spaces. Always at least one line for
526
+ * non-empty text; a single over-wide character still gets its own line.
527
+ */
528
+ export function wrapChartText(text: string, maxWidth: number, measure: (text: string) => number): string[] {
529
+ const lines: string[] = []
530
+ let line = ""
531
+ for (const ch of text) {
532
+ if (ch === "\n") {
533
+ lines.push(line)
534
+ line = ""
535
+ continue
536
+ }
537
+ if (line !== "" && measure(line + ch) > maxWidth) {
538
+ lines.push(line)
539
+ line = ch.trim() === "" ? "" : ch
540
+ } else {
541
+ line += ch
542
+ }
543
+ }
544
+ if (line !== "" || lines.length === 0) lines.push(line)
545
+ return lines.filter((l, i) => l !== "" || i < lines.length - 1)
546
+ }
547
+
548
+ /**
549
+ * The lines and total height (CSS px) of the PNG's heading band for a plot
550
+ * `width` px wide. `measure` takes (text, kind) and returns its width in px.
551
+ * No title and no summary → no band (`height` 0).
552
+ */
553
+ export function chartExportHeaderLayout(
554
+ header: ChartExportHeader | undefined,
555
+ width: number,
556
+ measure: (text: string, kind: "title" | "summary") => number,
557
+ ): { lines: ChartExportHeaderLine[]; height: number } {
558
+ const title = header?.title?.trim()
559
+ const summary = header?.summary?.trim()
560
+ if (!title && !summary) return { lines: [], height: 0 }
561
+ const room = Math.max(40, width - HEADER_PAD_X * 2)
562
+ const lines: ChartExportHeaderLine[] = [
563
+ ...(title ? wrapChartText(title, room, (t) => measure(t, "title")).map((text) => ({ text, kind: "title" as const })) : []),
564
+ ...(summary ? wrapChartText(summary, room, (t) => measure(t, "summary")).map((text) => ({ text, kind: "summary" as const })) : []),
565
+ ]
566
+ const gap = title && summary ? HEADER_GAP : 0
567
+ return { lines, height: HEADER_PAD_TOP + lines.length * HEADER_LINE_HEIGHT + gap + HEADER_PAD_BOTTOM }
568
+ }
569
+
570
+ function loadImage(src: string): Promise<HTMLImageElement> {
571
+ return new Promise((resolve, reject) => {
572
+ const image = new Image()
573
+ image.onload = () => resolve(image)
574
+ image.onerror = () => reject(new Error("PNG の読み込みに失敗しました"))
575
+ image.src = src
576
+ })
577
+ }
578
+
579
+ /** Draw a heading band (title in foreground, summary muted) above the exported plot and return the new PNG. */
580
+ async function pngWithHeader(
581
+ dataUrl: string,
582
+ header: ChartExportHeader,
583
+ look: { background: string; color: string; mutedColor: string; fontFamily: string },
584
+ pixelRatio: number,
585
+ ): Promise<string> {
586
+ const image = await loadImage(dataUrl)
587
+ const width = image.width / pixelRatio
588
+ const plotHeight = image.height / pixelRatio
589
+ const canvas = document.createElement("canvas")
590
+ const ctx = canvas.getContext("2d")
591
+ if (!ctx) return dataUrl
592
+ const font = (kind: "title" | "summary") => `${kind === "title" ? 500 : 400} ${HEADER_FONT_SIZE}px ${look.fontFamily}`
593
+ ctx.font = font("title")
594
+ const layout = chartExportHeaderLayout(header, width, (text, kind) => {
595
+ ctx.font = font(kind)
596
+ return ctx.measureText(text).width
597
+ })
598
+ if (layout.height === 0) return dataUrl
599
+ canvas.width = Math.round(image.width)
600
+ canvas.height = Math.round((layout.height + plotHeight) * pixelRatio)
601
+ ctx.scale(pixelRatio, pixelRatio)
602
+ ctx.fillStyle = look.background
603
+ ctx.fillRect(0, 0, width, layout.height + plotHeight)
604
+ ctx.textBaseline = "middle"
605
+ let y = HEADER_PAD_TOP
606
+ let previous: "title" | "summary" | null = null
607
+ for (const line of layout.lines) {
608
+ if (previous === "title" && line.kind === "summary") y += HEADER_GAP
609
+ ctx.font = font(line.kind)
610
+ ctx.fillStyle = line.kind === "title" ? look.color : look.mutedColor
611
+ ctx.fillText(line.text, HEADER_PAD_X, y + HEADER_LINE_HEIGHT / 2)
612
+ y += HEADER_LINE_HEIGHT
613
+ previous = line.kind
614
+ }
615
+ ctx.drawImage(image, 0, layout.height, width, plotHeight)
616
+ return canvas.toDataURL("image/png")
617
+ }
618
+
619
+ /**
620
+ * Canvas-safe versions of theme colours, kept current across light/dark.
621
+ * Takes `"chart-1"`〜`"chart-5"`, `"var(--token)"` or any CSS colour; one that
622
+ * can't be resolved comes back as `""` (ECharts then uses the palette).
623
+ * Use it whenever a colour goes into a raw `EChart` option by hand.
624
+ *
625
+ * @example
626
+ * const [line, band] = useResolvedChartColors(["chart-1", "var(--muted-foreground)"])
627
+ */
628
+ export function useResolvedChartColors(colors: readonly string[]): string[] {
629
+ const tokens = useChartTokens()
630
+ const key = colors.join("\u0000")
631
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `key` stands for the array's contents
632
+ return useMemo(() => resolveChartColors(colors, tokens), [key, tokens])
633
+ }
634
+
635
+ type EChartsEventHandler = (params: unknown) => void
636
+
637
+ /**
638
+ * ECharts component main types. Listed in `replaceMerge`, a type keeps only the
639
+ * components whose `id` matches (merged, so they can transition) and drops the
640
+ * rest — including every one of them when the new option no longer has the type.
641
+ */
642
+ const COMPONENT_MAIN_TYPES = new Set([
643
+ "title", "legend", "grid", "xAxis", "yAxis", "polar", "radiusAxis", "angleAxis", "radar",
644
+ "dataZoom", "visualMap", "tooltip", "axisPointer", "toolbox", "brush", "geo", "parallel",
645
+ "parallelAxis", "singleAxis", "graphic", "calendar", "dataset", "series", "matrix", "thumbnail",
646
+ ])
647
+
648
+ export interface EChartSetOptionOpts {
649
+ notMerge: boolean
650
+ replaceMerge?: string[]
651
+ lazyUpdate: boolean
652
+ }
653
+
654
+ /**
655
+ * How to apply `next` after `prev`: the first option and timeline / media
656
+ * options replace everything; otherwise merge, replacing every component type
657
+ * either option uses so nothing stale survives.
658
+ */
659
+ export function echartSetOptionOpts(prev: EChartsOption | null, next: EChartsOption): EChartSetOptionOpts {
660
+ const special = (o: EChartsOption) => "timeline" in o || "baseOption" in o || "options" in o || "media" in o
661
+ if (!prev || special(prev) || special(next)) return { notMerge: true, lazyUpdate: true }
662
+ const types = new Set<string>()
663
+ for (const key of [...Object.keys(prev), ...Object.keys(next)]) if (COMPONENT_MAIN_TYPES.has(key)) types.add(key)
664
+ return { notMerge: false, replaceMerge: [...types], lazyUpdate: true }
665
+ }
666
+
667
+ /** `graphic` text doesn't inherit the global `textStyle`, so give it the inherited font unless it sets its own. */
668
+ function withGraphicFont(option: EChartsOption, fontFamily: string | undefined): EChartsOption {
669
+ const graphic = (option as { graphic?: unknown }).graphic
670
+ if (!fontFamily || !Array.isArray(graphic)) return option
671
+ return {
672
+ ...option,
673
+ graphic: graphic.map((g: { type?: string; style?: Record<string, unknown> }) =>
674
+ g && g.type === "text" && g.style && !g.style.fontFamily ? { ...g, style: { ...g.style, fontFamily } } : g,
675
+ ),
676
+ } as EChartsOption
677
+ }
678
+
679
+ /** `option` as applied: reduced motion forces `animation: false`; otherwise animation stays explicit so it can flip back. */
680
+ export function withMotionPreference(option: EChartsOption, reducedMotion: boolean): EChartsOption {
681
+ return { ...option, animation: reducedMotion ? false : (option.animation ?? true) }
682
+ }
683
+
684
+ const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"
685
+
686
+ /** `prefers-reduced-motion: reduce`, kept current. `false` on the server. */
687
+ export function usePrefersReducedMotion(): boolean {
688
+ const [reduced, setReduced] = useState(
689
+ () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia(REDUCED_MOTION_QUERY).matches,
690
+ )
691
+ useEffect(() => {
692
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return
693
+ const media = window.matchMedia(REDUCED_MOTION_QUERY)
694
+ const sync = () => setReduced(media.matches)
695
+ sync()
696
+ media.addEventListener("change", sync)
697
+ return () => media.removeEventListener("change", sync)
698
+ }, [])
699
+ return reduced
700
+ }
701
+
702
+ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
703
+ {
704
+ option,
705
+ height,
706
+ loading = false,
707
+ theme,
708
+ onEvents,
709
+ ariaLabel,
710
+ className,
711
+ actions = false,
712
+ fileName = "chart.png",
713
+ exportHeader,
714
+ },
715
+ ref,
716
+ ) {
717
+ const containerRef = useRef<HTMLDivElement | null>(null)
718
+ const [instance, setInstance] = useState<ECharts | null>(null)
719
+ const [copied, setCopied] = useState(false)
720
+ // 初回はレンダー前に読めるので、マウント直後の作り直しが起きない
721
+ const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
722
+ const reducedMotion = usePrefersReducedMotion()
723
+ // What the current instance last received, to pick the merge mode of the next update.
724
+ const appliedRef = useRef<EChartsOption | null>(null)
725
+
726
+ // 明暗の切り替えとスタイルシートの差し替え(dev で styles.css を書き換えたときなど)を
727
+ // 拾ってトークンを読み直す ― 何を見張るかは `watchChartTokens` 側が持つ。値が変わら
728
+ // ないときは state を更新せず、再 init を防ぐ。比較は updater 関数の中ではなく ref で行う:
729
+ // DOM を読む updater は React に何度も再生され、そのたびに新しいオブジェクトを返して
730
+ // 再描画が止まらなくなる(変数を後から当てた直後に Maximum update depth exceeded)。
731
+ const tokensRef = useRef(tokens)
732
+ useEffect(() => {
733
+ if (theme !== undefined) return
734
+ // Colours come from the document root, but the font is whatever this container inherits: an app
735
+ // can set its own font on a wrapper, and the canvas text has to match the DOM around it.
736
+ const sync = () => {
737
+ const base = readChartTokens()
738
+ const font = containerRef.current ? getComputedStyle(containerRef.current).fontFamily : ""
739
+ const next = base && font ? { ...base, fontFamily: font } : base
740
+ if (sameChartTokens(tokensRef.current, next)) return
741
+ tokensRef.current = next
742
+ setTokens(next)
743
+ }
744
+ sync()
745
+ const stop = watchChartTokens(sync)
746
+ // A font set on a wrapper (class / inline style) isn't seen by the document-level watcher.
747
+ const ancestors = new MutationObserver(sync)
748
+ for (let node = containerRef.current?.parentElement; node; node = node.parentElement) {
749
+ ancestors.observe(node, { attributes: true, attributeFilter: ["class", "style"] })
750
+ }
751
+ return () => {
752
+ stop()
753
+ ancestors.disconnect()
754
+ }
755
+ }, [theme])
756
+
757
+ const autoTheme = useMemo(() => (tokens ? buildChartTheme(tokens) : undefined), [tokens])
758
+ const resolvedTheme = theme ?? autoTheme
759
+
760
+ // init / re-init: ECharts はテーマの差し替えに対応しないので、テーマが変わったら
761
+ // インスタンスを破棄して作り直す(トークンが実際に変わったときだけここに来る)
762
+ useEffect(() => {
763
+ const container = containerRef.current
764
+ if (!container) return
765
+ const inst = init(container, resolvedTheme, { renderer: "canvas" })
766
+ appliedRef.current = null
767
+ // `data-rendering` stays on until the (animated) paint is done, so anything
768
+ // capturing the page — printing a document — can wait for real pixels.
769
+ container.dataset.rendering = ""
770
+ inst.on("finished", () => {
771
+ delete container.dataset.rendering
772
+ })
773
+ setInstance(inst)
774
+
775
+ // At most one resize per frame (the sidebar animates the width).
776
+ let frame = 0
777
+ const observer = new ResizeObserver(() => {
778
+ cancelAnimationFrame(frame)
779
+ frame = requestAnimationFrame(() => {
780
+ if (!inst.isDisposed()) inst.resize()
781
+ })
782
+ })
783
+ observer.observe(container)
784
+
785
+ return () => {
786
+ observer.disconnect()
787
+ cancelAnimationFrame(frame)
788
+ inst.dispose()
789
+ }
790
+ }, [resolvedTheme])
791
+
792
+ useEffect(() => {
793
+ if (!instance) return
794
+ if (containerRef.current) containerRef.current.dataset.rendering = ""
795
+ const next = withGraphicFont(withMotionPreference(option, reducedMotion), theme === undefined ? tokens?.fontFamily : undefined)
796
+ instance.setOption(next, echartSetOptionOpts(appliedRef.current, next))
797
+ appliedRef.current = next
798
+ }, [instance, option, reducedMotion, theme, tokens?.fontFamily])
799
+
800
+ useEffect(() => {
801
+ if (!instance) return
802
+ if (loading) instance.showLoading()
803
+ else instance.hideLoading()
804
+ }, [instance, loading])
805
+
806
+ useEffect(() => {
807
+ if (!instance || !onEvents) return
808
+ const entries = Object.entries(onEvents)
809
+ for (const [event, handler] of entries) {
810
+ instance.on(event, handler as EChartsEventHandler)
811
+ }
812
+ return () => {
813
+ if (instance.isDisposed()) return
814
+ for (const [event, handler] of entries) {
815
+ instance.off(event, handler as EChartsEventHandler)
816
+ }
817
+ }
818
+ }, [instance, onEvents])
819
+
820
+ const includeTitle = typeof actions === "object" ? (actions.includeTitle ?? true) : true
821
+ const getPngDataUrl = useCallback(async (): Promise<string | null> => {
822
+ if (!instance) return null
823
+ // Export over the nearest opaque background (card, then page) so the PNG
824
+ // matches what is on screen in dark too. Computed styles resolve to rgb(a),
825
+ // so this stays canvas-safe whatever the token.
826
+ let backgroundColor = "#ffffff"
827
+ for (let el: HTMLElement | null = containerRef.current; el; el = el.parentElement) {
828
+ const bg = getComputedStyle(el).backgroundColor
829
+ if (bg && bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent") {
830
+ backgroundColor = bg
831
+ break
832
+ }
833
+ }
834
+ const pixelRatio = 2
835
+ const dataUrl = instance.getDataURL({ type: "png", pixelRatio, backgroundColor })
836
+ if (!includeTitle || !exportHeader || !(exportHeader.title || exportHeader.summary) || !containerRef.current) return dataUrl
837
+ try {
838
+ const style = getComputedStyle(containerRef.current)
839
+ return await pngWithHeader(
840
+ dataUrl,
841
+ exportHeader,
842
+ { background: backgroundColor, color: style.color, mutedColor: tokens?.mutedForeground ?? style.color, fontFamily: style.fontFamily },
843
+ pixelRatio,
844
+ )
845
+ } catch {
846
+ return dataUrl
847
+ }
848
+ }, [instance, includeTitle, exportHeader, tokens])
849
+
850
+ const handleCopy = useCallback(async () => {
851
+ const dataUrl = await getPngDataUrl()
852
+ if (!dataUrl) return
853
+ try {
854
+ const blob = await (await fetch(dataUrl)).blob()
855
+ await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })])
856
+ setCopied(true)
857
+ setTimeout(() => setCopied(false), 2000)
858
+ } catch {
859
+ // ClipboardItem 未対応ブラウザでは silent fail
860
+ }
861
+ }, [getPngDataUrl])
862
+
863
+ const handleDownload = useCallback(async () => {
864
+ const dataUrl = await getPngDataUrl()
865
+ if (!dataUrl) return
866
+ const link = document.createElement("a")
867
+ link.href = dataUrl
868
+ link.download = fileName
869
+ link.click()
870
+ }, [getPngDataUrl, fileName])
871
+
872
+ // 高さの解決: height prop を渡した場合はインライン style が勝つ。未指定なら
873
+ // h-[400px] をデフォルトで当て、className の高さクラスに後勝ちさせる。
874
+ const inFrame = useContext(ChartFrameContext)
875
+ const styleHeight =
876
+ height == null ? undefined : typeof height === "number" ? `${height}px` : height
877
+
878
+ return (
879
+ <div
880
+ ref={ref}
881
+ data-slot="echart"
882
+ className={cn("group relative w-full", height == null && "h-[400px]", className)}
883
+ style={styleHeight != null ? { height: styleHeight } : undefined}
884
+ >
885
+ {/* `role="img"` wraps the canvas only, so the action buttons stay reachable. Inside a ChartFrame the plot area is the one image. */}
886
+ <div
887
+ ref={containerRef}
888
+ data-slot="echart-canvas"
889
+ className="h-full w-full"
890
+ role={inFrame ? undefined : "img"}
891
+ aria-label={inFrame ? undefined : (ariaLabel ?? "チャート")}
892
+ />
893
+ {actions && (
894
+ <div
895
+ data-slot="echart-actions"
896
+ className="absolute top-2 right-2 z-10 flex gap-1 pointer-events-none opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100"
897
+ >
898
+ <Button
899
+ type="button"
900
+ variant="ghost"
901
+ size="icon-sm"
902
+ onClick={handleCopy}
903
+ aria-label={copied ? "コピーしました" : "PNG をクリップボードにコピー"}
904
+ >
905
+ {copied ? <Check className="size-4" /> : <Copy className="size-4" />}
906
+ </Button>
907
+ <Button
908
+ type="button"
909
+ variant="ghost"
910
+ size="icon-sm"
911
+ onClick={handleDownload}
912
+ aria-label="PNG をダウンロード"
913
+ >
914
+ <Download className="size-4" />
915
+ </Button>
916
+ </div>
917
+ )}
918
+ </div>
919
+ )
920
+ })
921
+ EChart.displayName = "EChart"
922
+
923
+ export type { EChartsOption } from "echarts"