@squadbase/vantage 0.6.2-dev1.5aae04e → 0.6.2-dev1.5c79ef5

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 (228) hide show
  1. package/dist/{build-X5NVCQKT.js → build-U7GO26HR.js} +7 -7
  2. package/dist/{build-X5NVCQKT.js.map → build-U7GO26HR.js.map} +1 -1
  3. package/dist/{check-ZBIH422T.js → check-MRVAIHG5.js} +5 -5
  4. package/dist/{check-ZBIH422T.js.map → check-MRVAIHG5.js.map} +1 -1
  5. package/dist/chunk-2IAPIOPS.js +152 -0
  6. package/dist/chunk-2IAPIOPS.js.map +1 -0
  7. package/dist/chunk-3RJEOP3J.js +174 -0
  8. package/dist/chunk-3RJEOP3J.js.map +1 -0
  9. package/dist/{chunk-HKSVYMYJ.js → chunk-77CISBMG.js} +1049 -1387
  10. package/dist/chunk-77CISBMG.js.map +1 -0
  11. package/dist/{chunk-V3LF7BE7.js → chunk-ANZLVSEP.js} +4 -5
  12. package/dist/chunk-ANZLVSEP.js.map +1 -0
  13. package/dist/{chunk-XQUSGIDG.js → chunk-CKWPK4RO.js} +10 -3
  14. package/dist/chunk-CKWPK4RO.js.map +1 -0
  15. package/dist/chunk-EG4PX726.js +678 -0
  16. package/dist/chunk-EG4PX726.js.map +1 -0
  17. package/dist/{chunk-2N72FKLB.js → chunk-NZ5QIIG4.js} +33 -11
  18. package/dist/chunk-NZ5QIIG4.js.map +1 -0
  19. package/dist/{chunk-7DZGJQ3P.js → chunk-PJ4SPXHS.js} +23 -6
  20. package/dist/chunk-PJ4SPXHS.js.map +1 -0
  21. package/dist/{chunk-XPQSTZUU.js → chunk-QNOX7WLZ.js} +5 -4
  22. package/dist/chunk-QNOX7WLZ.js.map +1 -0
  23. package/dist/{chunk-PDOBHMUQ.js → chunk-ROHNXP7J.js} +72 -3
  24. package/dist/chunk-ROHNXP7J.js.map +1 -0
  25. package/dist/{chunk-CEXAQQ5W.js → chunk-S5MDTHH7.js} +180 -18
  26. package/dist/chunk-S5MDTHH7.js.map +1 -0
  27. package/dist/{chunk-2ICNHUG6.js → chunk-SDJTTMLK.js} +64 -7
  28. package/dist/chunk-SDJTTMLK.js.map +1 -0
  29. package/dist/{chunk-WAMZWZJB.js → chunk-SHZM3EXB.js} +19 -8
  30. package/dist/chunk-SHZM3EXB.js.map +1 -0
  31. package/dist/chunk-TRMHNMMZ.js +61 -0
  32. package/dist/chunk-TRMHNMMZ.js.map +1 -0
  33. package/dist/{chunk-JKLLFVEU.js → chunk-UWSQUHX6.js} +4 -99
  34. package/dist/chunk-UWSQUHX6.js.map +1 -0
  35. package/dist/cli.js +6 -6
  36. package/dist/client/index.d.ts +2 -0
  37. package/dist/client/index.js +8 -2
  38. package/dist/client/index.js.map +1 -1
  39. package/dist/components/index.d.ts +57 -273
  40. package/dist/components/index.js +6 -3
  41. package/dist/delta-text-CbrOaW83.d.ts +17 -0
  42. package/dist/{dev-4XEDTELV.js → dev-O42JFQ64.js} +7 -7
  43. package/dist/{dev-4XEDTELV.js.map → dev-O42JFQ64.js.map} +1 -1
  44. package/dist/doc/index.d.ts +215 -2
  45. package/dist/doc/index.js +422 -4
  46. package/dist/doc/index.js.map +1 -1
  47. package/dist/{doctor-5E2VEZPK.js → doctor-YD3XAAOT.js} +5 -5
  48. package/dist/{doctor-5E2VEZPK.js.map → doctor-YD3XAAOT.js.map} +1 -1
  49. package/dist/format-DihQkL1N.d.ts +249 -0
  50. package/dist/query/index.js +2 -2
  51. package/dist/{routes-3SGAFDMC.js → routes-PVKNT777.js} +4 -4
  52. package/dist/{routes-3SGAFDMC.js.map → routes-PVKNT777.js.map} +1 -1
  53. package/dist/slide/index.d.ts +272 -1
  54. package/dist/slide/index.js +1023 -2
  55. package/dist/slide/index.js.map +1 -1
  56. package/dist/table/index.js +9 -6
  57. package/dist/table/index.js.map +1 -1
  58. package/dist/ui/index.d.ts +5 -2
  59. package/dist/ui/index.js +6 -6
  60. package/dist/ui/index.js.map +1 -1
  61. package/dist/{upgrade-JHXZBGQS.js → upgrade-6DT7ADRP.js} +4 -4
  62. package/dist/{upgrade-JHXZBGQS.js.map → upgrade-6DT7ADRP.js.map} +1 -1
  63. package/dist/vite/index.js +3 -3
  64. package/docs/en/agent-skills.md +1 -1
  65. package/docs/en/changelog.md +2 -0
  66. package/docs/en/components.md +13 -2
  67. package/docs/en/doc/delta-text.md +40 -0
  68. package/docs/en/doc/doc-action-items.md +49 -0
  69. package/docs/en/doc/doc-callout.md +38 -0
  70. package/docs/en/doc/doc-data-table.md +60 -0
  71. package/docs/en/doc/doc-executive-summary.md +64 -0
  72. package/docs/en/doc/doc-figure.md +56 -0
  73. package/docs/en/doc/doc-key-findings.md +61 -0
  74. package/docs/en/doc/doc-kpi-scorecard.md +80 -0
  75. package/docs/en/doc/doc-notes-and-sources.md +43 -0
  76. package/docs/en/doc/doc-report-header.md +46 -0
  77. package/docs/en/pages-and-metadata.md +15 -0
  78. package/docs/en/parts/area-chart.md +99 -0
  79. package/docs/en/parts/bar-chart.md +91 -0
  80. package/docs/en/parts/bar-list.md +50 -0
  81. package/docs/en/parts/category-bar.md +37 -0
  82. package/docs/en/parts/chart-frame.md +39 -0
  83. package/docs/en/parts/delta.md +4 -3
  84. package/docs/en/parts/donut-chart.md +72 -0
  85. package/docs/en/parts/funnel-steps.md +2 -1
  86. package/docs/en/parts/heatmap.md +53 -0
  87. package/docs/en/parts/kpi-card.md +2 -1
  88. package/docs/en/parts/legend.md +35 -0
  89. package/docs/en/parts/line-chart.md +94 -0
  90. package/docs/en/parts/progress-circle.md +33 -0
  91. package/docs/en/parts/sample-data.md +39 -0
  92. package/docs/en/parts/scatter-chart.md +56 -0
  93. package/docs/en/parts/tracker.md +36 -0
  94. package/docs/en/parts/updated-at.md +30 -0
  95. package/docs/en/parts/waterfall-chart.md +56 -0
  96. package/docs/en/slide/slide-chart-full.md +48 -0
  97. package/docs/en/slide/slide-chart-insight.md +52 -0
  98. package/docs/en/slide/slide-columns.md +46 -0
  99. package/docs/en/slide/slide-comparison.md +68 -0
  100. package/docs/en/slide/slide-cover.md +39 -0
  101. package/docs/en/slide/slide-exec-summary.md +47 -0
  102. package/docs/en/slide/slide-heading.md +38 -0
  103. package/docs/en/slide/slide-kpi-grid.md +55 -0
  104. package/docs/en/slide/slide-kpi-hero.md +58 -0
  105. package/docs/en/slide/slide-next-actions.md +49 -0
  106. package/docs/en/slide/slide-scorecard-table.md +54 -0
  107. package/docs/en/slide/slide-section.md +44 -0
  108. package/docs/en/slide/slide-source-line.md +34 -0
  109. package/docs/en/ui/alert-dialog.md +42 -0
  110. package/docs/en/ui/avatar.md +31 -0
  111. package/docs/en/ui/combobox.md +74 -0
  112. package/docs/en/ui/drawer.md +41 -0
  113. package/docs/en/ui/empty.md +1 -1
  114. package/docs/en/ui/error-state.md +4 -3
  115. package/docs/en/ui/field.md +42 -0
  116. package/docs/en/ui/hover-card.md +21 -0
  117. package/docs/en/ui/kbd.md +17 -0
  118. package/docs/en/ui/loading.md +1 -1
  119. package/docs/en/ui/pagination.md +36 -0
  120. package/docs/en/ui/radio-group.md +21 -0
  121. package/docs/en/ui/toast.md +44 -0
  122. package/docs/en/ui/toggle-group.md +4 -3
  123. package/docs/index.json +857 -77
  124. package/docs/ja/agent-skills.md +1 -1
  125. package/docs/ja/changelog.md +2 -0
  126. package/docs/ja/components.md +12 -1
  127. package/docs/ja/doc/delta-text.md +40 -0
  128. package/docs/ja/doc/doc-action-items.md +49 -0
  129. package/docs/ja/doc/doc-callout.md +38 -0
  130. package/docs/ja/doc/doc-data-table.md +60 -0
  131. package/docs/ja/doc/doc-executive-summary.md +64 -0
  132. package/docs/ja/doc/doc-figure.md +56 -0
  133. package/docs/ja/doc/doc-key-findings.md +61 -0
  134. package/docs/ja/doc/doc-kpi-scorecard.md +80 -0
  135. package/docs/ja/doc/doc-notes-and-sources.md +43 -0
  136. package/docs/ja/doc/doc-report-header.md +46 -0
  137. package/docs/ja/page-types/slide.md +1 -0
  138. package/docs/ja/pages-and-metadata.md +15 -0
  139. package/docs/ja/parts/area-chart.md +49 -25
  140. package/docs/ja/parts/bar-chart.md +50 -24
  141. package/docs/ja/parts/bar-list.md +31 -8
  142. package/docs/ja/parts/chart-frame.md +36 -0
  143. package/docs/ja/parts/delta.md +5 -4
  144. package/docs/ja/parts/donut-chart.md +45 -26
  145. package/docs/ja/parts/funnel-steps.md +27 -8
  146. package/docs/ja/parts/heatmap.md +45 -0
  147. package/docs/ja/parts/kpi-card.md +2 -1
  148. package/docs/ja/parts/legend.md +36 -0
  149. package/docs/ja/parts/line-chart.md +48 -22
  150. package/docs/ja/parts/sample-data.md +61 -0
  151. package/docs/ja/parts/scatter-chart.md +69 -0
  152. package/docs/ja/parts/updated-at.md +27 -0
  153. package/docs/ja/parts/waterfall-chart.md +61 -0
  154. package/docs/ja/slide/slide-chart-full.md +48 -0
  155. package/docs/ja/slide/slide-chart-insight.md +52 -0
  156. package/docs/ja/slide/slide-columns.md +46 -0
  157. package/docs/ja/slide/slide-comparison.md +68 -0
  158. package/docs/ja/slide/slide-cover.md +39 -0
  159. package/docs/ja/slide/slide-exec-summary.md +47 -0
  160. package/docs/ja/slide/slide-heading.md +38 -0
  161. package/docs/ja/slide/slide-kpi-grid.md +55 -0
  162. package/docs/ja/slide/slide-kpi-hero.md +58 -0
  163. package/docs/ja/slide/slide-next-actions.md +49 -0
  164. package/docs/ja/slide/slide-scorecard-table.md +54 -0
  165. package/docs/ja/slide/slide-section.md +44 -0
  166. package/docs/ja/slide/slide-source-line.md +34 -0
  167. package/docs/ja/ui/empty.md +1 -1
  168. package/docs/ja/ui/error-state.md +4 -3
  169. package/docs/ja/ui/loading.md +1 -1
  170. package/docs/ja/ui/toggle-group.md +4 -3
  171. package/package.json +2 -2
  172. package/registry/ui/app-shell.tsx +138 -1
  173. package/registry/ui/area-chart.tsx +389 -104
  174. package/registry/ui/attention-list.tsx +177 -15
  175. package/registry/ui/bar-chart.tsx +386 -104
  176. package/registry/ui/bar-list.tsx +376 -94
  177. package/registry/ui/box-plot-chart.tsx +450 -116
  178. package/registry/ui/category-bar.tsx +292 -57
  179. package/registry/ui/chart-frame.tsx +301 -63
  180. package/registry/ui/data-table.tsx +229 -37
  181. package/registry/ui/date-range-picker.tsx +190 -36
  182. package/registry/ui/delta.tsx +181 -19
  183. package/registry/ui/donut-chart.tsx +385 -103
  184. package/registry/ui/echart.tsx +313 -58
  185. package/registry/ui/filter-bar.tsx +179 -20
  186. package/registry/ui/funnel-steps.tsx +354 -80
  187. package/registry/ui/heatmap.tsx +338 -71
  188. package/registry/ui/histogram-chart.tsx +440 -116
  189. package/registry/ui/kpi-card.tsx +254 -48
  190. package/registry/ui/legend.tsx +374 -93
  191. package/registry/ui/line-chart.tsx +389 -104
  192. package/registry/ui/multi-select.tsx +159 -9
  193. package/registry/ui/page-shell.tsx +148 -10
  194. package/registry/ui/paired-chart.tsx +394 -109
  195. package/registry/ui/progress-circle.tsx +205 -39
  196. package/registry/ui/sample-data.tsx +154 -10
  197. package/registry/ui/scatter-chart.tsx +436 -119
  198. package/registry/ui/searchable-select.tsx +145 -7
  199. package/registry/ui/slider.tsx +1 -1
  200. package/registry/ui/states.tsx +170 -13
  201. package/registry/ui/status-badge.tsx +201 -36
  202. package/registry/ui/tooltip.tsx +6 -2
  203. package/registry/ui/tracker.tsx +214 -42
  204. package/registry/ui/trend-indicator.tsx +151 -8
  205. package/registry/ui/updated-at.tsx +170 -16
  206. package/registry/ui/waterfall-chart.tsx +460 -121
  207. package/skills/vantage-design/SKILL.md +54 -54
  208. package/skills/vantage-design/references/extract.md +26 -27
  209. package/skills/vantage-design/references/format.md +12 -172
  210. package/skills/vantage-design/references/presets.md +50 -110
  211. package/skills/vantage-pitfalls/SKILL.md +5 -0
  212. package/skills/vantage-slide/SKILL.md +54 -36
  213. package/templates/AGENTS.md +32 -16
  214. package/templates/agents/doc.md +48 -0
  215. package/templates/agents/page.md +23 -2
  216. package/templates/agents/slide.md +77 -3
  217. package/theme.css +23 -19
  218. package/dist/chunk-2ICNHUG6.js.map +0 -1
  219. package/dist/chunk-2N72FKLB.js.map +0 -1
  220. package/dist/chunk-7DZGJQ3P.js.map +0 -1
  221. package/dist/chunk-CEXAQQ5W.js.map +0 -1
  222. package/dist/chunk-HKSVYMYJ.js.map +0 -1
  223. package/dist/chunk-JKLLFVEU.js.map +0 -1
  224. package/dist/chunk-PDOBHMUQ.js.map +0 -1
  225. package/dist/chunk-V3LF7BE7.js.map +0 -1
  226. package/dist/chunk-WAMZWZJB.js.map +0 -1
  227. package/dist/chunk-XPQSTZUU.js.map +0 -1
  228. package/dist/chunk-XQUSGIDG.js.map +0 -1
