@squadbase/vantage 0.6.2-dev1.42c4e48 → 0.6.2-dev1.81c1bbe

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 (240) hide show
  1. package/dist/{build-MFWRPYKW.js → build-X5NVCQKT.js} +4 -4
  2. package/dist/{build-MFWRPYKW.js.map → build-X5NVCQKT.js.map} +1 -1
  3. package/dist/chunk-2IAPIOPS.js +152 -0
  4. package/dist/chunk-2IAPIOPS.js.map +1 -0
  5. package/dist/{chunk-IBGOCWJB.js → chunk-2ICNHUG6.js} +186 -82
  6. package/dist/chunk-2ICNHUG6.js.map +1 -0
  7. package/dist/{chunk-5ZOP4SQR.js → chunk-2N72FKLB.js} +12 -7
  8. package/dist/chunk-2N72FKLB.js.map +1 -0
  9. package/dist/chunk-57GHR5LN.js +163 -0
  10. package/dist/chunk-57GHR5LN.js.map +1 -0
  11. package/dist/{chunk-OB6UBJXF.js → chunk-7S52M2SL.js} +127 -36
  12. package/dist/chunk-7S52M2SL.js.map +1 -0
  13. package/dist/chunk-JXFCSF3K.js +673 -0
  14. package/dist/chunk-JXFCSF3K.js.map +1 -0
  15. package/dist/{chunk-PXRX5PEC.js → chunk-PDOBHMUQ.js} +3 -3
  16. package/dist/chunk-PDOBHMUQ.js.map +1 -0
  17. package/dist/chunk-RU6WKEOG.js +7048 -0
  18. package/dist/chunk-RU6WKEOG.js.map +1 -0
  19. package/dist/{chunk-A2UUGASH.js → chunk-U736PVHY.js} +2 -2
  20. package/dist/chunk-U736PVHY.js.map +1 -0
  21. package/dist/{chunk-QEWCYW3I.js → chunk-WAMZWZJB.js} +13 -3
  22. package/dist/chunk-WAMZWZJB.js.map +1 -0
  23. package/dist/cli.js +3 -3
  24. package/dist/components/index.d.ts +1592 -100
  25. package/dist/components/index.js +7 -4
  26. package/dist/{dev-DCTOHQ6W.js → dev-4XEDTELV.js} +4 -4
  27. package/dist/{dev-DCTOHQ6W.js.map → dev-4XEDTELV.js.map} +1 -1
  28. package/dist/doc/index.d.ts +194 -2
  29. package/dist/doc/index.js +336 -5
  30. package/dist/doc/index.js.map +1 -1
  31. package/dist/format-U0cQl069.d.ts +243 -0
  32. package/dist/markdown/index.js +1 -1
  33. package/dist/markdown/index.js.map +1 -1
  34. package/dist/slide/index.d.ts +177 -1
  35. package/dist/slide/index.js +866 -3
  36. package/dist/slide/index.js.map +1 -1
  37. package/dist/table/index.js +9 -6
  38. package/dist/table/index.js.map +1 -1
  39. package/dist/ui/index.d.ts +8 -2
  40. package/dist/ui/index.js +22 -112
  41. package/dist/ui/index.js.map +1 -1
  42. package/dist/{upgrade-DBBE6GIR.js → upgrade-JHXZBGQS.js} +3 -3
  43. package/dist/{upgrade-DBBE6GIR.js.map → upgrade-JHXZBGQS.js.map} +1 -1
  44. package/dist/vite/index.js +2 -2
  45. package/docs/en/components.md +40 -17
  46. package/docs/en/doc/doc-action-items.md +49 -0
  47. package/docs/en/doc/doc-callout.md +38 -0
  48. package/docs/en/doc/doc-data-table.md +60 -0
  49. package/docs/en/doc/doc-executive-summary.md +64 -0
  50. package/docs/en/doc/doc-figure.md +56 -0
  51. package/docs/en/doc/doc-key-findings.md +61 -0
  52. package/docs/en/doc/doc-kpi-scorecard.md +80 -0
  53. package/docs/en/doc/doc-report-header.md +46 -0
  54. package/docs/en/parts/area-chart.md +99 -0
  55. package/docs/en/parts/bar-chart.md +91 -0
  56. package/docs/en/parts/bar-list.md +50 -0
  57. package/docs/en/parts/category-bar.md +37 -0
  58. package/docs/en/parts/chart-frame.md +39 -0
  59. package/docs/en/parts/data-table.md +22 -0
  60. package/docs/en/parts/delta.md +50 -0
  61. package/docs/en/parts/donut-chart.md +72 -0
  62. package/docs/en/parts/format.md +43 -0
  63. package/docs/en/parts/funnel-steps.md +2 -1
  64. package/docs/en/parts/heatmap.md +53 -0
  65. package/docs/en/parts/kpi-card.md +57 -0
  66. package/docs/en/parts/legend.md +35 -0
  67. package/docs/en/parts/line-chart.md +94 -0
  68. package/docs/en/parts/metric-value.md +3 -2
  69. package/docs/en/parts/progress-circle.md +33 -0
  70. package/docs/en/parts/sample-data.md +39 -0
  71. package/docs/en/parts/scatter-chart.md +56 -0
  72. package/docs/en/parts/sparkline.md +1 -1
  73. package/docs/en/parts/tracker.md +36 -0
  74. package/docs/en/parts/trend-indicator.md +17 -1
  75. package/docs/en/parts/updated-at.md +30 -0
  76. package/docs/en/parts/waterfall-chart.md +56 -0
  77. package/docs/en/slide/slide-chart-insight.md +52 -0
  78. package/docs/en/slide/slide-cover.md +39 -0
  79. package/docs/en/slide/slide-exec-summary.md +47 -0
  80. package/docs/en/slide/slide-kpi-grid.md +55 -0
  81. package/docs/en/slide/slide-kpi-hero.md +58 -0
  82. package/docs/en/slide/slide-next-actions.md +49 -0
  83. package/docs/en/slide/slide-scorecard-table.md +54 -0
  84. package/docs/en/ui/alert-dialog.md +42 -0
  85. package/docs/en/ui/avatar.md +31 -0
  86. package/docs/en/ui/combobox.md +74 -0
  87. package/docs/en/ui/drawer.md +41 -0
  88. package/docs/en/ui/empty.md +1 -1
  89. package/docs/en/ui/error-state.md +4 -3
  90. package/docs/en/ui/field.md +42 -0
  91. package/docs/en/ui/hover-card.md +21 -0
  92. package/docs/en/ui/kbd.md +17 -0
  93. package/docs/en/ui/loading.md +1 -1
  94. package/docs/en/ui/pagination.md +36 -0
  95. package/docs/en/ui/radio-group.md +21 -0
  96. package/docs/en/ui/toast.md +44 -0
  97. package/docs/en/ui/toggle-group.md +4 -3
  98. package/docs/en/ui-and-theming.md +1 -1
  99. package/docs/index.json +753 -73
  100. package/docs/ja/components.md +37 -16
  101. package/docs/ja/doc/doc-action-items.md +49 -0
  102. package/docs/ja/doc/doc-callout.md +38 -0
  103. package/docs/ja/doc/doc-data-table.md +60 -0
  104. package/docs/ja/doc/doc-executive-summary.md +64 -0
  105. package/docs/ja/doc/doc-figure.md +56 -0
  106. package/docs/ja/doc/doc-key-findings.md +61 -0
  107. package/docs/ja/doc/doc-kpi-scorecard.md +80 -0
  108. package/docs/ja/doc/doc-report-header.md +46 -0
  109. package/docs/ja/parts/area-chart.md +49 -25
  110. package/docs/ja/parts/bar-chart.md +50 -24
  111. package/docs/ja/parts/bar-list.md +31 -8
  112. package/docs/ja/parts/chart-frame.md +36 -0
  113. package/docs/ja/parts/data-table.md +22 -0
  114. package/docs/ja/parts/delta.md +50 -0
  115. package/docs/ja/parts/donut-chart.md +45 -26
  116. package/docs/ja/parts/format.md +44 -0
  117. package/docs/ja/parts/funnel-steps.md +27 -8
  118. package/docs/ja/parts/heatmap.md +45 -0
  119. package/docs/ja/parts/kpi-card.md +55 -0
  120. package/docs/ja/parts/legend.md +36 -0
  121. package/docs/ja/parts/line-chart.md +48 -22
  122. package/docs/ja/parts/metric-value.md +3 -2
  123. package/docs/ja/parts/sample-data.md +61 -0
  124. package/docs/ja/parts/scatter-chart.md +69 -0
  125. package/docs/ja/parts/sparkline.md +1 -1
  126. package/docs/ja/parts/trend-indicator.md +17 -1
  127. package/docs/ja/parts/updated-at.md +27 -0
  128. package/docs/ja/parts/waterfall-chart.md +61 -0
  129. package/docs/ja/slide/slide-chart-insight.md +52 -0
  130. package/docs/ja/slide/slide-cover.md +39 -0
  131. package/docs/ja/slide/slide-exec-summary.md +47 -0
  132. package/docs/ja/slide/slide-kpi-grid.md +55 -0
  133. package/docs/ja/slide/slide-kpi-hero.md +58 -0
  134. package/docs/ja/slide/slide-next-actions.md +49 -0
  135. package/docs/ja/slide/slide-scorecard-table.md +54 -0
  136. package/docs/ja/ui/empty.md +1 -1
  137. package/docs/ja/ui/error-state.md +4 -3
  138. package/docs/ja/ui/loading.md +1 -1
  139. package/docs/ja/ui/toggle-group.md +4 -3
  140. package/docs/ja/ui-and-theming.md +2 -2
  141. package/package.json +26 -24
  142. package/registry/ui/accordion.tsx +4 -2
  143. package/registry/ui/alert-dialog.tsx +3 -3
  144. package/registry/ui/alert.tsx +3 -1
  145. package/registry/ui/app-shell.tsx +7 -1
  146. package/registry/ui/area-chart.tsx +2852 -166
  147. package/registry/ui/attention-list.tsx +303 -0
  148. package/registry/ui/avatar.tsx +1 -1
  149. package/registry/ui/badge.tsx +3 -1
  150. package/registry/ui/bar-chart.tsx +2851 -166
  151. package/registry/ui/bar-list.tsx +3531 -90
  152. package/registry/ui/box-plot-chart.tsx +3906 -0
  153. package/registry/ui/breadcrumb.tsx +3 -1
  154. package/registry/ui/button.tsx +3 -1
  155. package/registry/ui/calendar.tsx +3 -1
  156. package/registry/ui/card.tsx +4 -2
  157. package/registry/ui/category-bar.tsx +1025 -16
  158. package/registry/ui/chart-frame.tsx +1393 -0
  159. package/registry/ui/checkbox.tsx +3 -1
  160. package/registry/ui/collapsible.tsx +3 -1
  161. package/registry/ui/combobox.tsx +2 -2
  162. package/registry/ui/command.tsx +3 -1
  163. package/registry/ui/dashboard-card.tsx +71 -28
  164. package/registry/ui/data-table.tsx +678 -56
  165. package/registry/ui/date-range-picker.tsx +245 -64
  166. package/registry/ui/delta.tsx +188 -0
  167. package/registry/ui/dialog.tsx +5 -3
  168. package/registry/ui/donut-chart.tsx +2929 -182
  169. package/registry/ui/drawer.tsx +1 -1
  170. package/registry/ui/dropdown-menu.tsx +5 -3
  171. package/registry/ui/echart.tsx +621 -67
  172. package/registry/ui/field.tsx +1 -1
  173. package/registry/ui/filter-bar.tsx +33 -6
  174. package/registry/ui/funnel-steps.tsx +3344 -49
  175. package/registry/ui/heatmap.tsx +1786 -0
  176. package/registry/ui/histogram-chart.tsx +3986 -0
  177. package/registry/ui/hover-card.tsx +2 -2
  178. package/registry/ui/input-group.tsx +3 -1
  179. package/registry/ui/input.tsx +3 -1
  180. package/registry/ui/kbd.tsx +3 -1
  181. package/registry/ui/kpi-card.tsx +699 -0
  182. package/registry/ui/label.tsx +3 -1
  183. package/registry/ui/legend.tsx +3563 -0
  184. package/registry/ui/line-chart.tsx +2852 -160
  185. package/registry/ui/metric-value.tsx +9 -1
  186. package/registry/ui/multi-select.tsx +11 -5
  187. package/registry/ui/page-shell.tsx +106 -51
  188. package/registry/ui/pagination.tsx +3 -1
  189. package/registry/ui/paired-chart.tsx +3827 -0
  190. package/registry/ui/placeholder.tsx +8 -2
  191. package/registry/ui/popover.tsx +4 -2
  192. package/registry/ui/progress-circle.tsx +607 -12
  193. package/registry/ui/progress.tsx +3 -1
  194. package/registry/ui/radio-group.tsx +1 -1
  195. package/registry/ui/sample-data.tsx +86 -0
  196. package/registry/ui/scatter-chart.tsx +4212 -0
  197. package/registry/ui/scroll-area.tsx +3 -2
  198. package/registry/ui/searchable-select.tsx +8 -3
  199. package/registry/ui/section-header.tsx +5 -3
  200. package/registry/ui/segmented-control.tsx +7 -2
  201. package/registry/ui/select.tsx +4 -2
  202. package/registry/ui/separator.tsx +3 -1
  203. package/registry/ui/sheet.tsx +4 -2
  204. package/registry/ui/sidebar.tsx +4 -2
  205. package/registry/ui/skeleton.tsx +4 -2
  206. package/registry/ui/slider.tsx +4 -2
  207. package/registry/ui/sparkline.tsx +84 -9
  208. package/registry/ui/spinner.tsx +3 -1
  209. package/registry/ui/states.tsx +34 -5
  210. package/registry/ui/status-badge.tsx +83 -41
  211. package/registry/ui/switch.tsx +3 -1
  212. package/registry/ui/table.tsx +4 -2
  213. package/registry/ui/tabs.tsx +5 -3
  214. package/registry/ui/textarea.tsx +3 -1
  215. package/registry/ui/toast.tsx +2 -2
  216. package/registry/ui/toggle-group.tsx +3 -1
  217. package/registry/ui/toggle.tsx +3 -1
  218. package/registry/ui/tooltip.tsx +10 -4
  219. package/registry/ui/tracker.tsx +632 -19
  220. package/registry/ui/trend-indicator.tsx +15 -3
  221. package/registry/ui/updated-at.tsx +122 -0
  222. package/registry/ui/waterfall-chart.tsx +4248 -0
  223. package/skills/vantage-design/SKILL.md +34 -24
  224. package/skills/vantage-design/references/extract.md +16 -14
  225. package/skills/vantage-design/references/format.md +6 -5
  226. package/skills/vantage-design/references/presets.md +60 -110
  227. package/skills/vantage-slide/SKILL.md +54 -36
  228. package/templates/AGENTS.md +15 -3
  229. package/templates/agents/doc.md +45 -0
  230. package/templates/agents/page.md +32 -1
  231. package/templates/agents/slide.md +55 -2
  232. package/theme.css +244 -149
  233. package/dist/chunk-5ZOP4SQR.js.map +0 -1
  234. package/dist/chunk-A2UUGASH.js.map +0 -1
  235. package/dist/chunk-AG6RZ2RR.js +0 -3208
  236. package/dist/chunk-AG6RZ2RR.js.map +0 -1
  237. package/dist/chunk-IBGOCWJB.js.map +0 -1
  238. package/dist/chunk-OB6UBJXF.js.map +0 -1
  239. package/dist/chunk-PXRX5PEC.js.map +0 -1
  240. package/dist/chunk-QEWCYW3I.js.map +0 -1
