@squadbase/vantage 0.6.2-dev1.9365dcd → 0.6.2-dev1.9d7130b

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 (230) 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-add-feature/SKILL.md +5 -0
  208. package/skills/vantage-design/SKILL.md +54 -54
  209. package/skills/vantage-design/references/extract.md +26 -27
  210. package/skills/vantage-design/references/format.md +12 -172
  211. package/skills/vantage-design/references/presets.md +50 -110
  212. package/skills/vantage-pitfalls/SKILL.md +5 -0
  213. package/skills/vantage-slide/SKILL.md +54 -36
  214. package/templates/AGENTS.md +35 -18
  215. package/templates/agents/api.md +7 -0
  216. package/templates/agents/doc.md +48 -0
  217. package/templates/agents/page.md +59 -2
  218. package/templates/agents/slide.md +77 -3
  219. package/theme.css +23 -19
  220. package/dist/chunk-2ICNHUG6.js.map +0 -1
  221. package/dist/chunk-2N72FKLB.js.map +0 -1
  222. package/dist/chunk-7DZGJQ3P.js.map +0 -1
  223. package/dist/chunk-CEXAQQ5W.js.map +0 -1
  224. package/dist/chunk-HKSVYMYJ.js.map +0 -1
  225. package/dist/chunk-JKLLFVEU.js.map +0 -1
  226. package/dist/chunk-PDOBHMUQ.js.map +0 -1
  227. package/dist/chunk-V3LF7BE7.js.map +0 -1
  228. package/dist/chunk-WAMZWZJB.js.map +0 -1
  229. package/dist/chunk-XPQSTZUU.js.map +0 -1
  230. package/dist/chunk-XQUSGIDG.js.map +0 -1
@@ -32,7 +32,8 @@ import { EChart } from "@squadbase/vantage/components"
32
32
  import type { Polarity } from "@squadbase/vantage/components"
33
33
  import { EMPTY_VALUE, MINUS_SIGN } from "@squadbase/vantage/components"
34
34
  import type { ChartCommonProps, NumericKeys, ValueFormatter } from "@squadbase/vantage/components"
35
- import { createContext, useCallback, useEffect, useRef, useState } from "react"
35
+ import { useSyncExternalStore } from "react"
36
+ import { createContext, useCallback, useEffect, useRef, useState, type RefObject } from "react"
36
37
  import type { ChartTone } from "@squadbase/vantage/components"
37
38
  import { type ReactNode } from "react"
38
39
  import { computeComparison, defaultChangeDisplay, formatChange, toneForValue, type ComparisonKind, type PeriodGrain } from "@squadbase/vantage/components"
@@ -41,6 +42,137 @@ import type { ChartThreshold, ChartAnnotation, ChartEventPayload, ChartForecast,
41
42
  import { formatValue, toValueFormatter } from "@squadbase/vantage/components"
42
43
  import type { ChartColor, ChartColors, ChartSeriesColor } from "@squadbase/vantage/components"
43
44
 
45
+ /* ---------- inlined from @squadbase/squadkit registry/lib/locale.ts (internal to @squadbase/vantage) ---------- */
46
+
47
+ /**
48
+ * The display language of the parts and the defaults that follow it.
49
+ *
50
+ * The language comes from `<html lang>`: a tag starting with `ja` reads Japanese, anything else English.
51
+ * Without a usable `lang` (missing, invalid, or no `document` — SSR, Node) the locale is `FALLBACK_LOCALE`
52
+ * (`en-US`). A `locale` passed explicitly (a prop, an argument) always wins.
53
+ *
54
+ * Parts read the locale through `useLocale` (`hooks/use-locale`) and pass it down; the pure helpers here
55
+ * and in `format` / `comparison` / `chart-*` take it as an argument and fall back to `documentLocale()`.
56
+ */
57
+
58
+ /** The language of the built-in wording: Japanese or English. */
59
+ type UiLanguage = "ja" | "en"
60
+
61
+ /** The locale used when `<html lang>` gives none (and on the server). */
62
+ const FALLBACK_LOCALE = "en-US"
63
+
64
+ /** `"ja"` when `locale` starts with `ja` (`ja`, `ja-JP`), else `"en"`. A missing locale is `"en"`. */
65
+ function uiLanguage(locale: string | null | undefined): UiLanguage {
66
+ return typeof locale === "string" && locale.trim().toLowerCase().startsWith("ja") ? "ja" : "en"
67
+ }
68
+
69
+ /** A BCP 47 tag in its canonical form (`en-gb` → `en-GB`), or `FALLBACK_LOCALE` when it is empty or invalid. */
70
+ function normalizeLocale(tag: string | null | undefined): string {
71
+ const value = tag?.trim()
72
+ if (!value) return FALLBACK_LOCALE
73
+ try {
74
+ return Intl.getCanonicalLocales(value)[0] ?? FALLBACK_LOCALE
75
+ } catch {
76
+ return FALLBACK_LOCALE
77
+ }
78
+ }
79
+
80
+ /**
81
+ * The locale of the page: `document.documentElement.lang`, canonicalized. `FALLBACK_LOCALE` when it is empty or
82
+ * invalid, or there is no `document`. Parts read it through `useLocale`, never while rendering.
83
+ */
84
+ function documentLocale(): string {
85
+ if (typeof document === "undefined") return FALLBACK_LOCALE
86
+ return normalizeLocale(document.documentElement?.getAttribute("lang"))
87
+ }
88
+
89
+ /** `locale` when given, else the page's locale (`documentLocale()`). */
90
+ function resolveLocale(locale?: string | null): string {
91
+ return locale || documentLocale()
92
+ }
93
+
94
+ /** Picks the wording table of the locale's language. */
95
+ function pickLocale<T>(table: { ja: T; en: T }, locale?: string | null): T {
96
+ return table[uiLanguage(resolveLocale(locale))]
97
+ }
98
+
99
+ /** The currency `format="currency"` uses without a `currency`: JPY for Japanese, USD otherwise. */
100
+ function defaultCurrency(locale?: string | null): string {
101
+ return uiLanguage(resolveLocale(locale)) === "ja" ? "JPY" : "USD"
102
+ }
103
+
104
+ /** The month a fiscal year starts in (1–12) when none is given: April for Japanese, January otherwise. */
105
+ function defaultFiscalYearStartMonth(locale?: string | null): number {
106
+ return uiLanguage(resolveLocale(locale)) === "ja" ? 4 : 1
107
+ }
108
+
109
+ /** A day of the week, 0 = Sunday … 6 = Saturday (the `Date#getDay` / date-fns numbering). */
110
+ type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6
111
+
112
+ /** Locales whose week starts on Sunday when the runtime has no week data. */
113
+ const SUNDAY_FIRST = new Set(["en", "en-US", "en-CA"])
114
+
115
+ /**
116
+ * The first day of the week: Monday for Japanese; otherwise the locale's convention from `Intl.Locale`
117
+ * (`en-US` Sunday, `en-GB` Monday). Without week data in the runtime: Sunday for `en` / `en-US` / `en-CA`,
118
+ * Monday for the rest.
119
+ */
120
+ function weekStartsOn(locale?: string | null): WeekDay {
121
+ const tag = normalizeLocale(resolveLocale(locale))
122
+ if (uiLanguage(tag) === "ja") return 1
123
+ try {
124
+ const info = new Intl.Locale(tag) as Intl.Locale & {
125
+ getWeekInfo?: () => { firstDay: number }
126
+ weekInfo?: { firstDay: number }
127
+ }
128
+ const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay
129
+ if (typeof firstDay === "number" && firstDay >= 1 && firstDay <= 7) return (firstDay % 7) as WeekDay
130
+ } catch {
131
+ // Fall through to the table below.
132
+ }
133
+ return SUNDAY_FIRST.has(tag) ? 0 : 1
134
+ }
135
+
136
+ /* ---------- inlined from @squadbase/squadkit registry/hooks/use-locale.ts (internal to @squadbase/vantage) ---------- */
137
+
138
+ const listeners = new Set<() => void>()
139
+ let observer: MutationObserver | null = null
140
+
141
+ /** One observer on `<html lang>` for every subscribed part. */
142
+ function subscribe(onChange: () => void): () => void {
143
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {}
144
+ listeners.add(onChange)
145
+ if (!observer) {
146
+ observer = new MutationObserver(() => {
147
+ for (const listener of [...listeners]) listener()
148
+ })
149
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] })
150
+ }
151
+ return () => {
152
+ listeners.delete(onChange)
153
+ if (listeners.size === 0 && observer) {
154
+ observer.disconnect()
155
+ observer = null
156
+ }
157
+ }
158
+ }
159
+
160
+ const serverLocale = () => FALLBACK_LOCALE
161
+
162
+ /**
163
+ * The page's locale (`<html lang>`), following changes made while the page runs. On the server and during
164
+ * hydration it is `FALLBACK_LOCALE` (`en-US`), so the markup matches; it switches right after.
165
+ */
166
+ function useDocumentLocale(): string {
167
+ return useSyncExternalStore(subscribe, documentLocale, serverLocale)
168
+ }
169
+
170
+ /** The locale a part renders in: its `locale` prop when given, else the page's (`useDocumentLocale`). */
171
+ function useLocale(locale?: string | null): string {
172
+ const page = useDocumentLocale()
173
+ return locale || page
174
+ }
175
+
44
176
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-theme.ts (internal to @squadbase/vantage) ---------- */
45
177
 
