@squadbase/vantage 0.6.2-stg.3a2ba98 → 0.7.0-dev1.275b4ed

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 (346) 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-U7GO26HR.js} +9 -9
  4. package/dist/{build-O5TVNH4E.js.map → build-U7GO26HR.js.map} +1 -1
  5. package/dist/{check-WFBEDHDR.js → check-MRVAIHG5.js} +7 -7
  6. package/dist/{check-WFBEDHDR.js.map → check-MRVAIHG5.js.map} +1 -1
  7. package/dist/chunk-2IAPIOPS.js +152 -0
  8. package/dist/chunk-2IAPIOPS.js.map +1 -0
  9. package/dist/chunk-3RJEOP3J.js +174 -0
  10. package/dist/chunk-3RJEOP3J.js.map +1 -0
  11. package/dist/chunk-77CISBMG.js +7581 -0
  12. package/dist/chunk-77CISBMG.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-PVTHHV7X.js → chunk-ANZLVSEP.js} +28 -7
  16. package/dist/chunk-ANZLVSEP.js.map +1 -0
  17. package/dist/chunk-CKWPK4RO.js +50 -0
  18. package/dist/chunk-CKWPK4RO.js.map +1 -0
  19. package/dist/chunk-EG4PX726.js +678 -0
  20. package/dist/chunk-EG4PX726.js.map +1 -0
  21. package/dist/{chunk-3G43C6OL.js → chunk-GNXCX4WE.js} +46 -3
  22. package/dist/chunk-GNXCX4WE.js.map +1 -0
  23. package/dist/chunk-NZ5QIIG4.js +74 -0
  24. package/dist/chunk-NZ5QIIG4.js.map +1 -0
  25. package/dist/{chunk-BCE22JSM.js → chunk-PJ4SPXHS.js} +52 -16
  26. package/dist/chunk-PJ4SPXHS.js.map +1 -0
  27. package/dist/{chunk-RPZAALCX.js → chunk-QNOX7WLZ.js} +256 -165
  28. package/dist/chunk-QNOX7WLZ.js.map +1 -0
  29. package/dist/{chunk-ZVANIULZ.js → chunk-RNTDBX6S.js} +4 -4
  30. package/dist/{chunk-ZVANIULZ.js.map → chunk-RNTDBX6S.js.map} +1 -1
  31. package/dist/{chunk-PXRX5PEC.js → chunk-ROHNXP7J.js} +73 -4
  32. package/dist/chunk-ROHNXP7J.js.map +1 -0
  33. package/dist/chunk-S5MDTHH7.js +466 -0
  34. package/dist/chunk-S5MDTHH7.js.map +1 -0
  35. package/dist/chunk-SDJTTMLK.js +1877 -0
  36. package/dist/chunk-SDJTTMLK.js.map +1 -0
  37. package/dist/{chunk-EP6JHXSR.js → chunk-SHZM3EXB.js} +68 -12
  38. package/dist/chunk-SHZM3EXB.js.map +1 -0
  39. package/dist/chunk-TRMHNMMZ.js +61 -0
  40. package/dist/chunk-TRMHNMMZ.js.map +1 -0
  41. package/dist/{chunk-A2UUGASH.js → chunk-U736PVHY.js} +2 -2
  42. package/dist/chunk-U736PVHY.js.map +1 -0
  43. package/dist/{chunk-JGRMRXXZ.js → chunk-UWSQUHX6.js} +4 -99
  44. package/dist/chunk-UWSQUHX6.js.map +1 -0
  45. package/dist/cli.js +15 -11
  46. package/dist/cli.js.map +1 -1
  47. package/dist/client/index.d.ts +34 -3
  48. package/dist/client/index.js +211 -12
  49. package/dist/client/index.js.map +1 -1
  50. package/dist/components/index.d.ts +1768 -30
  51. package/dist/components/index.js +7 -4
  52. package/dist/delta-text-CbrOaW83.d.ts +17 -0
  53. package/dist/{dev-NZRLOQCL.js → dev-O42JFQ64.js} +24 -13
  54. package/dist/dev-O42JFQ64.js.map +1 -0
  55. package/dist/doc/index.d.ts +215 -2
  56. package/dist/doc/index.js +423 -5
  57. package/dist/doc/index.js.map +1 -1
  58. package/dist/{docs-ZGDJUHJI.js → docs-JVP4RPKT.js} +3 -3
  59. package/dist/{docs-ZGDJUHJI.js.map → docs-JVP4RPKT.js.map} +1 -1
  60. package/dist/{doctor-VFKEMR54.js → doctor-YD3XAAOT.js} +8 -8
  61. package/dist/doctor-YD3XAAOT.js.map +1 -0
  62. package/dist/format-DihQkL1N.d.ts +249 -0
  63. package/dist/markdown/index.js +1 -1
  64. package/dist/markdown/index.js.map +1 -1
  65. package/dist/{preview-QDCIVVSR.js → preview-T6CLSQVX.js} +12 -4
  66. package/dist/preview-T6CLSQVX.js.map +1 -0
  67. package/dist/query/index.d.ts +0 -11
  68. package/dist/query/index.js +2 -2
  69. package/dist/{routes-RHP5FVGD.js → routes-PVKNT777.js} +6 -6
  70. package/dist/{routes-RHP5FVGD.js.map → routes-PVKNT777.js.map} +1 -1
  71. package/dist/{search-RFBELGY7.js → search-5LC4XPB5.js} +3 -3
  72. package/dist/{search-RFBELGY7.js.map → search-5LC4XPB5.js.map} +1 -1
  73. package/dist/slide/index.d.ts +272 -1
  74. package/dist/slide/index.js +1086 -19
  75. package/dist/slide/index.js.map +1 -1
  76. package/dist/table/index.js +14 -10
  77. package/dist/table/index.js.map +1 -1
  78. package/dist/ui/index.d.ts +148 -12
  79. package/dist/ui/index.js +1340 -88
  80. package/dist/ui/index.js.map +1 -1
  81. package/dist/{upgrade-GF2P67M6.js → upgrade-6DT7ADRP.js} +7 -7
  82. package/dist/{upgrade-GF2P67M6.js.map → upgrade-6DT7ADRP.js.map} +1 -1
  83. package/dist/vite/index.d.ts +10 -0
  84. package/dist/vite/index.js +4 -4
  85. package/docs/en/agent-skills.md +1 -1
  86. package/docs/en/changelog.md +82 -0
  87. package/docs/en/cli-reference.md +30 -0
  88. package/docs/en/components.md +42 -17
  89. package/docs/en/doc/delta-text.md +40 -0
  90. package/docs/en/doc/doc-action-items.md +49 -0
  91. package/docs/en/doc/doc-callout.md +38 -0
  92. package/docs/en/doc/doc-data-table.md +60 -0
  93. package/docs/en/doc/doc-executive-summary.md +64 -0
  94. package/docs/en/doc/doc-figure.md +56 -0
  95. package/docs/en/doc/doc-key-findings.md +61 -0
  96. package/docs/en/doc/doc-kpi-scorecard.md +80 -0
  97. package/docs/en/doc/doc-notes-and-sources.md +43 -0
  98. package/docs/en/doc/doc-report-header.md +46 -0
  99. package/docs/en/pages-and-metadata.md +15 -0
  100. package/docs/en/parts/area-chart.md +99 -0
  101. package/docs/en/parts/bar-chart.md +91 -0
  102. package/docs/en/parts/bar-list.md +50 -0
  103. package/docs/en/parts/category-bar.md +37 -0
  104. package/docs/en/parts/chart-frame.md +39 -0
  105. package/docs/en/parts/data-table.md +22 -0
  106. package/docs/en/parts/delta.md +51 -0
  107. package/docs/en/parts/donut-chart.md +72 -0
  108. package/docs/en/parts/format.md +43 -0
  109. package/docs/en/parts/funnel-steps.md +2 -1
  110. package/docs/en/parts/heatmap.md +53 -0
  111. package/docs/en/parts/kpi-card.md +58 -0
  112. package/docs/en/parts/legend.md +35 -0
  113. package/docs/en/parts/line-chart.md +94 -0
  114. package/docs/en/parts/metric-value.md +3 -2
  115. package/docs/en/parts/page-shell.md +1 -1
  116. package/docs/en/parts/progress-circle.md +33 -0
  117. package/docs/en/parts/sample-data.md +39 -0
  118. package/docs/en/parts/scatter-chart.md +56 -0
  119. package/docs/en/parts/sparkline.md +1 -1
  120. package/docs/en/parts/tracker.md +36 -0
  121. package/docs/en/parts/trend-indicator.md +17 -1
  122. package/docs/en/parts/updated-at.md +30 -0
  123. package/docs/en/parts/waterfall-chart.md +56 -0
  124. package/docs/en/slide/slide-chart-full.md +48 -0
  125. package/docs/en/slide/slide-chart-insight.md +52 -0
  126. package/docs/en/slide/slide-columns.md +46 -0
  127. package/docs/en/slide/slide-comparison.md +68 -0
  128. package/docs/en/slide/slide-cover.md +39 -0
  129. package/docs/en/slide/slide-exec-summary.md +47 -0
  130. package/docs/en/slide/slide-heading.md +38 -0
  131. package/docs/en/slide/slide-kpi-grid.md +55 -0
  132. package/docs/en/slide/slide-kpi-hero.md +58 -0
  133. package/docs/en/slide/slide-next-actions.md +49 -0
  134. package/docs/en/slide/slide-scorecard-table.md +54 -0
  135. package/docs/en/slide/slide-section.md +44 -0
  136. package/docs/en/slide/slide-source-line.md +34 -0
  137. package/docs/en/ui/alert-dialog.md +42 -0
  138. package/docs/en/ui/avatar.md +31 -0
  139. package/docs/en/ui/combobox.md +74 -0
  140. package/docs/en/ui/drawer.md +41 -0
  141. package/docs/en/ui/empty.md +1 -1
  142. package/docs/en/ui/error-state.md +4 -3
  143. package/docs/en/ui/field.md +42 -0
  144. package/docs/en/ui/hover-card.md +21 -0
  145. package/docs/en/ui/kbd.md +17 -0
  146. package/docs/en/ui/loading.md +1 -1
  147. package/docs/en/ui/pagination.md +36 -0
  148. package/docs/en/ui/radio-group.md +21 -0
  149. package/docs/en/ui/toast.md +44 -0
  150. package/docs/en/ui/toggle-group.md +4 -3
  151. package/docs/en/ui-and-theming.md +1 -1
  152. package/docs/index.json +1184 -114
  153. package/docs/ja/agent-skills.md +1 -1
  154. package/docs/ja/changelog.md +84 -0
  155. package/docs/ja/cli-reference.md +29 -0
  156. package/docs/ja/components.md +68 -101
  157. package/docs/ja/doc/delta-text.md +40 -0
  158. package/docs/ja/doc/doc-action-items.md +49 -0
  159. package/docs/ja/doc/doc-callout.md +38 -0
  160. package/docs/ja/doc/doc-data-table.md +60 -0
  161. package/docs/ja/doc/doc-executive-summary.md +64 -0
  162. package/docs/ja/doc/doc-figure.md +56 -0
  163. package/docs/ja/doc/doc-key-findings.md +61 -0
  164. package/docs/ja/doc/doc-kpi-scorecard.md +80 -0
  165. package/docs/ja/doc/doc-notes-and-sources.md +43 -0
  166. package/docs/ja/doc/doc-report-header.md +46 -0
  167. package/docs/ja/page-types/csv.md +83 -0
  168. package/docs/ja/page-types/doc.md +83 -0
  169. package/docs/ja/page-types/excel.md +82 -0
  170. package/docs/ja/page-types/slide.md +133 -0
  171. package/docs/ja/page-types.md +47 -0
  172. package/docs/ja/pages-and-metadata.md +15 -0
  173. package/docs/ja/parts/area-chart.md +85 -0
  174. package/docs/ja/parts/bar-chart.md +95 -0
  175. package/docs/ja/parts/bar-list.md +58 -0
  176. package/docs/ja/parts/category-bar.md +36 -0
  177. package/docs/ja/parts/chart-frame.md +36 -0
  178. package/docs/ja/parts/data-table.md +22 -0
  179. package/docs/ja/parts/delta.md +51 -0
  180. package/docs/ja/parts/donut-chart.md +66 -0
  181. package/docs/ja/parts/echart.md +3 -0
  182. package/docs/ja/parts/format.md +44 -0
  183. package/docs/ja/parts/funnel-steps.md +27 -8
  184. package/docs/ja/parts/heatmap.md +45 -0
  185. package/docs/ja/parts/kpi-card.md +56 -0
  186. package/docs/ja/parts/legend.md +36 -0
  187. package/docs/ja/parts/line-chart.md +84 -0
  188. package/docs/ja/parts/metric-value.md +3 -2
  189. package/docs/ja/parts/page-shell.md +1 -1
  190. package/docs/ja/parts/progress-circle.md +34 -0
  191. package/docs/ja/parts/sample-data.md +61 -0
  192. package/docs/ja/parts/scatter-chart.md +69 -0
  193. package/docs/ja/parts/sparkline.md +2 -2
  194. package/docs/ja/parts/tracker.md +32 -0
  195. package/docs/ja/parts/trend-indicator.md +17 -1
  196. package/docs/ja/parts/updated-at.md +27 -0
  197. package/docs/ja/parts/waterfall-chart.md +61 -0
  198. package/docs/ja/routing.md +8 -86
  199. package/docs/ja/slide/slide-chart-full.md +48 -0
  200. package/docs/ja/slide/slide-chart-insight.md +52 -0
  201. package/docs/ja/slide/slide-columns.md +46 -0
  202. package/docs/ja/slide/slide-comparison.md +68 -0
  203. package/docs/ja/slide/slide-cover.md +39 -0
  204. package/docs/ja/slide/slide-exec-summary.md +47 -0
  205. package/docs/ja/slide/slide-heading.md +38 -0
  206. package/docs/ja/slide/slide-kpi-grid.md +55 -0
  207. package/docs/ja/slide/slide-kpi-hero.md +58 -0
  208. package/docs/ja/slide/slide-next-actions.md +49 -0
  209. package/docs/ja/slide/slide-scorecard-table.md +54 -0
  210. package/docs/ja/slide/slide-section.md +44 -0
  211. package/docs/ja/slide/slide-source-line.md +34 -0
  212. package/docs/ja/ui/alert-dialog.md +41 -0
  213. package/docs/ja/ui/avatar.md +31 -0
  214. package/docs/ja/ui/combobox.md +74 -0
  215. package/docs/ja/ui/drawer.md +41 -0
  216. package/docs/ja/ui/empty.md +1 -1
  217. package/docs/ja/ui/error-state.md +4 -3
  218. package/docs/ja/ui/field.md +42 -0
  219. package/docs/ja/ui/hover-card.md +21 -0
  220. package/docs/ja/ui/kbd.md +16 -0
  221. package/docs/ja/ui/loading.md +1 -1
  222. package/docs/ja/ui/pagination.md +36 -0
  223. package/docs/ja/ui/radio-group.md +21 -0
  224. package/docs/ja/ui/toast.md +46 -0
  225. package/docs/ja/ui/toggle-group.md +4 -3
  226. package/docs/ja/ui-and-theming.md +14 -7
  227. package/package.json +43 -29
  228. package/registry/ui/accordion.tsx +76 -0
  229. package/registry/ui/alert-dialog.tsx +189 -0
  230. package/registry/ui/alert.tsx +80 -0
  231. package/registry/ui/app-shell.tsx +424 -0
  232. package/registry/ui/area-chart.tsx +3960 -0
  233. package/registry/ui/attention-list.tsx +465 -0
  234. package/registry/ui/avatar.tsx +110 -0
  235. package/registry/ui/badge.tsx +56 -0
  236. package/registry/ui/bar-chart.tsx +3954 -0
  237. package/registry/ui/bar-list.tsx +3998 -0
  238. package/registry/ui/box-plot-chart.tsx +4214 -0
  239. package/registry/ui/breadcrumb.tsx +128 -0
  240. package/registry/ui/button.tsx +62 -0
  241. package/registry/ui/calendar.tsx +223 -0
  242. package/registry/ui/card.tsx +107 -0
  243. package/registry/ui/category-bar.tsx +1498 -0
  244. package/registry/ui/chart-frame.tsx +1604 -0
  245. package/registry/ui/checkbox.tsx +30 -0
  246. package/registry/ui/collapsible.tsx +23 -0
  247. package/registry/ui/combobox.tsx +310 -0
  248. package/registry/ui/command.tsx +198 -0
  249. package/registry/ui/dashboard-card.tsx +217 -0
  250. package/registry/ui/data-table.tsx +896 -157
  251. package/registry/ui/date-range-picker.tsx +544 -0
  252. package/registry/ui/delta.tsx +350 -0
  253. package/registry/ui/dialog.tsx +161 -0
  254. package/registry/ui/donut-chart.tsx +4006 -0
  255. package/registry/ui/drawer.tsx +229 -0
  256. package/registry/ui/dropdown-menu.tsx +274 -0
  257. package/registry/ui/echart.tsx +1178 -0
  258. package/registry/ui/field.tsx +240 -0
  259. package/registry/ui/filter-bar.tsx +551 -0
  260. package/registry/ui/funnel-steps.tsx +3843 -0
  261. package/registry/ui/heatmap.tsx +2027 -0
  262. package/registry/ui/histogram-chart.tsx +4284 -0
  263. package/registry/ui/hover-card.tsx +52 -0
  264. package/registry/ui/input-group.tsx +160 -0
  265. package/registry/ui/input.tsx +24 -0
  266. package/registry/ui/kbd.tsx +30 -0
  267. package/registry/ui/kpi-card.tsx +905 -0
  268. package/registry/ui/label.tsx +22 -0
  269. package/registry/ui/legend.tsx +3817 -0
  270. package/registry/ui/line-chart.tsx +3958 -0
  271. package/registry/ui/metric-value.tsx +62 -0
  272. package/registry/ui/multi-select.tsx +382 -0
  273. package/registry/ui/page-shell.tsx +420 -0
  274. package/registry/ui/pagination.tsx +140 -0
  275. package/registry/ui/paired-chart.tsx +4086 -0
  276. package/registry/ui/placeholder.tsx +36 -0
  277. package/registry/ui/popover.tsx +92 -0
  278. package/registry/ui/progress-circle.tsx +1033 -0
  279. package/registry/ui/progress.tsx +85 -0
  280. package/registry/ui/radio-group.tsx +39 -0
  281. package/registry/ui/sample-data.tsx +230 -0
  282. package/registry/ui/scatter-chart.tsx +4503 -0
  283. package/registry/ui/scroll-area.tsx +56 -0
  284. package/registry/ui/searchable-select.tsx +352 -0
  285. package/registry/ui/section-header.tsx +43 -0
  286. package/registry/ui/segmented-control.tsx +76 -0
  287. package/registry/ui/select.tsx +255 -0
  288. package/registry/ui/separator.tsx +27 -0
  289. package/registry/ui/sheet.tsx +139 -0
  290. package/registry/ui/sidebar.tsx +725 -0
  291. package/registry/ui/skeleton.tsx +17 -0
  292. package/registry/ui/slider.tsx +56 -0
  293. package/registry/ui/sparkline.tsx +230 -0
  294. package/registry/ui/spinner.tsx +14 -0
  295. package/registry/ui/states.tsx +247 -0
  296. package/registry/ui/status-badge.tsx +286 -0
  297. package/registry/ui/switch.tsx +34 -0
  298. package/registry/ui/table.tsx +118 -0
  299. package/registry/ui/tabs.tsx +84 -0
  300. package/registry/ui/textarea.tsx +22 -0
  301. package/registry/ui/toast.tsx +250 -0
  302. package/registry/ui/toggle-group.tsx +91 -0
  303. package/registry/ui/toggle.tsx +47 -0
  304. package/registry/ui/tooltip.tsx +72 -0
  305. package/registry/ui/tracker.tsx +1015 -0
  306. package/registry/ui/trend-indicator.tsx +217 -0
  307. package/registry/ui/updated-at.tsx +276 -0
  308. package/registry/ui/waterfall-chart.tsx +4561 -0
  309. package/skills/vantage-add-feature/SKILL.md +14 -7
  310. package/skills/vantage-design/SKILL.md +62 -52
  311. package/skills/vantage-design/references/extract.md +26 -27
  312. package/skills/vantage-design/references/format.md +12 -172
  313. package/skills/vantage-design/references/presets.md +50 -110
  314. package/skills/vantage-pitfalls/SKILL.md +10 -1
  315. package/skills/vantage-slide/SKILL.md +71 -36
  316. package/templates/AGENTS.md +54 -16
  317. package/templates/agents/api.md +36 -0
  318. package/templates/agents/csv.md +2 -1
  319. package/templates/agents/doc.md +52 -3
  320. package/templates/agents/page.md +91 -10
  321. package/templates/agents/slide.md +90 -4
  322. package/templates/agents/xlsx.md +2 -1
  323. package/theme.css +247 -132
  324. package/dist/add-EISGSZAH.js +0 -6
  325. package/dist/add-EISGSZAH.js.map +0 -1
  326. package/dist/chunk-3G43C6OL.js.map +0 -1
  327. package/dist/chunk-5ZOP4SQR.js +0 -47
  328. package/dist/chunk-5ZOP4SQR.js.map +0 -1
  329. package/dist/chunk-A2UUGASH.js.map +0 -1
  330. package/dist/chunk-BCE22JSM.js.map +0 -1
  331. package/dist/chunk-DTDVSFRY.js +0 -29
  332. package/dist/chunk-DTDVSFRY.js.map +0 -1
  333. package/dist/chunk-EP6JHXSR.js.map +0 -1
  334. package/dist/chunk-JGRMRXXZ.js.map +0 -1
  335. package/dist/chunk-KLEIVDUV.js +0 -165
  336. package/dist/chunk-KLEIVDUV.js.map +0 -1
  337. package/dist/chunk-LSRMRIXA.js +0 -2227
  338. package/dist/chunk-LSRMRIXA.js.map +0 -1
  339. package/dist/chunk-PVTHHV7X.js.map +0 -1
  340. package/dist/chunk-PXRX5PEC.js.map +0 -1
  341. package/dist/chunk-RPZAALCX.js.map +0 -1
  342. package/dist/chunk-T3FLAGI6.js +0 -316
  343. package/dist/chunk-T3FLAGI6.js.map +0 -1
  344. package/dist/dev-NZRLOQCL.js.map +0 -1
  345. package/dist/doctor-VFKEMR54.js.map +0 -1
  346. package/dist/preview-QDCIVVSR.js.map +0 -1