@@ -1,5 +1,7 @@
1
1
  // Copied by `vantage add ui echart`. This file is yours to edit.
2
- // Generated from @squadbase/vantage src/components/echart.tsx by `npm run registry` (in Vantage, edit src/, not registry/).
2
+ // Generated from @squadbase/squadkit registry/charts/echart.tsx by `pnpm run registry` (in Vantage, edit packages/squadkit, not registry/).
3
+ "use client"
4
+
3
5
  /**
4
6
  * EChart — a thin Apache ECharts wrapper. It owns the canvas lifecycle (init,
5
7
  * resize, dispose), forwards `option` and event handlers, drives the loading
@@ -11,16 +13,71 @@
11
13
  * have changed — a light/dark flip, or a stylesheet swap such as editing
12
14
  * `styles.css` while the dev server is running. `theme` opts out entirely;
13
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])
14
42
  */
15
- import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react"
16
- import * as echarts from "echarts"
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"
17
55
  import type { ECharts, EChartsOption } from "echarts"
18
56
  import { Check, Copy, Download } from "lucide-react"
19
57
 
20
58
  import { cn } from "@squadbase/vantage/ui"
21
59
  import { Button } from "@squadbase/vantage/ui"
60
+ import type { ChartActionsOptions } from "@squadbase/vantage/components"
61
+ import { createContext, type ForwardedRef, type RefObject } 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) ---------- */
22
65
 