@@ -17,15 +17,148 @@ import { forwardRef } from "react"
17
17
 
18
18
  import { cn } from "@squadbase/vantage/ui"
19
19
  import type { ChartSeriesColor } from "@squadbase/vantage/components"
20
- import { createContext, useCallback, useEffect, useRef, useState, type ForwardedRef } from "react"
20
+ import { useSyncExternalStore } from "react"
21
+ import { createContext, useCallback, useEffect, useRef, useState, type ForwardedRef, type RefObject } from "react"
21
22
  import type { ChartTone } from "@squadbase/vantage/components"
22
- import { DEFAULT_LOCALE, EMPTY_VALUE, formatValue, toValueFormatter, type ValueFormat } from "@squadbase/vantage/components"
23
+ import { EMPTY_VALUE, formatValue, toValueFormatter, type ValueFormat } from "@squadbase/vantage/components"
23
24
  import type { ChartColor, ChartColors, ValueFormatter } from "@squadbase/vantage/components"
24
25
  import { useMemo, type ReactNode } from "react"
25
26
  import type { EChartsOption } from "@squadbase/vantage/components"
26
27
  import { computeComparison, defaultChangeDisplay, formatChange, toneForValue, type ComparisonKind, type PeriodGrain } from "@squadbase/vantage/components"
28
+ import { DEFAULT_LOCALE } from "@squadbase/vantage/components"
27
29
  import type { ChartThreshold, ChartAnnotation, ChartCommonProps, ChartEventPayload, ChartForecast, ChartForecastRow, ChartHighlight, NumericKeys } from "@squadbase/vantage/components"