46
178
  /**
@@ -75,10 +207,23 @@ interface ChartTokens {
75
207
  border: string | null
76
208
  /** Tooltip surface. */
77
209
  popover: string | null
210
+ /** Semantic and surface dependencies used by typed chart options. Optional for hand-built tokens. */
211
+ colors?: Record<string, string>
212
+ /** Actual inherited painting surface (may change without a token changing). */
213
+ surface?: string | null
78
214
  /** Tooltip text. */
79
215
  popoverForeground: string | null
80
216
  }
81
217
 
218
+ // Keep scope out of the public token shape. A resolved snapshot remembers where arbitrary
219
+ // var(--token) expressions must be read, without retaining unmounted charts globally.
220
+ const tokenScopes = new WeakMap<ChartTokens, Element>()
221
+ const OPTION_COLOR_TOKENS = [
222
+ "--background", "--card", "--muted", "--primary", "--destructive",
223
+ ...["positive", "negative", "warning", "neutral", "info"].flatMap((tone) =>
224
+ ["", "-fill", "-subtle"].map((suffix) => `--${tone}${suffix}`)),
225
+ ]
226
+
82
227
  let normalizeContext: CanvasRenderingContext2D | null | undefined
83
228
 
84
229
  /** The forms `fillStyle` serializes legacy colours to, and zrender can parse. */
@@ -134,8 +279,10 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
134
279
  const color = read(token)
135
280
  if (color) series.push(color)
136
281
  }
137
- return {
282
+ const tokens: ChartTokens = {
138
283
  series,
284
+ colors: Object.fromEntries(OPTION_COLOR_TOKENS.map((name) => [name, styles.getPropertyValue(name).trim()])),
285
+ surface: readChartSurface(target),
139
286
  ...(styles.fontFamily ? { fontFamily: styles.fontFamily } : {}),
140
287
  foreground: read("--foreground"),
141
288
  mutedForeground: read("--muted-foreground"),
@@ -143,6 +290,8 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
143
290
  popover: read("--popover"),
144
291
  popoverForeground: read("--popover-foreground"),
145
292
  }
293
+ tokenScopes.set(tokens, target)
294
+ return tokens
146
295
  }
147
296
 
148
297
  /** Structural equality, so a theme toggle that changes nothing doesn't re-init. */
@@ -150,6 +299,10 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
150
299
  if (a === b) return true
151
300
  if (!a || !b) return false