23
- /* ---------- inlined from src/components/chart-theme.ts (internal to @squadbase/vantage) ---------- */
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)
24
81
 
25
82
  /** Series palette, in order. Missing tokens are skipped, not defaulted. */
26
83
  const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"] as const
@@ -28,6 +85,8 @@ const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--ch
28
85
  interface ChartTokens {
29
86
  /** Resolved `--chart-1..5`, in order. */
30
87
  series: string[]
88
+ /** Computed `font-family` of the container, so canvas text matches the DOM. Optional. */
89
+ fontFamily?: string
31
90
  /** Body text: titles and the default `textStyle`. */
32
91
  foreground: string | null
33
92
  /** Secondary text: axis labels, legend, subtitles. */
@@ -36,10 +95,23 @@ interface ChartTokens {
36
95
  border: string | null
37
96
  /** Tooltip surface. */
38
97
  popover: string | null
98
+ /** Semantic and surface dependencies used by typed chart options. Optional for hand-built tokens. */
99
+ colors?: Record<string, string>
100
+ /** Actual inherited painting surface (may change without a token changing). */
101
+ surface?: string | null
39
102
  /** Tooltip text. */
40
103
  popoverForeground: string | null
41
104
  }
42
105
 
106
+ // Keep scope out of the public token shape. A resolved snapshot remembers where arbitrary
107
+ // var(--token) expressions must be read, without retaining unmounted charts globally.
108
+ const tokenScopes = new WeakMap<ChartTokens, Element>()
109
+ const OPTION_COLOR_TOKENS = [
110
+ "--background", "--card", "--muted", "--primary", "--destructive",
111
+ ...["positive", "negative", "warning", "neutral", "info"].flatMap((tone) =>
112
+ ["", "-fill", "-subtle"].map((suffix) => `--${tone}${suffix}`)),
113
+ ]
114
+
43
115
  let normalizeContext: CanvasRenderingContext2D | null | undefined
44
116
 
45
117
  /** The forms `fillStyle` serializes legacy colours to, and zrender can parse. */
@@ -95,14 +167,19 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
95
167
  const color = read(token)
96
168
  if (color) series.push(color)
97
169
  }
98
- return {
170
+ const tokens: ChartTokens = {
99
171
  series,
172
+ colors: Object.fromEntries(OPTION_COLOR_TOKENS.map((name) => [name, styles.getPropertyValue(name).trim()])),
173
+ surface: readChartSurface(target),
174
+ ...(styles.fontFamily ? { fontFamily: styles.fontFamily } : {}),
100
175
  foreground: read("--foreground"),
101
176
  mutedForeground: read("--muted-foreground"),
102
177
  border: read("--border"),
103
178
  popover: read("--popover"),
104
179
  popoverForeground: read("--popover-foreground"),
105
180
  }
181
+ tokenScopes.set(tokens, target)
182
+ return tokens
106
183
  }
107
184
 
108
185
  /** Structural equality, so a theme toggle that changes nothing doesn't re-init. */
@@ -110,8 +187,13 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
110
187
  if (a === b) return true
111
188
  if (!a || !b) return false