28
30
 
31
+ /* ---------- inlined from @squadbase/squadkit registry/lib/locale.ts (internal to @squadbase/vantage) ---------- */
32
+
33
+ /**
34
+ * The display language of the parts and the defaults that follow it.
35
+ *
36
+ * The language comes from `<html lang>`: a tag starting with `ja` reads Japanese, anything else English.
37
+ * Without a usable `lang` (missing, invalid, or no `document` — SSR, Node) the locale is `FALLBACK_LOCALE`
38
+ * (`en-US`). A `locale` passed explicitly (a prop, an argument) always wins.
39
+ *
40
+ * Parts read the locale through `useLocale` (`hooks/use-locale`) and pass it down; the pure helpers here
41
+ * and in `format` / `comparison` / `chart-*` take it as an argument and fall back to `documentLocale()`.
42
+ */
43
+
44
+ /** The language of the built-in wording: Japanese or English. */
45
+ type UiLanguage = "ja" | "en"
46
+
47
+ /** The locale used when `<html lang>` gives none (and on the server). */
48
+ const FALLBACK_LOCALE = "en-US"
49
+
50
+ /** `"ja"` when `locale` starts with `ja` (`ja`, `ja-JP`), else `"en"`. A missing locale is `"en"`. */
51
+ function uiLanguage(locale: string | null | undefined): UiLanguage {
52
+ return typeof locale === "string" && locale.trim().toLowerCase().startsWith("ja") ? "ja" : "en"
53
+ }
54
+
55
+ /** A BCP 47 tag in its canonical form (`en-gb` → `en-GB`), or `FALLBACK_LOCALE` when it is empty or invalid. */
56
+ function normalizeLocale(tag: string | null | undefined): string {
57
+ const value = tag?.trim()
58
+ if (!value) return FALLBACK_LOCALE
59
+ try {
60
+ return Intl.getCanonicalLocales(value)[0] ?? FALLBACK_LOCALE
61
+ } catch {
62
+ return FALLBACK_LOCALE
63
+ }
64
+ }
65
+
66
+ /**
67
+ * The locale of the page: `document.documentElement.lang`, canonicalized. `FALLBACK_LOCALE` when it is empty or
68
+ * invalid, or there is no `document`. Parts read it through `useLocale`, never while rendering.
69
+ */
70
+ function documentLocale(): string {
71
+ if (typeof document === "undefined") return FALLBACK_LOCALE
72
+ return normalizeLocale(document.documentElement?.getAttribute("lang"))
73
+ }
74
+
75
+ /** `locale` when given, else the page's locale (`documentLocale()`). */
76
+ function resolveLocale(locale?: string | null): string {
77
+ return locale || documentLocale()
78
+ }
79
+
80
+ /** Picks the wording table of the locale's language. */
81
+ function pickLocale<T>(table: { ja: T; en: T }, locale?: string | null): T {
82
+ return table[uiLanguage(resolveLocale(locale))]
83
+ }
84
+
85
+ /** The currency `format="currency"` uses without a `currency`: JPY for Japanese, USD otherwise. */
86
+ function defaultCurrency(locale?: string | null): string {
87
+ return uiLanguage(resolveLocale(locale)) === "ja" ? "JPY" : "USD"
88
+ }
89
+
90
+ /** The month a fiscal year starts in (1–12) when none is given: April for Japanese, January otherwise. */
91
+ function defaultFiscalYearStartMonth(locale?: string | null): number {
92
+ return uiLanguage(resolveLocale(locale)) === "ja" ? 4 : 1
93
+ }
94
+
95
+ /** A day of the week, 0 = Sunday … 6 = Saturday (the `Date#getDay` / date-fns numbering). */
96
+ type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6
97
+
98
+ /** Locales whose week starts on Sunday when the runtime has no week data. */
99
+ const SUNDAY_FIRST = new Set(["en", "en-US", "en-CA"])
100
+
101
+ /**
102
+ * The first day of the week: Monday for Japanese; otherwise the locale's convention from `Intl.Locale`
103
+ * (`en-US` Sunday, `en-GB` Monday). Without week data in the runtime: Sunday for `en` / `en-US` / `en-CA`,
104
+ * Monday for the rest.
105
+ */
106
+ function weekStartsOn(locale?: string | null): WeekDay {
107
+ const tag = normalizeLocale(resolveLocale(locale))
108
+ if (uiLanguage(tag) === "ja") return 1
109
+ try {
110
+ const info = new Intl.Locale(tag) as Intl.Locale & {
111
+ getWeekInfo?: () => { firstDay: number }
112
+ weekInfo?: { firstDay: number }
113
+ }
114
+ const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay
115
+ if (typeof firstDay === "number" && firstDay >= 1 && firstDay <= 7) return (firstDay % 7) as WeekDay
116
+ } catch {
117
+ // Fall through to the table below.
118
+ }
119
+ return SUNDAY_FIRST.has(tag) ? 0 : 1
120
+ }
121
+
122
+ /* ---------- inlined from @squadbase/squadkit registry/hooks/use-locale.ts (internal to @squadbase/vantage) ---------- */
123
+
124
+ const listeners = new Set<() => void>()
125
+ let observer: MutationObserver | null = null
126
+
127
+ /** One observer on `<html lang>` for every subscribed part. */
128
+ function subscribe(onChange: () => void): () => void {
129
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {}
130
+ listeners.add(onChange)
131
+ if (!observer) {
132
+ observer = new MutationObserver(() => {
133
+ for (const listener of [...listeners]) listener()
134
+ })
135
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] })
136
+ }
137
+ return () => {
138
+ listeners.delete(onChange)
139
+ if (listeners.size === 0 && observer) {
140
+ observer.disconnect()
141
+ observer = null
142
+ }
143
+ }
144
+ }
145
+
146
+ const serverLocale = () => FALLBACK_LOCALE
147
+
148
+ /**
149
+ * The page's locale (`<html lang>`), following changes made while the page runs. On the server and during
150
+ * hydration it is `FALLBACK_LOCALE` (`en-US`), so the markup matches; it switches right after.
151
+ */
152
+ function useDocumentLocale(): string {
153
+ return useSyncExternalStore(subscribe, documentLocale, serverLocale)
154
+ }
155
+
156
+ /** The locale a part renders in: its `locale` prop when given, else the page's (`useDocumentLocale`). */
157
+ function useLocale(locale?: string | null): string {
158
+ const page = useDocumentLocale()
159
+ return locale || page
160
+ }
161
+
29
162
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-theme.ts (internal to @squadbase/vantage) ---------- */
30
163
 
31
164
  /**
@@ -60,10 +193,23 @@ interface ChartTokens {
60
193
  border: string | null
61
194
  /** Tooltip surface. */
62
195
  popover: string | null
196
+ /** Semantic and surface dependencies used by typed chart options. Optional for hand-built tokens. */
197
+ colors?: Record<string, string>
198
+ /** Actual inherited painting surface (may change without a token changing). */
199
+ surface?: string | null
63
200
  /** Tooltip text. */
64
201
  popoverForeground: string | null
65
202
  }
66
203
 
204
+ // Keep scope out of the public token shape. A resolved snapshot remembers where arbitrary
205
+ // var(--token) expressions must be read, without retaining unmounted charts globally.
206
+ const tokenScopes = new WeakMap<ChartTokens, Element>()
207
+ const OPTION_COLOR_TOKENS = [
208
+ "--background", "--card", "--muted", "--primary", "--destructive",
209
+ ...["positive", "negative", "warning", "neutral", "info"].flatMap((tone) =>
210
+ ["", "-fill", "-subtle"].map((suffix) => `--${tone}${suffix}`)),
211
+ ]
212
+
67
213
  let normalizeContext: CanvasRenderingContext2D | null | undefined
68
214
 
69
215
  /** The forms `fillStyle` serializes legacy colours to, and zrender can parse. */
@@ -119,8 +265,10 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
119
265
  const color = read(token)
120
266
  if (color) series.push(color)
121
267
  }
122
- return {
268
+ const tokens: ChartTokens = {
123
269
  series,
270
+ colors: Object.fromEntries(OPTION_COLOR_TOKENS.map((name) => [name, styles.getPropertyValue(name).trim()])),
271
+ surface: readChartSurface(target),
124
272
  ...(styles.fontFamily ? { fontFamily: styles.fontFamily } : {}),
125
273
  foreground: read("--foreground"),
126
274
  mutedForeground: read("--muted-foreground"),
@@ -128,6 +276,8 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
128
276
  popover: read("--popover"),
129
277
  popoverForeground: read("--popover-foreground"),
130
278
  }
279
+ tokenScopes.set(tokens, target)
280
+ return tokens
131
281
  }
132
282
 
