@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
@@ -29,7 +29,8 @@ import { ChartFrame } from "@squadbase/vantage/components"
29
29
  import { EChart } from "@squadbase/vantage/components"
30
30
  import { EMPTY_VALUE, type ValueFormat } from "@squadbase/vantage/components"
31
31
  import type { ChartCommonProps, ChartEmptyState, ChartSeriesColor, NumericKeys, ValueFormatter } from "@squadbase/vantage/components"
32
- import { createContext, useCallback, useEffect, useRef, useState } from "react"
32
+ import { useSyncExternalStore } from "react"
33
+ import { createContext, useCallback, useEffect, useRef, useState, type RefObject } from "react"
33
34
  import type { ChartTone } from "@squadbase/vantage/components"
34
35
  import { type ReactNode } from "react"
35
36
  import { computeComparison, defaultChangeDisplay, formatChange, toneForValue, type ComparisonKind, type PeriodGrain } from "@squadbase/vantage/components"
@@ -38,6 +39,137 @@ import type { ChartThreshold, ChartAnnotation, ChartEventPayload, ChartForecast,
38
39
  import { formatValue, toValueFormatter } from "@squadbase/vantage/components"
39
40
  import type { ChartColor, ChartColors } from "@squadbase/vantage/components"
40
41
 
42
+ /* ---------- inlined from @squadbase/squadkit registry/lib/locale.ts (internal to @squadbase/vantage) ---------- */
43
+
44
+ /**
45
+ * The display language of the parts and the defaults that follow it.
46
+ *
47
+ * The language comes from `<html lang>`: a tag starting with `ja` reads Japanese, anything else English.
48
+ * Without a usable `lang` (missing, invalid, or no `document` — SSR, Node) the locale is `FALLBACK_LOCALE`
49
+ * (`en-US`). A `locale` passed explicitly (a prop, an argument) always wins.
50
+ *
51
+ * Parts read the locale through `useLocale` (`hooks/use-locale`) and pass it down; the pure helpers here
52
+ * and in `format` / `comparison` / `chart-*` take it as an argument and fall back to `documentLocale()`.
53
+ */
54
+
55
+ /** The language of the built-in wording: Japanese or English. */
56
+ type UiLanguage = "ja" | "en"
57
+
58
+ /** The locale used when `<html lang>` gives none (and on the server). */
59
+ const FALLBACK_LOCALE = "en-US"
60
+
61
+ /** `"ja"` when `locale` starts with `ja` (`ja`, `ja-JP`), else `"en"`. A missing locale is `"en"`. */
62
+ function uiLanguage(locale: string | null | undefined): UiLanguage {
63
+ return typeof locale === "string" && locale.trim().toLowerCase().startsWith("ja") ? "ja" : "en"
64
+ }
65
+
66
+ /** A BCP 47 tag in its canonical form (`en-gb` → `en-GB`), or `FALLBACK_LOCALE` when it is empty or invalid. */
67
+ function normalizeLocale(tag: string | null | undefined): string {
68
+ const value = tag?.trim()
69
+ if (!value) return FALLBACK_LOCALE
70
+ try {
71
+ return Intl.getCanonicalLocales(value)[0] ?? FALLBACK_LOCALE
72
+ } catch {
73
+ return FALLBACK_LOCALE
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The locale of the page: `document.documentElement.lang`, canonicalized. `FALLBACK_LOCALE` when it is empty or
79
+ * invalid, or there is no `document`. Parts read it through `useLocale`, never while rendering.
80
+ */
81
+ function documentLocale(): string {
82
+ if (typeof document === "undefined") return FALLBACK_LOCALE
83
+ return normalizeLocale(document.documentElement?.getAttribute("lang"))
84
+ }
85
+
86
+ /** `locale` when given, else the page's locale (`documentLocale()`). */
87
+ function resolveLocale(locale?: string | null): string {
88
+ return locale || documentLocale()
89
+ }
90
+
91
+ /** Picks the wording table of the locale's language. */
92
+ function pickLocale<T>(table: { ja: T; en: T }, locale?: string | null): T {
93
+ return table[uiLanguage(resolveLocale(locale))]
94
+ }
95
+
96
+ /** The currency `format="currency"` uses without a `currency`: JPY for Japanese, USD otherwise. */
97
+ function defaultCurrency(locale?: string | null): string {
98
+ return uiLanguage(resolveLocale(locale)) === "ja" ? "JPY" : "USD"
99
+ }
100
+
101
+ /** The month a fiscal year starts in (1–12) when none is given: April for Japanese, January otherwise. */
102
+ function defaultFiscalYearStartMonth(locale?: string | null): number {
103
+ return uiLanguage(resolveLocale(locale)) === "ja" ? 4 : 1
104
+ }
105
+
106
+ /** A day of the week, 0 = Sunday … 6 = Saturday (the `Date#getDay` / date-fns numbering). */
107
+ type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6
108
+
109
+ /** Locales whose week starts on Sunday when the runtime has no week data. */
110
+ const SUNDAY_FIRST = new Set(["en", "en-US", "en-CA"])
111
+
112
+ /**
113
+ * The first day of the week: Monday for Japanese; otherwise the locale's convention from `Intl.Locale`
114
+ * (`en-US` Sunday, `en-GB` Monday). Without week data in the runtime: Sunday for `en` / `en-US` / `en-CA`,
115
+ * Monday for the rest.
116
+ */
117
+ function weekStartsOn(locale?: string | null): WeekDay {
118
+ const tag = normalizeLocale(resolveLocale(locale))
119
+ if (uiLanguage(tag) === "ja") return 1
120
+ try {
121
+ const info = new Intl.Locale(tag) as Intl.Locale & {
122
+ getWeekInfo?: () => { firstDay: number }
123
+ weekInfo?: { firstDay: number }
124
+ }
125
+ const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay
126
+ if (typeof firstDay === "number" && firstDay >= 1 && firstDay <= 7) return (firstDay % 7) as WeekDay
127
+ } catch {
128
+ // Fall through to the table below.
129
+ }
130
+ return SUNDAY_FIRST.has(tag) ? 0 : 1
131
+ }
132
+
133
+ /* ---------- inlined from @squadbase/squadkit registry/hooks/use-locale.ts (internal to @squadbase/vantage) ---------- */
134
+
135
+ const listeners = new Set<() => void>()
136
+ let observer: MutationObserver | null = null
137
+
138
+ /** One observer on `<html lang>` for every subscribed part. */
139
+ function subscribe(onChange: () => void): () => void {
140
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {}
141
+ listeners.add(onChange)
142
+ if (!observer) {
143
+ observer = new MutationObserver(() => {
144
+ for (const listener of [...listeners]) listener()
145
+ })
146
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] })
147
+ }
148
+ return () => {
149
+ listeners.delete(onChange)
150
+ if (listeners.size === 0 && observer) {
151
+ observer.disconnect()
152
+ observer = null
153
+ }
154
+ }
155
+ }
156
+
157
+ const serverLocale = () => FALLBACK_LOCALE
158
+
159
+ /**
160
+ * The page's locale (`<html lang>`), following changes made while the page runs. On the server and during
161
+ * hydration it is `FALLBACK_LOCALE` (`en-US`), so the markup matches; it switches right after.
162
+ */
163
+ function useDocumentLocale(): string {
164
+ return useSyncExternalStore(subscribe, documentLocale, serverLocale)
165
+ }
166
+
167
+ /** The locale a part renders in: its `locale` prop when given, else the page's (`useDocumentLocale`). */
168
+ function useLocale(locale?: string | null): string {
169
+ const page = useDocumentLocale()
170
+ return locale || page
171
+ }
172
+
41
173
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-theme.ts (internal to @squadbase/vantage) ---------- */
42
174
 
43
175
  /**
@@ -72,10 +204,23 @@ interface ChartTokens {
72
204
  border: string | null
73
205
  /** Tooltip surface. */
74
206
  popover: string | null
207
+ /** Semantic and surface dependencies used by typed chart options. Optional for hand-built tokens. */
208
+ colors?: Record<string, string>
209
+ /** Actual inherited painting surface (may change without a token changing). */
210
+ surface?: string | null
75
211
  /** Tooltip text. */