112
189
  return (
190
+ tokenScopes.get(a) === tokenScopes.get(b) &&
191
+ a.surface === b.surface &&
192
+ Object.keys(a.colors ?? {}).length === Object.keys(b.colors ?? {}).length &&
193
+ Object.entries(a.colors ?? {}).every(([name, value]) => b.colors?.[name] === value) &&
113
194
  a.series.length === b.series.length &&
114
195
  a.series.every((color, i) => color === b.series[i]) &&
196
+ a.fontFamily === b.fontFamily &&
115
197
  a.foreground === b.foreground &&
116
198
  a.mutedForeground === b.mutedForeground &&
117
199
  a.border === b.border &&
@@ -134,7 +216,7 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
134
216
  * attributes alone leaves the chart painted in the previous palette until a
135
217
  * reload — the tokens in the DOM are new, the canvas is not.
136
218
  */
137
- function watchChartTokens(onChange: () => void): () => void {
219
+ function watchChartTokens(onChange: () => void, scope?: Element | null): () => void {
138
220
  if (typeof document === "undefined") return () => {}
139
221
  // Style edits arrive as bursts of mutations; coalesce them into one read, and
140
222
  // take it after the next frame so a swapped stylesheet has been applied.
@@ -149,8 +231,11 @@ function watchChartTokens(onChange: () => void): () => void {
149
231
 
150
232
  const themeObserver = new MutationObserver(schedule)
151
233
  const attributeFilter = ["class", "style", "data-theme"]
152
- themeObserver.observe(document.documentElement, { attributes: true, attributeFilter })
153
- if (document.body) themeObserver.observe(document.body, { attributes: true, attributeFilter })
234
+ // Only the chart and its ancestors can change its inherited theme. Never observe
235
+ // the document subtree: canvas/tooltip/rendering mutations would trigger every chart.
236
+ for (let node: Element | null = scope ?? document.body ?? document.documentElement; node; node = node.parentElement) {
237
+ themeObserver.observe(node, { attributes: true, attributeFilter })
238
+ }
154
239
 
155
240
  const styleObserver = new MutationObserver(schedule)
156
241
  if (document.head) {
@@ -185,10 +270,11 @@ function watchChartTokens(onChange: () => void): () => void {
185
270
  function axisTheme(tokens: ChartTokens, splitLine: boolean) {
186
271
  const line = tokens.border ?? undefined
187
272
  return {
188
- axisLine: { show: true, lineStyle: { color: line } },
189
- axisTick: { show: true, lineStyle: { color: line } },
190
- axisLabel: { show: true, color: tokens.mutedForeground ?? undefined },
191
- splitLine: { show: splitLine, lineStyle: { color: line ? [line] : undefined } },
273
+ axisLine: { show: false },
274
+ axisTick: { show: false },
275
+ axisLabel: { show: true, color: tokens.mutedForeground ?? undefined, fontSize: 12 },
276
+ // Faint horizontal rules only: the border colour at low opacity.
277
+ splitLine: { show: splitLine, lineStyle: { color: line ? [line] : undefined, opacity: 0.5, width: 1 } },
192
278
  splitArea: { show: false },
193
279
  }
194
280
  }
@@ -205,7 +291,7 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
205
291
  // Let the page background show through: charts sit on cards, not on a canvas.
206
292
  backgroundColor: "transparent",
207
293
  ...(tokens.series.length > 0 ? { color: tokens.series } : {}),
208
- textStyle: { color: text },
294
+ textStyle: { color: text, ...(tokens.fontFamily ? { fontFamily: tokens.fontFamily } : {}) },
209
295
  title: { textStyle: { color: text }, subtextStyle: { color: muted } },
210
296
  legend: { textStyle: { color: muted } },
211
297
  categoryAxis: axisTheme(tokens, false),
@@ -213,10 +299,19 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
213
299
  logAxis: axisTheme(tokens, true),
214
300
  timeAxis: axisTheme(tokens, true),
215
301
  tooltip: {
302
+ // ECharts' default 0.4s transform lags the tooltip ~300ms behind the cursor.
303
+ transitionDuration: 0,
216
304
  backgroundColor: tokens.popover ?? undefined,
217
305
  borderColor: line,
218
306
  borderWidth: 1,
219
- textStyle: { color: tokens.popoverForeground ?? undefined },
307
+ padding: [8, 12],
308
+ // Rounded card with a soft shadow; in dark mode the shadow barely shows, so the border carries the edge.
309
+ extraCssText: "border-radius:8px;box-shadow:var(--shadow-card, 0 4px 12px rgb(0 0 0 / 0.08));",
310
+ textStyle: {
311
+ color: tokens.popoverForeground ?? undefined,
312
+ fontSize: 12,
313
+ ...(tokens.fontFamily ? { fontFamily: tokens.fontFamily } : {}),
314
+ },
220
315
  axisPointer: { lineStyle: { color: line }, crossStyle: { color: line } },
221
316
  },
222
317
  }
@@ -228,27 +323,35 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
228
323
  * components that build an ECharts option from token colours themselves (the
229
324
  * typed charts), so their palette follows light/dark like `EChart`'s theme.
230
325
  */
231
- function useChartTokens(): ChartTokens | null {
326
+ function useChartTokens(scope?: Element | null | RefObject<Element | null>): ChartTokens | null {
232
327
  const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
328
+ // Compared here, not inside a `setTokens` updater: an updater that reads the DOM is
329
+ // replayed by React on every rebase and returns a fresh object each time, so the state
330
+ // never settles ("Maximum update depth exceeded" once the tokens change after mount).
331
+ const tokensRef = useRef(tokens)
233
332
  useEffect(() => {
333
+ const target = scope && "current" in scope ? scope.current : scope
234
334
  const sync = () => {
235
- setTokens((prev) => {
236
- const next = readChartTokens()
237
- return sameChartTokens(prev, next) ? prev : next
238
- })
335
+ const next = readChartTokens(target)
336
+ if (sameChartTokens(tokensRef.current, next)) return
337
+ tokensRef.current = next
338
+ setTokens(next)
239
339
  }
240
340
  sync()
241
- return watchChartTokens(sync)
242
- }, [])
341
+ return watchChartTokens(sync, target)
342
+ }, [scope])
243
343
  return tokens
244
344
  }
245
345
 
246
346
  const SERIES_TOKEN_NAME_RE = /^chart-([1-5])$/
347
+ const TONE_NAME_RE = /^(?:positive|warning|negative|neutral|info)(?:-fill)?$/
247
348
  const CSS_VAR_RE = /^var\(\s*(--[\w-]+)\s*(?:,\s*(.+))?\)$/
349
+ const EMBEDDED_VAR_RE = /var\(\s*(--[\w-]+)\s*\)/g
248
350
 
249
351
  /**
250
352
  * Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
251
- * the resolved series tokens; `var(--token)` is read off the document root; any
353
+ * the resolved series tokens; `"positive"` / `"warning"` / `"negative"` / `"neutral"` (and the area
354
+ * variants `"positive-fill"`, …) are read in the snapshot's inherited scope (`--positive`, …); `var(--token)` uses the same scope; any
252
355
  * other CSS colour is normalized like the tokens are. A colour that can't be
253
356
  * resolved comes back as `""`, which ECharts treats as "use the palette".
254
357
  */
@@ -256,19 +359,122 @@ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | nul
256
359
  return colors.map((color) => {
257
360
  const token = SERIES_TOKEN_NAME_RE.exec(color.trim())
258
361
  if (token) return tokens?.series[Number(token[1]) - 1] ?? ""
362
+ // Meaning colours are the theme tokens of the same name: same path as `var(--positive)`.
363
+ if (TONE_NAME_RE.test(color.trim())) {
364
+ if (typeof document === "undefined") return ""
365
+ const target = (tokens && tokenScopes.get(tokens)) ?? document.documentElement
366
+ return toCanvasColor(getComputedStyle(target).getPropertyValue(`--${color.trim()}`)) ?? ""
367
+ }
259
368
  if (typeof document === "undefined") return color
260
369
  let value = color.trim()
261
370
  const cssVar = CSS_VAR_RE.exec(value)
371
+ const styles = getComputedStyle((tokens && tokenScopes.get(tokens)) ?? document.documentElement)
262
372
  if (cssVar) {
263
- const styles = getComputedStyle(document.documentElement)
264
373
  value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
374
+ } else if (value.includes("var(")) {
375
+ // A colour function over tokens (`color-mix(… var(--muted-foreground) …)`):
376
+ // substitute the tokens, then let the canvas resolve the rest.
377
+ value = value.replace(EMBEDDED_VAR_RE, (_, name: string) => styles.getPropertyValue(name).trim())
265
378
  }
266
379
  return toCanvasColor(value) ?? ""
267
380
  })
268
381
  }
269
382
 
383
+ /** Every meaning colour resolved for the canvas (`""` when it can't be resolved). */
384
+ function resolveToneColors(tokens: ChartTokens | null): Record<ChartTone, string> {
385
+ const [positive = "", warning = "", negative = "", neutral = ""] = resolveChartColors(
386
+ ["positive", "warning", "negative", "neutral"],
387
+ tokens,
388
+ )
389
+ return { positive, warning, negative, neutral }
390
+ }
391
+
392
+ /** The area (`--<tone>-fill`) colours of every meaning tone, resolved for the canvas (`""` when it can't be resolved). */
393
+ function resolveFillColors(tokens: ChartTokens | null): Record<ChartTone, string> {
394
+ const [positive = "", warning = "", negative = "", neutral = ""] = resolveChartColors(
395
+ ["positive-fill", "warning-fill", "negative-fill", "neutral-fill"],
396
+ tokens,
397
+ )
398
+ return { positive, warning, negative, neutral }
399
+ }
400
+
401
+ const TRANSPARENT_RE = /^(?:transparent|rgba\([^)]*,\s*0(?:\.0+)?\))$/i
402
+
403
+ /**
404
+ * The colour the chart is painted on: the first opaque `background-color` up
405
+ * from `el` (a card, the page), else `--background`. Canvas-safe, or `null`
406
+ * outside the browser.
407
+ */
408
+ function readChartSurface(el: Element | null): string | null {
409
+ if (typeof document === "undefined") return null
410
+ for (let node: Element | null = el; node; node = node.parentElement) {
411
+ const bg = getComputedStyle(node).backgroundColor.trim()
412
+ if (bg && !TRANSPARENT_RE.test(bg)) {
413
+ const color = toCanvasColor(bg)
414
+ if (color) return color
415
+ }
416
+ }
417
+ return toCanvasColor(getComputedStyle(el ?? document.documentElement).getPropertyValue("--background"))
418
+ }
419
+
420
+ /** Widths are bucketed so a resize only rebuilds the option when the label budget can change. */
421
+ const WIDTH_BUCKET = 40
422
+
423
+ /**
424
+ * The surface colour behind a chart (see `readChartSurface`), re-read when the
425
+ * tokens move (light/dark), and the plot's width (rounded down to 40px;
426
+ * `undefined` until measured). Returns a ref for the plot that also feeds the
427
+ * component's forwarded ref.
428
+ */
429
+ function useChartSurface<E extends Element>(
430
+ forwarded: ForwardedRef<E>,
431
+ tokens?: ChartTokens | null,
432
+ ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined; tokens: ChartTokens | null } {
433
+ const [node, setNode] = useState<E | null>(null)
434
+ const scopedTokens = useChartTokens(node)
435
+ const [surface, setSurface] = useState<string | undefined>(undefined)
436
+ const [width, setWidth] = useState<number | undefined>(undefined)
437
+ const ref = useCallback(
438
+ (el: E | null) => {
439
+ setNode(el)
440
+ if (typeof forwarded === "function") forwarded(el)
441
+ else if (forwarded) forwarded.current = el
442
+ },
443
+ [forwarded],
444
+ )
445
+ useEffect(() => {
446
+ if (!node) return
447
+ setSurface(readChartSurface(node) ?? undefined)
448
+ }, [node, tokens, scopedTokens])
449
+ useEffect(() => {
450
+ if (!node || typeof ResizeObserver === "undefined") return
451
+ const update = (w: number) => setWidth(Math.floor(w / WIDTH_BUCKET) * WIDTH_BUCKET)
452
+ update(node.getBoundingClientRect().width)
453
+ const observer = new ResizeObserver((entries) => update(entries[0]?.contentRect.width ?? 0))
454
+ observer.observe(node)
455
+ return () => observer.disconnect()
456
+ }, [node])
457
+ return { ref, surface, width, tokens: scopedTokens }
458
+ }
459
+
270
460
  /* ---------- end of inlined code ---------- */
271
461
 
462
+ // What the typed charts (Bar / Line / Area / Donut) emit. Other series and
463
+ // components are the consumer's to `use()` — see the note at the top.
464
+ use([
465
+ BarChart,
466
+ CustomChart,
467
+ LineChart,
468
+ PieChart,
469
+ GridComponent,
470
+ LegendComponent,
471
+ MarkAreaComponent,
472
+ MarkLineComponent,
473
+ MarkPointComponent,
474
+ TooltipComponent,
475
+ CanvasRenderer,
476
+ ])
477
+
272
478
  export interface EChartProps {
273
479
  /**
274
480
  * ECharts のオプション。
@@ -278,7 +484,7 @@ export interface EChartProps {
278
484
  * literal が `string` に拡張され、ビルドが失敗する。
279
485
  *
280
486
  * @example
281
- * import type { EChartsOption } from "@squadbase/vantage/components"
487
+ * import type { EChartsOption } from "@/components/echart"
282
488
  *
283
489
  * const option: EChartsOption = {
284
490
  * xAxis: { type: "category", data: ["A", "B"] },
@@ -309,14 +515,327 @@ export interface EChartProps {
309
515
  onEvents?: Record<string, (params: unknown) => void>
310
516
  ariaLabel?: string
311
517
  className?: string
312
- /** true にするとチャート右上に PNG コピー / ダウンロードボタンを表示する */
313
- actions?: boolean
518
+ /**
519
+ * true にするとチャート右上に PNG コピー / ダウンロードボタンを表示する。
520
+ * `exportHeader` があれば PNG の上にその見出しを含める(`{ includeTitle: false }` で含めない)。
521
+ */
522
+ actions?: boolean | ChartActionsOptions
314
523
  /** ダウンロード時のファイル名(拡張子含む)。省略時は "chart.png" */
315
524
  fileName?: string
525
+ /**
526
+ * PNG の上に含める見出しと要約。型付きグラフは自分の `title` / `summary` を渡す。
527
+ * 自分で `ChartFrame` を組むときも同じものを渡すと、保存した画像だけで何のグラフか分かる。
528
+ */
529
+ exportHeader?: ChartExportHeader
530
+ }
531
+
532
+ /** The heading a saved PNG carries above the plot. */
533
+ export interface ChartExportHeader {
534
+ title?: string
535
+ summary?: string
536
+ }
537
+
538
+ const HEADER_PAD_X = 16
539
+ const HEADER_PAD_TOP = 16
540
+ const HEADER_PAD_BOTTOM = 12
541
+ const HEADER_LINE_HEIGHT = 20
542
+ const HEADER_GAP = 4
543
+ const HEADER_FONT_SIZE = 14
544
+
545
+ export interface ChartExportHeaderLine {
546
+ text: string
547
+ kind: "title" | "summary"
548
+ }
549
+
550
+ /**
551
+ * Break `text` into lines no wider than `maxWidth` (px, via `measure`), character
552
+ * by character so Japanese wraps without spaces. Always at least one line for
553
+ * non-empty text; a single over-wide character still gets its own line.
554
+ */
555
+ export function wrapChartText(text: string, maxWidth: number, measure: (text: string) => number): string[] {
556
+ const lines: string[] = []
557
+ let line = ""
558
+ for (const ch of text) {
559
+ if (ch === "\n") {
560
+ lines.push(line)
561
+ line = ""
562
+ continue
563
+ }
564
+ if (line !== "" && measure(line + ch) > maxWidth) {
565
+ lines.push(line)
566
+ line = ch.trim() === "" ? "" : ch
567
+ } else {
568
+ line += ch
569
+ }
570
+ }
571
+ if (line !== "" || lines.length === 0) lines.push(line)
572
+ return lines.filter((l, i) => l !== "" || i < lines.length - 1)
573
+ }
574
+
575
+ /**
576
+ * The lines and total height (CSS px) of the PNG's heading band for a plot
577
+ * `width` px wide. `measure` takes (text, kind) and returns its width in px.
578
+ * No title and no summary → no band (`height` 0).
579
+ */
580
+ export function chartExportHeaderLayout(
581
+ header: ChartExportHeader | undefined,
582
+ width: number,
583
+ measure: (text: string, kind: "title" | "summary") => number,
584
+ ): { lines: ChartExportHeaderLine[]; height: number } {
585
+ const title = header?.title?.trim()
586
+ const summary = header?.summary?.trim()
587
+ if (!title && !summary) return { lines: [], height: 0 }
588
+ const room = Math.max(40, width - HEADER_PAD_X * 2)
589
+ const lines: ChartExportHeaderLine[] = [
590
+ ...(title ? wrapChartText(title, room, (t) => measure(t, "title")).map((text) => ({ text, kind: "title" as const })) : []),
591
+ ...(summary ? wrapChartText(summary, room, (t) => measure(t, "summary")).map((text) => ({ text, kind: "summary" as const })) : []),
592
+ ]
593
+ const gap = title && summary ? HEADER_GAP : 0
594
+ return { lines, height: HEADER_PAD_TOP + lines.length * HEADER_LINE_HEIGHT + gap + HEADER_PAD_BOTTOM }
595
+ }
596
+
597
+ function loadImage(src: string): Promise<HTMLImageElement> {
598
+ return new Promise((resolve, reject) => {
599
+ const image = new Image()
600
+ image.onload = () => resolve(image)
601
+ image.onerror = () => reject(new Error("PNG の読み込みに失敗しました"))
602
+ image.src = src
603
+ })
604
+ }
605
+
606
+ /** Draw a heading band (title in foreground, summary muted) above the exported plot and return the new PNG. */
607
+ async function pngWithHeader(
608
+ dataUrl: string,
609
+ header: ChartExportHeader,
610
+ look: { background: string; color: string; mutedColor: string; fontFamily: string },
611
+ pixelRatio: number,
612
+ ): Promise<string> {
613
+ const image = await loadImage(dataUrl)
614
+ const width = image.width / pixelRatio
615
+ const plotHeight = image.height / pixelRatio
616
+ const canvas = document.createElement("canvas")
617
+ const ctx = canvas.getContext("2d")
618
+ if (!ctx) return dataUrl
619
+ const font = (kind: "title" | "summary") => `${kind === "title" ? 500 : 400} ${HEADER_FONT_SIZE}px ${look.fontFamily}`
620
+ ctx.font = font("title")
621
+ const layout = chartExportHeaderLayout(header, width, (text, kind) => {
622
+ ctx.font = font(kind)
623
+ return ctx.measureText(text).width
624
+ })
625
+ if (layout.height === 0) return dataUrl
626
+ canvas.width = Math.round(image.width)
627
+ canvas.height = Math.round((layout.height + plotHeight) * pixelRatio)
628
+ ctx.scale(pixelRatio, pixelRatio)
629
+ ctx.fillStyle = look.background
630
+ ctx.fillRect(0, 0, width, layout.height + plotHeight)
631
+ ctx.textBaseline = "middle"
632
+ let y = HEADER_PAD_TOP
633
+ let previous: "title" | "summary" | null = null
634
+ for (const line of layout.lines) {
635
+ if (previous === "title" && line.kind === "summary") y += HEADER_GAP
636
+ ctx.font = font(line.kind)
637
+ ctx.fillStyle = line.kind === "title" ? look.color : look.mutedColor
638
+ ctx.fillText(line.text, HEADER_PAD_X, y + HEADER_LINE_HEIGHT / 2)
639
+ y += HEADER_LINE_HEIGHT
640
+ previous = line.kind
641
+ }
642
+ ctx.drawImage(image, 0, layout.height, width, plotHeight)
643
+ return canvas.toDataURL("image/png")
644
+ }
645
+
646
+ /**
647
+ * Canvas-safe versions of theme colours, kept current across light/dark.
648
+ * Takes `"chart-1"`〜`"chart-5"`, `"var(--token)"` or any CSS colour; one that
649
+ * can't be resolved comes back as `""` (ECharts then uses the palette).
650
+ * Use it whenever a colour goes into a raw `EChart` option by hand.
651
+ *
652
+ * @example
653
+ * const [line, band] = useResolvedChartColors(["chart-1", "var(--muted-foreground)"])
654
+ */
655
+ export function useResolvedChartColors(colors: readonly string[]): string[] {
656
+ const tokens = useChartTokens()
657
+ const key = colors.join("\u0000")
658
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `key` stands for the array's contents
659
+ return useMemo(() => resolveChartColors(colors, tokens), [key, tokens])
316
660
  }
317
661
 
318
662
  type EChartsEventHandler = (params: unknown) => void
319
663
 
664
+ /**
665
+ * ECharts component main types. Listed in `replaceMerge`, a type keeps only the
666
+ * components whose `id` matches (merged, so they can transition) and drops the
667
+ * rest — including every one of them when the new option no longer has the type.
668
+ */
669
+ const COMPONENT_MAIN_TYPES = new Set([
670
+ "title", "legend", "grid", "xAxis", "yAxis", "polar", "radiusAxis", "angleAxis", "radar",
671
+ "dataZoom", "visualMap", "tooltip", "axisPointer", "toolbox", "brush", "geo", "parallel",
672
+ "parallelAxis", "singleAxis", "graphic", "calendar", "dataset", "series", "matrix", "thumbnail",
673
+ ])
674
+
675
+ export interface EChartSetOptionOpts {
676
+ notMerge: boolean
677
+ replaceMerge?: string[]
678
+ lazyUpdate: boolean
679
+ }
680
+
681
+ /**
682
+ * How to apply `next` after `prev`: the first option and timeline / media
683
+ * options replace everything; otherwise merge, replacing every component type
684
+ * either option uses so nothing stale survives.
685
+ */
686
+ export function echartSetOptionOpts(prev: EChartsOption | null, next: EChartsOption): EChartSetOptionOpts {
687
+ const special = (o: EChartsOption) => "timeline" in o || "baseOption" in o || "options" in o || "media" in o
688
+ if (!prev || special(prev) || special(next)) return { notMerge: true, lazyUpdate: true }
689
+ const types = new Set<string>()
690
+ for (const key of [...Object.keys(prev), ...Object.keys(next)]) if (COMPONENT_MAIN_TYPES.has(key)) types.add(key)
691
+ return { notMerge: false, replaceMerge: [...types], lazyUpdate: true }
692
+ }
693
+
694
+ /**
695
+ * Component types that get a generated `id` when the option has none, so replaceMerge can match them across updates.
696
+ * Not `dataset` / `graphic` / `tooltip`: they are rebuilt on every update (there is nothing to transition).
697
+ */
698
+ const ID_TYPES = [
699
+ "title", "legend", "grid", "xAxis", "yAxis", "polar", "radiusAxis", "angleAxis", "radar",
700
+ "dataZoom", "visualMap", "geo", "singleAxis", "calendar",
701
+ ] as const
702
+
703
+ /**
704
+ * What `withStableIds` remembers between updates: per generated id, the shape last applied and how many
705
+ * times it changed (the id's `~v<n>` suffix); per component type, the order of its ids.
706
+ */
707
+ export interface StableIdState {
708
+ ids: Map<string, { shape: string; version: number }>
709
+ order: Map<string, string[]>
710
+ }
711
+ export const createStableIdState = (): StableIdState => ({ ids: new Map(), order: new Map() })
712
+
713
+ /**
714
+ * Shape of a component: which keys it has, recursively (arrays only as "array", values not at all —
715
+ * except `type`). A merge keeps keys the new option dropped, so a changed shape must not be merged.
716
+ */
717
+ function shapeOf(v: unknown): string {
718
+ if (Array.isArray(v)) return "[]"
719
+ if (!v || typeof v !== "object" || Object.getPrototypeOf(v) !== Object.prototype) return "."
720
+ const o = v as Record<string, unknown>
721
+ return `{${Object.keys(o)
722
+ .filter((k) => o[k] !== undefined)
723
+ .sort()
724
+ .map((k) => (k === "type" || k === "id" ? `${k}=${String(o[k])}` : `${k}:${shapeOf(o[k])}`))
725
+ .join(",")}}`
726
+ }
727
+
728
+ /**
729
+ * Gives every series (and the common components) an `id` it does not have yet —
730
+ * series: `series:<name ?? index>`, others `<type>:<index>` — so an update that
731
+ * only changes values is matched to the existing component and transitions
732
+ * instead of being rebuilt. If the component's shape changed since `state` last
733
+ * saw it (a key was removed or added, the type changed), the id gets a new
734
+ * `~v<n>` suffix so replaceMerge builds it from scratch: the result then always
735
+ * equals applying the option with `notMerge`. Explicit ids are kept; the input is not mutated.
736
+ */
737
+ export function withStableIds(option: EChartsOption, state: StableIdState = createStableIdState()): EChartsOption {
738
+ const out: Record<string, unknown> = { ...(option as Record<string, unknown>) }
739
+ const taken = new Set<string>()
740
+ const list = (v: unknown): Record<string, unknown>[] | null =>
741
+ Array.isArray(v) ? (v as Record<string, unknown>[]) : v && typeof v === "object" ? [v as Record<string, unknown>] : null
742
+ const give = (key: string, pick: (c: Record<string, unknown>, i: number) => string) => {
743
+ const items = list(out[key])
744
+ if (!items) return
745
+ const used = new Set<string>(items.flatMap((c) => (c && c.id != null ? [String(c.id)] : [])))
746
+ const bases = items.map((c, i) => {
747
+ if (!c || typeof c !== "object" || c.id != null) return null
748
+ let base = pick(c, i)
749
+ for (let n = 2; used.has(base) || taken.has(`${key}\u0000${base}`); n++) base = `${pick(c, i)}#${n}`
750
+ used.add(base)
751
+ taken.add(`${key}\u0000${base}`)
752
+ return base
753
+ })
754
+ // A merge fills the empty slots a removal left and keeps the slot order of what exists, so after any
755
+ // change in which components there are (or their order) it would not match a fresh render: rebuild them all.
756
+ const prevOrder = state.order.get(key) ?? []
757
+ const cur = bases.filter((b): b is string => b !== null)
758
+ const inOrder = cur.length === prevOrder.length && cur.every((b, i) => b === prevOrder[i])
759
+ state.order.set(key, cur)
760
+ out[key] = items.map((c, i) => {
761
+ const base = bases[i]
762
+ if (base == null) return c
763
+ const shape = shapeOf(c)
764
+ const prev = state.ids.get(base)
765
+ const version = !prev ? 0 : prev.shape === shape && inOrder ? prev.version : prev.version + 1
766
+ state.ids.set(base, { shape, version })
767
+ return { ...c, id: version ? `${base}~v${version}` : base }
768
+ })
769
+ }
770
+ give("series", (c, i) => `series:${typeof c.name === "string" && c.name ? c.name : i}`)
771
+ for (const key of ID_TYPES) give(key, (_c, i) => `${key}:${i}`)
772
+ return out as EChartsOption
773
+ }
774
+
775
+ /** Copy of the plain objects / arrays of an option (functions, class instances, Dates stay shared), to compare against later in-place edits. */
776
+ export function snapshotOption<T>(v: T): T {
777
+ if (Array.isArray(v)) return v.map(snapshotOption) as T
778
+ if (v && typeof v === "object") {
779
+ const proto = Object.getPrototypeOf(v)
780
+ if (proto === Object.prototype || proto === null) {
781
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, snapshotOption(x)])) as T
782
+ }
783
+ }
784
+ return v
785
+ }
786
+
787
+ /** Structural equality for option trees; functions and other non-plain objects compare by reference. */
788
+ export function optionEquals(a: unknown, b: unknown): boolean {
789
+ if (Object.is(a, b)) return true
790
+ if (typeof a !== "object" || typeof b !== "object" || !a || !b) return false
791
+ if (Array.isArray(a) !== Array.isArray(b)) return false
792
+ if (Array.isArray(a)) {
793
+ const bb = b as unknown[]
794
+ return a.length === bb.length && a.every((v, i) => optionEquals(v, bb[i]))
795
+ }
796
+ if (a instanceof Date || b instanceof Date) return a instanceof Date && b instanceof Date && a.getTime() === b.getTime()
797
+ const pa = Object.getPrototypeOf(a)
798
+ if ((pa !== Object.prototype && pa !== null) || Object.getPrototypeOf(b) !== pa) return false
799
+ const ka = Object.keys(a)
800
+ const kb = Object.keys(b)
801
+ return ka.length === kb.length && ka.every((k) => Object.hasOwn(b, k) && optionEquals((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]))
802
+ }
803
+
804
+ /** `graphic` text doesn't inherit the global `textStyle`, so give it the inherited font unless it sets its own. */
805
+ function withGraphicFont(option: EChartsOption, fontFamily: string | undefined): EChartsOption {
806
+ const graphic = (option as { graphic?: unknown }).graphic
807
+ if (!fontFamily || !Array.isArray(graphic)) return option
808
+ return {
809
+ ...option,
810
+ graphic: graphic.map((g: { type?: string; style?: Record<string, unknown> }) =>
811
+ g && g.type === "text" && g.style && !g.style.fontFamily ? { ...g, style: { ...g.style, fontFamily } } : g,
812
+ ),
813
+ } as EChartsOption
814
+ }
815
+
816
+ /** `option` as applied: reduced motion forces `animation: false`; otherwise animation stays explicit so it can flip back. */
817
+ export function withMotionPreference(option: EChartsOption, reducedMotion: boolean): EChartsOption {
818
+ return { ...option, animation: reducedMotion ? false : (option.animation ?? true) }
819
+ }
820
+
821
+ const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"
822
+
823
+ /** `prefers-reduced-motion: reduce`, kept current. `false` on the server. */
824
+ export function usePrefersReducedMotion(): boolean {
825
+ const [reduced, setReduced] = useState(
826
+ () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia(REDUCED_MOTION_QUERY).matches,
827
+ )
828
+ useEffect(() => {
829
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return
830
+ const media = window.matchMedia(REDUCED_MOTION_QUERY)
831
+ const sync = () => setReduced(media.matches)
832
+ sync()
833
+ media.addEventListener("change", sync)
834
+ return () => media.removeEventListener("change", sync)
835
+ }, [])
836
+ return reduced
837
+ }
838
+
320
839
  export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