152
301
  return (
302
+ tokenScopes.get(a) === tokenScopes.get(b) &&
303
+ a.surface === b.surface &&
304
+ Object.keys(a.colors ?? {}).length === Object.keys(b.colors ?? {}).length &&
305
+ Object.entries(a.colors ?? {}).every(([name, value]) => b.colors?.[name] === value) &&
153
306
  a.series.length === b.series.length &&
154
307
  a.series.every((color, i) => color === b.series[i]) &&
155
308
  a.fontFamily === b.fontFamily &&
@@ -175,7 +328,7 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
175
328
  * attributes alone leaves the chart painted in the previous palette until a
176
329
  * reload — the tokens in the DOM are new, the canvas is not.
177
330
  */
178
- function watchChartTokens(onChange: () => void): () => void {
331
+ function watchChartTokens(onChange: () => void, scope?: Element | null): () => void {
179
332
  if (typeof document === "undefined") return () => {}
180
333
  // Style edits arrive as bursts of mutations; coalesce them into one read, and
181
334
  // take it after the next frame so a swapped stylesheet has been applied.
@@ -190,8 +343,11 @@ function watchChartTokens(onChange: () => void): () => void {
190
343
 
191
344
  const themeObserver = new MutationObserver(schedule)
192
345
  const attributeFilter = ["class", "style", "data-theme"]
193
- themeObserver.observe(document.documentElement, { attributes: true, attributeFilter })
194
- if (document.body) themeObserver.observe(document.body, { attributes: true, attributeFilter })
346
+ // Only the chart and its ancestors can change its inherited theme. Never observe
347
+ // the document subtree: canvas/tooltip/rendering mutations would trigger every chart.
348
+ for (let node: Element | null = scope ?? document.body ?? document.documentElement; node; node = node.parentElement) {
349
+ themeObserver.observe(node, { attributes: true, attributeFilter })
350
+ }
195
351
 
196
352
  const styleObserver = new MutationObserver(schedule)
197
353
  if (document.head) {
@@ -279,22 +435,23 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
279
435
  * components that build an ECharts option from token colours themselves (the
280
436
  * typed charts), so their palette follows light/dark like `EChart`'s theme.
281
437
  */
282
- function useChartTokens(): ChartTokens | null {
438
+ function useChartTokens(scope?: Element | null | RefObject<Element | null>): ChartTokens | null {
283
439
  const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
284
440
  // Compared here, not inside a `setTokens` updater: an updater that reads the DOM is
285
441
  // replayed by React on every rebase and returns a fresh object each time, so the state
286
442
  // never settles ("Maximum update depth exceeded" once the tokens change after mount).
287
443
  const tokensRef = useRef(tokens)
288
444
  useEffect(() => {
445
+ const target = scope && "current" in scope ? scope.current : scope
289
446
  const sync = () => {
290
- const next = readChartTokens()
447
+ const next = readChartTokens(target)
291
448
  if (sameChartTokens(tokensRef.current, next)) return
292
449
  tokensRef.current = next
293
450
  setTokens(next)
294
451
  }
295
452
  sync()
296
- return watchChartTokens(sync)
297
- }, [])
453
+ return watchChartTokens(sync, target)
454
+ }, [scope])
298
455
  return tokens
299
456
  }
300
457
 
@@ -306,7 +463,7 @@ const EMBEDDED_VAR_RE = /var\(\s*(--[\w-]+)\s*\)/g
306
463
  /**
307
464
  * Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
308
465
  * the resolved series tokens; `"positive"` / `"warning"` / `"negative"` / `"neutral"` (and the area
309
- * variants `"positive-fill"`, …) are read off the document root (`--positive`, …); `var(--token)` is read off the document root; any
466
+ * variants `"positive-fill"`, …) are read in the snapshot's inherited scope (`--positive`, …); `var(--token)` uses the same scope; any
310
467
  * other CSS colour is normalized like the tokens are. A colour that can't be
311
468
  * resolved comes back as `""`, which ECharts treats as "use the palette".
312
469
  */
@@ -317,12 +474,13 @@ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | nul
317
474
  // Meaning colours are the theme tokens of the same name: same path as `var(--positive)`.
318
475
  if (TONE_NAME_RE.test(color.trim())) {
319
476
  if (typeof document === "undefined") return ""
320
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue(`--${color.trim()}`)) ?? ""
477
+ const target = (tokens && tokenScopes.get(tokens)) ?? document.documentElement
478
+ return toCanvasColor(getComputedStyle(target).getPropertyValue(`--${color.trim()}`)) ?? ""
321
479
  }
322
480
  if (typeof document === "undefined") return color
323
481
  let value = color.trim()
324
482
  const cssVar = CSS_VAR_RE.exec(value)
325
- const styles = getComputedStyle(document.documentElement)
483
+ const styles = getComputedStyle((tokens && tokenScopes.get(tokens)) ?? document.documentElement)
326
484
  if (cssVar) {
327
485
  value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
328
486
  } else if (value.includes("var(")) {
@@ -368,7 +526,7 @@ function readChartSurface(el: Element | null): string | null {
368
526
  if (color) return color
369
527
  }
370
528
  }
371
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue("--background"))
529
+ return toCanvasColor(getComputedStyle(el ?? document.documentElement).getPropertyValue("--background"))
372
530
  }
373
531
 
374
532
  /** Widths are bucketed so a resize only rebuilds the option when the label budget can change. */
@@ -382,9 +540,10 @@ const WIDTH_BUCKET = 40
382
540
  */
383
541
  function useChartSurface<E extends Element>(
384
542
  forwarded: ForwardedRef<E>,
385
- tokens: ChartTokens | null,
386
- ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined } {
543
+ tokens?: ChartTokens | null,
544
+ ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined; tokens: ChartTokens | null } {
387
545
  const [node, setNode] = useState<E | null>(null)
546
+ const scopedTokens = useChartTokens(node)
388
547
  const [surface, setSurface] = useState<string | undefined>(undefined)
389
548
  const [width, setWidth] = useState<number | undefined>(undefined)
390
549
  const ref = useCallback(
@@ -398,7 +557,7 @@ function useChartSurface<E extends Element>(
398
557
  useEffect(() => {
399
558
  if (!node) return
400
559
  setSurface(readChartSurface(node) ?? undefined)
401
- }, [node, tokens])
560
+ }, [node, tokens, scopedTokens])
402
561
  useEffect(() => {
403
562
  if (!node || typeof ResizeObserver === "undefined") return
404
563
  const update = (w: number) => setWidth(Math.floor(w / WIDTH_BUCKET) * WIDTH_BUCKET)
@@ -407,11 +566,37 @@ function useChartSurface<E extends Element>(
407
566
  observer.observe(node)
408
567
  return () => observer.disconnect()
409
568
  }, [node])
410
- return { ref, surface, width }
569
+ return { ref, surface, width, tokens: scopedTokens }
411
570
  }
412
571
 
413
572
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-options.ts (internal to @squadbase/vantage) ---------- */
414
573
 
574
+ /** The built-in words of the option builders, by language. */
575
+ const CHART_TEXT = {
576
+ ja: {
577
+ target: "目標",
578
+ forecast: "予測",
579
+ total: "合計",
580
+ provisional: "速報",
581
+ noComparison: "比較なし",
582
+ share: (share: string) => `(${share})`,
583
+ targetRate: (rate: string) => `(達成率 ${rate})`,
584
+ provisionalTitle: (title: string, label: string) => `${title}(${label})`,
585
+ },
586
+ en: {
587
+ target: "Target",
588
+ forecast: "Forecast",
589
+ total: "Total",
590
+ provisional: "Preliminary",
591
+ noComparison: "No comparison",
592
+ share: (share: string) => ` (${share})`,
593
+ targetRate: (rate: string) => ` (${rate} achieved)`,
594
+ provisionalTitle: (title: string, label: string) => `${title} (${label})`,
595
+ },
596
+ } satisfies Record<UiLanguage, unknown>
597
+
598
+ const chartText = (locale?: string) => CHART_TEXT[uiLanguage(resolveLocale(locale))]
599
+
415
600
  type CartesianChartKind = "area" | "bar" | "line"
416
601
  type CartesianStackType = "default" | "stacked" | "percent"
417
602
  type CartesianCurveType = "linear" | "smooth" | "step"
@@ -596,7 +781,7 @@ interface CartesianChartConfig {
596
781
  * all ticks read in one unit (¥0.5億 / ¥1億). Default: `axisFormatter` for every tick.
597
782
  */
598
783
  axisTicks?: (top: number) => (value: number) => string
599
- /** Locale of the time-axis labels. Default `"ja-JP"`. */
784
+ /** Locale of the time-axis labels and the built-in words (total, target, forecast). Default the page's (`<html lang>`). */
600
785
  locale?: string
601
786
  type?: CartesianStackType
602
787
  /** Bar only. `"vertical"` lays the categories down the y axis (horizontal bars). */
@@ -834,11 +1019,12 @@ function timeAxisLabelStep(values: readonly unknown[], maxLabels: number = TIME_
834
1019
  */
835
1020
  function timeAxisLabels(
836
1021
  values: readonly unknown[],
837
- locale: string = DEFAULT_LOCALE,
1022
+ localeArg?: string,
838
1023
  shown: (i: number) => boolean = () => true,
839
1024
  ): string[] | null {
840
1025
  const parsed = parseTimeIndex(values)
841
1026
  if (!parsed) return null
1027
+ const locale = resolveLocale(localeArg)
842
1028
  const { granularity, times } = parsed
843
1029
  let prevYear: number | null = null
844
1030
  return times.map((t, i) => {
@@ -861,9 +1047,10 @@ function timeAxisLabels(
861
1047
  }
862
1048
 
863
1049
  /** The tooltip titles for time-like x values (`2025年9月`, `2025年9月1日`), or `null` when they are not dates. */
864
- function timeTooltipLabels(values: readonly unknown[], locale: string = DEFAULT_LOCALE): string[] | null {
1050
+ function timeTooltipLabels(values: readonly unknown[], localeArg?: string): string[] | null {
865
1051
  const parsed = parseTimeIndex(values)
866
1052
  if (!parsed) return null
1053
+ const locale = resolveLocale(localeArg)
867
1054
  const options: Intl.DateTimeFormatOptions =
868
1055
  parsed.granularity === "year"
869
1056
  ? { year: "numeric" }
@@ -1003,7 +1190,9 @@ function tooltipHtml(
1003
1190
  rows: readonly TooltipRow[],
1004
1191
  valueFormatter: (value: number) => string,
1005
1192
  notes: readonly string[] = [],
1193
+ locale?: string,
1006
1194
  ): string {
1195
+ const t = chartText(locale)
1007
1196
  const lines = rows.map(
1008
1197
  (row) =>
1009
1198
  `<div style="display:flex;align-items:center;gap:8px;line-height:20px">` +
@@ -1012,7 +1201,7 @@ function tooltipHtml(
1012
1201
  `<span style="font-weight:600;font-variant-numeric:tabular-nums;margin-left:12px">${
1013
1202
  row.value === null ? EMPTY_VALUE : escapeHtml(valueFormatter(row.value))
1014
1203
  }${
1015
- row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px">(${escapeHtml(formatShare(row.share))})</span>`
1204
+ row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px">${escapeHtml(t.share(formatShare(row.share)))}</span>`
1016
1205
  }</span></div>` +
1017
1206
  (row.note === undefined
1018
1207
  ? ""
@@ -1026,8 +1215,10 @@ function tooltipHtml(
1026
1215
 
1027
1216
  /* ------------------------------------------------- per-bar targets / notes */
1028
1217
 
1029
- /** Default name of the per-bar target marker (legend, tooltip). */
1030
- const BAR_TARGET_LABEL = "目標"
1218
+ /** Default name of the per-bar target marker (legend, tooltip) in `locale`: "Target" / 「目標」. Default the page's locale. */
1219
+ function barTargetLabel(locale?: string): string {
1220
+ return chartText(locale).target
1221
+ }
1031
1222
 
1032
1223
  /** How far (px) the target marker reaches past each side of its bar. */
1033
1224
  const TARGET_MARKER_OVERHANG = 4
@@ -1078,16 +1269,17 @@ function achievementRate(value: number | null | undefined, target: number | null
1078
1269
  return (value / target) * 100
1079
1270
  }
1080
1271
 
1081
- /** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3%)」. `null` without a target. */
1272
+ /** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3%)」 / "Target $12M (85.3% achieved)". `null` without a target. */
1082
1273
  function barTargetNote(
1083
1274
  label: string,
1084
1275
  value: number | null,
1085
1276
  target: number | null,
1086
1277
  valueFormatter: (value: number) => string,
1278
+ locale?: string,
1087
1279
  ): string | null {
1088
1280
  if (target === null) return null
1089
1281
  const rate = achievementRate(value, target)
1090
- return `${label} ${valueFormatter(target)}${rate === null ? "" : `(達成率 ${formatShare(rate)})`}`
1282
+ return `${label} ${valueFormatter(target)}${rate === null ? "" : chartText(locale).targetRate(formatShare(rate))}`
1091
1283
  }
1092
1284
 
1093
1285
  /** Half the width of a bar in category-axis units, from `barCategoryGap` (`"20%"`; a px gap or none → ECharts' 20%). */
@@ -1179,11 +1371,12 @@ const TARGET_LEGEND_SERIES_ID = "__target-legend"
1179
1371
  /** A flat tick (8×8 legend box, aspect kept): the per-bar target's legend icon. */
1180
1372
  const TARGET_LEGEND_ICON = "path://M0,0 L100,0 L100,24 L0,24 Z"
1181
1373
 
1182
- const DEFAULT_FORECAST_LABEL = "予測"
1183
-
1184
- /** 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. */
1185
- function forecastBoundaryText(label: string = DEFAULT_FORECAST_LABEL): string {
1186
- return `${label} →`
1374
+ /**
1375
+ * 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.
1376
+ * Without a `label`, "Forecast" / 「予測」 in `locale` (default the page's).
1377
+ */
1378
+ function forecastBoundaryText(label?: string, locale?: string): string {
1379
+ return `${label ?? chartText(locale).forecast} →`
1187
1380
  }
1188
1381
 
1189
1382
  /** A comparison series: a thin, muted, solid line with no markers (60% of `--muted-foreground`). */
@@ -1223,8 +1416,10 @@ interface AxisExtent {
1223
1416
  max: number
1224
1417
  }
1225
1418
 
1226
- /** The label of a stack's total (highlight and tooltip). */
1227
- const STACK_TOTAL_LABEL = "合計"
1419
+ /** The label of a stack's total (highlight and tooltip) in `locale`: "Total" / 「合計」. Default the page's locale. */
1420
+ function stackTotalLabel(locale?: string): string {
1421
+ return chartText(locale).total
1422
+ }
1228
1423
 
1229
1424
  /** Per point, the sum of the stacked series; `null` where every series is missing. */
1230
1425
  function stackTotalsOf(values: readonly (readonly (number | null)[])[]): (number | null)[] {
@@ -1346,8 +1541,8 @@ function indexGrain(values: readonly unknown[]): PeriodGrain | null {
1346
1541
 
1347
1542
  /**
1348
1543
  * The tooltip line under the main series' row when a comparison series is drawn: the arrow, the signed change and the
1349
- * comparison's name, as `Delta` writes it ("↑ +12.4% 前年同月比"; a minus is U+2212). "比較なし 前年同月比" when
1350
- * either value is missing or the baseline is 0.
1544
+ * comparison's name, as `Delta` writes it ("↑ +12.4% vs. same month last year" / "↑ +12.4% 前年同月比"; a minus is U+2212).
1545
+ * "No comparison vs. same month last year" / "比較なし 前年同月比" when either value is missing or the baseline is 0.
1351
1546
  */
1352
1547
  function comparisonNote(
1353
1548
  current: number | null,
@@ -1355,16 +1550,18 @@ function comparisonNote(
1355
1550
  comparison: Pick<CartesianComparisonConfig, "vs" | "label">,
1356
1551
  grain?: PeriodGrain,
1357
1552
  format?: ValueFormat,
1358
- locale: string = DEFAULT_LOCALE,
1553
+ localeArg?: string,
1359
1554
  ): string {
1555
+ const locale = resolveLocale(localeArg)
1360
1556
  const result = computeComparison(
1361
1557
  current,
1362
1558
  comparison.vs === "custom"
1363
1559
  ? { previous, vs: "custom", label: comparison.label ?? "", grain }
1364
1560
  : { previous, vs: comparison.vs, label: comparison.label, grain },
1561
+ { locale },
1365
1562
  )
1366
1563
  const text = formatChange(result, defaultChangeDisplay(format), locale, format)
1367
- if (text === null) return `比較なし ${result.label}`
1564
+ if (text === null) return `${chartText(locale).noComparison} ${result.label}`
1368
1565
  const arrow = result.direction === "up" ? "↑" : result.direction === "down" ? "↓" : "→"
1369
1566
  return `${arrow} ${text.replace(/^-/, "−")} ${result.label}`
1370
1567
  }
@@ -1407,7 +1604,6 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1407
1604
  toneColors,
1408
1605
  valueFormatter,
1409
1606
  axisFormatter = valueFormatter,
1410
- locale = DEFAULT_LOCALE,
1411
1607
  type = "default",
1412
1608
  layout = "horizontal",
1413
1609
  curveType = "linear",
@@ -1444,7 +1640,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1444
1640
  chartOptions,
1445
1641
  reserveActions = false,
1446
1642
  } = cfg
1447
- const provisionalLabel = cfg.provisionalLabel ?? DEFAULT_PROVISIONAL_LABEL
1643
+ const locale = resolveLocale(cfg.locale)
1644
+ const text = chartText(locale)
1645
+ const provisionalLabel = cfg.provisionalLabel ?? text.provisional
1448
1646
 
1449
1647
  const stacked = kind !== "line" && type !== "default"
1450
1648
  const percent = kind !== "line" && type === "percent"
@@ -1593,9 +1791,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1593
1791
  // `barOptions.targetKey` (a target per bar) takes over from the single `target` line.
1594
1792
  const perBarTarget = kind === "bar" && Boolean(targetKey)
1595
1793
  const hasTarget = !perBarTarget && target !== undefined && Number.isFinite(target)
1596
- const targetText = hasTarget ? (targetLabel ?? `目標 ${axisFormatter(target)}`) : ""
1794
+ const targetText = hasTarget ? (targetLabel ?? `${text.target} ${axisFormatter(target)}`) : ""
1597
1795
  const highlightText = (value: number) =>
1598
- shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${STACK_TOTAL_LABEL} ` : ""}${axisFormatter(value)}`
1796
+ shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${text.total} ` : ""}${axisFormatter(value)}`
1599
1797
  /** Whether `p`'s label sits within the target's value band and under the target label (at the right end). */
1600
1798
  const nearTargetLabel = (p: HighlightPoint, side: "under" | "over") => {
1601
1799
  if (!hasTarget || !plotWidth || kind === "bar" || labels.length < 2) return false
@@ -1621,7 +1819,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1621
1819
  // One series (or a stack, against its total) can be compared to a target; grouped series and 100% stacks cannot.
1622
1820
  const barTargets: (number | null)[] | null =
1623
1821
  perBarTarget && targetKey && !percent && (stacked || categories.length === 1) ? barTargetValues(data, targetKey) : null
1624
- const barTargetName = targetLabel ?? BAR_TARGET_LABEL
1822
+ const barTargetName = targetLabel ?? text.target
1625
1823
  const barTargetColor = labelColor ?? guideColor
1626
1824
 
1627
1825
  const markPointFor = (c: number, color: string | undefined) =>
@@ -1835,7 +2033,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1835
2033
  }
1836
2034
 
1837
2035
  /* -- forecast: dashed continuation (+ optional band) of one series */
1838
- const forecastLabel = forecast?.label ?? DEFAULT_FORECAST_LABEL
2036
+ const forecastLabel = forecast?.label ?? text.forecast
1839
2037
  const forecastBandAlpha = isDarkSurface(labelBackground) ? FORECAST_BAND_ALPHA_DARK : FORECAST_BAND_ALPHA_LIGHT
1840
2038
  const forecastValues: (number | null)[] = []
1841
2039
  if (forecast) {
@@ -2311,7 +2509,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2311
2509
  })
2312
2510
  }
2313
2511
  if (stackTotals && i <= lastActual && rows.length > 1) {
2314
- rows.push({ name: STACK_TOTAL_LABEL, color: "transparent", value: stackTotals[i] ?? null })
2512
+ rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
2315
2513
  }
2316
2514
  const provisional = provStart !== -1 && i >= provStart && i <= lastActual
2317
2515
  if (provisional) {
@@ -2327,19 +2525,19 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2327
2525
  ...noteFor(at),
2328
2526
  })
2329
2527
  })
2330
- if (stackTotals && rows.length > 1) rows.push({ name: STACK_TOTAL_LABEL, color: "transparent", value: stackTotals[i] ?? null })
2528
+ if (stackTotals && rows.length > 1) rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
2331
2529
  }
2332
2530
  const title = tooltipTitles[i] ?? labels[i] ?? ""
2333
2531
  const lines: string[] = []
2334
2532
  if (barTargets && i <= lastActual) {
2335
2533
  const total = stackTotals ? (stackTotals[i] ?? null) : (values[0]?.[i] ?? null)
2336
- const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter)
2534
+ const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter, locale)
2337
2535
  if (note) lines.push(note)
2338
2536
  }
2339
2537
  for (const note of notes) {
2340
2538
  if (note.to === undefined ? note.from === i : note.from <= i && i <= note.to) lines.push(note.label)
2341
2539
  }
2342
- return tooltipHtml(provisional ? `${title}(${provisionalLabel})` : title, rows, valueFormatter, lines)
2540
+ return tooltipHtml(provisional ? text.provisionalTitle(title, provisionalLabel) : title, rows, valueFormatter, lines, locale)
2343
2541
  },
2344
2542
  },
2345
2543
  xAxis: horizontalBars ? valueAxis : categoryAxis,
@@ -2431,8 +2629,6 @@ interface DonutOptionSlice {
2431
2629
  /** How many of the largest slices get an outside label (「自然検索 41%」) by default. */
2432
2630
  const DONUT_OUTSIDE_LABELS = 2
2433
2631
 
2434
- const DEFAULT_TOTAL_LABEL = "合計"
2435
-
2436
2632
  const sliceValue = (value: number) => (Number.isFinite(value) && value > 0 ? value : 0)
2437
2633
 
2438
2634
  /** A slice's share of `total` as text: whole percent, `<1%` for a sliver, `0%` for nothing. */
@@ -2465,15 +2661,16 @@ function donutLegendItems(slices: readonly DonutOptionSlice[]): DonutLegendItem[
2465
2661
  }
2466
2662
 
2467
2663
  /**
2468
- * The default center label: what is totalled (「合計」), the formatted total
2664
+ * The default center label: what is totalled (「合計」 / "Total" in `locale`, default the page's), the formatted total
2469
2665
  * (`amount`, the big line), the unit (a small line under it) and both as one
2470
2666
  * string (`value`, 「9.2万 セッション」).
2471
2667
  */
2472
2668
  function donutCenterText(
2473
2669
  total: number,
2474
2670
  valueFormatter: (value: number) => string,
2475
- { totalLabel = DEFAULT_TOTAL_LABEL, unit }: { totalLabel?: string; unit?: string } = {},
2671
+ { totalLabel, unit, locale }: { totalLabel?: string; unit?: string; locale?: string } = {},
2476
2672
  ): { label: string; value: string; amount: string; unit?: string } {
2673
+ totalLabel ??= chartText(locale).total
2477
2674
  const amount = valueFormatter(total)
2478
2675
  return { label: totalLabel, value: unit ? `${amount} ${unit}` : amount, amount, ...(unit ? { unit } : {}) }
2479
2676
  }
@@ -2701,8 +2898,10 @@ function hasLabel(labels: Readonly<Record<string, string>> | undefined, key: str
2701
2898
 
2702
2899
  /* ------------------------------------------------------------ provisional */
2703
2900
 
2704
- /** The default label for points that are not final yet. */
2705
- const DEFAULT_PROVISIONAL_LABEL = "速報"
2901
+ /** The default label for points that are not final yet, in `locale`: "Preliminary" / 「速報」. Default the page's locale. */
2902
+ function defaultProvisionalLabel(locale?: string): string {
2903
+ return chartText(locale).provisional
2904
+ }
2706
2905
 
2707
2906
  /**
2708
2907
  * Position of the first provisional point: the first entry of `indexValues`
@@ -2840,9 +3039,10 @@ function resolveValueFormatter(
2840
3039
  function resolveAxisFormatter(
2841
3040
  valueFormatter: ValueFormatter | undefined,
2842
3041
  format: ValueFormat | undefined,
2843
- locale: string = DEFAULT_LOCALE,
3042
+ localeArg?: string,
2844
3043
  ): ValueFormatter {
2845
3044
  if (valueFormatter) return valueFormatter
3045
+ const locale = resolveLocale(localeArg)
2846
3046
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
2847
3047
  if (spec.type !== "compact" && spec.type !== "number" && spec.type !== "currency") return toValueFormatter(format, locale)
2848
3048
  const currency = "currency" in spec ? spec.currency : undefined
@@ -2851,40 +3051,75 @@ function resolveAxisFormatter(
2851
3051
  return (value) => formatValue(value, short, locale)
2852
3052
  }
2853
3053
 
2854
- /** The unit words of a ja number (`1.2万` / `3.4億` / `1兆`) and their sizes, largest first. */
2855
- const JA_UNITS = [
2856
- { char: "兆", size: 1e12 },
2857
- { char: "億", size: 1e8 },
2858
- { char: "万", size: 1e4 },
2859
- ] as const
3054
+ interface CompactUnit {
3055
+ token: string
3056
+ size: number
3057
+ }
2860
3058
 
2861
- const JA_NUMBER_TOKEN_RE = /\d[\d,]*(?:\.\d+)?(?:兆|億|万)?/
3059
+ const compactUnitCache = new Map<string, CompactUnit[] | null>()
3060
+
3061
+ /**
3062
+ * The compact-notation unit words of a locale and their sizes, largest first: `兆` / `億` / `万` in ja,
3063
+ * `T` / `B` / `M` / `K` in en. `null` when the locale's compact form puts anything between the number and its
3064
+ * word (`1,5 Mio.`), where a token cannot be swapped in place.
3065
+ */
3066
+ function compactUnits(locale: string): CompactUnit[] | null {
3067
+ const cached = compactUnitCache.get(locale)
3068
+ if (cached !== undefined) return cached
3069
+ let units: CompactUnit[] | null = []
3070
+ try {
3071
+ const compact = new Intl.NumberFormat(locale, { notation: "compact" })
3072
+ const seen = new Set<string>()
3073
+ for (let exp = 3; exp <= 15; exp++) {
3074
+ const parts = compact.formatToParts(10 ** exp)
3075
+ const at = parts.findIndex((p) => p.type === "compact")
3076
+ if (at === -1) continue
3077
+ if (parts[at - 1]?.type !== "integer" || at !== parts.length - 1) {
3078
+ units = null
3079
+ break
3080
+ }
3081
+ const token = parts[at]!.value
3082
+ if (seen.has(token)) continue
3083
+ seen.add(token)
3084
+ units.push({ token, size: 10 ** exp })
3085
+ }
3086
+ } catch {
3087
+ units = null
3088
+ }
3089
+ const sorted = units && units.length > 0 ? units.sort((a, b) => b.size - a.size) : null
3090
+ compactUnitCache.set(locale, sorted)
3091
+ return sorted
3092
+ }
3093
+
3094
+ const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
2862
3095
 
2863
3096
  /**
2864
3097
  * The value-axis tick formatter for an axis whose largest tick is `top`: every tick reads in the unit of
2865
- * that largest tick, so 万 and 億 never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`).
2866
- * Only the ja short forms (`compact` / `number` / `currency`, no `valueFormatter`) have units to align;
2867
- * any other format — and an axis whose largest tick is under 1万 — keeps `resolveAxisFormatter`'s output as it is.
3098
+ * that largest tick, so units never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`;
3099
+ * `$0.5M` / `$1M` / `$1.5M`, not `$500K` / `$1M`). Only the short forms (`compact` / `number` / `currency`,
3100
+ * no `valueFormatter`) have units to align; any other format — an axis whose largest tick is under the smallest
3101
+ * unit (1万 / 1K), or a locale whose compact form cannot be aligned in place — keeps `resolveAxisFormatter`'s output as it is.
2868
3102
  */
2869
3103
  function resolveAxisTickFormatter(
2870
3104
  valueFormatter: ValueFormatter | undefined,
2871
3105
  format: ValueFormat | undefined,
2872
- locale: string = DEFAULT_LOCALE,
3106
+ localeArg?: string,
2873
3107
  ): (top: number) => ValueFormatter {
3108
+ const locale = resolveLocale(localeArg)
2874
3109
  const base = resolveAxisFormatter(valueFormatter, format, locale)
2875
3110
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
2876
- const aligns =
2877
- !valueFormatter && locale.toLowerCase().startsWith("ja") && (spec.type === "compact" || spec.type === "number" || spec.type === "currency")
2878
- if (!aligns) return () => base
3111
+ const units = !valueFormatter && (spec.type === "compact" || spec.type === "number" || spec.type === "currency") ? compactUnits(locale) : null
3112
+ if (!units) return () => base
2879
3113
  const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 3 })
3114
+ const numberToken = new RegExp(`\\d[\\d,]*(?:\\.\\d+)?(?:${units.map((u) => escapeRegExp(u.token)).join("|")})?`)
2880
3115
  return (top) => {
2881
- const unit = JA_UNITS.find((u) => Math.abs(top) >= u.size)
3116
+ const unit = units.find((u) => Math.abs(top) >= u.size)
2882
3117
  if (!unit) return base
2883
3118
  return (value) => {
2884
3119
  const text = base(value)
2885
3120
  if (value === 0 || !Number.isFinite(value)) return text
2886
- const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.char}`
2887
- return text.replace(JA_NUMBER_TOKEN_RE, () => scaled)
3121
+ const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.token}`
3122
+ return text.replace(numberToken, () => scaled)
2888
3123
  }
2889
3124
  }
2890
3125
  }
@@ -2913,8 +3148,20 @@ function clamp(value: number, min: number, max: number): number {
2913
3148
  /** How many categories a chart colours individually; the rest fold into「その他」. */
2914
3149
  const MAX_CHART_SERIES = 5
2915
3150
 
2916
- /** The series name the folded categories get. */
2917
- const OTHER_SERIES_LABEL = "その他"
3151
+ /** The series name the folded categories get, by language. */
3152
+ const OTHER_SERIES_LABELS: Record<UiLanguage, string> = { ja: "その他", en: "Other" }
3153
+
3154
+ /** The series name the folded categories get in `locale` ("Other" / "その他"). Default the page's locale (`<html lang>`). */
3155
+ function otherSeriesLabel(locale?: string): string {
3156
+ return OTHER_SERIES_LABELS[uiLanguage(resolveLocale(locale))]
3157
+ }
3158
+
3159
+ const OTHER_NAMES = new Set(Object.values(OTHER_SERIES_LABELS))
3160
+
3161
+ /** Whether a category / row is the "other" bucket by name (「その他」 or "Other", whatever the page language). */
3162
+ function isOtherName(name: string | null | undefined): boolean {
3163
+ return typeof name === "string" && OTHER_NAMES.has(name)
3164
+ }
2918
3165
 
2919
3166
  interface AggregatedItems<Item extends { name: string; value: number }> {
2920
3167
  /** The `n` largest items, in their given order. All items when there are `n` or fewer. */
@@ -2934,7 +3181,7 @@ interface AggregatedItems<Item extends { name: string; value: number }> {
2934
3181
  function aggregateTopN<Item extends { name: string; value: number }>(
2935
3182
  items: readonly Item[],
2936
3183
  n: number = MAX_CHART_SERIES,
2937
- otherLabel: string = OTHER_SERIES_LABEL,
3184
+ otherLabel: string = otherSeriesLabel(),
2938
3185
  ): AggregatedItems<Item> {
2939
3186
  if (items.length <= n) return { top: [...items], rest: [], other: null }
2940
3187
  const ranked = items
@@ -2963,7 +3210,7 @@ function aggregateSeries(
2963
3210
  data: readonly Record<string, unknown>[],
2964
3211
  categories: readonly string[],
2965
3212
  n: number = MAX_CHART_SERIES,
2966
- otherLabel: string = OTHER_SERIES_LABEL,
3213
+ otherLabel: string = otherSeriesLabel(),
2967
3214
  ): AggregatedSeries {
2968
3215
  const totals = categories.map((name) => ({
2969
3216
  name,
@@ -2995,12 +3242,14 @@ function aggregateSeriesKeeping(
2995
3242
  categories: readonly string[],
2996
3243
  keep: string | undefined,
2997
3244
  n: number = MAX_CHART_SERIES,
3245
+ otherLabel: string = otherSeriesLabel(),
2998
3246
  ): AggregatedSeries {
2999
- if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n)
3247
+ if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n, otherLabel)
3000
3248
  const aggregated = aggregateSeries(
3001
3249
  data,
3002
3250
  categories.filter((c) => c !== keep),
3003
3251
  n,
3252
+ otherLabel,
3004
3253
  )
3005
3254
  return { ...aggregated, categories: [...aggregated.categories, keep] }
3006
3255
  }
@@ -3036,7 +3285,7 @@ function seriesColorsWithComparison(
3036
3285
  * Colour per drawn series: the record's token for that category, else the
3037
3286
  * array's token at the category's position in `allCategories` (cycled), else
3038
3287
  * the token palette by drawn position. `otherCategory` — and a category the
3039
- * data itself names「その他」(`OTHER_SERIES_LABEL`) — always gets `otherColor`.
3288
+ * data itself names「その他」/ "Other" (`OTHER_SERIES_LABELS`) — always gets `otherColor`.
3040
3289
  */
3041
3290
  function seriesColorsFor(
3042
3291
  categories: readonly string[],
@@ -3048,7 +3297,7 @@ function seriesColorsFor(
3048
3297
  const list = Array.isArray(colors) ? (colors as ChartSeriesColor[]) : undefined
3049
3298
  const record = !Array.isArray(colors) && colors ? (colors as Partial<Record<string, ChartSeriesColor>>) : undefined
3050
3299
  return categories.map((category, i) => {
3051
- if ((otherCategory !== null && category === otherCategory) || category === OTHER_SERIES_LABEL) return otherColor
3300
+ if ((otherCategory !== null && category === otherCategory) || isOtherName(category)) return otherColor
3052
3301
  const fromRecord = record?.[category]
3053
3302
  if (fromRecord) return fromRecord
3054
3303
  if (list && list.length > 0) {
@@ -3095,7 +3344,7 @@ interface DonutSlicesConfig {
3095
3344
  */
3096
3345
  function donutSlices(
3097
3346
  data: readonly Record<string, unknown>[],
3098
- { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = OTHER_SERIES_LABEL }: DonutSlicesConfig,
3347
+ { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = otherSeriesLabel() }: DonutSlicesConfig,
3099
3348
  ): DonutSlice[] {
3100
3349
  const items = data
3101
3350
  .map((row, i) => {
@@ -3108,7 +3357,8 @@ function donutSlices(
3108
3357
  const slices: DonutSlice[] = top.map((item, rank) => ({
3109
3358
  name: item.name,
3110
3359
  value: item.value,
3111
- color: palette[rank % palette.length]!,
3360
+ // A row the data itself names「その他」/ "Other" is the same remainder: grey, like the folded slice.
3361
+ color: isOtherName(item.name) ? otherColor : palette[rank % palette.length]!,
3112
3362
  isOther: false,
3113
3363
  row: item.row,
3114
3364
  }))
@@ -3181,7 +3431,7 @@ interface BarListRowsConfig extends BarListOptions {
3181
3431
  */
3182
3432
  function barListRows<Row extends object>(
3183
3433
  data: readonly Row[],
3184
- { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = OTHER_SERIES_LABEL, exclude }: BarListRowsConfig = {},
3434
+ { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = otherSeriesLabel(), exclude }: BarListRowsConfig = {},
3185
3435
  ): BarListRow<Row>[] {
3186
3436
  const items = data.map((row): BarListRow<Row> => {
3187
3437
  const cells = row as Record<string, unknown>
@@ -3227,19 +3477,21 @@ const BAR_LIST_OTHER_TINT = 30
3227
3477
 
3228
3478
  /**
3229
3479
  * The fill of each BarList row: all real rows in the same tint of `color`
3230
- * (`BAR_LIST_TINT`), the folded「その他」row in a tint of `otherColor`
3480
+ * (`BAR_LIST_TINT`), the folded「その他」row (and a row the data names「その他」/ "Other") in a tint of `otherColor`
3231
3481
  * (`BAR_LIST_OTHER_TINT`) — the palest fill, a remainder never outweighs a
3232
3482
  * ranked row. A legacy per-row `color` (or a threshold tone) replaces `color`
3233
3483
  * for that row. A tint, not the solid token, so the label printed over the bar
3234
3484
  * keeps its contrast.
3235
3485
  */
3236
3486
  function barListFills(
3237
- rows: readonly Pick<BarListRow, "isOther" | "color">[],
3487
+ rows: readonly (Pick<BarListRow, "isOther" | "color"> & { name?: string })[],
3238
3488
  color: ChartColor,
3239
3489
  otherColor: string,
3240
3490
  ): string[] {
3241
3491
  return rows.map((row) =>
3242
- row.isOther ? tintCss(otherColor, BAR_LIST_OTHER_TINT) : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
3492
+ row.isOther || isOtherName(row.name)
3493
+ ? tintCss(otherColor, BAR_LIST_OTHER_TINT)
3494
+ : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
3243
3495
  )
3244
3496
  }
3245
3497
 
@@ -3455,11 +3707,30 @@ interface ChartSummaryInput {
3455
3707
  format?: ValueFormat
3456
3708
  /** Wins over `format`. */
3457
3709
  valueFormatter?: ValueFormatter
3710
+ /** Language of the sentence. Default the page's locale (`<html lang>`). */
3711
+ locale?: string
3712
+ }
3713
+
3714
+ const SUMMARY_TEXT = {
3715
+ ja: {
3716
+ up: "、前の点より増加",
3717
+ down: "、前の点より減少",
3718
+ flat: "、前の点と横ばい",
3719
+ latest: (label: string, value: string) => `最新 ${label} は ${value}`,
3720
+ top: (name: string, share: string) => `最大は ${name}(${share})`,
3721
+ },
3722
+ en: {
3723
+ up: ", up from the previous point",
3724
+ down: ", down from the previous point",
3725
+ flat: ", flat from the previous point",
3726
+ latest: (label: string, value: string) => `Latest ${label}: ${value}`,
3727
+ top: (name: string, share: string) => `Largest: ${name} (${share})`,
3728
+ },
3458
3729
  }
3459
3730
 
3460
3731
  /**
3461
3732
  * One-sentence summary built from the rows, for `summary="auto"`:
3462
- * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」. The direction is
3733
+ * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」("<title>: Latest <index>: <value>, up from the previous point"). The direction is
3463
3734
  * computed from the last two non-empty points of the first series — never
3464
3735
  * written by hand, so it cannot contradict the data. `undefined` when there is
3465
3736
  * no usable point.
@@ -3475,26 +3746,29 @@ function buildChartSummary(input: ChartSummaryInput): string | undefined {
3475
3746
  }
3476
3747
  const latest = points[points.length - 1]
3477
3748
  if (!latest) return undefined
3478
- const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact")
3749
+ const locale = resolveLocale(input.locale)
3750
+ const t = SUMMARY_TEXT[uiLanguage(locale)]
3751
+ const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact", locale)
3479
3752
  const prev = points[points.length - 2]
3480
- const direction = !prev ? "" : latest.value > prev.value ? "、前の点より増加" : latest.value < prev.value ? "、前の点より減少" : "、前の点と横ばい"
3753
+ const direction = !prev ? "" : latest.value > prev.value ? t.up : latest.value < prev.value ? t.down : t.flat
3481
3754
  const head = input.title ? `${input.title}: ` : ""
3482
- return `${head}最新 ${latest.label} は ${text(latest.value)}${direction}`
3755
+ return `${head}${t.latest(latest.label, text(latest.value))}${direction}`
3483
3756
  }
3484
3757
 
3485
3758
  /**
3486
- * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」 from the slices
3487
- * (largest first). `undefined` when there is nothing to describe.
3759
+ * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」("<title>: Largest: <name> (<share>)") from the slices
3760
+ * (largest first). `undefined` when there is nothing to describe. `locale` picks the language; default the page's.
3488
3761
  */
3489
3762
  function buildShareSummary(
3490
3763
  slices: readonly { name: string; value: number }[],
3491
3764
  title?: string,
3765
+ locale?: string,
3492
3766
  ): string | undefined {
3493
3767
  const total = slices.reduce((sum, s) => sum + s.value, 0)
3494
3768
  const top = slices[0]
3495
3769
  if (!top || total <= 0) return undefined
3496
3770
  const share = `${Math.round((top.value / total) * 1000) / 10}%`
3497
- return `${title ? `${title}: ` : ""}最大は ${top.name}(${share})`
3771
+ return `${title ? `${title}: ` : ""}${SUMMARY_TEXT[uiLanguage(resolveLocale(locale))].top(top.name, share)}`
3498
3772
  }
3499
3773
 
3500
3774
  /* ---------- end of inlined code ---------- */
@@ -3561,6 +3835,47 @@ export interface WaterfallChartProps<T extends Record<string, unknown> = Record<
3561
3835
 
3562
3836
  /* ------------------------------------------------------------------ model */
3563
3837
 
3838
+ const TEXT = {
3839
+ ja: {
3840
+ net: "純増",
3841
+ total: "合計",
3842
+ change: "増減",
3843
+ cumulative: "累計",
3844
+ cumulativeChange: "累計増減",
3845
+ item: "項目",
3846
+ start: "開始",
3847
+ end: "終了",
3848
+ fromTo: (from: string, to: string, net: string) => `${from} → ${to}(${net})`,
3849
+ from: (from: string, net: string) => `${from} から ${net}`,
3850
+ to: (to: string, net: string) => `${to}(${net})`,
3851
+ netOnly: (net: string) => `増減の合計 ${net}`,
3852
+ increase: (name: string, value: string) => `最大の増加は ${name}(${value})`,
3853
+ decrease: (name: string, value: string) => `最大の減少は ${name}(${value})`,
3854
+ sentenceJoin: "。",
3855
+ chart: (label: string) => `${label}のウォーターフォールチャート`,
3856
+ },
3857
+ en: {
3858
+ net: "Net change",
3859
+ total: "Total",
3860
+ change: "Change",
3861
+ cumulative: "Cumulative",
3862
+ cumulativeChange: "Cumulative change",
3863
+ item: "Item",
3864
+ start: "Start",
3865
+ end: "End",
3866
+ fromTo: (from: string, to: string, net: string) => `${from} → ${to} (${net})`,
3867
+ from: (from: string, net: string) => `${net} from ${from}`,
3868
+ to: (to: string, net: string) => `${to} (${net})`,
3869
+ netOnly: (net: string) => `Net change ${net}`,
3870
+ increase: (name: string, value: string) => `Largest increase: ${name} (${value})`,
3871
+ decrease: (name: string, value: string) => `Largest decrease: ${name} (${value})`,
3872
+ sentenceJoin: ". ",
3873
+ chart: (label: string) => `Waterfall chart of ${label}`,
3874
+ },
3875
+ }
3876
+
3877
+ const textOf = (locale?: string) => TEXT[uiLanguage(resolveLocale(locale))]
3878
+
3564
3879
  export type WaterfallKind = "total" | "increase" | "decrease" | "flat" | "gap"
3565
3880
 
3566
3881
  export interface WaterfallStep {
@@ -3582,7 +3897,7 @@ export interface WaterfallStep {
3582
3897
  */
3583
3898
  export function waterfallSteps(
3584
3899
  data: readonly Record<string, unknown>[],
3585
- config: { index: string; value: string; totals?: readonly string[]; mode?: WaterfallMode },
3900
+ config: { index: string; value: string; totals?: readonly string[]; mode?: WaterfallMode; locale?: string },
3586
3901
  ): WaterfallStep[] {
3587
3902
  const totals = new Set(config.totals ?? [])
3588
3903
  const delta = config.mode === "delta"
@@ -3603,13 +3918,15 @@ export function waterfallSteps(
3603
3918
  })
3604
3919
  // `delta`: the net change closes the plot as a total bar from 0 (series colour 1), so the moves add up to something.
3605
3920
  if (delta && steps.some((s) => s.kind !== "gap")) {
3606
- steps.push({ name: DELTA_TOTAL_NAME, kind: "total", value: running, start: 0, end: running, row: {} })
3921
+ steps.push({ name: deltaTotalName(config.locale), kind: "total", value: running, start: 0, end: running, row: {} })
3607
3922
  }
3608
3923
  return steps
3609
3924
  }
3610
3925
 
3611
- /** The name of the net-change bar that `mode="delta"` appends. */
3612
- export const DELTA_TOTAL_NAME = "純増"
3926
+ /** The name of the net-change bar that `mode="delta"` appends, in `locale` ("Net change" / 「純増」). Default the page's locale. */
3927
+ export function deltaTotalName(locale?: string): string {
3928
+ return textOf(locale).net
3929
+ }
3613
3930
 
3614
3931
  /** What `mode="delta"` leaves out of the bars: the opening / closing totals and the net change of the moves. */
3615
3932
  export interface WaterfallDeltaEnds {
@@ -3647,16 +3964,22 @@ export function waterfallDeltaEnds(
3647
3964
  }
3648
3965
 
3649
3966
  /** The note above a delta plot: `前期 ¥2.2億 → 当期 ¥2.2億(+¥130万)`; without the totals' values, the net change alone. */
3650
- export function waterfallDeltaNote(ends: WaterfallDeltaEnds, formatter: ValueFormatter, labels?: Record<string, string>): string {
3967
+ export function waterfallDeltaNote(
3968
+ ends: WaterfallDeltaEnds,
3969
+ formatter: ValueFormatter,
3970
+ labels?: Record<string, string>,
3971
+ locale?: string,
3972
+ ): string {
3973
+ const t = textOf(locale)
3651
3974
  const net = signedText(ends.net, formatter)
3652
3975
  const { opening, closing } = ends
3653
3976
  const side = (e: { name: string; value: number | null } | null) => (e && e.value !== null ? `${labelOf(e.name, labels)} ${formatter(e.value)}` : null)
3654
3977
  const from = side(opening)
3655
3978
  const to = side(closing)
3656
- if (from && to) return `${from} → ${to}(${net})`
3657
- if (from) return `${from} から ${net}`
3658
- if (to) return `${to}(${net})`
3659
- return `増減の合計 ${net}`
3979
+ if (from && to) return t.fromTo(from, to, net)
3980
+ if (from) return t.from(from, net)
3981
+ if (to) return t.to(to, net)
3982
+ return t.netOnly(net)
3660
3983
  }
3661
3984
 
3662
3985
  export interface WaterfallStack {
@@ -3740,7 +4063,9 @@ export function buildWaterfallSummary(
3740
4063
  title?: string,
3741
4064
  labels?: Record<string, string>,
3742
4065
  delta?: WaterfallDeltaEnds,
4066
+ locale?: string,
3743
4067
  ): string | undefined {
4068
+ const t = textOf(locale)
3744
4069
  const real = steps.filter((s) => s.kind !== "gap")
3745
4070
  const first = real[0]
3746
4071
  const last = real[real.length - 1]
@@ -3750,7 +4075,7 @@ export function buildWaterfallSummary(
3750
4075
  // A first step starts at 0, so its end is its value; a total's end is the total. `delta`: the note (totals and net change) instead.
3751
4076
  const parts = [
3752
4077
  delta
3753
- ? waterfallDeltaNote(delta, formatter, labels)
4078
+ ? waterfallDeltaNote(delta, formatter, labels, locale)
3754
4079
  : real.length === 1
3755
4080
  ? `${name(first)} ${formatter(first.end)}`
3756
4081
  : `${name(first)} ${formatter(first.end)} → ${name(last)} ${formatter(last.end)}`,
@@ -3761,9 +4086,9 @@ export function buildWaterfallSummary(
3761
4086
  if (s.kind === "increase" && (!up || s.value! > up.value!)) up = s
3762
4087
  if (s.kind === "decrease" && (!down || s.value! < down.value!)) down = s
3763
4088
  }
3764
- if (up) parts.push(`最大の増加は ${name(up)}(${signedText(up.value!, formatter)})`)
3765
- if (down) parts.push(`最大の減少は ${name(down)}(${signedText(down.value!, formatter)})`)
3766
- return `${head}${parts.join("。")}`
4089
+ if (up) parts.push(t.increase(name(up), signedText(up.value!, formatter)))
4090
+ if (down) parts.push(t.decrease(name(down), signedText(down.value!, formatter)))
4091
+ return `${head}${parts.join(t.sentenceJoin)}`
3767
4092
  }
3768
4093
 
3769
4094
  /** The screen-reader table: one row per step with its change and the running total. */
@@ -3773,19 +4098,21 @@ export function waterfallDataTable(
3773
4098
  formatter: ValueFormatter,
3774
4099
  labels?: Record<string, string>,
3775
4100
  delta?: WaterfallDeltaEnds,
4101
+ locale?: string,
3776
4102
  ): ChartDataTable {
4103
+ const t = textOf(locale)
3777
4104
  const label = (key: string) => labelOf(key, labels)
3778
4105
  const rows = steps.map((s) => [
3779
4106
  label(s.name),
3780
- s.kind === "total" ? (delta ? DELTA_TOTAL_NAME : "合計") : s.value === null ? EMPTY_VALUE : signedText(s.value, formatter),
4107
+ s.kind === "total" ? (delta ? t.net : t.total) : s.value === null ? EMPTY_VALUE : signedText(s.value, formatter),
3781
4108
  s.kind === "gap" ? EMPTY_VALUE : delta ? signedText(s.end, formatter) : formatter(s.end),
3782
4109
  ])
3783
4110
  // `delta`: the totals are not bars, so the opening / closing values are rows of their own (they are not in `steps`).
3784
4111
  const end = (e: WaterfallDeltaEnds["opening"], role: string) =>
3785
4112
  e && e.value !== null ? [[label(e.name), role, formatter(e.value)]] : []
3786
4113
  return {
3787
- columns: [labels?.[names.index] ?? "項目", "増減", delta ? "累計増減" : "累計"],
3788
- rows: delta ? [...end(delta.opening, "開始"), ...rows, ...end(delta.closing, "終了")] : rows,
4114
+ columns: [labels?.[names.index] ?? t.item, t.change, delta ? t.cumulativeChange : t.cumulative],
4115
+ rows: delta ? [...end(delta.opening, t.start), ...rows, ...end(delta.closing, t.end)] : rows,
3789
4116
  }
3790
4117
  }
3791
4118
 
@@ -3820,6 +4147,8 @@ export interface WaterfallOptionConfig {
3820
4147
  noteColor?: string
3821
4148
  /** Colour of the 1px line joining the bars' tops (the border token). `undefined` draws no connector. */
3822
4149
  connectorColor?: string
4150
+ /** Language of the tooltip. Default the page's locale (`<html lang>`). */
4151
+ locale?: string
3823
4152
  }
3824
4153
 
3825
4154
  /** The bar fills 45% of its category band. ECharts reads `barCategoryGap` as a share of the band (not of the bar), so the gap is the other 55%; a gap over 100% leaves the bar at its 1px minimum. */
@@ -3960,6 +4289,7 @@ const ROW_STYLE = "display:flex;align-items:center;gap:8px;line-height:20px"
3960
4289
  /** The ECharts option: a transparent base plus the part above and below 0, coloured per step. */
3961
4290
  export function buildWaterfallOption(config: WaterfallOptionConfig): EChartsOption {
3962
4291
  const { steps, colors, polarity, labels, valueFormatter, showValues = true, showTooltip = true } = config
4292
+ const t = textOf(config.locale)
3963
4293
  const delta = config.mode === "delta"
3964
4294
  const axisFormatter = config.axisFormatter ?? valueFormatter
3965
4295
  const noteWidth = config.plotWidth ? Math.max(120, config.plotWidth - 16) : undefined
@@ -4041,9 +4371,13 @@ export function buildWaterfallOption(config: WaterfallOptionConfig): EChartsOpti
4041
4371
  `<span style="font-weight:500;font-variant-numeric:tabular-nums;margin-left:12px">${escapeHtml(text)}</span></div>`
4042
4372
  const rows =
4043
4373
  step.kind === "total"
4044
- ? line(delta ? DELTA_TOTAL_NAME : "合計", delta ? signedText(step.end, valueFormatter) : valueFormatter(step.end))
4045
- : line("増減", waterfallStepText(step, valueFormatter)) +
4046
- (step.kind === "gap" ? "" : delta ? line("累計増減", signedText(step.end, valueFormatter)) : line("累計", valueFormatter(step.end)))
4374
+ ? line(delta ? t.net : t.total, delta ? signedText(step.end, valueFormatter) : valueFormatter(step.end))
4375
+ : line(t.change, waterfallStepText(step, valueFormatter)) +
4376
+ (step.kind === "gap"
4377
+ ? ""
4378
+ : delta
4379
+ ? line(t.cumulativeChange, signedText(step.end, valueFormatter))
4380
+ : line(t.cumulative, valueFormatter(step.end)))
4047
4381
  return `<div style="font-weight:500;margin-bottom:4px">${escapeHtml(labelOf(step.name, labels))}</div>${rows}`
4048
4382
  },
4049
4383
  },
@@ -4141,14 +4475,17 @@ function WaterfallChartImpl<T extends Record<string, unknown>>(
4141
4475
  ref: ForwardedRef<HTMLDivElement>,
4142
4476
  ) {
4143
4477
  const valueKey: string = categories[0] ?? ""
4144
- const tokens = useChartTokens()
4145
- const { ref: frameRef, width: plotWidth } = useChartSurface(ref, tokens)
4478
+ const { ref: frameRef, width: plotWidth, tokens } = useChartSurface(ref)
4146
4479
  // Value labels and the tooltip read in full (as `format` says); ticks read short.
4147
- const valueFormatter = useMemo(() => resolveValueFormatter(valueFormatterProp, format), [valueFormatterProp, format])
4148
- const axisFormatter = useMemo(() => resolveAxisFormatter(valueFormatterProp, format), [valueFormatterProp, format])
4149
- const steps = useMemo(() => waterfallSteps(data, { index, value: valueKey, totals, mode }), [data, index, valueKey, totals, mode])
4480
+ const locale = useLocale()
4481
+ const valueFormatter = useMemo(() => resolveValueFormatter(valueFormatterProp, format, locale), [valueFormatterProp, format, locale])
4482
+ const axisFormatter = useMemo(() => resolveAxisFormatter(valueFormatterProp, format, locale), [valueFormatterProp, format, locale])
4483
+ const steps = useMemo(
4484
+ () => waterfallSteps(data, { index, value: valueKey, totals, mode, locale }),
4485
+ [data, index, valueKey, totals, mode, locale],
4486
+ )
4150
4487
  const ends = useMemo(() => (mode === "delta" ? waterfallDeltaEnds(data, { index, value: valueKey, totals }) : undefined), [data, index, valueKey, totals, mode])
4151
- const deltaNote = useMemo(() => (ends ? waterfallDeltaNote(ends, valueFormatter, labels) : undefined), [ends, valueFormatter, labels])
4488
+ const deltaNote = useMemo(() => (ends ? waterfallDeltaNote(ends, valueFormatter, labels, locale) : undefined), [ends, valueFormatter, labels, locale])
4152
4489
  const colors = useMemo(() => {
4153
4490
  const fills = resolveFillColors(tokens)
4154
4491
  const [total = ""] = resolveChartColors(["chart-1"], tokens)
@@ -4181,18 +4518,20 @@ function WaterfallChartImpl<T extends Record<string, unknown>>(
4181
4518
  minValue,
4182
4519
  maxValue,
4183
4520
  yAxisWidth,
4521
+ locale,
4184
4522
  }),
4185
- [steps, colors, polarity, labels, valueFormatter, axisFormatter, showValues, showTooltip, tokens, mode, deltaNote, plotWidth, chartOptions, minValue, maxValue, yAxisWidth],
4523
+ [steps, colors, polarity, labels, valueFormatter, axisFormatter, showValues, showTooltip, tokens, mode, deltaNote, plotWidth, chartOptions, minValue, maxValue, yAxisWidth, locale],
4186
4524
  )
4187
4525
  const dataTable = useMemo(
4188
- () => waterfallDataTable(steps, { index, value: valueKey }, valueFormatter, labels, ends),
4189
- [steps, index, valueKey, valueFormatter, labels, ends],
4526
+ () => waterfallDataTable(steps, { index, value: valueKey }, valueFormatter, labels, ends, locale),
4527
+ [steps, index, valueKey, valueFormatter, labels, ends, locale],
4190
4528
  )
4191
4529
  const resolvedSummary = useMemo(
4192
- () => (summary === "auto" ? buildWaterfallSummary(steps, valueFormatter, title, labels, ends) : summary),
4193
- [summary, steps, valueFormatter, title, labels, ends],
4530
+ () => (summary === "auto" ? buildWaterfallSummary(steps, valueFormatter, title, labels, ends, locale) : summary),
4531
+ [summary, steps, valueFormatter, title, labels, ends, locale],
4194
4532
  )
4195
- const label = ariaLabel ?? `${labelOf(valueKey, labels) || "増減"}のウォーターフォールチャート`
4533
+ const t = textOf(locale)
4534
+ const label = ariaLabel ?? t.chart(labelOf(valueKey, labels) || t.change.toLowerCase())
4196
4535
  return (
4197
4536
  <ChartFrame
4198
4537
  {...chartRootProps(rest)}