76
212
  popoverForeground: string | null
77
213
  }
78
214
 
215
+ // Keep scope out of the public token shape. A resolved snapshot remembers where arbitrary
216
+ // var(--token) expressions must be read, without retaining unmounted charts globally.
217
+ const tokenScopes = new WeakMap<ChartTokens, Element>()
218
+ const OPTION_COLOR_TOKENS = [
219
+ "--background", "--card", "--muted", "--primary", "--destructive",
220
+ ...["positive", "negative", "warning", "neutral", "info"].flatMap((tone) =>
221
+ ["", "-fill", "-subtle"].map((suffix) => `--${tone}${suffix}`)),
222
+ ]
223
+
79
224
  let normalizeContext: CanvasRenderingContext2D | null | undefined
80
225
 
81
226
  /** The forms `fillStyle` serializes legacy colours to, and zrender can parse. */
@@ -131,8 +276,10 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
131
276
  const color = read(token)
132
277
  if (color) series.push(color)
133
278
  }
134
- return {
279
+ const tokens: ChartTokens = {
135
280
  series,
281
+ colors: Object.fromEntries(OPTION_COLOR_TOKENS.map((name) => [name, styles.getPropertyValue(name).trim()])),
282
+ surface: readChartSurface(target),
136
283
  ...(styles.fontFamily ? { fontFamily: styles.fontFamily } : {}),
137
284
  foreground: read("--foreground"),
138
285
  mutedForeground: read("--muted-foreground"),
@@ -140,6 +287,8 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
140
287
  popover: read("--popover"),
141
288
  popoverForeground: read("--popover-foreground"),
142
289
  }
290
+ tokenScopes.set(tokens, target)
291
+ return tokens
143
292
  }
144
293
 
145
294
  /** Structural equality, so a theme toggle that changes nothing doesn't re-init. */
@@ -147,6 +296,10 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
147
296
  if (a === b) return true
148
297
  if (!a || !b) return false