321
840
  {
322
841
  option,
@@ -328,29 +847,20 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
328
847
  className,
329
848
  actions = false,
330
849
  fileName = "chart.png",
850
+ exportHeader,
331
851
  },
332
852
  ref,
333
853
  ) {
334
854
  const containerRef = useRef<HTMLDivElement | null>(null)
335
855
  const [instance, setInstance] = useState<ECharts | null>(null)
336
856
  const [copied, setCopied] = useState(false)
337
- // 初回はレンダー前に読めるので、マウント直後の作り直しが起きない
338
- const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
339
-
340
- // 明暗の切り替えとスタイルシートの差し替え(dev で styles.css を書き換えたときなど)を
341
- // 拾ってトークンを読み直す ― 何を見張るかは `watchChartTokens` 側が持つ。値が変わら
342
- // ないときは同じオブジェクトを返して再 init を防ぐ。
343
- useEffect(() => {
344
- if (theme !== undefined) return
345
- const sync = () => {
346
- setTokens((prev) => {
347
- const next = readChartTokens()
348
- return sameChartTokens(prev, next) ? prev : next
349
- })
350
- }
351
- sync()
352
- return watchChartTokens(sync)
353
- }, [theme])
857
+ const tokens = useChartTokens(containerRef)
858
+ const reducedMotion = usePrefersReducedMotion()
859
+ // What the current instance last received, to pick the merge mode of the next update.
860
+ const appliedRef = useRef<EChartsOption | null>(null)
861
+ // The option (before ids) as it was when applied, deep-copied; and the shape history behind the generated ids.
862
+ const snapshotRef = useRef<EChartsOption | null>(null)
863
+ const idStateRef = useRef<StableIdState>(createStableIdState())
354
864
 
355
865
  const autoTheme = useMemo(() => (tokens ? buildChartTheme(tokens) : undefined), [tokens])
356
866
  const resolvedTheme = theme ?? autoTheme
@@ -360,7 +870,9 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
360
870
  useEffect(() => {
361
871
  const container = containerRef.current
362
872
  if (!container) return
363
- const inst = echarts.init(container, resolvedTheme, { renderer: "canvas" })
873
+ const inst = init(container, resolvedTheme, { renderer: "canvas" })
874
+ appliedRef.current = null
875
+ snapshotRef.current = null
364
876
  // `data-rendering` stays on until the (animated) paint is done, so anything
365
877
  // capturing the page — printing a document — can wait for real pixels.
366
878
  container.dataset.rendering = ""
@@ -369,29 +881,47 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
369
881
  })
370
882
  setInstance(inst)
371
883
 
372
- const observer = new ResizeObserver(() => inst.resize())
884
+ // At most one resize per frame (the sidebar animates the width).
885
+ let frame = 0
886
+ const observer = new ResizeObserver(() => {
887
+ cancelAnimationFrame(frame)
888
+ frame = requestAnimationFrame(() => {
889
+ if (!inst.isDisposed()) inst.resize()
890
+ })
891
+ })
373
892
  observer.observe(container)
374
893
 
375
894
  return () => {
376
895
  observer.disconnect()
896
+ cancelAnimationFrame(frame)
377
897
  inst.dispose()
378
898
  }
379
899
  }, [resolvedTheme])