133
283
  /** Structural equality, so a theme toggle that changes nothing doesn't re-init. */
@@ -135,6 +285,10 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
135
285
  if (a === b) return true
136
286
  if (!a || !b) return false
137
287
  return (
288
+ tokenScopes.get(a) === tokenScopes.get(b) &&
289
+ a.surface === b.surface &&
290
+ Object.keys(a.colors ?? {}).length === Object.keys(b.colors ?? {}).length &&
291
+ Object.entries(a.colors ?? {}).every(([name, value]) => b.colors?.[name] === value) &&
138
292
  a.series.length === b.series.length &&
139
293
  a.series.every((color, i) => color === b.series[i]) &&
140
294
  a.fontFamily === b.fontFamily &&
@@ -160,7 +314,7 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
160
314
  * attributes alone leaves the chart painted in the previous palette until a
161
315
  * reload — the tokens in the DOM are new, the canvas is not.
162
316
  */
163
- function watchChartTokens(onChange: () => void): () => void {
317
+ function watchChartTokens(onChange: () => void, scope?: Element | null): () => void {
164
318
  if (typeof document === "undefined") return () => {}
165
319
  // Style edits arrive as bursts of mutations; coalesce them into one read, and
166
320
  // take it after the next frame so a swapped stylesheet has been applied.
@@ -175,8 +329,11 @@ function watchChartTokens(onChange: () => void): () => void {
175
329
 
176
330
  const themeObserver = new MutationObserver(schedule)
177
331
  const attributeFilter = ["class", "style", "data-theme"]
178
- themeObserver.observe(document.documentElement, { attributes: true, attributeFilter })
179
- if (document.body) themeObserver.observe(document.body, { attributes: true, attributeFilter })
332
+ // Only the chart and its ancestors can change its inherited theme. Never observe
333
+ // the document subtree: canvas/tooltip/rendering mutations would trigger every chart.
334
+ for (let node: Element | null = scope ?? document.body ?? document.documentElement; node; node = node.parentElement) {
335
+ themeObserver.observe(node, { attributes: true, attributeFilter })
336
+ }
180
337
 
181
338
  const styleObserver = new MutationObserver(schedule)
182
339
  if (document.head) {
@@ -264,22 +421,23 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
264
421
  * components that build an ECharts option from token colours themselves (the
265
422
  * typed charts), so their palette follows light/dark like `EChart`'s theme.
266
423
  */
267
- function useChartTokens(): ChartTokens | null {
424
+ function useChartTokens(scope?: Element | null | RefObject<Element | null>): ChartTokens | null {
268
425
  const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
269
426
  // Compared here, not inside a `setTokens` updater: an updater that reads the DOM is
270
427
  // replayed by React on every rebase and returns a fresh object each time, so the state
271
428
  // never settles ("Maximum update depth exceeded" once the tokens change after mount).
272
429
  const tokensRef = useRef(tokens)
273
430
  useEffect(() => {
431
+ const target = scope && "current" in scope ? scope.current : scope
274
432
  const sync = () => {
275
- const next = readChartTokens()
433
+ const next = readChartTokens(target)
276
434
  if (sameChartTokens(tokensRef.current, next)) return
277
435
  tokensRef.current = next
278
436
  setTokens(next)
279
437
  }
280
438
  sync()
281
- return watchChartTokens(sync)
282
- }, [])
439
+ return watchChartTokens(sync, target)
440
+ }, [scope])
283
441
  return tokens
284
442
  }
285
443
 
@@ -291,7 +449,7 @@ const EMBEDDED_VAR_RE = /var\(\s*(--[\w-]+)\s*\)/g
291
449
  /**
292
450
  * Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
293
451
  * the resolved series tokens; `"positive"` / `"warning"` / `"negative"` / `"neutral"` (and the area
294
- * variants `"positive-fill"`, …) are read off the document root (`--positive`, …); `var(--token)` is read off the document root; any
452
+ * variants `"positive-fill"`, …) are read in the snapshot's inherited scope (`--positive`, …); `var(--token)` uses the same scope; any
295
453
  * other CSS colour is normalized like the tokens are. A colour that can't be
296
454
  * resolved comes back as `""`, which ECharts treats as "use the palette".
297
455
  */
@@ -302,12 +460,13 @@ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | nul
302
460
  // Meaning colours are the theme tokens of the same name: same path as `var(--positive)`.
303
461
  if (TONE_NAME_RE.test(color.trim())) {
304
462
  if (typeof document === "undefined") return ""
305
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue(`--${color.trim()}`)) ?? ""
463
+ const target = (tokens && tokenScopes.get(tokens)) ?? document.documentElement
464
+ return toCanvasColor(getComputedStyle(target).getPropertyValue(`--${color.trim()}`)) ?? ""
306
465
  }
307
466
  if (typeof document === "undefined") return color
308
467
  let value = color.trim()
309
468
  const cssVar = CSS_VAR_RE.exec(value)
310
- const styles = getComputedStyle(document.documentElement)
469
+ const styles = getComputedStyle((tokens && tokenScopes.get(tokens)) ?? document.documentElement)
311
470
  if (cssVar) {
312
471
  value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
313
472
  } else if (value.includes("var(")) {
@@ -353,7 +512,7 @@ function readChartSurface(el: Element | null): string | null {
353
512
  if (color) return color
354
513
  }
355
514
  }
356
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue("--background"))
515
+ return toCanvasColor(getComputedStyle(el ?? document.documentElement).getPropertyValue("--background"))
357
516
  }
358
517
 
359
518
  /** Widths are bucketed so a resize only rebuilds the option when the label budget can change. */
@@ -367,9 +526,10 @@ const WIDTH_BUCKET = 40
367
526
  */
368
527
  function useChartSurface<E extends Element>(
369
528
  forwarded: ForwardedRef<E>,
370
- tokens: ChartTokens | null,
371
- ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined } {
529
+ tokens?: ChartTokens | null,
530
+ ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined; tokens: ChartTokens | null } {
372
531
  const [node, setNode] = useState<E | null>(null)
532
+ const scopedTokens = useChartTokens(node)
373
533
  const [surface, setSurface] = useState<string | undefined>(undefined)
374
534
  const [width, setWidth] = useState<number | undefined>(undefined)
375
535
  const ref = useCallback(
@@ -383,7 +543,7 @@ function useChartSurface<E extends Element>(
383
543
  useEffect(() => {
384
544
  if (!node) return
385
545
  setSurface(readChartSurface(node) ?? undefined)
386
- }, [node, tokens])
546
+ }, [node, tokens, scopedTokens])
387
547
  useEffect(() => {
388
548
  if (!node || typeof ResizeObserver === "undefined") return
389
549
  const update = (w: number) => setWidth(Math.floor(w / WIDTH_BUCKET) * WIDTH_BUCKET)
@@ -392,7 +552,7 @@ function useChartSurface<E extends Element>(
392
552
  observer.observe(node)
393
553
  return () => observer.disconnect()
394
554
  }, [node])
395
- return { ref, surface, width }
555
+ return { ref, surface, width, tokens: scopedTokens }
396
556
  }
397
557
 
398
558
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-utils.ts (internal to @squadbase/vantage) ---------- */
@@ -502,9 +662,10 @@ function resolveValueFormatter(
502
662
  function resolveAxisFormatter(
503
663
  valueFormatter: ValueFormatter | undefined,
504
664
  format: ValueFormat | undefined,
505
- locale: string = DEFAULT_LOCALE,
665
+ localeArg?: string,
506
666
  ): ValueFormatter {
507
667
  if (valueFormatter) return valueFormatter
668
+ const locale = resolveLocale(localeArg)
508
669
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
509
670
  if (spec.type !== "compact" && spec.type !== "number" && spec.type !== "currency") return toValueFormatter(format, locale)
510
671
  const currency = "currency" in spec ? spec.currency : undefined
@@ -513,40 +674,75 @@ function resolveAxisFormatter(
513
674
  return (value) => formatValue(value, short, locale)
514
675
  }
515
676
 
516
- /** The unit words of a ja number (`1.2万` / `3.4億` / `1兆`) and their sizes, largest first. */
517
- const JA_UNITS = [
518
- { char: "兆", size: 1e12 },
519
- { char: "億", size: 1e8 },
520
- { char: "万", size: 1e4 },
521
- ] as const
677
+ interface CompactUnit {
678
+ token: string
679
+ size: number
680
+ }
681
+
682
+ const compactUnitCache = new Map<string, CompactUnit[] | null>()
683
+
684
+ /**
685
+ * The compact-notation unit words of a locale and their sizes, largest first: `兆` / `億` / `万` in ja,
686
+ * `T` / `B` / `M` / `K` in en. `null` when the locale's compact form puts anything between the number and its
687
+ * word (`1,5 Mio.`), where a token cannot be swapped in place.
688
+ */
689
+ function compactUnits(locale: string): CompactUnit[] | null {
690
+ const cached = compactUnitCache.get(locale)
691
+ if (cached !== undefined) return cached
692
+ let units: CompactUnit[] | null = []
693
+ try {
694
+ const compact = new Intl.NumberFormat(locale, { notation: "compact" })
695
+ const seen = new Set<string>()
696
+ for (let exp = 3; exp <= 15; exp++) {
697
+ const parts = compact.formatToParts(10 ** exp)
698
+ const at = parts.findIndex((p) => p.type === "compact")
699
+ if (at === -1) continue
700
+ if (parts[at - 1]?.type !== "integer" || at !== parts.length - 1) {
701
+ units = null
702
+ break
703
+ }
704
+ const token = parts[at]!.value
705
+ if (seen.has(token)) continue
706
+ seen.add(token)
707
+ units.push({ token, size: 10 ** exp })
708
+ }
709
+ } catch {
710
+ units = null
711
+ }
712
+ const sorted = units && units.length > 0 ? units.sort((a, b) => b.size - a.size) : null
713
+ compactUnitCache.set(locale, sorted)
714
+ return sorted
715
+ }
522
716
 
523
- const JA_NUMBER_TOKEN_RE = /\d[\d,]*(?:\.\d+)?(?:兆|億|万)?/
717
+ const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
524
718
 
525
719
  /**
526
720
  * The value-axis tick formatter for an axis whose largest tick is `top`: every tick reads in the unit of
527
- * that largest tick, so 万 and 億 never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`).
528
- * Only the ja short forms (`compact` / `number` / `currency`, no `valueFormatter`) have units to align;
529
- * any other format — and an axis whose largest tick is under 1万 — keeps `resolveAxisFormatter`'s output as it is.
721
+ * that largest tick, so units never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`;
722
+ * `$0.5M` / `$1M` / `$1.5M`, not `$500K` / `$1M`). Only the short forms (`compact` / `number` / `currency`,
723
+ * no `valueFormatter`) have units to align; any other format — an axis whose largest tick is under the smallest
724
+ * unit (1万 / 1K), or a locale whose compact form cannot be aligned in place — keeps `resolveAxisFormatter`'s output as it is.
530
725
  */
531
726
  function resolveAxisTickFormatter(
532
727
  valueFormatter: ValueFormatter | undefined,
533
728
  format: ValueFormat | undefined,
534
- locale: string = DEFAULT_LOCALE,
729
+ localeArg?: string,
535
730
  ): (top: number) => ValueFormatter {
731
+ const locale = resolveLocale(localeArg)
536
732
  const base = resolveAxisFormatter(valueFormatter, format, locale)
537
733
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
538
- const aligns =
539
- !valueFormatter && locale.toLowerCase().startsWith("ja") && (spec.type === "compact" || spec.type === "number" || spec.type === "currency")
540
- if (!aligns) return () => base
734
+ const units = !valueFormatter && (spec.type === "compact" || spec.type === "number" || spec.type === "currency") ? compactUnits(locale) : null
735
+ if (!units) return () => base
541
736
  const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 3 })
737
+ const numberToken = new RegExp(`\\d[\\d,]*(?:\\.\\d+)?(?:${units.map((u) => escapeRegExp(u.token)).join("|")})?`)
542
738
  return (top) => {
543
- const unit = JA_UNITS.find((u) => Math.abs(top) >= u.size)
739
+ const unit = units.find((u) => Math.abs(top) >= u.size)
544
740
  if (!unit) return base
545
741
  return (value) => {
546
742
  const text = base(value)
547
743
  if (value === 0 || !Number.isFinite(value)) return text
548
- const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.char}`
549
- return text.replace(JA_NUMBER_TOKEN_RE, () => scaled)
744
+ const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.token}`
745
+ return text.replace(numberToken, () => scaled)
550
746
  }
551
747
  }
552
748
  }
@@ -575,8 +771,20 @@ function clamp(value: number, min: number, max: number): number {
575
771
  /** How many categories a chart colours individually; the rest fold into「その他」. */
576
772
  const MAX_CHART_SERIES = 5
577
773
 
578
- /** The series name the folded categories get. */
579
- const OTHER_SERIES_LABEL = "その他"
774
+ /** The series name the folded categories get, by language. */
775
+ const OTHER_SERIES_LABELS: Record<UiLanguage, string> = { ja: "その他", en: "Other" }
776
+
777
+ /** The series name the folded categories get in `locale` ("Other" / "その他"). Default the page's locale (`<html lang>`). */
778
+ function otherSeriesLabel(locale?: string): string {
779
+ return OTHER_SERIES_LABELS[uiLanguage(resolveLocale(locale))]
780
+ }
781
+
782
+ const OTHER_NAMES = new Set(Object.values(OTHER_SERIES_LABELS))
783
+
784
+ /** Whether a category / row is the "other" bucket by name (「その他」 or "Other", whatever the page language). */
785
+ function isOtherName(name: string | null | undefined): boolean {
786
+ return typeof name === "string" && OTHER_NAMES.has(name)
787
+ }
580
788
 
581
789
  interface AggregatedItems<Item extends { name: string; value: number }> {
582
790
  /** The `n` largest items, in their given order. All items when there are `n` or fewer. */
@@ -596,7 +804,7 @@ interface AggregatedItems<Item extends { name: string; value: number }> {
596
804
  function aggregateTopN<Item extends { name: string; value: number }>(
597
805
  items: readonly Item[],
598
806
  n: number = MAX_CHART_SERIES,
599
- otherLabel: string = OTHER_SERIES_LABEL,
807
+ otherLabel: string = otherSeriesLabel(),
600
808
  ): AggregatedItems<Item> {
601
809
  if (items.length <= n) return { top: [...items], rest: [], other: null }
602
810
  const ranked = items
@@ -625,7 +833,7 @@ function aggregateSeries(
625
833
  data: readonly Record<string, unknown>[],
626
834
  categories: readonly string[],
627
835
  n: number = MAX_CHART_SERIES,
628
- otherLabel: string = OTHER_SERIES_LABEL,
836
+ otherLabel: string = otherSeriesLabel(),
629
837
  ): AggregatedSeries {
630
838
  const totals = categories.map((name) => ({
631
839
  name,
@@ -657,12 +865,14 @@ function aggregateSeriesKeeping(
657
865
  categories: readonly string[],
658
866
  keep: string | undefined,
659
867
  n: number = MAX_CHART_SERIES,
868
+ otherLabel: string = otherSeriesLabel(),
660
869
  ): AggregatedSeries {
661
- if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n)
870
+ if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n, otherLabel)
662
871
  const aggregated = aggregateSeries(
663
872
  data,
664
873
  categories.filter((c) => c !== keep),
665
874
  n,
875
+ otherLabel,
666
876
  )
667
877
  return { ...aggregated, categories: [...aggregated.categories, keep] }
668
878
  }
@@ -698,7 +908,7 @@ function seriesColorsWithComparison(
698
908
  * Colour per drawn series: the record's token for that category, else the
699
909
  * array's token at the category's position in `allCategories` (cycled), else
700
910
  * the token palette by drawn position. `otherCategory` — and a category the
701
- * data itself names「その他」(`OTHER_SERIES_LABEL`) — always gets `otherColor`.
911
+ * data itself names「その他」/ "Other" (`OTHER_SERIES_LABELS`) — always gets `otherColor`.
702
912
  */
703
913
  function seriesColorsFor(
704
914
  categories: readonly string[],
@@ -710,7 +920,7 @@ function seriesColorsFor(
710
920
  const list = Array.isArray(colors) ? (colors as ChartSeriesColor[]) : undefined
711
921
  const record = !Array.isArray(colors) && colors ? (colors as Partial<Record<string, ChartSeriesColor>>) : undefined
712
922
  return categories.map((category, i) => {
713
- if ((otherCategory !== null && category === otherCategory) || category === OTHER_SERIES_LABEL) return otherColor
923
+ if ((otherCategory !== null && category === otherCategory) || isOtherName(category)) return otherColor
714
924
  const fromRecord = record?.[category]
715
925
  if (fromRecord) return fromRecord
716
926
  if (list && list.length > 0) {
@@ -757,7 +967,7 @@ interface DonutSlicesConfig {
757
967
  */
758
968
  function donutSlices(
759
969
  data: readonly Record<string, unknown>[],
760
- { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = OTHER_SERIES_LABEL }: DonutSlicesConfig,
970
+ { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = otherSeriesLabel() }: DonutSlicesConfig,
761
971
  ): DonutSlice[] {
762
972
  const items = data
763
973
  .map((row, i) => {
@@ -770,7 +980,8 @@ function donutSlices(
770
980
  const slices: DonutSlice[] = top.map((item, rank) => ({
771
981
  name: item.name,
772
982
  value: item.value,
773
- color: palette[rank % palette.length]!,
983
+ // A row the data itself names「その他」/ "Other" is the same remainder: grey, like the folded slice.
984
+ color: isOtherName(item.name) ? otherColor : palette[rank % palette.length]!,
774
985
  isOther: false,
775
986
  row: item.row,
776
987
  }))
@@ -843,7 +1054,7 @@ interface BarListRowsConfig extends BarListOptions {
843
1054
  */
844
1055
  function barListRows<Row extends object>(
845
1056
  data: readonly Row[],
846
- { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = OTHER_SERIES_LABEL, exclude }: BarListRowsConfig = {},
1057
+ { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = otherSeriesLabel(), exclude }: BarListRowsConfig = {},
847
1058
  ): BarListRow<Row>[] {
848
1059
  const items = data.map((row): BarListRow<Row> => {
849
1060
  const cells = row as Record<string, unknown>
@@ -889,19 +1100,21 @@ const BAR_LIST_OTHER_TINT = 30
889
1100
 
890
1101
  /**
891
1102
  * The fill of each BarList row: all real rows in the same tint of `color`
892
- * (`BAR_LIST_TINT`), the folded「その他」row in a tint of `otherColor`
1103
+ * (`BAR_LIST_TINT`), the folded「その他」row (and a row the data names「その他」/ "Other") in a tint of `otherColor`
893
1104
  * (`BAR_LIST_OTHER_TINT`) — the palest fill, a remainder never outweighs a
894
1105
  * ranked row. A legacy per-row `color` (or a threshold tone) replaces `color`
895
1106
  * for that row. A tint, not the solid token, so the label printed over the bar
896
1107
  * keeps its contrast.
897
1108
  */
898
1109
  function barListFills(
899
- rows: readonly Pick<BarListRow, "isOther" | "color">[],
1110
+ rows: readonly (Pick<BarListRow, "isOther" | "color"> & { name?: string })[],
900
1111
  color: ChartColor,
901
1112
  otherColor: string,
902
1113
  ): string[] {
903
1114
  return rows.map((row) =>
904
- row.isOther ? tintCss(otherColor, BAR_LIST_OTHER_TINT) : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
1115
+ row.isOther || isOtherName(row.name)
1116
+ ? tintCss(otherColor, BAR_LIST_OTHER_TINT)
1117
+ : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
905
1118
  )
906
1119
  }
907
1120
 
@@ -1117,11 +1330,30 @@ interface ChartSummaryInput {
1117
1330
  format?: ValueFormat
1118
1331
  /** Wins over `format`. */
1119
1332
  valueFormatter?: ValueFormatter
1333
+ /** Language of the sentence. Default the page's locale (`<html lang>`). */
1334
+ locale?: string
1335
+ }
1336
+
1337
+ const SUMMARY_TEXT = {
1338
+ ja: {
1339
+ up: "、前の点より増加",
1340
+ down: "、前の点より減少",
1341
+ flat: "、前の点と横ばい",
1342
+ latest: (label: string, value: string) => `最新 ${label} は ${value}`,
1343
+ top: (name: string, share: string) => `最大は ${name}(${share})`,
1344
+ },
1345
+ en: {
1346
+ up: ", up from the previous point",
1347
+ down: ", down from the previous point",
1348
+ flat: ", flat from the previous point",
1349
+ latest: (label: string, value: string) => `Latest ${label}: ${value}`,
1350
+ top: (name: string, share: string) => `Largest: ${name} (${share})`,
1351
+ },
1120
1352
  }
1121
1353
 
1122
1354
  /**
1123
1355
  * One-sentence summary built from the rows, for `summary="auto"`:
1124
- * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」. The direction is
1356
+ * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」("<title>: Latest <index>: <value>, up from the previous point"). The direction is
1125
1357
  * computed from the last two non-empty points of the first series — never
1126
1358
  * written by hand, so it cannot contradict the data. `undefined` when there is
1127
1359
  * no usable point.
@@ -1137,30 +1369,59 @@ function buildChartSummary(input: ChartSummaryInput): string | undefined {
1137
1369
  }
1138
1370
  const latest = points[points.length - 1]
1139
1371
  if (!latest) return undefined
1140
- const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact")
1372
+ const locale = resolveLocale(input.locale)
1373
+ const t = SUMMARY_TEXT[uiLanguage(locale)]
1374
+ const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact", locale)
1141
1375
  const prev = points[points.length - 2]
1142
- const direction = !prev ? "" : latest.value > prev.value ? "、前の点より増加" : latest.value < prev.value ? "、前の点より減少" : "、前の点と横ばい"
1376
+ const direction = !prev ? "" : latest.value > prev.value ? t.up : latest.value < prev.value ? t.down : t.flat
1143
1377
  const head = input.title ? `${input.title}: ` : ""
1144
- return `${head}最新 ${latest.label} は ${text(latest.value)}${direction}`
1378
+ return `${head}${t.latest(latest.label, text(latest.value))}${direction}`
1145
1379
  }
1146
1380
 
1147
1381
  /**
1148
- * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」 from the slices
1149
- * (largest first). `undefined` when there is nothing to describe.
1382
+ * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」("<title>: Largest: <name> (<share>)") from the slices
1383
+ * (largest first). `undefined` when there is nothing to describe. `locale` picks the language; default the page's.
1150
1384
  */
1151
1385
  function buildShareSummary(
1152
1386
  slices: readonly { name: string; value: number }[],
1153
1387
  title?: string,
1388
+ locale?: string,
1154
1389
  ): string | undefined {
1155
1390
  const total = slices.reduce((sum, s) => sum + s.value, 0)
1156
1391
  const top = slices[0]
1157
1392
  if (!top || total <= 0) return undefined
1158
1393
  const share = `${Math.round((top.value / total) * 1000) / 10}%`
1159
- return `${title ? `${title}: ` : ""}最大は ${top.name}(${share})`
1394
+ return `${title ? `${title}: ` : ""}${SUMMARY_TEXT[uiLanguage(resolveLocale(locale))].top(top.name, share)}`
1160
1395
  }
1161
1396
 
1162
1397
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-options.ts (internal to @squadbase/vantage) ---------- */
1163
1398
 
1399
+ /** The built-in words of the option builders, by language. */
1400
+ const CHART_TEXT = {
1401
+ ja: {
1402
+ target: "目標",
1403
+ forecast: "予測",
1404
+ total: "合計",
1405
+ provisional: "速報",
1406
+ noComparison: "比較なし",
1407
+ share: (share: string) => `(${share})`,
1408
+ targetRate: (rate: string) => `(達成率 ${rate})`,
1409
+ provisionalTitle: (title: string, label: string) => `${title}(${label})`,
1410
+ },
1411
+ en: {
1412
+ target: "Target",
1413
+ forecast: "Forecast",
1414
+ total: "Total",
1415
+ provisional: "Preliminary",
1416
+ noComparison: "No comparison",
1417
+ share: (share: string) => ` (${share})`,
1418
+ targetRate: (rate: string) => ` (${rate} achieved)`,
1419
+ provisionalTitle: (title: string, label: string) => `${title} (${label})`,
1420
+ },
1421
+ } satisfies Record<UiLanguage, unknown>
1422
+
1423
+ const chartText = (locale?: string) => CHART_TEXT[uiLanguage(resolveLocale(locale))]
1424
+
1164
1425
  type CartesianChartKind = "area" | "bar" | "line"
1165
1426
  type CartesianStackType = "default" | "stacked" | "percent"
1166
1427
  type CartesianCurveType = "linear" | "smooth" | "step"
@@ -1345,7 +1606,7 @@ interface CartesianChartConfig {
1345
1606
  * all ticks read in one unit (¥0.5億 / ¥1億). Default: `axisFormatter` for every tick.
1346
1607
  */
1347
1608
  axisTicks?: (top: number) => (value: number) => string
1348
- /** Locale of the time-axis labels. Default `"ja-JP"`. */
1609
+ /** Locale of the time-axis labels and the built-in words (total, target, forecast). Default the page's (`<html lang>`). */
1349
1610
  locale?: string
1350
1611
  type?: CartesianStackType
1351
1612
  /** Bar only. `"vertical"` lays the categories down the y axis (horizontal bars). */
@@ -1583,11 +1844,12 @@ function timeAxisLabelStep(values: readonly unknown[], maxLabels: number = TIME_
1583
1844
  */
1584
1845
  function timeAxisLabels(
1585
1846
  values: readonly unknown[],
1586
- locale: string = DEFAULT_LOCALE,
1847
+ localeArg?: string,
1587
1848
  shown: (i: number) => boolean = () => true,
1588
1849
  ): string[] | null {
1589
1850
  const parsed = parseTimeIndex(values)
1590
1851
  if (!parsed) return null
1852
+ const locale = resolveLocale(localeArg)
1591
1853
  const { granularity, times } = parsed
1592
1854
  let prevYear: number | null = null
1593
1855
  return times.map((t, i) => {
@@ -1610,9 +1872,10 @@ function timeAxisLabels(
1610
1872
  }
1611
1873
 
1612
1874
  /** The tooltip titles for time-like x values (`2025年9月`, `2025年9月1日`), or `null` when they are not dates. */
1613
- function timeTooltipLabels(values: readonly unknown[], locale: string = DEFAULT_LOCALE): string[] | null {
1875
+ function timeTooltipLabels(values: readonly unknown[], localeArg?: string): string[] | null {
1614
1876
  const parsed = parseTimeIndex(values)
1615
1877
  if (!parsed) return null
1878
+ const locale = resolveLocale(localeArg)
1616
1879
  const options: Intl.DateTimeFormatOptions =
1617
1880
  parsed.granularity === "year"
1618
1881
  ? { year: "numeric" }
@@ -1752,7 +2015,9 @@ function tooltipHtml(
1752
2015
  rows: readonly TooltipRow[],
1753
2016
  valueFormatter: (value: number) => string,
1754
2017
  notes: readonly string[] = [],
2018
+ locale?: string,
1755
2019
  ): string {
2020
+ const t = chartText(locale)
1756
2021
  const lines = rows.map(
1757
2022
  (row) =>
1758
2023
  `<div style="display:flex;align-items:center;gap:8px;line-height:20px">` +
@@ -1761,7 +2026,7 @@ function tooltipHtml(
1761
2026
  `<span style="font-weight:600;font-variant-numeric:tabular-nums;margin-left:12px">${
1762
2027
  row.value === null ? EMPTY_VALUE : escapeHtml(valueFormatter(row.value))
1763
2028
  }${
1764
- row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px">(${escapeHtml(formatShare(row.share))})</span>`
2029
+ row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px">${escapeHtml(t.share(formatShare(row.share)))}</span>`
1765
2030
  }</span></div>` +
1766
2031
  (row.note === undefined
1767
2032
  ? ""
@@ -1775,8 +2040,10 @@ function tooltipHtml(
1775
2040
 
1776
2041
  /* ------------------------------------------------- per-bar targets / notes */
1777
2042
 
1778
- /** Default name of the per-bar target marker (legend, tooltip). */
1779
- const BAR_TARGET_LABEL = "目標"
2043
+ /** Default name of the per-bar target marker (legend, tooltip) in `locale`: "Target" / 「目標」. Default the page's locale. */
2044
+ function barTargetLabel(locale?: string): string {
2045
+ return chartText(locale).target
2046
+ }
1780
2047
 
1781
2048
  /** How far (px) the target marker reaches past each side of its bar. */
1782
2049
  const TARGET_MARKER_OVERHANG = 4
@@ -1827,16 +2094,17 @@ function achievementRate(value: number | null | undefined, target: number | null
1827
2094
  return (value / target) * 100
1828
2095
  }
1829
2096
 
1830
- /** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3%)」. `null` without a target. */
2097
+ /** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3%)」 / "Target $12M (85.3% achieved)". `null` without a target. */
1831
2098
  function barTargetNote(
1832
2099
  label: string,
1833
2100
  value: number | null,
1834
2101
  target: number | null,
1835
2102
  valueFormatter: (value: number) => string,
2103
+ locale?: string,
1836
2104
  ): string | null {
1837
2105
  if (target === null) return null
1838
2106
  const rate = achievementRate(value, target)
1839
- return `${label} ${valueFormatter(target)}${rate === null ? "" : `(達成率 ${formatShare(rate)})`}`
2107
+ return `${label} ${valueFormatter(target)}${rate === null ? "" : chartText(locale).targetRate(formatShare(rate))}`
1840
2108
  }
1841
2109
 
1842
2110
  /** Half the width of a bar in category-axis units, from `barCategoryGap` (`"20%"`; a px gap or none → ECharts' 20%). */
@@ -1928,11 +2196,12 @@ const TARGET_LEGEND_SERIES_ID = "__target-legend"
1928
2196
  /** A flat tick (8×8 legend box, aspect kept): the per-bar target's legend icon. */
1929
2197
  const TARGET_LEGEND_ICON = "path://M0,0 L100,0 L100,24 L0,24 Z"
1930
2198
 
1931
- const DEFAULT_FORECAST_LABEL = "予測"
1932
-
1933
- /** The label at the top of a forecast's boundary line: the forecast's name (as in the legend) and an arrow toward the forecast side. */
1934
- function forecastBoundaryText(label: string = DEFAULT_FORECAST_LABEL): string {
1935
- return `${label} →`
2199
+ /**
2200
+ * The label at the top of a forecast's boundary line: the forecast's name (as in the legend) and an arrow toward the forecast side.
2201
+ * Without a `label`, "Forecast" / 「予測」 in `locale` (default the page's).
2202
+ */
2203
+ function forecastBoundaryText(label?: string, locale?: string): string {
2204
+ return `${label ?? chartText(locale).forecast} →`
1936
2205
  }
1937
2206
 
1938
2207
  /** A comparison series: a thin, muted, solid line with no markers (60% of `--muted-foreground`). */
@@ -1972,8 +2241,10 @@ interface AxisExtent {
1972
2241
  max: number
1973
2242
  }
1974
2243
 
1975
- /** The label of a stack's total (highlight and tooltip). */
1976
- const STACK_TOTAL_LABEL = "合計"
2244
+ /** The label of a stack's total (highlight and tooltip) in `locale`: "Total" / 「合計」. Default the page's locale. */
2245
+ function stackTotalLabel(locale?: string): string {
2246
+ return chartText(locale).total
2247
+ }
1977
2248
 
1978
2249
  /** Per point, the sum of the stacked series; `null` where every series is missing. */
1979
2250
  function stackTotalsOf(values: readonly (readonly (number | null)[])[]): (number | null)[] {
@@ -2095,8 +2366,8 @@ function indexGrain(values: readonly unknown[]): PeriodGrain | null {
2095
2366
 
2096
2367
  /**
2097
2368
  * The tooltip line under the main series' row when a comparison series is drawn: the arrow, the signed change and the
2098
- * comparison's name, as `Delta` writes it ("↑ +12.4% 前年同月比"; a minus is U+2212). "比較なし 前年同月比" when
2099
- * either value is missing or the baseline is 0.
2369
+ * comparison's name, as `Delta` writes it ("↑ +12.4% vs. same month last year" / "↑ +12.4% 前年同月比"; a minus is U+2212).
2370
+ * "No comparison vs. same month last year" / "比較なし 前年同月比" when either value is missing or the baseline is 0.
2100
2371
  */
2101
2372
  function comparisonNote(
2102
2373
  current: number | null,
@@ -2104,16 +2375,18 @@ function comparisonNote(
2104
2375
  comparison: Pick<CartesianComparisonConfig, "vs" | "label">,
2105
2376
  grain?: PeriodGrain,
2106
2377
  format?: ValueFormat,
2107
- locale: string = DEFAULT_LOCALE,
2378
+ localeArg?: string,
2108
2379
  ): string {
2380
+ const locale = resolveLocale(localeArg)
2109
2381
  const result = computeComparison(
2110
2382
  current,
2111
2383
  comparison.vs === "custom"
2112
2384
  ? { previous, vs: "custom", label: comparison.label ?? "", grain }
2113
2385
  : { previous, vs: comparison.vs, label: comparison.label, grain },
2386
+ { locale },
2114
2387
  )
2115
2388
  const text = formatChange(result, defaultChangeDisplay(format), locale, format)
2116
- if (text === null) return `比較なし ${result.label}`
2389
+ if (text === null) return `${chartText(locale).noComparison} ${result.label}`
2117
2390
  const arrow = result.direction === "up" ? "↑" : result.direction === "down" ? "↓" : "→"
2118
2391
  return `${arrow} ${text.replace(/^-/, "−")} ${result.label}`
2119
2392
  }
@@ -2156,7 +2429,6 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2156
2429
  toneColors,
2157
2430
  valueFormatter,
2158
2431
  axisFormatter = valueFormatter,
2159
- locale = DEFAULT_LOCALE,
2160
2432
  type = "default",
2161
2433
  layout = "horizontal",
2162
2434
  curveType = "linear",
@@ -2193,7 +2465,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2193
2465
  chartOptions,
2194
2466
  reserveActions = false,
2195
2467
  } = cfg
2196
- const provisionalLabel = cfg.provisionalLabel ?? DEFAULT_PROVISIONAL_LABEL
2468
+ const locale = resolveLocale(cfg.locale)
2469
+ const text = chartText(locale)
2470
+ const provisionalLabel = cfg.provisionalLabel ?? text.provisional
2197
2471
 
2198
2472
  const stacked = kind !== "line" && type !== "default"
2199
2473
  const percent = kind !== "line" && type === "percent"
@@ -2342,9 +2616,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2342
2616
  // `barOptions.targetKey` (a target per bar) takes over from the single `target` line.
2343
2617
  const perBarTarget = kind === "bar" && Boolean(targetKey)
2344
2618
  const hasTarget = !perBarTarget && target !== undefined && Number.isFinite(target)
2345
- const targetText = hasTarget ? (targetLabel ?? `目標 ${axisFormatter(target)}`) : ""
2619
+ const targetText = hasTarget ? (targetLabel ?? `${text.target} ${axisFormatter(target)}`) : ""
2346
2620
  const highlightText = (value: number) =>
2347
- shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${STACK_TOTAL_LABEL} ` : ""}${axisFormatter(value)}`
2621
+ shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${text.total} ` : ""}${axisFormatter(value)}`
2348
2622
  /** Whether `p`'s label sits within the target's value band and under the target label (at the right end). */
2349
2623
  const nearTargetLabel = (p: HighlightPoint, side: "under" | "over") => {
2350
2624
  if (!hasTarget || !plotWidth || kind === "bar" || labels.length < 2) return false
@@ -2370,7 +2644,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2370
2644
  // One series (or a stack, against its total) can be compared to a target; grouped series and 100% stacks cannot.
2371
2645
  const barTargets: (number | null)[] | null =
2372
2646
  perBarTarget && targetKey && !percent && (stacked || categories.length === 1) ? barTargetValues(data, targetKey) : null
2373
- const barTargetName = targetLabel ?? BAR_TARGET_LABEL
2647
+ const barTargetName = targetLabel ?? text.target
2374
2648
  const barTargetColor = labelColor ?? guideColor
2375
2649
 
2376
2650
  const markPointFor = (c: number, color: string | undefined) =>
@@ -2584,7 +2858,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2584
2858
  }
2585
2859
 
2586
2860
  /* -- forecast: dashed continuation (+ optional band) of one series */
2587
- const forecastLabel = forecast?.label ?? DEFAULT_FORECAST_LABEL
2861
+ const forecastLabel = forecast?.label ?? text.forecast
2588
2862
  const forecastBandAlpha = isDarkSurface(labelBackground) ? FORECAST_BAND_ALPHA_DARK : FORECAST_BAND_ALPHA_LIGHT
2589
2863
  const forecastValues: (number | null)[] = []
2590
2864
  if (forecast) {
@@ -3060,7 +3334,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
3060
3334
  })
3061
3335
  }
3062
3336
  if (stackTotals && i <= lastActual && rows.length > 1) {
3063
- rows.push({ name: STACK_TOTAL_LABEL, color: "transparent", value: stackTotals[i] ?? null })
3337
+ rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
3064
3338
  }
3065
3339
  const provisional = provStart !== -1 && i >= provStart && i <= lastActual
3066
3340
  if (provisional) {
@@ -3076,19 +3350,19 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
3076
3350
  ...noteFor(at),
3077
3351
  })
3078
3352
  })
3079
- if (stackTotals && rows.length > 1) rows.push({ name: STACK_TOTAL_LABEL, color: "transparent", value: stackTotals[i] ?? null })
3353
+ if (stackTotals && rows.length > 1) rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
3080
3354
  }
3081
3355
  const title = tooltipTitles[i] ?? labels[i] ?? ""
3082
3356
  const lines: string[] = []
3083
3357
  if (barTargets && i <= lastActual) {
3084
3358
  const total = stackTotals ? (stackTotals[i] ?? null) : (values[0]?.[i] ?? null)
3085
- const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter)
3359
+ const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter, locale)
3086
3360
  if (note) lines.push(note)
3087
3361
  }
3088
3362
  for (const note of notes) {
3089
3363
  if (note.to === undefined ? note.from === i : note.from <= i && i <= note.to) lines.push(note.label)
3090
3364
  }
3091
- return tooltipHtml(provisional ? `${title}(${provisionalLabel})` : title, rows, valueFormatter, lines)
3365
+ return tooltipHtml(provisional ? text.provisionalTitle(title, provisionalLabel) : title, rows, valueFormatter, lines, locale)
3092
3366
  },
3093
3367
  },
3094
3368
  xAxis: horizontalBars ? valueAxis : categoryAxis,
@@ -3180,8 +3454,6 @@ interface DonutOptionSlice {
3180
3454
  /** How many of the largest slices get an outside label (「自然検索 41%」) by default. */
3181
3455
  const DONUT_OUTSIDE_LABELS = 2
3182
3456
 
3183
- const DEFAULT_TOTAL_LABEL = "合計"
3184
-
3185
3457
  const sliceValue = (value: number) => (Number.isFinite(value) && value > 0 ? value : 0)
3186
3458
 
3187
3459
  /** A slice's share of `total` as text: whole percent, `<1%` for a sliver, `0%` for nothing. */
@@ -3214,15 +3486,16 @@ function donutLegendItems(slices: readonly DonutOptionSlice[]): DonutLegendItem[
3214
3486
  }
3215
3487
 
3216
3488
  /**
3217
- * The default center label: what is totalled (「合計」), the formatted total
3489
+ * The default center label: what is totalled (「合計」 / "Total" in `locale`, default the page's), the formatted total
3218
3490
  * (`amount`, the big line), the unit (a small line under it) and both as one
3219
3491
  * string (`value`, 「9.2万 セッション」).
3220
3492
  */
3221
3493
  function donutCenterText(
3222
3494
  total: number,
3223
3495
  valueFormatter: (value: number) => string,
3224
- { totalLabel = DEFAULT_TOTAL_LABEL, unit }: { totalLabel?: string; unit?: string } = {},
3496
+ { totalLabel, unit, locale }: { totalLabel?: string; unit?: string; locale?: string } = {},
3225
3497
  ): { label: string; value: string; amount: string; unit?: string } {
3498
+ totalLabel ??= chartText(locale).total
3226
3499
  const amount = valueFormatter(total)
3227
3500
  return { label: totalLabel, value: unit ? `${amount} ${unit}` : amount, amount, ...(unit ? { unit } : {}) }
3228
3501
  }
@@ -3450,8 +3723,10 @@ function hasLabel(labels: Readonly<Record<string, string>> | undefined, key: str
3450
3723
 
3451
3724
  /* ------------------------------------------------------------ provisional */
3452
3725
 
3453
- /** The default label for points that are not final yet. */
3454
- const DEFAULT_PROVISIONAL_LABEL = "速報"
3726
+ /** The default label for points that are not final yet, in `locale`: "Preliminary" / 「速報」. Default the page's locale. */
3727
+ function defaultProvisionalLabel(locale?: string): string {
3728
+ return chartText(locale).provisional
3729
+ }
3455
3730
 
3456
3731
  /**
3457
3732
  * Position of the first provisional point: the first entry of `indexValues`
@@ -3509,11 +3784,17 @@ export function legendColors(
3509
3784
  return seriesColorsFor(categories, colors, CHART_OTHER_COLOR).map((color) => chartFillToCss(color))
3510
3785
  }
3511
3786
 
3787
+ const TEXT = {
3788
+ ja: { legend: "凡例" },
3789
+ en: { legend: "Legend" },
3790
+ }
3791
+
3512
3792
  /** Plain-HTML legend. Renders nothing for an empty `categories`. */
3513
3793
  export const Legend = forwardRef<HTMLUListElement, LegendProps>(function Legend(
3514
- { categories, colors, labels, layout = "horizontal", ariaLabel = "凡例", className },
3794
+ { categories, colors, labels, layout = "horizontal", ariaLabel, className },
3515
3795
  ref,
3516
3796
  ) {
3797
+ const t = TEXT[uiLanguage(useLocale())]
3517
3798
  if (categories.length === 0) return null
3518
3799
  const swatches = legendColors(categories, colors)
3519
3800
  return (
@@ -3521,7 +3802,7 @@ export const Legend = forwardRef<HTMLUListElement, LegendProps>(function Legend(
3521
3802
  ref={ref}
3522
3803
  data-slot="legend"
3523
3804
  data-layout={layout}
3524
- aria-label={ariaLabel}
3805
+ aria-label={ariaLabel ?? t.legend}
3525
3806
  className={cn("m-0 flex list-none gap-x-4 gap-y-1 p-0 text-xs", layout === "vertical" ? "flex-col" : "flex-row flex-wrap", className)}
3526
3807
  >
3527
3808
  {categories.map((category, i) => (