149
298
  return (
299
+ tokenScopes.get(a) === tokenScopes.get(b) &&
300
+ a.surface === b.surface &&
301
+ Object.keys(a.colors ?? {}).length === Object.keys(b.colors ?? {}).length &&
302
+ Object.entries(a.colors ?? {}).every(([name, value]) => b.colors?.[name] === value) &&
150
303
  a.series.length === b.series.length &&
151
304
  a.series.every((color, i) => color === b.series[i]) &&
152
305
  a.fontFamily === b.fontFamily &&
@@ -172,7 +325,7 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
172
325
  * attributes alone leaves the chart painted in the previous palette until a
173
326
  * reload — the tokens in the DOM are new, the canvas is not.
174
327
  */
175
- function watchChartTokens(onChange: () => void): () => void {
328
+ function watchChartTokens(onChange: () => void, scope?: Element | null): () => void {
176
329
  if (typeof document === "undefined") return () => {}
177
330
  // Style edits arrive as bursts of mutations; coalesce them into one read, and
178
331
  // take it after the next frame so a swapped stylesheet has been applied.
@@ -187,8 +340,11 @@ function watchChartTokens(onChange: () => void): () => void {
187
340
 
188
341
  const themeObserver = new MutationObserver(schedule)
189
342
  const attributeFilter = ["class", "style", "data-theme"]
190
- themeObserver.observe(document.documentElement, { attributes: true, attributeFilter })
191
- if (document.body) themeObserver.observe(document.body, { attributes: true, attributeFilter })
343
+ // Only the chart and its ancestors can change its inherited theme. Never observe
344
+ // the document subtree: canvas/tooltip/rendering mutations would trigger every chart.
345
+ for (let node: Element | null = scope ?? document.body ?? document.documentElement; node; node = node.parentElement) {
346
+ themeObserver.observe(node, { attributes: true, attributeFilter })
347
+ }
192
348
 
193
349
  const styleObserver = new MutationObserver(schedule)
194
350
  if (document.head) {
@@ -276,22 +432,23 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
276
432
  * components that build an ECharts option from token colours themselves (the
277
433
  * typed charts), so their palette follows light/dark like `EChart`'s theme.
278
434
  */
279
- function useChartTokens(): ChartTokens | null {
435
+ function useChartTokens(scope?: Element | null | RefObject<Element | null>): ChartTokens | null {
280
436
  const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
281
437
  // Compared here, not inside a `setTokens` updater: an updater that reads the DOM is
282
438
  // replayed by React on every rebase and returns a fresh object each time, so the state
283
439
  // never settles ("Maximum update depth exceeded" once the tokens change after mount).
284
440
  const tokensRef = useRef(tokens)
285
441
  useEffect(() => {
442
+ const target = scope && "current" in scope ? scope.current : scope
286
443
  const sync = () => {
287
- const next = readChartTokens()
444
+ const next = readChartTokens(target)
288
445
  if (sameChartTokens(tokensRef.current, next)) return
289
446
  tokensRef.current = next
290
447
  setTokens(next)
291
448
  }
292
449
  sync()
293
- return watchChartTokens(sync)
294
- }, [])
450
+ return watchChartTokens(sync, target)
451
+ }, [scope])
295
452
  return tokens
296
453
  }
297
454
 
@@ -303,7 +460,7 @@ const EMBEDDED_VAR_RE = /var\(\s*(--[\w-]+)\s*\)/g
303
460
  /**
304
461
  * Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
305
462
  * the resolved series tokens; `"positive"` / `"warning"` / `"negative"` / `"neutral"` (and the area
306
- * variants `"positive-fill"`, …) are read off the document root (`--positive`, …); `var(--token)` is read off the document root; any
463
+ * variants `"positive-fill"`, …) are read in the snapshot's inherited scope (`--positive`, …); `var(--token)` uses the same scope; any
307
464
  * other CSS colour is normalized like the tokens are. A colour that can't be
308
465
  * resolved comes back as `""`, which ECharts treats as "use the palette".
309
466
  */
@@ -314,12 +471,13 @@ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | nul
314
471
  // Meaning colours are the theme tokens of the same name: same path as `var(--positive)`.
315
472
  if (TONE_NAME_RE.test(color.trim())) {
316
473
  if (typeof document === "undefined") return ""
317
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue(`--${color.trim()}`)) ?? ""
474
+ const target = (tokens && tokenScopes.get(tokens)) ?? document.documentElement
475
+ return toCanvasColor(getComputedStyle(target).getPropertyValue(`--${color.trim()}`)) ?? ""
318
476
  }
319
477
  if (typeof document === "undefined") return color
320
478
  let value = color.trim()
321
479
  const cssVar = CSS_VAR_RE.exec(value)
322
- const styles = getComputedStyle(document.documentElement)
480
+ const styles = getComputedStyle((tokens && tokenScopes.get(tokens)) ?? document.documentElement)
323
481
  if (cssVar) {
324
482
  value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
325
483
  } else if (value.includes("var(")) {
@@ -365,7 +523,7 @@ function readChartSurface(el: Element | null): string | null {
365
523
  if (color) return color
366
524
  }
367
525
  }
368
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue("--background"))
526
+ return toCanvasColor(getComputedStyle(el ?? document.documentElement).getPropertyValue("--background"))
369
527
  }
370
528
 
371
529
  /** Widths are bucketed so a resize only rebuilds the option when the label budget can change. */
@@ -379,9 +537,10 @@ const WIDTH_BUCKET = 40
379
537
  */
380
538
  function useChartSurface<E extends Element>(
381
539
  forwarded: ForwardedRef<E>,
382
- tokens: ChartTokens | null,
383
- ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined } {
540
+ tokens?: ChartTokens | null,
541
+ ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined; tokens: ChartTokens | null } {
384
542
  const [node, setNode] = useState<E | null>(null)
543
+ const scopedTokens = useChartTokens(node)
385
544
  const [surface, setSurface] = useState<string | undefined>(undefined)
386
545
  const [width, setWidth] = useState<number | undefined>(undefined)
387
546
  const ref = useCallback(
@@ -395,7 +554,7 @@ function useChartSurface<E extends Element>(
395
554
  useEffect(() => {
396
555
  if (!node) return
397
556
  setSurface(readChartSurface(node) ?? undefined)
398
- }, [node, tokens])
557
+ }, [node, tokens, scopedTokens])
399
558
  useEffect(() => {
400
559
  if (!node || typeof ResizeObserver === "undefined") return
401
560
  const update = (w: number) => setWidth(Math.floor(w / WIDTH_BUCKET) * WIDTH_BUCKET)
@@ -404,11 +563,37 @@ function useChartSurface<E extends Element>(
404
563
  observer.observe(node)
405
564
  return () => observer.disconnect()
406
565
  }, [node])
407
- return { ref, surface, width }
566
+ return { ref, surface, width, tokens: scopedTokens }
408
567
  }
409
568
 
410
569
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-options.ts (internal to @squadbase/vantage) ---------- */
411
570
 
571
+ /** The built-in words of the option builders, by language. */
572
+ const CHART_TEXT = {
573
+ ja: {
574
+ target: "目標",
575
+ forecast: "予測",
576
+ total: "合計",
577
+ provisional: "速報",
578
+ noComparison: "比較なし",
579
+ share: (share: string) => `(${share})`,
580
+ targetRate: (rate: string) => `(達成率 ${rate})`,
581
+ provisionalTitle: (title: string, label: string) => `${title}(${label})`,
582
+ },
583
+ en: {
584
+ target: "Target",
585
+ forecast: "Forecast",
586
+ total: "Total",
587
+ provisional: "Preliminary",
588
+ noComparison: "No comparison",
589
+ share: (share: string) => ` (${share})`,
590
+ targetRate: (rate: string) => ` (${rate} achieved)`,
591
+ provisionalTitle: (title: string, label: string) => `${title} (${label})`,
592
+ },
593
+ } satisfies Record<UiLanguage, unknown>
594
+
595
+ const chartText = (locale?: string) => CHART_TEXT[uiLanguage(resolveLocale(locale))]
596
+
412
597
  type CartesianChartKind = "area" | "bar" | "line"
413
598
  type CartesianStackType = "default" | "stacked" | "percent"
414
599
  type CartesianCurveType = "linear" | "smooth" | "step"
@@ -593,7 +778,7 @@ interface CartesianChartConfig {
593
778
  * all ticks read in one unit (¥0.5億 / ¥1億). Default: `axisFormatter` for every tick.
594
779
  */
595
780
  axisTicks?: (top: number) => (value: number) => string
596
- /** Locale of the time-axis labels. Default `"ja-JP"`. */
781
+ /** Locale of the time-axis labels and the built-in words (total, target, forecast). Default the page's (`<html lang>`). */
597
782
  locale?: string
598
783
  type?: CartesianStackType
599
784
  /** Bar only. `"vertical"` lays the categories down the y axis (horizontal bars). */
@@ -831,11 +1016,12 @@ function timeAxisLabelStep(values: readonly unknown[], maxLabels: number = TIME_
831
1016
  */
832
1017
  function timeAxisLabels(
833
1018
  values: readonly unknown[],
834
- locale: string = DEFAULT_LOCALE,
1019
+ localeArg?: string,
835
1020
  shown: (i: number) => boolean = () => true,
836
1021
  ): string[] | null {
837
1022
  const parsed = parseTimeIndex(values)
838
1023
  if (!parsed) return null
1024
+ const locale = resolveLocale(localeArg)
839
1025
  const { granularity, times } = parsed
840
1026
  let prevYear: number | null = null
841
1027
  return times.map((t, i) => {
@@ -858,9 +1044,10 @@ function timeAxisLabels(
858
1044
  }
859
1045
 
860
1046
  /** The tooltip titles for time-like x values (`2025年9月`, `2025年9月1日`), or `null` when they are not dates. */
861
- function timeTooltipLabels(values: readonly unknown[], locale: string = DEFAULT_LOCALE): string[] | null {
1047
+ function timeTooltipLabels(values: readonly unknown[], localeArg?: string): string[] | null {
862
1048
  const parsed = parseTimeIndex(values)
863
1049
  if (!parsed) return null
1050
+ const locale = resolveLocale(localeArg)
864
1051
  const options: Intl.DateTimeFormatOptions =
865
1052
  parsed.granularity === "year"
866
1053
  ? { year: "numeric" }
@@ -1000,7 +1187,9 @@ function tooltipHtml(
1000
1187
  rows: readonly TooltipRow[],
1001
1188
  valueFormatter: (value: number) => string,
1002
1189
  notes: readonly string[] = [],
1190
+ locale?: string,
1003
1191
  ): string {
1192
+ const t = chartText(locale)
1004
1193
  const lines = rows.map(
1005
1194
  (row) =>
1006
1195
  `<div style="display:flex;align-items:center;gap:8px;line-height:20px">` +
@@ -1009,7 +1198,7 @@ function tooltipHtml(
1009
1198
  `<span style="font-weight:600;font-variant-numeric:tabular-nums;margin-left:12px">${
1010
1199
  row.value === null ? EMPTY_VALUE : escapeHtml(valueFormatter(row.value))
1011
1200
  }${
1012
- row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px">(${escapeHtml(formatShare(row.share))})</span>`
1201
+ row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px">${escapeHtml(t.share(formatShare(row.share)))}</span>`
1013
1202
  }</span></div>` +
1014
1203
  (row.note === undefined
1015
1204
  ? ""
@@ -1023,8 +1212,10 @@ function tooltipHtml(
1023
1212
 
1024
1213
  /* ------------------------------------------------- per-bar targets / notes */
1025
1214
 
1026
- /** Default name of the per-bar target marker (legend, tooltip). */
1027
- const BAR_TARGET_LABEL = "目標"
1215
+ /** Default name of the per-bar target marker (legend, tooltip) in `locale`: "Target" / 「目標」. Default the page's locale. */
1216
+ function barTargetLabel(locale?: string): string {
1217
+ return chartText(locale).target
1218
+ }
1028
1219
 
1029
1220
  /** How far (px) the target marker reaches past each side of its bar. */
1030
1221
  const TARGET_MARKER_OVERHANG = 4
@@ -1075,16 +1266,17 @@ function achievementRate(value: number | null | undefined, target: number | null
1075
1266
  return (value / target) * 100
1076
1267
  }
1077
1268
 
1078
- /** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3%)」. `null` without a target. */
1269
+ /** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3%)」 / "Target $12M (85.3% achieved)". `null` without a target. */
1079
1270
  function barTargetNote(
1080
1271
  label: string,
1081
1272
  value: number | null,
1082
1273
  target: number | null,
1083
1274
  valueFormatter: (value: number) => string,
1275
+ locale?: string,
1084
1276
  ): string | null {
1085
1277
  if (target === null) return null
1086
1278
  const rate = achievementRate(value, target)
1087
- return `${label} ${valueFormatter(target)}${rate === null ? "" : `(達成率 ${formatShare(rate)})`}`
1279
+ return `${label} ${valueFormatter(target)}${rate === null ? "" : chartText(locale).targetRate(formatShare(rate))}`
1088
1280
  }
1089
1281
 
1090
1282
  /** Half the width of a bar in category-axis units, from `barCategoryGap` (`"20%"`; a px gap or none → ECharts' 20%). */
@@ -1176,11 +1368,12 @@ const TARGET_LEGEND_SERIES_ID = "__target-legend"
1176
1368
  /** A flat tick (8×8 legend box, aspect kept): the per-bar target's legend icon. */
1177
1369
  const TARGET_LEGEND_ICON = "path://M0,0 L100,0 L100,24 L0,24 Z"
1178
1370
 
1179
- const DEFAULT_FORECAST_LABEL = "予測"
1180
-
1181
- /** 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. */
1182
- function forecastBoundaryText(label: string = DEFAULT_FORECAST_LABEL): string {
1183
- return `${label} →`
1371
+ /**
1372
+ * 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.
1373
+ * Without a `label`, "Forecast" / 「予測」 in `locale` (default the page's).
1374
+ */
1375
+ function forecastBoundaryText(label?: string, locale?: string): string {
1376
+ return `${label ?? chartText(locale).forecast} →`
1184
1377
  }
1185
1378
 
1186
1379
  /** A comparison series: a thin, muted, solid line with no markers (60% of `--muted-foreground`). */
@@ -1220,8 +1413,10 @@ interface AxisExtent {
1220
1413
  max: number
1221
1414
  }
1222
1415
 
1223
- /** The label of a stack's total (highlight and tooltip). */
1224
- const STACK_TOTAL_LABEL = "合計"
1416
+ /** The label of a stack's total (highlight and tooltip) in `locale`: "Total" / 「合計」. Default the page's locale. */
1417
+ function stackTotalLabel(locale?: string): string {
1418
+ return chartText(locale).total
1419
+ }
1225
1420
 
1226
1421
  /** Per point, the sum of the stacked series; `null` where every series is missing. */
1227
1422
  function stackTotalsOf(values: readonly (readonly (number | null)[])[]): (number | null)[] {
@@ -1343,8 +1538,8 @@ function indexGrain(values: readonly unknown[]): PeriodGrain | null {
1343
1538
 
1344
1539
  /**
1345
1540
  * The tooltip line under the main series' row when a comparison series is drawn: the arrow, the signed change and the
1346
- * comparison's name, as `Delta` writes it ("↑ +12.4% 前年同月比"; a minus is U+2212). "比較なし 前年同月比" when
1347
- * either value is missing or the baseline is 0.
1541
+ * comparison's name, as `Delta` writes it ("↑ +12.4% vs. same month last year" / "↑ +12.4% 前年同月比"; a minus is U+2212).
1542
+ * "No comparison vs. same month last year" / "比較なし 前年同月比" when either value is missing or the baseline is 0.
1348
1543
  */
1349
1544
  function comparisonNote(
1350
1545
  current: number | null,
@@ -1352,16 +1547,18 @@ function comparisonNote(
1352
1547
  comparison: Pick<CartesianComparisonConfig, "vs" | "label">,
1353
1548
  grain?: PeriodGrain,
1354
1549
  format?: ValueFormat,
1355
- locale: string = DEFAULT_LOCALE,
1550
+ localeArg?: string,
1356
1551
  ): string {
1552
+ const locale = resolveLocale(localeArg)
1357
1553
  const result = computeComparison(
1358
1554
  current,
1359
1555
  comparison.vs === "custom"
1360
1556
  ? { previous, vs: "custom", label: comparison.label ?? "", grain }
1361
1557
  : { previous, vs: comparison.vs, label: comparison.label, grain },
1558
+ { locale },
1362
1559
  )
1363
1560
  const text = formatChange(result, defaultChangeDisplay(format), locale, format)
1364
- if (text === null) return `比較なし ${result.label}`
1561
+ if (text === null) return `${chartText(locale).noComparison} ${result.label}`
1365
1562
  const arrow = result.direction === "up" ? "↑" : result.direction === "down" ? "↓" : "→"
1366
1563
  return `${arrow} ${text.replace(/^-/, "−")} ${result.label}`
1367
1564
  }
@@ -1404,7 +1601,6 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1404
1601
  toneColors,
1405
1602
  valueFormatter,
1406
1603
  axisFormatter = valueFormatter,
1407
- locale = DEFAULT_LOCALE,
1408
1604
  type = "default",
1409
1605
  layout = "horizontal",
1410
1606
  curveType = "linear",
@@ -1441,7 +1637,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1441
1637
  chartOptions,
1442
1638
  reserveActions = false,
1443
1639
  } = cfg
1444
- const provisionalLabel = cfg.provisionalLabel ?? DEFAULT_PROVISIONAL_LABEL
1640
+ const locale = resolveLocale(cfg.locale)
1641
+ const text = chartText(locale)
1642
+ const provisionalLabel = cfg.provisionalLabel ?? text.provisional
1445
1643
 
1446
1644
  const stacked = kind !== "line" && type !== "default"
1447
1645
  const percent = kind !== "line" && type === "percent"
@@ -1590,9 +1788,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1590
1788
  // `barOptions.targetKey` (a target per bar) takes over from the single `target` line.
1591
1789
  const perBarTarget = kind === "bar" && Boolean(targetKey)
1592
1790
  const hasTarget = !perBarTarget && target !== undefined && Number.isFinite(target)
1593
- const targetText = hasTarget ? (targetLabel ?? `目標 ${axisFormatter(target)}`) : ""
1791
+ const targetText = hasTarget ? (targetLabel ?? `${text.target} ${axisFormatter(target)}`) : ""
1594
1792
  const highlightText = (value: number) =>
1595
- shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${STACK_TOTAL_LABEL} ` : ""}${axisFormatter(value)}`
1793
+ shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${text.total} ` : ""}${axisFormatter(value)}`
1596
1794
  /** Whether `p`'s label sits within the target's value band and under the target label (at the right end). */
1597
1795
  const nearTargetLabel = (p: HighlightPoint, side: "under" | "over") => {
1598
1796
  if (!hasTarget || !plotWidth || kind === "bar" || labels.length < 2) return false
@@ -1618,7 +1816,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1618
1816
  // One series (or a stack, against its total) can be compared to a target; grouped series and 100% stacks cannot.
1619
1817
  const barTargets: (number | null)[] | null =
1620
1818
  perBarTarget && targetKey && !percent && (stacked || categories.length === 1) ? barTargetValues(data, targetKey) : null
1621
- const barTargetName = targetLabel ?? BAR_TARGET_LABEL
1819
+ const barTargetName = targetLabel ?? text.target
1622
1820
  const barTargetColor = labelColor ?? guideColor
1623
1821
 
1624
1822
  const markPointFor = (c: number, color: string | undefined) =>
@@ -1832,7 +2030,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1832
2030
  }
1833
2031
 
1834
2032
  /* -- forecast: dashed continuation (+ optional band) of one series */
1835
- const forecastLabel = forecast?.label ?? DEFAULT_FORECAST_LABEL
2033
+ const forecastLabel = forecast?.label ?? text.forecast
1836
2034
  const forecastBandAlpha = isDarkSurface(labelBackground) ? FORECAST_BAND_ALPHA_DARK : FORECAST_BAND_ALPHA_LIGHT
1837
2035
  const forecastValues: (number | null)[] = []
1838
2036
  if (forecast) {
@@ -2308,7 +2506,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2308
2506
  })
2309
2507
  }
2310
2508
  if (stackTotals && i <= lastActual && rows.length > 1) {
2311
- rows.push({ name: STACK_TOTAL_LABEL, color: "transparent", value: stackTotals[i] ?? null })
2509
+ rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
2312
2510
  }
2313
2511
  const provisional = provStart !== -1 && i >= provStart && i <= lastActual
2314
2512
  if (provisional) {
@@ -2324,19 +2522,19 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2324
2522
  ...noteFor(at),
2325
2523
  })
2326
2524
  })
2327
- if (stackTotals && rows.length > 1) rows.push({ name: STACK_TOTAL_LABEL, color: "transparent", value: stackTotals[i] ?? null })
2525
+ if (stackTotals && rows.length > 1) rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
2328
2526
  }
2329
2527
  const title = tooltipTitles[i] ?? labels[i] ?? ""
2330
2528
  const lines: string[] = []
2331
2529
  if (barTargets && i <= lastActual) {
2332
2530
  const total = stackTotals ? (stackTotals[i] ?? null) : (values[0]?.[i] ?? null)
2333
- const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter)
2531
+ const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter, locale)
2334
2532
  if (note) lines.push(note)
2335
2533
  }
2336
2534
  for (const note of notes) {
2337
2535
  if (note.to === undefined ? note.from === i : note.from <= i && i <= note.to) lines.push(note.label)
2338
2536
  }
2339
- return tooltipHtml(provisional ? `${title}(${provisionalLabel})` : title, rows, valueFormatter, lines)
2537
+ return tooltipHtml(provisional ? text.provisionalTitle(title, provisionalLabel) : title, rows, valueFormatter, lines, locale)
2340
2538
  },
2341
2539
  },
2342
2540
  xAxis: horizontalBars ? valueAxis : categoryAxis,
@@ -2428,8 +2626,6 @@ interface DonutOptionSlice {
2428
2626
  /** How many of the largest slices get an outside label (「自然検索 41%」) by default. */
2429
2627
  const DONUT_OUTSIDE_LABELS = 2
2430
2628
 
2431
- const DEFAULT_TOTAL_LABEL = "合計"
2432
-
2433
2629
  const sliceValue = (value: number) => (Number.isFinite(value) && value > 0 ? value : 0)
2434
2630
 
2435
2631
  /** A slice's share of `total` as text: whole percent, `<1%` for a sliver, `0%` for nothing. */
@@ -2462,15 +2658,16 @@ function donutLegendItems(slices: readonly DonutOptionSlice[]): DonutLegendItem[
2462
2658
  }
2463
2659
 
2464
2660
  /**
2465
- * The default center label: what is totalled (「合計」), the formatted total
2661
+ * The default center label: what is totalled (「合計」 / "Total" in `locale`, default the page's), the formatted total
2466
2662
  * (`amount`, the big line), the unit (a small line under it) and both as one
2467
2663
  * string (`value`, 「9.2万 セッション」).
2468
2664
  */
2469
2665
  function donutCenterText(
2470
2666
  total: number,
2471
2667
  valueFormatter: (value: number) => string,
2472
- { totalLabel = DEFAULT_TOTAL_LABEL, unit }: { totalLabel?: string; unit?: string } = {},
2668
+ { totalLabel, unit, locale }: { totalLabel?: string; unit?: string; locale?: string } = {},
2473
2669
  ): { label: string; value: string; amount: string; unit?: string } {
2670
+ totalLabel ??= chartText(locale).total
2474
2671
  const amount = valueFormatter(total)
2475
2672
  return { label: totalLabel, value: unit ? `${amount} ${unit}` : amount, amount, ...(unit ? { unit } : {}) }
2476
2673
  }
@@ -2698,8 +2895,10 @@ function hasLabel(labels: Readonly<Record<string, string>> | undefined, key: str
2698
2895
 
2699
2896
  /* ------------------------------------------------------------ provisional */
2700
2897
 
2701
- /** The default label for points that are not final yet. */
2702
- const DEFAULT_PROVISIONAL_LABEL = "速報"
2898
+ /** The default label for points that are not final yet, in `locale`: "Preliminary" / 「速報」. Default the page's locale. */
2899
+ function defaultProvisionalLabel(locale?: string): string {
2900
+ return chartText(locale).provisional
2901
+ }
2703
2902
 
2704
2903
  /**
2705
2904
  * Position of the first provisional point: the first entry of `indexValues`
@@ -2837,9 +3036,10 @@ function resolveValueFormatter(
2837
3036
  function resolveAxisFormatter(
2838
3037
  valueFormatter: ValueFormatter | undefined,
2839
3038
  format: ValueFormat | undefined,
2840
- locale: string = DEFAULT_LOCALE,
3039
+ localeArg?: string,
2841
3040
  ): ValueFormatter {
2842
3041
  if (valueFormatter) return valueFormatter
3042
+ const locale = resolveLocale(localeArg)
2843
3043
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
2844
3044
  if (spec.type !== "compact" && spec.type !== "number" && spec.type !== "currency") return toValueFormatter(format, locale)
2845
3045
  const currency = "currency" in spec ? spec.currency : undefined
@@ -2848,40 +3048,75 @@ function resolveAxisFormatter(
2848
3048
  return (value) => formatValue(value, short, locale)
2849
3049
  }
2850
3050
 
2851
- /** The unit words of a ja number (`1.2万` / `3.4億` / `1兆`) and their sizes, largest first. */
2852
- const JA_UNITS = [
2853
- { char: "兆", size: 1e12 },
2854
- { char: "億", size: 1e8 },
2855
- { char: "万", size: 1e4 },
2856
- ] as const
3051
+ interface CompactUnit {
3052
+ token: string
3053
+ size: number
3054
+ }
3055
+
3056
+ const compactUnitCache = new Map<string, CompactUnit[] | null>()
3057
+
3058
+ /**
3059
+ * The compact-notation unit words of a locale and their sizes, largest first: `兆` / `億` / `万` in ja,
3060
+ * `T` / `B` / `M` / `K` in en. `null` when the locale's compact form puts anything between the number and its
3061
+ * word (`1,5 Mio.`), where a token cannot be swapped in place.
3062
+ */
3063
+ function compactUnits(locale: string): CompactUnit[] | null {
3064
+ const cached = compactUnitCache.get(locale)
3065
+ if (cached !== undefined) return cached
3066
+ let units: CompactUnit[] | null = []
3067
+ try {
3068
+ const compact = new Intl.NumberFormat(locale, { notation: "compact" })
3069
+ const seen = new Set<string>()
3070
+ for (let exp = 3; exp <= 15; exp++) {
3071
+ const parts = compact.formatToParts(10 ** exp)
3072
+ const at = parts.findIndex((p) => p.type === "compact")
3073
+ if (at === -1) continue
3074
+ if (parts[at - 1]?.type !== "integer" || at !== parts.length - 1) {
3075
+ units = null
3076
+ break
3077
+ }
3078
+ const token = parts[at]!.value
3079
+ if (seen.has(token)) continue
3080
+ seen.add(token)
3081
+ units.push({ token, size: 10 ** exp })
3082
+ }
3083
+ } catch {
3084
+ units = null
3085
+ }
3086
+ const sorted = units && units.length > 0 ? units.sort((a, b) => b.size - a.size) : null
3087
+ compactUnitCache.set(locale, sorted)
3088
+ return sorted
3089
+ }
2857
3090
 
2858
- const JA_NUMBER_TOKEN_RE = /\d[\d,]*(?:\.\d+)?(?:兆|億|万)?/
3091
+ const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
2859
3092
 
2860
3093
  /**
2861
3094
  * The value-axis tick formatter for an axis whose largest tick is `top`: every tick reads in the unit of
2862
- * that largest tick, so 万 and 億 never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`).
2863
- * Only the ja short forms (`compact` / `number` / `currency`, no `valueFormatter`) have units to align;
2864
- * any other format — and an axis whose largest tick is under 1万 — keeps `resolveAxisFormatter`'s output as it is.
3095
+ * that largest tick, so units never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`;
3096
+ * `$0.5M` / `$1M` / `$1.5M`, not `$500K` / `$1M`). Only the short forms (`compact` / `number` / `currency`,
3097
+ * no `valueFormatter`) have units to align; any other format — an axis whose largest tick is under the smallest
3098
+ * unit (1万 / 1K), or a locale whose compact form cannot be aligned in place — keeps `resolveAxisFormatter`'s output as it is.
2865
3099
  */
2866
3100
  function resolveAxisTickFormatter(
2867
3101
  valueFormatter: ValueFormatter | undefined,
2868
3102
  format: ValueFormat | undefined,
2869
- locale: string = DEFAULT_LOCALE,
3103
+ localeArg?: string,
2870
3104
  ): (top: number) => ValueFormatter {
3105
+ const locale = resolveLocale(localeArg)
2871
3106
  const base = resolveAxisFormatter(valueFormatter, format, locale)
2872
3107
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
2873
- const aligns =
2874
- !valueFormatter && locale.toLowerCase().startsWith("ja") && (spec.type === "compact" || spec.type === "number" || spec.type === "currency")
2875
- if (!aligns) return () => base
3108
+ const units = !valueFormatter && (spec.type === "compact" || spec.type === "number" || spec.type === "currency") ? compactUnits(locale) : null
3109
+ if (!units) return () => base
2876
3110
  const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 3 })
3111
+ const numberToken = new RegExp(`\\d[\\d,]*(?:\\.\\d+)?(?:${units.map((u) => escapeRegExp(u.token)).join("|")})?`)
2877
3112
  return (top) => {
2878
- const unit = JA_UNITS.find((u) => Math.abs(top) >= u.size)
3113
+ const unit = units.find((u) => Math.abs(top) >= u.size)
2879
3114
  if (!unit) return base
2880
3115
  return (value) => {
2881
3116
  const text = base(value)
2882
3117
  if (value === 0 || !Number.isFinite(value)) return text
2883
- const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.char}`
2884
- return text.replace(JA_NUMBER_TOKEN_RE, () => scaled)
3118
+ const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.token}`
3119
+ return text.replace(numberToken, () => scaled)
2885
3120
  }
2886
3121
  }
2887
3122
  }
@@ -2910,8 +3145,20 @@ function clamp(value: number, min: number, max: number): number {
2910
3145
  /** How many categories a chart colours individually; the rest fold into「その他」. */
2911
3146
  const MAX_CHART_SERIES = 5
2912
3147
 
2913
- /** The series name the folded categories get. */
2914
- const OTHER_SERIES_LABEL = "その他"
3148
+ /** The series name the folded categories get, by language. */
3149
+ const OTHER_SERIES_LABELS: Record<UiLanguage, string> = { ja: "その他", en: "Other" }
3150
+
3151
+ /** The series name the folded categories get in `locale` ("Other" / "その他"). Default the page's locale (`<html lang>`). */
3152
+ function otherSeriesLabel(locale?: string): string {
3153
+ return OTHER_SERIES_LABELS[uiLanguage(resolveLocale(locale))]
3154
+ }
3155
+
3156
+ const OTHER_NAMES = new Set(Object.values(OTHER_SERIES_LABELS))
3157
+
3158
+ /** Whether a category / row is the "other" bucket by name (「その他」 or "Other", whatever the page language). */
3159
+ function isOtherName(name: string | null | undefined): boolean {
3160
+ return typeof name === "string" && OTHER_NAMES.has(name)
3161
+ }
2915
3162
 
2916
3163
  interface AggregatedItems<Item extends { name: string; value: number }> {
2917
3164
  /** The `n` largest items, in their given order. All items when there are `n` or fewer. */
@@ -2931,7 +3178,7 @@ interface AggregatedItems<Item extends { name: string; value: number }> {
2931
3178
  function aggregateTopN<Item extends { name: string; value: number }>(
2932
3179
  items: readonly Item[],
2933
3180
  n: number = MAX_CHART_SERIES,
2934
- otherLabel: string = OTHER_SERIES_LABEL,
3181
+ otherLabel: string = otherSeriesLabel(),
2935
3182
  ): AggregatedItems<Item> {
2936
3183
  if (items.length <= n) return { top: [...items], rest: [], other: null }
2937
3184
  const ranked = items
@@ -2960,7 +3207,7 @@ function aggregateSeries(
2960
3207
  data: readonly Record<string, unknown>[],
2961
3208
  categories: readonly string[],
2962
3209
  n: number = MAX_CHART_SERIES,
2963
- otherLabel: string = OTHER_SERIES_LABEL,
3210
+ otherLabel: string = otherSeriesLabel(),
2964
3211
  ): AggregatedSeries {
2965
3212
  const totals = categories.map((name) => ({
2966
3213
  name,
@@ -2992,12 +3239,14 @@ function aggregateSeriesKeeping(
2992
3239
  categories: readonly string[],
2993
3240
  keep: string | undefined,
2994
3241
  n: number = MAX_CHART_SERIES,
3242
+ otherLabel: string = otherSeriesLabel(),
2995
3243
  ): AggregatedSeries {
2996
- if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n)
3244
+ if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n, otherLabel)
2997
3245
  const aggregated = aggregateSeries(
2998
3246
  data,
2999
3247
  categories.filter((c) => c !== keep),
3000
3248
  n,
3249
+ otherLabel,
3001
3250
  )
3002
3251
  return { ...aggregated, categories: [...aggregated.categories, keep] }
3003
3252
  }
@@ -3033,7 +3282,7 @@ function seriesColorsWithComparison(
3033
3282
  * Colour per drawn series: the record's token for that category, else the
3034
3283
  * array's token at the category's position in `allCategories` (cycled), else
3035
3284
  * the token palette by drawn position. `otherCategory` — and a category the
3036
- * data itself names「その他」(`OTHER_SERIES_LABEL`) — always gets `otherColor`.
3285
+ * data itself names「その他」/ "Other" (`OTHER_SERIES_LABELS`) — always gets `otherColor`.
3037
3286
  */
3038
3287
  function seriesColorsFor(
3039
3288
  categories: readonly string[],
@@ -3045,7 +3294,7 @@ function seriesColorsFor(
3045
3294
  const list = Array.isArray(colors) ? (colors as ChartSeriesColor[]) : undefined
3046
3295
  const record = !Array.isArray(colors) && colors ? (colors as Partial<Record<string, ChartSeriesColor>>) : undefined
3047
3296
  return categories.map((category, i) => {
3048
- if ((otherCategory !== null && category === otherCategory) || category === OTHER_SERIES_LABEL) return otherColor
3297
+ if ((otherCategory !== null && category === otherCategory) || isOtherName(category)) return otherColor
3049
3298
  const fromRecord = record?.[category]
3050
3299
  if (fromRecord) return fromRecord
3051
3300
  if (list && list.length > 0) {
@@ -3092,7 +3341,7 @@ interface DonutSlicesConfig {
3092
3341
  */
3093
3342
  function donutSlices(
3094
3343
  data: readonly Record<string, unknown>[],
3095
- { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = OTHER_SERIES_LABEL }: DonutSlicesConfig,
3344
+ { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = otherSeriesLabel() }: DonutSlicesConfig,
3096
3345
  ): DonutSlice[] {
3097
3346
  const items = data
3098
3347
  .map((row, i) => {
@@ -3105,7 +3354,8 @@ function donutSlices(
3105
3354
  const slices: DonutSlice[] = top.map((item, rank) => ({
3106
3355
  name: item.name,
3107
3356
  value: item.value,
3108
- color: palette[rank % palette.length]!,
3357
+ // A row the data itself names「その他」/ "Other" is the same remainder: grey, like the folded slice.
3358
+ color: isOtherName(item.name) ? otherColor : palette[rank % palette.length]!,
3109
3359
  isOther: false,
3110
3360
  row: item.row,
3111
3361
  }))
@@ -3178,7 +3428,7 @@ interface BarListRowsConfig extends BarListOptions {
3178
3428
  */
3179
3429
  function barListRows<Row extends object>(
3180
3430
  data: readonly Row[],
3181
- { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = OTHER_SERIES_LABEL, exclude }: BarListRowsConfig = {},
3431
+ { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = otherSeriesLabel(), exclude }: BarListRowsConfig = {},
3182
3432
  ): BarListRow<Row>[] {
3183
3433
  const items = data.map((row): BarListRow<Row> => {
3184
3434
  const cells = row as Record<string, unknown>
@@ -3224,19 +3474,21 @@ const BAR_LIST_OTHER_TINT = 30
3224
3474
 
3225
3475
  /**
3226
3476
  * The fill of each BarList row: all real rows in the same tint of `color`
3227
- * (`BAR_LIST_TINT`), the folded「その他」row in a tint of `otherColor`
3477
+ * (`BAR_LIST_TINT`), the folded「その他」row (and a row the data names「その他」/ "Other") in a tint of `otherColor`
3228
3478
  * (`BAR_LIST_OTHER_TINT`) — the palest fill, a remainder never outweighs a
3229
3479
  * ranked row. A legacy per-row `color` (or a threshold tone) replaces `color`
3230
3480
  * for that row. A tint, not the solid token, so the label printed over the bar
3231
3481
  * keeps its contrast.
3232
3482
  */
3233
3483
  function barListFills(
3234
- rows: readonly Pick<BarListRow, "isOther" | "color">[],
3484
+ rows: readonly (Pick<BarListRow, "isOther" | "color"> & { name?: string })[],
3235
3485
  color: ChartColor,
3236
3486
  otherColor: string,
3237
3487
  ): string[] {
3238
3488
  return rows.map((row) =>
3239
- row.isOther ? tintCss(otherColor, BAR_LIST_OTHER_TINT) : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
3489
+ row.isOther || isOtherName(row.name)
3490
+ ? tintCss(otherColor, BAR_LIST_OTHER_TINT)
3491
+ : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
3240
3492
  )
3241
3493
  }
3242
3494
 
@@ -3452,11 +3704,30 @@ interface ChartSummaryInput {
3452
3704
  format?: ValueFormat
3453
3705
  /** Wins over `format`. */
3454
3706
  valueFormatter?: ValueFormatter
3707
+ /** Language of the sentence. Default the page's locale (`<html lang>`). */
3708
+ locale?: string
3709
+ }
3710
+
3711
+ const SUMMARY_TEXT = {
3712
+ ja: {
3713
+ up: "、前の点より増加",
3714
+ down: "、前の点より減少",
3715
+ flat: "、前の点と横ばい",
3716
+ latest: (label: string, value: string) => `最新 ${label} は ${value}`,
3717
+ top: (name: string, share: string) => `最大は ${name}(${share})`,
3718
+ },
3719
+ en: {
3720
+ up: ", up from the previous point",
3721
+ down: ", down from the previous point",
3722
+ flat: ", flat from the previous point",
3723
+ latest: (label: string, value: string) => `Latest ${label}: ${value}`,
3724
+ top: (name: string, share: string) => `Largest: ${name} (${share})`,
3725
+ },
3455
3726
  }
3456
3727
 
3457
3728
  /**
3458
3729
  * One-sentence summary built from the rows, for `summary="auto"`:
3459
- * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」. The direction is
3730
+ * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」("<title>: Latest <index>: <value>, up from the previous point"). The direction is
3460
3731
  * computed from the last two non-empty points of the first series — never
3461
3732
  * written by hand, so it cannot contradict the data. `undefined` when there is
3462
3733
  * no usable point.
@@ -3472,26 +3743,29 @@ function buildChartSummary(input: ChartSummaryInput): string | undefined {
3472
3743
  }
3473
3744
  const latest = points[points.length - 1]
3474
3745
  if (!latest) return undefined
3475
- const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact")
3746
+ const locale = resolveLocale(input.locale)
3747
+ const t = SUMMARY_TEXT[uiLanguage(locale)]
3748
+ const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact", locale)
3476
3749
  const prev = points[points.length - 2]
3477
- const direction = !prev ? "" : latest.value > prev.value ? "、前の点より増加" : latest.value < prev.value ? "、前の点より減少" : "、前の点と横ばい"
3750
+ const direction = !prev ? "" : latest.value > prev.value ? t.up : latest.value < prev.value ? t.down : t.flat
3478
3751
  const head = input.title ? `${input.title}: ` : ""
3479
- return `${head}最新 ${latest.label} は ${text(latest.value)}${direction}`
3752
+ return `${head}${t.latest(latest.label, text(latest.value))}${direction}`
3480
3753
  }
3481
3754
 
3482
3755
  /**
3483
- * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」 from the slices
3484
- * (largest first). `undefined` when there is nothing to describe.
3756
+ * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」("<title>: Largest: <name> (<share>)") from the slices
3757
+ * (largest first). `undefined` when there is nothing to describe. `locale` picks the language; default the page's.
3485
3758
  */
3486
3759
  function buildShareSummary(
3487
3760
  slices: readonly { name: string; value: number }[],
3488
3761
  title?: string,
3762
+ locale?: string,
3489
3763
  ): string | undefined {
3490
3764
  const total = slices.reduce((sum, s) => sum + s.value, 0)
3491
3765
  const top = slices[0]
3492
3766
  if (!top || total <= 0) return undefined
3493
3767
  const share = `${Math.round((top.value / total) * 1000) / 10}%`
3494
- return `${title ? `${title}: ` : ""}最大は ${top.name}(${share})`
3768
+ return `${title ? `${title}: ` : ""}${SUMMARY_TEXT[uiLanguage(resolveLocale(locale))].top(top.name, share)}`
3495
3769
  }
3496
3770
 
3497
3771
  /* ---------- end of inlined code ---------- */
@@ -3629,12 +3903,49 @@ export interface ScatterSeries {
3629
3903
  points: ScatterPoint[]
3630
3904
  }
3631
3905
 
3906
+ const TEXT = {
3907
+ ja: {
3908
+ who: (name: string, value: string) => `${name}(${value})`,
3909
+ summary: (count: number, y: string, who: string) => `${count}点。${y}が最大なのは ${who}`,
3910
+ name: "名前",
3911
+ series: "系列",
3912
+ x: "横軸",
3913
+ y: "縦軸",
3914
+ size: "大きさ",
3915
+ pair: (y: string, x: string) => `${y}×${x}`,
3916
+ first: (pair: string, count: number) => `${pair}(先頭 ${count} 点)`,
3917
+ guide: (label: string, axis: string, value: string) => `${label}(${axis} ${value})`,
3918
+ caption: (base: string, guides: readonly string[]) => `${base}。基準線: ${guides.join("、")}`,
3919
+ chart: (y: string, x: string) => `${y}×${x}の散布図`,
3920
+ },
3921
+ en: {
3922
+ who: (name: string, value: string) => `${name} (${value})`,
3923
+ summary: (count: number, y: string, who: string) => `${count === 1 ? "1 point" : `${count} points`}. Highest ${y}: ${who}`,
3924
+ name: "Name",
3925
+ series: "Series",
3926
+ x: "X",
3927
+ y: "Y",
3928
+ size: "Size",
3929
+ pair: (y: string, x: string) => `${y} × ${x}`,
3930
+ first: (pair: string, count: number) => `${pair} (first ${count} points)`,
3931
+ guide: (label: string, axis: string, value: string) => `${label} (${axis} ${value})`,
3932
+ caption: (base: string, guides: readonly string[]) => `${base}. Reference lines: ${guides.join(", ")}`,
3933
+ chart: (y: string, x: string) => `Scatter plot of ${y} × ${x}`,
3934
+ },
3935
+ }
3936
+
3937
+ const textOf = (locale?: string) => TEXT[uiLanguage(resolveLocale(locale))]
3938
+
3632
3939
  /**
3633
3940
  * Points → series. Series keep their order of first appearance (that order is the
3634
3941
  * colour order). More than `max` of them: the `max` with the most points are kept
3635
3942
  * and the rest fold into one trailing「その他」series.
3636
3943
  */
3637
- export function scatterSeries(points: readonly ScatterPoint[], max: number = MAX_CHART_SERIES): ScatterSeries[] {
3944
+ export function scatterSeries(
3945
+ points: readonly ScatterPoint[],
3946
+ max: number = MAX_CHART_SERIES,
3947
+ otherLabel: string = otherSeriesLabel(),
3948
+ ): ScatterSeries[] {
3638
3949
  const order: string[] = []
3639
3950
  const counts = new Map<string, number>()
3640
3951
  for (const p of points) {
@@ -3649,7 +3960,7 @@ export function scatterSeries(points: readonly ScatterPoint[], max: number = MAX
3649
3960
  const result: ScatterSeries[] = order
3650
3961
  .filter((name) => kept.has(name))
3651
3962
  .map((name) => ({ name, isOther: false, points: points.filter((p) => p.series === name) }))
3652
- result.push({ name: OTHER_SERIES_LABEL, isOther: true, points: points.filter((p) => !kept.has(p.series)) })
3963
+ result.push({ name: otherLabel, isOther: true, points: points.filter((p) => !kept.has(p.series)) })
3653
3964
  return result
3654
3965
  }
3655
3966
 
@@ -3676,20 +3987,22 @@ function pointTitle(point: ScatterPoint, labels?: Record<string, string>): strin
3676
3987
  return labelOf(point.name || point.series, labels)
3677
3988
  }
3678
3989
 
3679
- /** `summary="auto"`: the point count and the highest y. `undefined` with no points. */
3990
+ /** `summary="auto"`: the point count and the highest y. `undefined` with no points. `locale` picks the language; default the page's. */
3680
3991
  export function buildScatterSummary(
3681
3992
  points: readonly ScatterPoint[],
3682
3993
  yName: string,
3683
3994
  yFormatter: ValueFormatter,
3684
3995
  title?: string,
3685
3996
  labels?: Record<string, string>,
3997
+ locale?: string,
3686
3998
  ): string | undefined {
3687
3999
  if (points.length === 0) return undefined
4000
+ const t = textOf(locale)
3688
4001
  let best = points[0]!
3689
4002
  for (const p of points) if (p.y > best.y) best = p
3690
4003
  const head = title ? `${title}: ` : ""
3691
- const who = best.name ? `${labelOf(best.name, labels)}(${yFormatter(best.y)})` : yFormatter(best.y)
3692
- return `${head}${points.length}点。${labelOf(yName, labels)}が最大なのは ${who}`
4004
+ const who = best.name ? t.who(labelOf(best.name, labels), yFormatter(best.y)) : yFormatter(best.y)
4005
+ return `${head}${t.summary(points.length, labelOf(yName, labels), who)}`
3693
4006
  }
3694
4007
 
3695
4008
  /** Cap of the screen-reader table (a scatter can hold thousands of dots). */
@@ -3707,17 +4020,20 @@ export function scatterDataTable(
3707
4020
  formatters: { x: ValueFormatter; y: ValueFormatter; size: ValueFormatter }
3708
4021
  labels?: Record<string, string>
3709
4022
  referenceLines?: readonly ScatterReferenceLine[]
4023
+ /** Language of the headers and caption. Default the page's locale (`<html lang>`). */
4024
+ locale?: string
3710
4025
  },
3711
4026
  ): ChartDataTable {
4027
+ const t = textOf(config.locale)
3712
4028
  const label = (key: string) => labelOf(key, config.labels)
3713
- // Column headers: the caller's `labels`, else a plain Japanese name (not the data key).
4029
+ // Column headers: the caller's `labels`, else a plain name (not the data key).
3714
4030
  const column = (key: string, fallback: string) => config.labels?.[key] ?? fallback
3715
4031
  const columns = [
3716
- ...(config.index ? [column(config.index, "名前")] : []),
3717
- ...(config.series ? [column(config.series, "系列")] : []),
3718
- column(config.x, "横軸"),
3719
- column(config.y, "縦軸"),
3720
- ...(config.size ? [column(config.size, "大きさ")] : []),
4032
+ ...(config.index ? [column(config.index, t.name)] : []),
4033
+ ...(config.series ? [column(config.series, t.series)] : []),
4034
+ column(config.x, t.x),
4035
+ column(config.y, t.y),
4036
+ ...(config.size ? [column(config.size, t.size)] : []),
3721
4037
  ]
3722
4038
  const shown = points.slice(0, SCATTER_TABLE_MAX_ROWS)
3723
4039
  const rows = shown.map((p, i) => {
@@ -3731,15 +4047,16 @@ export function scatterDataTable(
3731
4047
  // The first column is the row header; without an `index` column, number the dots.
3732
4048
  return config.index ? cells : [String(i + 1), ...cells]
3733
4049
  })
3734
- const base = points.length > shown.length ? `${label(config.y)}×${label(config.x)}(先頭 ${shown.length} 点)` : `${label(config.y)}×${label(config.x)}`
4050
+ const pair = t.pair(label(config.y), label(config.x))
4051
+ const base = points.length > shown.length ? t.first(pair, shown.length) : pair
3735
4052
  // The guide lines carry information the dots do not: read them out with the table.
3736
4053
  const guides = (config.referenceLines ?? []).map((line) => {
3737
4054
  const key = line.axis === "x" ? config.x : config.y
3738
4055
  const text = config.formatters[line.axis](line.value)
3739
- return line.label ? `${line.label}(${label(key)} ${text})` : `${label(key)} ${text}`
4056
+ return line.label ? t.guide(line.label, label(key), text) : `${label(key)} ${text}`
3740
4057
  })
3741
4058
  return {
3742
- caption: guides.length > 0 ? `${base}。基準線: ${guides.join("、")}` : base,
4059
+ caption: guides.length > 0 ? t.caption(base, guides) : base,
3743
4060
  columns: config.index ? columns : ["#", ...columns],
3744
4061
  rows,
3745
4062
  }
@@ -4090,29 +4407,29 @@ function ScatterChartImpl<T extends Record<string, unknown>>(
4090
4407
  }: ScatterChartProps<T>,
4091
4408
  ref: ForwardedRef<HTMLDivElement>,
4092
4409
  ) {
4093
- const tokens = useChartTokens()
4094
- const { ref: frameRef, width: plotWidth } = useChartSurface(ref, tokens)
4410
+ const { ref: frameRef, width: plotWidth, tokens } = useChartSurface(ref)
4411
+ const locale = useLocale()
4095
4412
  // Tooltips and the table read in full; axis ticks read short.
4096
4413
  const formatters = useMemo(
4097
4414
  () => ({
4098
- x: resolveValueFormatter(valueFormatter, xFormat ?? format),
4099
- y: resolveValueFormatter(valueFormatter, yFormat ?? format),
4100
- size: resolveValueFormatter(valueFormatter, sizeFormat ?? format),
4415
+ x: resolveValueFormatter(valueFormatter, xFormat ?? format, locale),
4416
+ y: resolveValueFormatter(valueFormatter, yFormat ?? format, locale),
4417
+ size: resolveValueFormatter(valueFormatter, sizeFormat ?? format, locale),
4101
4418
  }),
4102
- [valueFormatter, xFormat, yFormat, sizeFormat, format],
4419
+ [valueFormatter, xFormat, yFormat, sizeFormat, format, locale],
4103
4420
  )
4104
4421
  const axisFormatters = useMemo(
4105
4422
  () => ({
4106
- x: resolveAxisFormatter(valueFormatter, xFormat ?? format),
4107
- y: resolveAxisFormatter(valueFormatter, yFormat ?? format),
4423
+ x: resolveAxisFormatter(valueFormatter, xFormat ?? format, locale),
4424
+ y: resolveAxisFormatter(valueFormatter, yFormat ?? format, locale),
4108
4425
  }),
4109
- [valueFormatter, xFormat, yFormat, format],
4426
+ [valueFormatter, xFormat, yFormat, format, locale],
4110
4427
  )
4111
4428
  const points = useMemo(
4112
4429
  () => scatterPoints(data, { x, y, size, index, series: seriesKey, labelKey }),
4113
4430
  [data, x, y, size, index, seriesKey, labelKey],
4114
4431
  )
4115
- const series = useMemo(() => scatterSeries(points), [points])
4432
+ const series = useMemo(() => scatterSeries(points, undefined, otherSeriesLabel(locale)), [points, locale])
4116
4433
  const palette = useMemo(
4117
4434
  () => resolveChartColors(scatterSeriesColors(series, colors).map(chartFillName), tokens),
4118
4435
  [series, colors, tokens],
@@ -4149,14 +4466,14 @@ function ScatterChartImpl<T extends Record<string, unknown>>(
4149
4466
  [series, palette, x, y, size, seriesKey, seriesLabel, highlightList, referenceLines, labelKey, tokens, labels, formatters, axisFormatters, fitToData, showLegend, showTooltip, actions, plotWidth, chartOptions, minValue, maxValue, xMinValue, xMaxValue],
4150
4467
  )
4151
4468
  const dataTable = useMemo(
4152
- () => scatterDataTable(points, { x, y, size, index: index ?? labelKey, series: seriesKey, formatters, labels, referenceLines }),
4153
- [points, x, y, size, index, labelKey, seriesKey, formatters, labels, referenceLines],
4469
+ () => scatterDataTable(points, { x, y, size, index: index ?? labelKey, series: seriesKey, formatters, labels, referenceLines, locale }),
4470
+ [points, x, y, size, index, labelKey, seriesKey, formatters, labels, referenceLines, locale],
4154
4471
  )
4155
4472
  const resolvedSummary = useMemo(
4156
- () => (summary === "auto" ? buildScatterSummary(points, y, formatters.y, title, labels) : summary),
4157
- [summary, points, y, formatters, title, labels],
4473
+ () => (summary === "auto" ? buildScatterSummary(points, y, formatters.y, title, labels, locale) : summary),
4474
+ [summary, points, y, formatters, title, labels, locale],
4158
4475
  )
4159
- const label = ariaLabel ?? `${labelOf(y, labels)}×${labelOf(x, labels)}の散布図`
4476
+ const label = ariaLabel ?? textOf(locale).chart(labelOf(y, labels), labelOf(x, labels))
4160
4477
  return (
4161
4478
  <ChartFrame
4162
4479
  {...chartRootProps(rest)}