380
900
 
381
901
  useEffect(() => {
382
- if (!instance) return
902
+ if (!instance || instance.isDisposed()) return
903
+ const prepared = withGraphicFont(
904
+ withMotionPreference(option, reducedMotion),
905
+ theme === undefined ? tokens?.fontFamily : undefined,
906
+ )
907
+ // Same content as last time (a re-render that built an equal option): nothing to redraw.
908
+ // Compared with a snapshot, so an in-place edit of a nested array of the caller's option still counts as a change.
909
+ if (snapshotRef.current && optionEquals(snapshotRef.current, prepared)) return
383
910
  if (containerRef.current) containerRef.current.dataset.rendering = ""
384
- instance.setOption(option, { notMerge: true, lazyUpdate: true })
385
- }, [instance, option])
911
+ const next = withStableIds(prepared, idStateRef.current)
912
+ instance.setOption(next, echartSetOptionOpts(appliedRef.current, next))
913
+ appliedRef.current = next
914
+ snapshotRef.current = snapshotOption(prepared)
915
+ }, [instance, option, reducedMotion, theme, tokens?.fontFamily])
386
916
 
387
917
  useEffect(() => {
388
- if (!instance) return
918
+ if (!instance || instance.isDisposed()) return
389
919
  if (loading) instance.showLoading()
390
920
  else instance.hideLoading()
391
921
  }, [instance, loading])