@@ -0,0 +1,1178 @@
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 { useSyncExternalStore } from "react"
62
+ import { createContext, type ForwardedRef, type RefObject } from "react"
63
+ import type { ChartTone } from "@squadbase/vantage/components"
64
+
65
+ /* ---------- inlined from @squadbase/squadkit registry/lib/locale.ts (internal to @squadbase/vantage) ---------- */
66
+
67
+ /**
68
+ * The display language of the parts and the defaults that follow it.
69
+ *
70
+ * The language comes from `<html lang>`: a tag starting with `ja` reads Japanese, anything else English.
71
+ * Without a usable `lang` (missing, invalid, or no `document` — SSR, Node) the locale is `FALLBACK_LOCALE`
72
+ * (`en-US`). A `locale` passed explicitly (a prop, an argument) always wins.
73
+ *
74
+ * Parts read the locale through `useLocale` (`hooks/use-locale`) and pass it down; the pure helpers here
75
+ * and in `format` / `comparison` / `chart-*` take it as an argument and fall back to `documentLocale()`.
76
+ */
77
+
78
+ /** The language of the built-in wording: Japanese or English. */
79
+ type UiLanguage = "ja" | "en"
80
+
81
+ /** The locale used when `<html lang>` gives none (and on the server). */
82
+ const FALLBACK_LOCALE = "en-US"
83
+
84
+ /** `"ja"` when `locale` starts with `ja` (`ja`, `ja-JP`), else `"en"`. A missing locale is `"en"`. */
85
+ function uiLanguage(locale: string | null | undefined): UiLanguage {
86
+ return typeof locale === "string" && locale.trim().toLowerCase().startsWith("ja") ? "ja" : "en"
87
+ }
88
+
89
+ /** A BCP 47 tag in its canonical form (`en-gb` → `en-GB`), or `FALLBACK_LOCALE` when it is empty or invalid. */
90
+ function normalizeLocale(tag: string | null | undefined): string {
91
+ const value = tag?.trim()
92
+ if (!value) return FALLBACK_LOCALE
93
+ try {
94
+ return Intl.getCanonicalLocales(value)[0] ?? FALLBACK_LOCALE
95
+ } catch {
96
+ return FALLBACK_LOCALE
97
+ }
98
+ }
99
+
100
+ /**
101
+ * The locale of the page: `document.documentElement.lang`, canonicalized. `FALLBACK_LOCALE` when it is empty or
102
+ * invalid, or there is no `document`. Parts read it through `useLocale`, never while rendering.
103
+ */
104
+ function documentLocale(): string {
105
+ if (typeof document === "undefined") return FALLBACK_LOCALE
106
+ return normalizeLocale(document.documentElement?.getAttribute("lang"))
107
+ }
108
+
109
+ /** `locale` when given, else the page's locale (`documentLocale()`). */
110
+ function resolveLocale(locale?: string | null): string {
111
+ return locale || documentLocale()
112
+ }
113
+
114
+ /** Picks the wording table of the locale's language. */
115
+ function pickLocale<T>(table: { ja: T; en: T }, locale?: string | null): T {
116
+ return table[uiLanguage(resolveLocale(locale))]
117
+ }
118
+
119
+ /** The currency `format="currency"` uses without a `currency`: JPY for Japanese, USD otherwise. */
120
+ function defaultCurrency(locale?: string | null): string {
121
+ return uiLanguage(resolveLocale(locale)) === "ja" ? "JPY" : "USD"
122
+ }
123
+
124
+ /** The month a fiscal year starts in (1–12) when none is given: April for Japanese, January otherwise. */
125
+ function defaultFiscalYearStartMonth(locale?: string | null): number {
126
+ return uiLanguage(resolveLocale(locale)) === "ja" ? 4 : 1
127
+ }
128
+
129
+ /** A day of the week, 0 = Sunday … 6 = Saturday (the `Date#getDay` / date-fns numbering). */
130
+ type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6
131
+
132
+ /** Locales whose week starts on Sunday when the runtime has no week data. */
133
+ const SUNDAY_FIRST = new Set(["en", "en-US", "en-CA"])
134
+
135
+ /**
136
+ * The first day of the week: Monday for Japanese; otherwise the locale's convention from `Intl.Locale`
137
+ * (`en-US` Sunday, `en-GB` Monday). Without week data in the runtime: Sunday for `en` / `en-US` / `en-CA`,
138
+ * Monday for the rest.
139
+ */
140
+ function weekStartsOn(locale?: string | null): WeekDay {
141
+ const tag = normalizeLocale(resolveLocale(locale))
142
+ if (uiLanguage(tag) === "ja") return 1
143
+ try {
144
+ const info = new Intl.Locale(tag) as Intl.Locale & {
145
+ getWeekInfo?: () => { firstDay: number }
146
+ weekInfo?: { firstDay: number }
147
+ }
148
+ const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay
149
+ if (typeof firstDay === "number" && firstDay >= 1 && firstDay <= 7) return (firstDay % 7) as WeekDay
150
+ } catch {
151
+ // Fall through to the table below.
152
+ }
153
+ return SUNDAY_FIRST.has(tag) ? 0 : 1
154
+ }
155
+
156
+ /* ---------- inlined from @squadbase/squadkit registry/hooks/use-locale.ts (internal to @squadbase/vantage) ---------- */
157
+
158
+ const listeners = new Set<() => void>()
159
+ let observer: MutationObserver | null = null
160
+
161
+ /** One observer on `<html lang>` for every subscribed part. */
162
+ function subscribe(onChange: () => void): () => void {
163
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {}
164
+ listeners.add(onChange)
165
+ if (!observer) {
166
+ observer = new MutationObserver(() => {
167
+ for (const listener of [...listeners]) listener()
168
+ })
169
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] })
170
+ }
171
+ return () => {
172
+ listeners.delete(onChange)
173
+ if (listeners.size === 0 && observer) {
174
+ observer.disconnect()
175
+ observer = null
176
+ }
177
+ }
178
+ }
179
+
180
+ const serverLocale = () => FALLBACK_LOCALE
181
+
182
+ /**
183
+ * The page's locale (`<html lang>`), following changes made while the page runs. On the server and during
184
+ * hydration it is `FALLBACK_LOCALE` (`en-US`), so the markup matches; it switches right after.
185
+ */
186
+ function useDocumentLocale(): string {
187
+ return useSyncExternalStore(subscribe, documentLocale, serverLocale)
188
+ }
189
+
190
+ /** The locale a part renders in: its `locale` prop when given, else the page's (`useDocumentLocale`). */
191
+ function useLocale(locale?: string | null): string {
192
+ const page = useDocumentLocale()
193
+ return locale || page
194
+ }
195
+
196
+ /* ---------- inlined from @squadbase/squadkit registry/lib/chart-theme.ts (internal to @squadbase/vantage) ---------- */
197
+
198
+ /**
199
+ * The colour of the folded「その他」series / slice / row. The ONLY place this
200
+ * grey is defined — every chart takes it from here. It is the most subdued
201
+ * colour in a chart, not the darkest: the theme's `--neutral` mixed halfway into
202
+ * `--background`, so it recedes in light and dark alike (and brand themes follow
203
+ * it). `--neutral-subtle` alone sits too close to the background to read as a
204
+ * slice. Canvas charts resolve it through `resolveChartColors`.
205
+ */
206
+ const CHART_OTHER_COLOR = "color-mix(in oklab, var(--neutral) 50%, var(--background))"
207
+
208
+ /**
209
+ * True inside `ChartFrame`. The frame's plot area carries the chart's single
210
+ * `role="img"` and label, so `EChart` drops its own when it sees this.
211
+ */
212
+ const ChartFrameContext = createContext(false)
213
+
214
+ /** Series palette, in order. Missing tokens are skipped, not defaulted. */
215
+ const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"] as const
216
+
217
+ interface ChartTokens {
218
+ /** Resolved `--chart-1..5`, in order. */
219
+ series: string[]
220
+ /** Computed `font-family` of the container, so canvas text matches the DOM. Optional. */
221
+ fontFamily?: string
222
+ /** Body text: titles and the default `textStyle`. */
223
+ foreground: string | null
224
+ /** Secondary text: axis labels, legend, subtitles. */
225
+ mutedForeground: string | null
226
+ /** Axis lines, ticks, split lines, tooltip border. */
227
+ border: string | null
228
+ /** Tooltip surface. */
229
+ popover: string | null
230
+ /** Semantic and surface dependencies used by typed chart options. Optional for hand-built tokens. */
231
+ colors?: Record<string, string>
232
+ /** Actual inherited painting surface (may change without a token changing). */
233
+ surface?: string | null
234
+ /** Tooltip text. */
235
+ popoverForeground: string | null
236
+ }
237
+
238
+ // Keep scope out of the public token shape. A resolved snapshot remembers where arbitrary
239
+ // var(--token) expressions must be read, without retaining unmounted charts globally.
240
+ const tokenScopes = new WeakMap<ChartTokens, Element>()
241
+ const OPTION_COLOR_TOKENS = [
242
+ "--background", "--card", "--muted", "--primary", "--destructive",
243
+ ...["positive", "negative", "warning", "neutral", "info"].flatMap((tone) =>
244
+ ["", "-fill", "-subtle"].map((suffix) => `--${tone}${suffix}`)),
245
+ ]
246
+
247
+ let normalizeContext: CanvasRenderingContext2D | null | undefined
248
+
249
+ /** The forms `fillStyle` serializes legacy colours to, and zrender can parse. */
250
+ const ZRENDER_COLOR_RE = /^(?:#[0-9a-f]{6}|rgba?\()/i
251
+
252
+ /**
253
+ * Serialize a CSS colour into a canvas-safe form, or `null` if the browser
254
+ * rejects it. Assigning an invalid value to `fillStyle` is a no-op, so the same
255
+ * input is written over two different known colours: agreeing results mean the
256
+ * value parsed, differing ones mean it was dropped.
257
+ */
258
+ function toCanvasColor(value: string): string | null {
259
+ const raw = value.trim()
260
+ if (!raw) return null
261
+ if (normalizeContext === undefined) {
262
+ const canvas = document.createElement("canvas")
263
+ canvas.width = 1
264
+ canvas.height = 1
265
+ normalizeContext = canvas.getContext("2d", { willReadFrequently: true })
266
+ }
267
+ const ctx = normalizeContext
268
+ if (!ctx) return raw
269
+ ctx.fillStyle = "#000000"
270
+ ctx.fillStyle = raw
271
+ const onBlack = ctx.fillStyle
272
+ ctx.fillStyle = "#ffffff"
273
+ ctx.fillStyle = raw
274
+ if (onBlack !== ctx.fillStyle || typeof onBlack !== "string") return null
275
+ if (ZRENDER_COLOR_RE.test(onBlack)) return onBlack
276
+ // Still `oklch()` / `lab()` / `color(...)`: paint it and read the pixel back.
277
+ // Wide-gamut colours clip to sRGB, which is all zrender can express anyway.
278
+ ctx.clearRect(0, 0, 1, 1)
279
+ ctx.fillRect(0, 0, 1, 1)
280
+ const [r = 0, g = 0, b = 0, a = 0] = ctx.getImageData(0, 0, 1, 1).data
281
+ return a === 255
282
+ ? `rgb(${r}, ${g}, ${b})`
283
+ : `rgba(${r}, ${g}, ${b}, ${Math.round((a / 255) * 1000) / 1000})`
284
+ }
285
+
286
+ /**
287
+ * Read the chart tokens off `el` (the document root by default — tokens live on
288
+ * `:root` and inherit). Returns `null` outside the browser so callers fall back
289
+ * to plain ECharts defaults.
290
+ */
291
+ function readChartTokens(el?: Element | null): ChartTokens | null {
292
+ if (typeof document === "undefined") return null
293
+ const target = el ?? document.documentElement
294
+ if (!target) return null
295
+ const styles = getComputedStyle(target)
296
+ const read = (name: string) => toCanvasColor(styles.getPropertyValue(name))
297
+ const series: string[] = []
298
+ for (const token of SERIES_TOKENS) {
299
+ const color = read(token)
300
+ if (color) series.push(color)
301
+ }
302
+ const tokens: ChartTokens = {
303
+ series,
304
+ colors: Object.fromEntries(OPTION_COLOR_TOKENS.map((name) => [name, styles.getPropertyValue(name).trim()])),
305
+ surface: readChartSurface(target),
306
+ ...(styles.fontFamily ? { fontFamily: styles.fontFamily } : {}),
307
+ foreground: read("--foreground"),
308
+ mutedForeground: read("--muted-foreground"),
309
+ border: read("--border"),
310
+ popover: read("--popover"),
311
+ popoverForeground: read("--popover-foreground"),
312
+ }
313
+ tokenScopes.set(tokens, target)
314
+ return tokens
315
+ }
316
+
317
+ /** Structural equality, so a theme toggle that changes nothing doesn't re-init. */
318
+ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean {
319
+ if (a === b) return true
320
+ if (!a || !b) return false
321
+ return (
322
+ tokenScopes.get(a) === tokenScopes.get(b) &&
323
+ a.surface === b.surface &&
324
+ Object.keys(a.colors ?? {}).length === Object.keys(b.colors ?? {}).length &&
325
+ Object.entries(a.colors ?? {}).every(([name, value]) => b.colors?.[name] === value) &&
326
+ a.series.length === b.series.length &&
327
+ a.series.every((color, i) => color === b.series[i]) &&
328
+ a.fontFamily === b.fontFamily &&
329
+ a.foreground === b.foreground &&
330
+ a.mutedForeground === b.mutedForeground &&
331
+ a.border === b.border &&
332
+ a.popover === b.popover &&
333
+ a.popoverForeground === b.popoverForeground
334
+ )
335
+ }
336
+
337
+ /**
338
+ * Watch everything that can change the resolved tokens and call `onChange` when
339
+ * something might have. It only ever says "re-read" — the caller compares with
340
+ * `sameChartTokens` and decides whether anything actually moved. Returns a
341
+ * disposer.
342
+ *
343
+ * Two sources, not one:
344
+ * - light/dark flips, which land as `class` / `data-theme` (or an inline
345
+ * `style`) on `<html>`/`<body>`, or as an OS preference change
346
+ * - stylesheet changes. In dev, editing `styles.css` rewrites the injected
347
+ * `<style>` in `<head>` and touches nothing else, so watching the theme
348
+ * attributes alone leaves the chart painted in the previous palette until a
349
+ * reload — the tokens in the DOM are new, the canvas is not.
350
+ */
351
+ function watchChartTokens(onChange: () => void, scope?: Element | null): () => void {
352
+ if (typeof document === "undefined") return () => {}
353
+ // Style edits arrive as bursts of mutations; coalesce them into one read, and
354
+ // take it after the next frame so a swapped stylesheet has been applied.
355
+ let frame = 0
356
+ const schedule = () => {
357
+ if (frame) return
358
+ frame = requestAnimationFrame(() => {
359
+ frame = 0
360
+ onChange()
361
+ })
362
+ }
363
+
364
+ const themeObserver = new MutationObserver(schedule)
365
+ const attributeFilter = ["class", "style", "data-theme"]
366
+ // Only the chart and its ancestors can change its inherited theme. Never observe
367
+ // the document subtree: canvas/tooltip/rendering mutations would trigger every chart.
368
+ for (let node: Element | null = scope ?? document.body ?? document.documentElement; node; node = node.parentElement) {
369
+ themeObserver.observe(node, { attributes: true, attributeFilter })
370
+ }
371
+
372
+ const styleObserver = new MutationObserver(schedule)
373
+ if (document.head) {
374
+ styleObserver.observe(document.head, {
375
+ childList: true,
376
+ subtree: true,
377
+ characterData: true,
378
+ attributes: true,
379
+ attributeFilter: ["href", "media", "disabled"],
380
+ })
381
+ }
382
+
383
+ // A swapped `<link>` only applies once the sheet has loaded, after its mutation.
384
+ const onLoad = (event: Event) => {
385
+ if (event.target instanceof HTMLLinkElement) schedule()
386
+ }
387
+ document.addEventListener("load", onLoad, true)
388
+
389
+ const media = window.matchMedia("(prefers-color-scheme: dark)")
390
+ media.addEventListener("change", schedule)
391
+
392
+ return () => {
393
+ if (frame) cancelAnimationFrame(frame)
394
+ themeObserver.disconnect()
395
+ styleObserver.disconnect()
396
+ document.removeEventListener("load", onLoad, true)
397
+ media.removeEventListener("change", schedule)
398
+ }
399
+ }
400
+
401
+ /** Shared shape for the four axis types ECharts themes address separately. */
402
+ function axisTheme(tokens: ChartTokens, splitLine: boolean) {
403
+ const line = tokens.border ?? undefined
404
+ return {
405
+ axisLine: { show: false },
406
+ axisTick: { show: false },
407
+ axisLabel: { show: true, color: tokens.mutedForeground ?? undefined, fontSize: 12 },
408
+ // Faint horizontal rules only: the border colour at low opacity.
409
+ splitLine: { show: splitLine, lineStyle: { color: line ? [line] : undefined, opacity: 0.5, width: 1 } },
410
+ splitArea: { show: false },
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Build the ECharts theme. `option.color` still wins over `theme.color`, so an
416
+ * app that sets its own palette keeps it.
417
+ */
418
+ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
419
+ const text = tokens.foreground ?? undefined
420
+ const muted = tokens.mutedForeground ?? undefined
421
+ const line = tokens.border ?? undefined
422
+ return {
423
+ // Let the page background show through: charts sit on cards, not on a canvas.
424
+ backgroundColor: "transparent",
425
+ ...(tokens.series.length > 0 ? { color: tokens.series } : {}),
426
+ textStyle: { color: text, ...(tokens.fontFamily ? { fontFamily: tokens.fontFamily } : {}) },
427
+ title: { textStyle: { color: text }, subtextStyle: { color: muted } },
428
+ legend: { textStyle: { color: muted } },
429
+ categoryAxis: axisTheme(tokens, false),
430
+ valueAxis: axisTheme(tokens, true),
431
+ logAxis: axisTheme(tokens, true),
432
+ timeAxis: axisTheme(tokens, true),
433
+ tooltip: {
434
+ // ECharts' default 0.4s transform lags the tooltip ~300ms behind the cursor.
435
+ transitionDuration: 0,
436
+ backgroundColor: tokens.popover ?? undefined,
437
+ borderColor: line,
438
+ borderWidth: 1,
439
+ padding: [8, 12],
440
+ // Rounded card with a soft shadow; in dark mode the shadow barely shows, so the border carries the edge.
441
+ extraCssText: "border-radius:8px;box-shadow:var(--shadow-card, 0 4px 12px rgb(0 0 0 / 0.08));",
442
+ textStyle: {
443
+ color: tokens.popoverForeground ?? undefined,
444
+ fontSize: 12,
445
+ ...(tokens.fontFamily ? { fontFamily: tokens.fontFamily } : {}),
446
+ },
447
+ axisPointer: { lineStyle: { color: line }, crossStyle: { color: line } },
448
+ },
449
+ }
450
+ }
451
+
452
+ /**
453
+ * The resolved tokens, kept current: re-read whenever `watchChartTokens` says
454
+ * they may have moved, and only replaced when a value actually changed. For
455
+ * components that build an ECharts option from token colours themselves (the
456
+ * typed charts), so their palette follows light/dark like `EChart`'s theme.
457
+ */
458
+ function useChartTokens(scope?: Element | null | RefObject<Element | null>): ChartTokens | null {
459
+ const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
460
+ // Compared here, not inside a `setTokens` updater: an updater that reads the DOM is
461
+ // replayed by React on every rebase and returns a fresh object each time, so the state
462
+ // never settles ("Maximum update depth exceeded" once the tokens change after mount).
463
+ const tokensRef = useRef(tokens)
464
+ useEffect(() => {
465
+ const target = scope && "current" in scope ? scope.current : scope
466
+ const sync = () => {
467
+ const next = readChartTokens(target)
468
+ if (sameChartTokens(tokensRef.current, next)) return
469
+ tokensRef.current = next
470
+ setTokens(next)
471
+ }
472
+ sync()
473
+ return watchChartTokens(sync, target)
474
+ }, [scope])
475
+ return tokens
476
+ }
477
+
478
+ const SERIES_TOKEN_NAME_RE = /^chart-([1-5])$/
479
+ const TONE_NAME_RE = /^(?:positive|warning|negative|neutral|info)(?:-fill)?$/
480
+ const CSS_VAR_RE = /^var\(\s*(--[\w-]+)\s*(?:,\s*(.+))?\)$/
481
+ const EMBEDDED_VAR_RE = /var\(\s*(--[\w-]+)\s*\)/g
482
+
483
+ /**
484
+ * Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
485
+ * the resolved series tokens; `"positive"` / `"warning"` / `"negative"` / `"neutral"` (and the area
486
+ * variants `"positive-fill"`, …) are read in the snapshot's inherited scope (`--positive`, …); `var(--token)` uses the same scope; any
487
+ * other CSS colour is normalized like the tokens are. A colour that can't be
488
+ * resolved comes back as `""`, which ECharts treats as "use the palette".
489
+ */
490
+ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | null): string[] {
491
+ return colors.map((color) => {
492
+ const token = SERIES_TOKEN_NAME_RE.exec(color.trim())
493
+ if (token) return tokens?.series[Number(token[1]) - 1] ?? ""
494
+ // Meaning colours are the theme tokens of the same name: same path as `var(--positive)`.
495
+ if (TONE_NAME_RE.test(color.trim())) {
496
+ if (typeof document === "undefined") return ""
497
+ const target = (tokens && tokenScopes.get(tokens)) ?? document.documentElement
498
+ return toCanvasColor(getComputedStyle(target).getPropertyValue(`--${color.trim()}`)) ?? ""
499
+ }
500
+ if (typeof document === "undefined") return color
501
+ let value = color.trim()
502
+ const cssVar = CSS_VAR_RE.exec(value)
503
+ const styles = getComputedStyle((tokens && tokenScopes.get(tokens)) ?? document.documentElement)
504
+ if (cssVar) {
505
+ value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
506
+ } else if (value.includes("var(")) {
507
+ // A colour function over tokens (`color-mix(… var(--muted-foreground) …)`):
508
+ // substitute the tokens, then let the canvas resolve the rest.
509
+ value = value.replace(EMBEDDED_VAR_RE, (_, name: string) => styles.getPropertyValue(name).trim())
510
+ }
511
+ return toCanvasColor(value) ?? ""
512
+ })
513
+ }
514
+
515
+ /** Every meaning colour resolved for the canvas (`""` when it can't be resolved). */
516
+ function resolveToneColors(tokens: ChartTokens | null): Record<ChartTone, string> {
517
+ const [positive = "", warning = "", negative = "", neutral = ""] = resolveChartColors(
518
+ ["positive", "warning", "negative", "neutral"],
519
+ tokens,
520
+ )
521
+ return { positive, warning, negative, neutral }
522
+ }
523
+
524
+ /** The area (`--<tone>-fill`) colours of every meaning tone, resolved for the canvas (`""` when it can't be resolved). */
525
+ function resolveFillColors(tokens: ChartTokens | null): Record<ChartTone, string> {
526
+ const [positive = "", warning = "", negative = "", neutral = ""] = resolveChartColors(
527
+ ["positive-fill", "warning-fill", "negative-fill", "neutral-fill"],
528
+ tokens,
529
+ )
530
+ return { positive, warning, negative, neutral }
531
+ }
532
+
533
+ const TRANSPARENT_RE = /^(?:transparent|rgba\([^)]*,\s*0(?:\.0+)?\))$/i
534
+
535
+ /**
536
+ * The colour the chart is painted on: the first opaque `background-color` up
537
+ * from `el` (a card, the page), else `--background`. Canvas-safe, or `null`
538
+ * outside the browser.
539
+ */
540
+ function readChartSurface(el: Element | null): string | null {
541
+ if (typeof document === "undefined") return null
542
+ for (let node: Element | null = el; node; node = node.parentElement) {
543
+ const bg = getComputedStyle(node).backgroundColor.trim()
544
+ if (bg && !TRANSPARENT_RE.test(bg)) {
545
+ const color = toCanvasColor(bg)
546
+ if (color) return color
547
+ }
548
+ }
549
+ return toCanvasColor(getComputedStyle(el ?? document.documentElement).getPropertyValue("--background"))
550
+ }
551
+
552
+ /** Widths are bucketed so a resize only rebuilds the option when the label budget can change. */
553
+ const WIDTH_BUCKET = 40
554
+
555
+ /**
556
+ * The surface colour behind a chart (see `readChartSurface`), re-read when the
557
+ * tokens move (light/dark), and the plot's width (rounded down to 40px;
558
+ * `undefined` until measured). Returns a ref for the plot that also feeds the
559
+ * component's forwarded ref.
560
+ */
561
+ function useChartSurface<E extends Element>(
562
+ forwarded: ForwardedRef<E>,
563
+ tokens?: ChartTokens | null,
564
+ ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined; tokens: ChartTokens | null } {
565
+ const [node, setNode] = useState<E | null>(null)
566
+ const scopedTokens = useChartTokens(node)
567
+ const [surface, setSurface] = useState<string | undefined>(undefined)
568
+ const [width, setWidth] = useState<number | undefined>(undefined)
569
+ const ref = useCallback(
570
+ (el: E | null) => {
571
+ setNode(el)
572
+ if (typeof forwarded === "function") forwarded(el)
573
+ else if (forwarded) forwarded.current = el
574
+ },
575
+ [forwarded],
576
+ )
577
+ useEffect(() => {
578
+ if (!node) return
579
+ setSurface(readChartSurface(node) ?? undefined)
580
+ }, [node, tokens, scopedTokens])
581
+ useEffect(() => {
582
+ if (!node || typeof ResizeObserver === "undefined") return
583
+ const update = (w: number) => setWidth(Math.floor(w / WIDTH_BUCKET) * WIDTH_BUCKET)
584
+ update(node.getBoundingClientRect().width)
585
+ const observer = new ResizeObserver((entries) => update(entries[0]?.contentRect.width ?? 0))
586
+ observer.observe(node)
587
+ return () => observer.disconnect()
588
+ }, [node])
589
+ return { ref, surface, width, tokens: scopedTokens }
590
+ }
591
+
592
+ /* ---------- end of inlined code ---------- */
593
+
594
+ // What the typed charts (Bar / Line / Area / Donut) emit. Other series and
595
+ // components are the consumer's to `use()` — see the note at the top.
596
+ use([
597
+ BarChart,
598
+ CustomChart,
599
+ LineChart,
600
+ PieChart,
601
+ GridComponent,
602
+ LegendComponent,
603
+ MarkAreaComponent,
604
+ MarkLineComponent,
605
+ MarkPointComponent,
606
+ TooltipComponent,
607
+ CanvasRenderer,
608
+ ])
609
+
610
+ export interface EChartProps {
611
+ /**
612
+ * ECharts のオプション。
613
+ *
614
+ * 型推論を効かせるため、必ず `EChartsOption` を annotation するか
615
+ * `satisfies EChartsOption` を付けること。付けないと `type: "category"` などの
616
+ * literal が `string` に拡張され、ビルドが失敗する。
617
+ *
618
+ * @example
619
+ * import type { EChartsOption } from "@/components/echart"
620
+ *
621
+ * const option: EChartsOption = {
622
+ * xAxis: { type: "category", data: ["A", "B"] },
623
+ * yAxis: { type: "value" },
624
+ * series: [{ type: "bar", data: [1, 2] }],
625
+ * }
626
+ * <EChart option={option} />
627
+ */
628
+ option: EChartsOption
629
+ /**
630
+ * チャートの高さ。数値は px、文字列は CSS 値(例: `"50vh"`)として扱う。
631
+ *
632
+ * 省略した場合は外側ラッパーに `h-[400px]` がデフォルトで当たるが、`className` に
633
+ * `h-full` や `h-[360px]` などの高さクラスを渡せばそちらが優先される
634
+ * (`h-full` を使う場合は親要素に確定した高さが必要)。
635
+ * `height` と `className` の高さを両方指定した場合は `height`(インライン style)が勝つ。
636
+ */
637
+ height?: string | number
638
+ loading?: boolean
639
+ /**
640
+ * `echarts.init` にそのまま渡すテーマ(名前または登録済みテーマオブジェクト)。
641
+ *
642
+ * 渡すとデザイントークン追従は**完全に無効**になり、明暗の切り替えにも追従しなく
643
+ * なる。系列色だけ変えたいなら `option.color` を使う ― option はテーマより優先
644
+ * されるので、軸・凡例・ツールチップのトークン追従は残る。
645
+ */
646
+ theme?: string | object
647
+ onEvents?: Record<string, (params: unknown) => void>
648
+ ariaLabel?: string
649
+ className?: string
650
+ /**
651
+ * true にするとチャート右上に PNG コピー / ダウンロードボタンを表示する。
652
+ * `exportHeader` があれば PNG の上にその見出しを含める(`{ includeTitle: false }` で含めない)。
653
+ */
654
+ actions?: boolean | ChartActionsOptions
655
+ /** ダウンロード時のファイル名(拡張子含む)。省略時は "chart.png" */
656
+ fileName?: string
657
+ /**
658
+ * PNG の上に含める見出しと要約。型付きグラフは自分の `title` / `summary` を渡す。
659
+ * 自分で `ChartFrame` を組むときも同じものを渡すと、保存した画像だけで何のグラフか分かる。
660
+ */
661
+ exportHeader?: ChartExportHeader
662
+ }
663
+
664
+ /** The heading a saved PNG carries above the plot. */
665
+ export interface ChartExportHeader {
666
+ title?: string
667
+ summary?: string
668
+ }
669
+
670
+ const HEADER_PAD_X = 16
671
+ const HEADER_PAD_TOP = 16
672
+ const HEADER_PAD_BOTTOM = 12
673
+ const HEADER_LINE_HEIGHT = 20
674
+ const HEADER_GAP = 4
675
+ const HEADER_FONT_SIZE = 14
676
+
677
+ export interface ChartExportHeaderLine {
678
+ text: string
679
+ kind: "title" | "summary"
680
+ }
681
+
682
+ /**
683
+ * Break `text` into lines no wider than `maxWidth` (px, via `measure`), character
684
+ * by character so Japanese wraps without spaces. Always at least one line for
685
+ * non-empty text; a single over-wide character still gets its own line.
686
+ */
687
+ export function wrapChartText(text: string, maxWidth: number, measure: (text: string) => number): string[] {
688
+ const lines: string[] = []
689
+ let line = ""
690
+ for (const ch of text) {
691
+ if (ch === "\n") {
692
+ lines.push(line)
693
+ line = ""
694
+ continue
695
+ }
696
+ if (line !== "" && measure(line + ch) > maxWidth) {
697
+ lines.push(line)
698
+ line = ch.trim() === "" ? "" : ch
699
+ } else {
700
+ line += ch
701
+ }
702
+ }
703
+ if (line !== "" || lines.length === 0) lines.push(line)
704
+ return lines.filter((l, i) => l !== "" || i < lines.length - 1)
705
+ }
706
+
707
+ /**
708
+ * The lines and total height (CSS px) of the PNG's heading band for a plot
709
+ * `width` px wide. `measure` takes (text, kind) and returns its width in px.
710
+ * No title and no summary → no band (`height` 0).
711
+ */
712
+ export function chartExportHeaderLayout(
713
+ header: ChartExportHeader | undefined,
714
+ width: number,
715
+ measure: (text: string, kind: "title" | "summary") => number,
716
+ ): { lines: ChartExportHeaderLine[]; height: number } {
717
+ const title = header?.title?.trim()
718
+ const summary = header?.summary?.trim()
719
+ if (!title && !summary) return { lines: [], height: 0 }
720
+ const room = Math.max(40, width - HEADER_PAD_X * 2)
721
+ const lines: ChartExportHeaderLine[] = [
722
+ ...(title ? wrapChartText(title, room, (t) => measure(t, "title")).map((text) => ({ text, kind: "title" as const })) : []),
723
+ ...(summary ? wrapChartText(summary, room, (t) => measure(t, "summary")).map((text) => ({ text, kind: "summary" as const })) : []),
724
+ ]
725
+ const gap = title && summary ? HEADER_GAP : 0
726
+ return { lines, height: HEADER_PAD_TOP + lines.length * HEADER_LINE_HEIGHT + gap + HEADER_PAD_BOTTOM }
727
+ }
728
+
729
+ const TEXT = {
730
+ ja: { chart: "チャート", copied: "コピーしました", copy: "PNG をクリップボードにコピー", download: "PNG をダウンロード" },
731
+ en: { chart: "Chart", copied: "Copied", copy: "Copy PNG to clipboard", download: "Download PNG" },
732
+ }
733
+
734
+ function loadImage(src: string): Promise<HTMLImageElement> {
735
+ return new Promise((resolve, reject) => {
736
+ const image = new Image()
737
+ image.onload = () => resolve(image)
738
+ image.onerror = () => reject(new Error("Couldn't load the PNG"))
739
+ image.src = src
740
+ })
741
+ }
742
+
743
+ /** Draw a heading band (title in foreground, summary muted) above the exported plot and return the new PNG. */
744
+ async function pngWithHeader(
745
+ dataUrl: string,
746
+ header: ChartExportHeader,
747
+ look: { background: string; color: string; mutedColor: string; fontFamily: string },
748
+ pixelRatio: number,
749
+ ): Promise<string> {
750
+ const image = await loadImage(dataUrl)
751
+ const width = image.width / pixelRatio
752
+ const plotHeight = image.height / pixelRatio
753
+ const canvas = document.createElement("canvas")
754
+ const ctx = canvas.getContext("2d")
755
+ if (!ctx) return dataUrl
756
+ const font = (kind: "title" | "summary") => `${kind === "title" ? 500 : 400} ${HEADER_FONT_SIZE}px ${look.fontFamily}`
757
+ ctx.font = font("title")
758
+ const layout = chartExportHeaderLayout(header, width, (text, kind) => {
759
+ ctx.font = font(kind)
760
+ return ctx.measureText(text).width
761
+ })
762
+ if (layout.height === 0) return dataUrl
763
+ canvas.width = Math.round(image.width)
764
+ canvas.height = Math.round((layout.height + plotHeight) * pixelRatio)
765
+ ctx.scale(pixelRatio, pixelRatio)
766
+ ctx.fillStyle = look.background
767
+ ctx.fillRect(0, 0, width, layout.height + plotHeight)
768
+ ctx.textBaseline = "middle"
769
+ let y = HEADER_PAD_TOP
770
+ let previous: "title" | "summary" | null = null
771
+ for (const line of layout.lines) {
772
+ if (previous === "title" && line.kind === "summary") y += HEADER_GAP
773
+ ctx.font = font(line.kind)
774
+ ctx.fillStyle = line.kind === "title" ? look.color : look.mutedColor
775
+ ctx.fillText(line.text, HEADER_PAD_X, y + HEADER_LINE_HEIGHT / 2)
776
+ y += HEADER_LINE_HEIGHT
777
+ previous = line.kind
778
+ }
779
+ ctx.drawImage(image, 0, layout.height, width, plotHeight)
780
+ return canvas.toDataURL("image/png")
781
+ }
782
+
783
+ /**
784
+ * Canvas-safe versions of theme colours, kept current across light/dark.
785
+ * Takes `"chart-1"`〜`"chart-5"`, `"var(--token)"` or any CSS colour; one that
786
+ * can't be resolved comes back as `""` (ECharts then uses the palette).
787
+ * Use it whenever a colour goes into a raw `EChart` option by hand.
788
+ *
789
+ * @example
790
+ * const [line, band] = useResolvedChartColors(["chart-1", "var(--muted-foreground)"])
791
+ */
792
+ export function useResolvedChartColors(colors: readonly string[]): string[] {
793
+ const tokens = useChartTokens()
794
+ const key = colors.join("\u0000")
795
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `key` stands for the array's contents
796
+ return useMemo(() => resolveChartColors(colors, tokens), [key, tokens])
797
+ }
798
+
799
+ type EChartsEventHandler = (params: unknown) => void
800
+
801
+ /**
802
+ * ECharts component main types. Listed in `replaceMerge`, a type keeps only the
803
+ * components whose `id` matches (merged, so they can transition) and drops the
804
+ * rest — including every one of them when the new option no longer has the type.
805
+ */
806
+ const COMPONENT_MAIN_TYPES = new Set([
807
+ "title", "legend", "grid", "xAxis", "yAxis", "polar", "radiusAxis", "angleAxis", "radar",
808
+ "dataZoom", "visualMap", "tooltip", "axisPointer", "toolbox", "brush", "geo", "parallel",
809
+ "parallelAxis", "singleAxis", "graphic", "calendar", "dataset", "series", "matrix", "thumbnail",
810
+ ])
811
+
812
+ export interface EChartSetOptionOpts {
813
+ notMerge: boolean
814
+ replaceMerge?: string[]
815
+ lazyUpdate: boolean
816
+ }
817
+
818
+ /**
819
+ * How to apply `next` after `prev`: the first option and timeline / media
820
+ * options replace everything; otherwise merge, replacing every component type
821
+ * either option uses so nothing stale survives.
822
+ */
823
+ export function echartSetOptionOpts(prev: EChartsOption | null, next: EChartsOption): EChartSetOptionOpts {
824
+ const special = (o: EChartsOption) => "timeline" in o || "baseOption" in o || "options" in o || "media" in o
825
+ if (!prev || special(prev) || special(next)) return { notMerge: true, lazyUpdate: true }
826
+ const types = new Set<string>()
827
+ for (const key of [...Object.keys(prev), ...Object.keys(next)]) if (COMPONENT_MAIN_TYPES.has(key)) types.add(key)
828
+ return { notMerge: false, replaceMerge: [...types], lazyUpdate: true }
829
+ }
830
+
831
+ /**
832
+ * Component types that get a generated `id` when the option has none, so replaceMerge can match them across updates.
833
+ * Not `dataset` / `graphic` / `tooltip`: they are rebuilt on every update (there is nothing to transition).
834
+ */
835
+ const ID_TYPES = [
836
+ "title", "legend", "grid", "xAxis", "yAxis", "polar", "radiusAxis", "angleAxis", "radar",
837
+ "dataZoom", "visualMap", "geo", "singleAxis", "calendar",
838
+ ] as const
839
+
840
+ /**
841
+ * What `withStableIds` remembers between updates: per generated id, the shape last applied and how many
842
+ * times it changed (the id's `~v<n>` suffix); per component type, the order of its ids.
843
+ */
844
+ export interface StableIdState {
845
+ ids: Map<string, { shape: string; version: number }>
846
+ order: Map<string, string[]>
847
+ }
848
+ export const createStableIdState = (): StableIdState => ({ ids: new Map(), order: new Map() })
849
+
850
+ /**
851
+ * Shape of a component: which keys it has, recursively (arrays only as "array", values not at all —
852
+ * except `type`). A merge keeps keys the new option dropped, so a changed shape must not be merged.
853
+ */
854
+ function shapeOf(v: unknown): string {
855
+ if (Array.isArray(v)) return "[]"
856
+ if (!v || typeof v !== "object" || Object.getPrototypeOf(v) !== Object.prototype) return "."
857
+ const o = v as Record<string, unknown>
858
+ return `{${Object.keys(o)
859
+ .filter((k) => o[k] !== undefined)
860
+ .sort()
861
+ .map((k) => (k === "type" || k === "id" ? `${k}=${String(o[k])}` : `${k}:${shapeOf(o[k])}`))
862
+ .join(",")}}`
863
+ }
864
+
865
+ /**
866
+ * Gives every series (and the common components) an `id` it does not have yet —
867
+ * series: `series:<name ?? index>`, others `<type>:<index>` — so an update that
868
+ * only changes values is matched to the existing component and transitions
869
+ * instead of being rebuilt. If the component's shape changed since `state` last
870
+ * saw it (a key was removed or added, the type changed), the id gets a new
871
+ * `~v<n>` suffix so replaceMerge builds it from scratch: the result then always
872
+ * equals applying the option with `notMerge`. Explicit ids are kept; the input is not mutated.
873
+ */
874
+ export function withStableIds(option: EChartsOption, state: StableIdState = createStableIdState()): EChartsOption {
875
+ const out: Record<string, unknown> = { ...(option as Record<string, unknown>) }
876
+ const taken = new Set<string>()
877
+ const list = (v: unknown): Record<string, unknown>[] | null =>
878
+ Array.isArray(v) ? (v as Record<string, unknown>[]) : v && typeof v === "object" ? [v as Record<string, unknown>] : null
879
+ const give = (key: string, pick: (c: Record<string, unknown>, i: number) => string) => {
880
+ const items = list(out[key])
881
+ if (!items) return
882
+ const used = new Set<string>(items.flatMap((c) => (c && c.id != null ? [String(c.id)] : [])))
883
+ const bases = items.map((c, i) => {
884
+ if (!c || typeof c !== "object" || c.id != null) return null
885
+ let base = pick(c, i)
886
+ for (let n = 2; used.has(base) || taken.has(`${key}\u0000${base}`); n++) base = `${pick(c, i)}#${n}`
887
+ used.add(base)
888
+ taken.add(`${key}\u0000${base}`)
889
+ return base
890
+ })
891
+ // A merge fills the empty slots a removal left and keeps the slot order of what exists, so after any
892
+ // change in which components there are (or their order) it would not match a fresh render: rebuild them all.
893
+ const prevOrder = state.order.get(key) ?? []
894
+ const cur = bases.filter((b): b is string => b !== null)
895
+ const inOrder = cur.length === prevOrder.length && cur.every((b, i) => b === prevOrder[i])
896
+ state.order.set(key, cur)
897
+ out[key] = items.map((c, i) => {
898
+ const base = bases[i]
899
+ if (base == null) return c
900
+ const shape = shapeOf(c)
901
+ const prev = state.ids.get(base)
902
+ const version = !prev ? 0 : prev.shape === shape && inOrder ? prev.version : prev.version + 1
903
+ state.ids.set(base, { shape, version })
904
+ return { ...c, id: version ? `${base}~v${version}` : base }
905
+ })
906
+ }
907
+ give("series", (c, i) => `series:${typeof c.name === "string" && c.name ? c.name : i}`)
908
+ for (const key of ID_TYPES) give(key, (_c, i) => `${key}:${i}`)
909
+ return out as EChartsOption
910
+ }
911
+
912
+ /** Copy of the plain objects / arrays of an option (functions, class instances, Dates stay shared), to compare against later in-place edits. */
913
+ export function snapshotOption<T>(v: T): T {
914
+ if (Array.isArray(v)) return v.map(snapshotOption) as T
915
+ if (v && typeof v === "object") {
916
+ const proto = Object.getPrototypeOf(v)
917
+ if (proto === Object.prototype || proto === null) {
918
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, snapshotOption(x)])) as T
919
+ }
920
+ }
921
+ return v
922
+ }
923
+
924
+ /** Structural equality for option trees; functions and other non-plain objects compare by reference. */
925
+ export function optionEquals(a: unknown, b: unknown): boolean {
926
+ if (Object.is(a, b)) return true
927
+ if (typeof a !== "object" || typeof b !== "object" || !a || !b) return false
928
+ if (Array.isArray(a) !== Array.isArray(b)) return false
929
+ if (Array.isArray(a)) {
930
+ const bb = b as unknown[]
931
+ return a.length === bb.length && a.every((v, i) => optionEquals(v, bb[i]))
932
+ }
933
+ if (a instanceof Date || b instanceof Date) return a instanceof Date && b instanceof Date && a.getTime() === b.getTime()
934
+ const pa = Object.getPrototypeOf(a)
935
+ if ((pa !== Object.prototype && pa !== null) || Object.getPrototypeOf(b) !== pa) return false
936
+ const ka = Object.keys(a)
937
+ const kb = Object.keys(b)
938
+ 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]))
939
+ }
940
+
941
+ /** `graphic` text doesn't inherit the global `textStyle`, so give it the inherited font unless it sets its own. */
942
+ function withGraphicFont(option: EChartsOption, fontFamily: string | undefined): EChartsOption {
943
+ const graphic = (option as { graphic?: unknown }).graphic
944
+ if (!fontFamily || !Array.isArray(graphic)) return option
945
+ return {
946
+ ...option,
947
+ graphic: graphic.map((g: { type?: string; style?: Record<string, unknown> }) =>
948
+ g && g.type === "text" && g.style && !g.style.fontFamily ? { ...g, style: { ...g.style, fontFamily } } : g,
949
+ ),
950
+ } as EChartsOption
951
+ }
952
+
953
+ /** `option` as applied: reduced motion forces `animation: false`; otherwise animation stays explicit so it can flip back. */
954
+ export function withMotionPreference(option: EChartsOption, reducedMotion: boolean): EChartsOption {
955
+ return { ...option, animation: reducedMotion ? false : (option.animation ?? true) }
956
+ }
957
+
958
+ const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"
959
+
960
+ /** `prefers-reduced-motion: reduce`, kept current. `false` on the server. */
961
+ export function usePrefersReducedMotion(): boolean {
962
+ const [reduced, setReduced] = useState(
963
+ () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia(REDUCED_MOTION_QUERY).matches,
964
+ )
965
+ useEffect(() => {
966
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return
967
+ const media = window.matchMedia(REDUCED_MOTION_QUERY)
968
+ const sync = () => setReduced(media.matches)
969
+ sync()
970
+ media.addEventListener("change", sync)
971
+ return () => media.removeEventListener("change", sync)
972
+ }, [])
973
+ return reduced
974
+ }
975
+
976
+ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
977
+ {
978
+ option,
979
+ height,
980
+ loading = false,
981
+ theme,
982
+ onEvents,
983
+ ariaLabel,
984
+ className,
985
+ actions = false,
986
+ fileName = "chart.png",
987
+ exportHeader,
988
+ },
989
+ ref,
990
+ ) {
991
+ const containerRef = useRef<HTMLDivElement | null>(null)
992
+ const [instance, setInstance] = useState<ECharts | null>(null)
993
+ const [copied, setCopied] = useState(false)
994
+ const t = TEXT[uiLanguage(useLocale())]
995
+ const tokens = useChartTokens(containerRef)
996
+ const reducedMotion = usePrefersReducedMotion()
997
+ // What the current instance last received, to pick the merge mode of the next update.
998
+ const appliedRef = useRef<EChartsOption | null>(null)
999
+ // The option (before ids) as it was when applied, deep-copied; and the shape history behind the generated ids.
1000
+ const snapshotRef = useRef<EChartsOption | null>(null)
1001
+ const idStateRef = useRef<StableIdState>(createStableIdState())
1002
+
1003
+ const autoTheme = useMemo(() => (tokens ? buildChartTheme(tokens) : undefined), [tokens])
1004
+ const resolvedTheme = theme ?? autoTheme
1005
+
1006
+ // init / re-init: ECharts はテーマの差し替えに対応しないので、テーマが変わったら
1007
+ // インスタンスを破棄して作り直す(トークンが実際に変わったときだけここに来る)
1008
+ useEffect(() => {
1009
+ const container = containerRef.current
1010
+ if (!container) return
1011
+ const inst = init(container, resolvedTheme, { renderer: "canvas" })
1012
+ appliedRef.current = null
1013
+ snapshotRef.current = null
1014
+ // `data-rendering` stays on until the (animated) paint is done, so anything
1015
+ // capturing the page — printing a document — can wait for real pixels.
1016
+ container.dataset.rendering = ""
1017
+ inst.on("finished", () => {
1018
+ delete container.dataset.rendering
1019
+ })
1020
+ setInstance(inst)
1021
+
1022
+ // At most one resize per frame (the sidebar animates the width).
1023
+ let frame = 0
1024
+ const observer = new ResizeObserver(() => {
1025
+ cancelAnimationFrame(frame)
1026
+ frame = requestAnimationFrame(() => {
1027
+ if (!inst.isDisposed()) inst.resize()
1028
+ })
1029
+ })
1030
+ observer.observe(container)
1031
+
1032
+ return () => {
1033
+ observer.disconnect()
1034
+ cancelAnimationFrame(frame)
1035
+ inst.dispose()
1036
+ }
1037
+ }, [resolvedTheme])
1038
+
1039
+ useEffect(() => {
1040
+ if (!instance || instance.isDisposed()) return
1041
+ const prepared = withGraphicFont(
1042
+ withMotionPreference(option, reducedMotion),
1043
+ theme === undefined ? tokens?.fontFamily : undefined,
1044
+ )
1045
+ // Same content as last time (a re-render that built an equal option): nothing to redraw.
1046
+ // Compared with a snapshot, so an in-place edit of a nested array of the caller's option still counts as a change.
1047
+ if (snapshotRef.current && optionEquals(snapshotRef.current, prepared)) return
1048
+ if (containerRef.current) containerRef.current.dataset.rendering = ""
1049
+ const next = withStableIds(prepared, idStateRef.current)
1050
+ instance.setOption(next, echartSetOptionOpts(appliedRef.current, next))
1051
+ appliedRef.current = next
1052
+ snapshotRef.current = snapshotOption(prepared)
1053
+ }, [instance, option, reducedMotion, theme, tokens?.fontFamily])
1054
+
1055
+ useEffect(() => {
1056
+ if (!instance || instance.isDisposed()) return
1057
+ if (loading) instance.showLoading()
1058
+ else instance.hideLoading()
1059
+ }, [instance, loading])
1060
+
1061
+ useEffect(() => {
1062
+ if (!instance || instance.isDisposed() || !onEvents) return
1063
+ const entries = Object.entries(onEvents)
1064
+ for (const [event, handler] of entries) {
1065
+ instance.on(event, handler as EChartsEventHandler)
1066
+ }
1067
+ return () => {
1068
+ if (instance.isDisposed()) return
1069
+ for (const [event, handler] of entries) {
1070
+ instance.off(event, handler as EChartsEventHandler)
1071
+ }
1072
+ }
1073
+ }, [instance, onEvents])
1074
+
1075
+ const includeTitle = typeof actions === "object" ? (actions.includeTitle ?? true) : true
1076
+ const getPngDataUrl = useCallback(async (): Promise<string | null> => {
1077
+ if (!instance) return null
1078
+ // Export over the nearest opaque background (card, then page) so the PNG
1079
+ // matches what is on screen in dark too. Computed styles resolve to rgb(a),
1080
+ // so this stays canvas-safe whatever the token.
1081
+ let backgroundColor = "#ffffff"
1082
+ for (let el: HTMLElement | null = containerRef.current; el; el = el.parentElement) {
1083
+ const bg = getComputedStyle(el).backgroundColor
1084
+ if (bg && bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent") {
1085
+ backgroundColor = bg
1086
+ break
1087
+ }
1088
+ }
1089
+ const pixelRatio = 2
1090
+ const dataUrl = instance.getDataURL({ type: "png", pixelRatio, backgroundColor })
1091
+ if (!includeTitle || !exportHeader || !(exportHeader.title || exportHeader.summary) || !containerRef.current) return dataUrl
1092
+ try {
1093
+ const style = getComputedStyle(containerRef.current)
1094
+ return await pngWithHeader(
1095
+ dataUrl,
1096
+ exportHeader,
1097
+ { background: backgroundColor, color: style.color, mutedColor: tokens?.mutedForeground ?? style.color, fontFamily: style.fontFamily },
1098
+ pixelRatio,
1099
+ )
1100
+ } catch {
1101
+ return dataUrl
1102
+ }
1103
+ }, [instance, includeTitle, exportHeader, tokens])
1104
+
1105
+ const handleCopy = useCallback(async () => {
1106
+ const dataUrl = await getPngDataUrl()
1107
+ if (!dataUrl) return
1108
+ try {
1109
+ const blob = await (await fetch(dataUrl)).blob()
1110
+ await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })])
1111
+ setCopied(true)
1112
+ setTimeout(() => setCopied(false), 2000)
1113
+ } catch {
1114
+ // ClipboardItem 未対応ブラウザでは silent fail
1115
+ }
1116
+ }, [getPngDataUrl])
1117
+
1118
+ const handleDownload = useCallback(async () => {
1119
+ const dataUrl = await getPngDataUrl()
1120
+ if (!dataUrl) return
1121
+ const link = document.createElement("a")
1122
+ link.href = dataUrl
1123
+ link.download = fileName
1124
+ link.click()
1125
+ }, [getPngDataUrl, fileName])
1126
+
1127
+ // 高さの解決: height prop を渡した場合はインライン style が勝つ。未指定なら
1128
+ // h-[400px] をデフォルトで当て、className の高さクラスに後勝ちさせる。
1129
+ const inFrame = useContext(ChartFrameContext)
1130
+ const styleHeight =
1131
+ height == null ? undefined : typeof height === "number" ? `${height}px` : height
1132
+
1133
+ return (
1134
+ <div
1135
+ ref={ref}
1136
+ data-slot="echart"
1137
+ className={cn("group relative w-full", height == null && "h-[400px]", className)}
1138
+ style={styleHeight != null ? { height: styleHeight } : undefined}
1139
+ >
1140
+ {/* `role="img"` wraps the canvas only, so the action buttons stay reachable. Inside a ChartFrame the plot area is the one image. */}
1141
+ <div
1142
+ ref={containerRef}
1143
+ data-slot="echart-canvas"
1144
+ className="h-full w-full"
1145
+ role={inFrame ? undefined : "img"}
1146
+ aria-label={inFrame ? undefined : (ariaLabel ?? t.chart)}
1147
+ />
1148
+ {actions && (
1149
+ <div
1150
+ data-slot="echart-actions"
1151
+ 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"
1152
+ >
1153
+ <Button
1154
+ type="button"
1155
+ variant="ghost"
1156
+ size="icon-sm"
1157
+ onClick={handleCopy}
1158
+ aria-label={copied ? t.copied : t.copy}
1159
+ >
1160
+ {copied ? <Check className="size-4" /> : <Copy className="size-4" />}
1161
+ </Button>
1162
+ <Button
1163
+ type="button"
1164
+ variant="ghost"
1165
+ size="icon-sm"
1166
+ onClick={handleDownload}
1167
+ aria-label={t.download}
1168
+ >
1169
+ <Download className="size-4" />
1170
+ </Button>
1171
+ </div>
1172
+ )}
1173
+ </div>
1174
+ )
1175
+ })
1176
+ EChart.displayName = "EChart"
1177
+
1178
+ export type { EChartsOption } from "echarts"