392
922
 
393
923
  useEffect(() => {
394
- if (!instance || !onEvents) return
924
+ if (!instance || instance.isDisposed() || !onEvents) return
395
925
  const entries = Object.entries(onEvents)
396
926
  for (const [event, handler] of entries) {
397
927
  instance.on(event, handler as EChartsEventHandler)
@@ -404,20 +934,38 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
404
934
  }
405
935
  }, [instance, onEvents])
406
936
 
407
- const getPngDataUrl = useCallback(() => {
937
+ const includeTitle = typeof actions === "object" ? (actions.includeTitle ?? true) : true
938
+ const getPngDataUrl = useCallback(async (): Promise<string | null> => {
408
939
  if (!instance) return null
409
- // Export over the page background so the PNG isn't transparent. Computed
410
- // styles resolve to rgb(a), so this stays canvas-safe whatever the token.
411
- const pageBg = getComputedStyle(document.documentElement).backgroundColor
412
- return instance.getDataURL({
413
- type: "png",
414
- pixelRatio: 2,
415
- backgroundColor: pageBg && pageBg !== "rgba(0, 0, 0, 0)" ? pageBg : "#ffffff",
416
- })
417
- }, [instance])
940
+ // Export over the nearest opaque background (card, then page) so the PNG
941
+ // matches what is on screen in dark too. Computed styles resolve to rgb(a),
942
+ // so this stays canvas-safe whatever the token.
943
+ let backgroundColor = "#ffffff"
944
+ for (let el: HTMLElement | null = containerRef.current; el; el = el.parentElement) {
945
+ const bg = getComputedStyle(el).backgroundColor
946
+ if (bg && bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent") {
947
+ backgroundColor = bg
948
+ break
949
+ }
950
+ }
951
+ const pixelRatio = 2
952
+ const dataUrl = instance.getDataURL({ type: "png", pixelRatio, backgroundColor })
953
+ if (!includeTitle || !exportHeader || !(exportHeader.title || exportHeader.summary) || !containerRef.current) return dataUrl
954
+ try {
955
+ const style = getComputedStyle(containerRef.current)
956
+ return await pngWithHeader(
957
+ dataUrl,
958
+ exportHeader,
959
+ { background: backgroundColor, color: style.color, mutedColor: tokens?.mutedForeground ?? style.color, fontFamily: style.fontFamily },
960
+ pixelRatio,
961
+ )
962
+ } catch {
963
+ return dataUrl
964
+ }
965
+ }, [instance, includeTitle, exportHeader, tokens])
418
966
 
419
967
  const handleCopy = useCallback(async () => {
420
- const dataUrl = getPngDataUrl()
968
+ const dataUrl = await getPngDataUrl()
421
969
  if (!dataUrl) return
422
970
  try {
423
971
  const blob = await (await fetch(dataUrl)).blob()
@@ -429,8 +977,8 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
429
977
  }
430
978
  }, [getPngDataUrl])
431
979
 
432
- const handleDownload = useCallback(() => {
433
- const dataUrl = getPngDataUrl()
980
+ const handleDownload = useCallback(async () => {
981
+ const dataUrl = await getPngDataUrl()
434
982
  if (!dataUrl) return
435
983
  const link = document.createElement("a")
436
984
  link.href = dataUrl
@@ -440,6 +988,7 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
440
988
 
441
989
  // 高さの解決: height prop を渡した場合はインライン style が勝つ。未指定なら
442
990
  // h-[400px] をデフォルトで当て、className の高さクラスに後勝ちさせる。
991
+ const inFrame = useContext(ChartFrameContext)
443
992
  const styleHeight =
444
993
  height == null ? undefined : typeof height === "number" ? `${height}px` : height
445
994
 
@@ -449,14 +998,19 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
449
998
  data-slot="echart"
450
999
  className={cn("group relative w-full", height == null && "h-[400px]", className)}
451
1000
  style={styleHeight != null ? { height: styleHeight } : undefined}
452
- role="img"
453
- aria-label={ariaLabel ?? "チャート"}
454
1001
  >
455
- <div ref={containerRef} data-slot="echart-canvas" className="h-full w-full" />
1002
+ {/* `role="img"` wraps the canvas only, so the action buttons stay reachable. Inside a ChartFrame the plot area is the one image. */}
1003
+ <div
1004
+ ref={containerRef}
1005
+ data-slot="echart-canvas"
1006
+ className="h-full w-full"
1007
+ role={inFrame ? undefined : "img"}
1008
+ aria-label={inFrame ? undefined : (ariaLabel ?? "チャート")}
1009
+ />
456
1010
  {actions && (
457
1011
  <div
458
1012
  data-slot="echart-actions"
459
- className="absolute top-2 right-2 z-10 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
1013
+ 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"
460
1014
  >
461
1015
  <Button
462
1016
  type="button"