@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
@@ -19,7 +19,8 @@ import { ChartFrame } from "@squadbase/vantage/components"
19
19
  import { EChart } from "@squadbase/vantage/components"
20
20
  import { toValueFormatter } from "@squadbase/vantage/components"
21
21
  import type { ChartCommonProps, ValueFormatter } from "@squadbase/vantage/components"
22
- import { createContext, useCallback, useEffect, useRef, useState } from "react"
22
+ import { useSyncExternalStore } from "react"
23
+ import { createContext, useCallback, useEffect, useRef, useState, type RefObject } from "react"
23
24
  import type { ChartTone } from "@squadbase/vantage/components"
24
25
  import { type ReactNode } from "react"
25
26
  import { computeComparison, defaultChangeDisplay, formatChange, toneForValue, type ComparisonKind, type PeriodGrain } from "@squadbase/vantage/components"
@@ -28,6 +29,137 @@ import type { ChartThreshold, ChartAnnotation, ChartEventPayload, ChartForecast,
28
29
  import { formatValue } from "@squadbase/vantage/components"
29
30
  import type { ChartColor, ChartColors, ChartSeriesColor } from "@squadbase/vantage/components"
30
31
 
32
+ /* ---------- inlined from @squadbase/squadkit registry/lib/locale.ts (internal to @squadbase/vantage) ---------- */
33
+
34
+ /**
35
+ * The display language of the parts and the defaults that follow it.
36
+ *
37
+ * The language comes from `<html lang>`: a tag starting with `ja` reads Japanese, anything else English.
38
+ * Without a usable `lang` (missing, invalid, or no `document` — SSR, Node) the locale is `FALLBACK_LOCALE`
39
+ * (`en-US`). A `locale` passed explicitly (a prop, an argument) always wins.
40
+ *
41
+ * Parts read the locale through `useLocale` (`hooks/use-locale`) and pass it down; the pure helpers here
42
+ * and in `format` / `comparison` / `chart-*` take it as an argument and fall back to `documentLocale()`.
43
+ */
44
+
45
+ /** The language of the built-in wording: Japanese or English. */
46
+ type UiLanguage = "ja" | "en"
47
+
48
+ /** The locale used when `<html lang>` gives none (and on the server). */
49
+ const FALLBACK_LOCALE = "en-US"
50
+
51
+ /** `"ja"` when `locale` starts with `ja` (`ja`, `ja-JP`), else `"en"`. A missing locale is `"en"`. */
52
+ function uiLanguage(locale: string | null | undefined): UiLanguage {
53
+ return typeof locale === "string" && locale.trim().toLowerCase().startsWith("ja") ? "ja" : "en"
54
+ }
55
+
56
+ /** A BCP 47 tag in its canonical form (`en-gb` → `en-GB`), or `FALLBACK_LOCALE` when it is empty or invalid. */
57
+ function normalizeLocale(tag: string | null | undefined): string {
58
+ const value = tag?.trim()
59
+ if (!value) return FALLBACK_LOCALE
60
+ try {
61
+ return Intl.getCanonicalLocales(value)[0] ?? FALLBACK_LOCALE
62
+ } catch {
63
+ return FALLBACK_LOCALE
64
+ }
65
+ }
66
+
67
+ /**
68
+ * The locale of the page: `document.documentElement.lang`, canonicalized. `FALLBACK_LOCALE` when it is empty or
69
+ * invalid, or there is no `document`. Parts read it through `useLocale`, never while rendering.
70
+ */
71
+ function documentLocale(): string {
72
+ if (typeof document === "undefined") return FALLBACK_LOCALE
73
+ return normalizeLocale(document.documentElement?.getAttribute("lang"))
74
+ }
75
+
76
+ /** `locale` when given, else the page's locale (`documentLocale()`). */
77
+ function resolveLocale(locale?: string | null): string {
78
+ return locale || documentLocale()
79
+ }
80
+
81
+ /** Picks the wording table of the locale's language. */
82
+ function pickLocale<T>(table: { ja: T; en: T }, locale?: string | null): T {
83
+ return table[uiLanguage(resolveLocale(locale))]
84
+ }
85
+
86
+ /** The currency `format="currency"` uses without a `currency`: JPY for Japanese, USD otherwise. */
87
+ function defaultCurrency(locale?: string | null): string {
88
+ return uiLanguage(resolveLocale(locale)) === "ja" ? "JPY" : "USD"
89
+ }
90
+
91
+ /** The month a fiscal year starts in (1–12) when none is given: April for Japanese, January otherwise. */
92
+ function defaultFiscalYearStartMonth(locale?: string | null): number {
93
+ return uiLanguage(resolveLocale(locale)) === "ja" ? 4 : 1
94
+ }
95
+
96
+ /** A day of the week, 0 = Sunday … 6 = Saturday (the `Date#getDay` / date-fns numbering). */
97
+ type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6
98
+
99
+ /** Locales whose week starts on Sunday when the runtime has no week data. */
100
+ const SUNDAY_FIRST = new Set(["en", "en-US", "en-CA"])
101
+
102
+ /**
103
+ * The first day of the week: Monday for Japanese; otherwise the locale's convention from `Intl.Locale`
104
+ * (`en-US` Sunday, `en-GB` Monday). Without week data in the runtime: Sunday for `en` / `en-US` / `en-CA`,
105
+ * Monday for the rest.
106
+ */
107
+ function weekStartsOn(locale?: string | null): WeekDay {
108
+ const tag = normalizeLocale(resolveLocale(locale))
109
+ if (uiLanguage(tag) === "ja") return 1
110
+ try {
111
+ const info = new Intl.Locale(tag) as Intl.Locale & {
112
+ getWeekInfo?: () => { firstDay: number }
113
+ weekInfo?: { firstDay: number }
114
+ }
115
+ const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay
116
+ if (typeof firstDay === "number" && firstDay >= 1 && firstDay <= 7) return (firstDay % 7) as WeekDay
117
+ } catch {
118
+ // Fall through to the table below.
119
+ }
120
+ return SUNDAY_FIRST.has(tag) ? 0 : 1
121
+ }
122
+
123
+ /* ---------- inlined from @squadbase/squadkit registry/hooks/use-locale.ts (internal to @squadbase/vantage) ---------- */
124
+
125
+ const listeners = new Set<() => void>()
126
+ let observer: MutationObserver | null = null
127
+
128
+ /** One observer on `<html lang>` for every subscribed part. */
129
+ function subscribe(onChange: () => void): () => void {
130
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {}
131
+ listeners.add(onChange)
132
+ if (!observer) {
133
+ observer = new MutationObserver(() => {
134
+ for (const listener of [...listeners]) listener()
135
+ })
136
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] })
137
+ }
138
+ return () => {
139
+ listeners.delete(onChange)
140
+ if (listeners.size === 0 && observer) {
141
+ observer.disconnect()
142
+ observer = null
143
+ }
144
+ }
145
+ }
146
+
147
+ const serverLocale = () => FALLBACK_LOCALE
148
+
149
+ /**
150
+ * The page's locale (`<html lang>`), following changes made while the page runs. On the server and during
151
+ * hydration it is `FALLBACK_LOCALE` (`en-US`), so the markup matches; it switches right after.
152
+ */
153
+ function useDocumentLocale(): string {
154
+ return useSyncExternalStore(subscribe, documentLocale, serverLocale)
155
+ }
156
+
157
+ /** The locale a part renders in: its `locale` prop when given, else the page's (`useDocumentLocale`). */
158
+ function useLocale(locale?: string | null): string {
159
+ const page = useDocumentLocale()
160
+ return locale || page
161
+ }
162
+
31
163
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-theme.ts (internal to @squadbase/vantage) ---------- */
32
164
 
33
165
  /**
@@ -62,10 +194,23 @@ interface ChartTokens {
62
194
  border: string | null
63
195
  /** Tooltip surface. */
64
196
  popover: string | null
197
+ /** Semantic and surface dependencies used by typed chart options. Optional for hand-built tokens. */
198
+ colors?: Record<string, string>
199
+ /** Actual inherited painting surface (may change without a token changing). */
200
+ surface?: string | null
65
201
  /** Tooltip text. */
66
202
  popoverForeground: string | null
67
203
  }
68
204
 
205
+ // Keep scope out of the public token shape. A resolved snapshot remembers where arbitrary
206
+ // var(--token) expressions must be read, without retaining unmounted charts globally.
207
+ const tokenScopes = new WeakMap<ChartTokens, Element>()
208
+ const OPTION_COLOR_TOKENS = [
209
+ "--background", "--card", "--muted", "--primary", "--destructive",
210
+ ...["positive", "negative", "warning", "neutral", "info"].flatMap((tone) =>
211
+ ["", "-fill", "-subtle"].map((suffix) => `--${tone}${suffix}`)),
212
+ ]
213
+
69
214
  let normalizeContext: CanvasRenderingContext2D | null | undefined
70
215
 
71
216
  /** The forms `fillStyle` serializes legacy colours to, and zrender can parse. */
@@ -121,8 +266,10 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
121
266
  const color = read(token)
122
267
  if (color) series.push(color)
123
268
  }
124
- return {
269
+ const tokens: ChartTokens = {
125
270
  series,
271
+ colors: Object.fromEntries(OPTION_COLOR_TOKENS.map((name) => [name, styles.getPropertyValue(name).trim()])),
272
+ surface: readChartSurface(target),
126
273
  ...(styles.fontFamily ? { fontFamily: styles.fontFamily } : {}),
127
274
  foreground: read("--foreground"),
128
275
  mutedForeground: read("--muted-foreground"),
@@ -130,6 +277,8 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
130
277
  popover: read("--popover"),
131
278
  popoverForeground: read("--popover-foreground"),
132
279
  }
280
+ tokenScopes.set(tokens, target)
281
+ return tokens
133
282
  }
134
283
 
135
284
  /** Structural equality, so a theme toggle that changes nothing doesn't re-init. */
@@ -137,6 +286,10 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
137
286
  if (a === b) return true
138
287
  if (!a || !b) return false
139
288
  return (
289
+ tokenScopes.get(a) === tokenScopes.get(b) &&
290
+ a.surface === b.surface &&
291
+ Object.keys(a.colors ?? {}).length === Object.keys(b.colors ?? {}).length &&
292
+ Object.entries(a.colors ?? {}).every(([name, value]) => b.colors?.[name] === value) &&
140
293
  a.series.length === b.series.length &&
141
294
  a.series.every((color, i) => color === b.series[i]) &&
142
295
  a.fontFamily === b.fontFamily &&
@@ -162,7 +315,7 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
162
315
  * attributes alone leaves the chart painted in the previous palette until a
163
316
  * reload — the tokens in the DOM are new, the canvas is not.
164
317
  */
165
- function watchChartTokens(onChange: () => void): () => void {
318
+ function watchChartTokens(onChange: () => void, scope?: Element | null): () => void {
166
319
  if (typeof document === "undefined") return () => {}
167
320
  // Style edits arrive as bursts of mutations; coalesce them into one read, and
168
321
  // take it after the next frame so a swapped stylesheet has been applied.
@@ -177,8 +330,11 @@ function watchChartTokens(onChange: () => void): () => void {
177
330
 
178
331
  const themeObserver = new MutationObserver(schedule)
179
332
  const attributeFilter = ["class", "style", "data-theme"]
180
- themeObserver.observe(document.documentElement, { attributes: true, attributeFilter })
181
- if (document.body) themeObserver.observe(document.body, { attributes: true, attributeFilter })
333
+ // Only the chart and its ancestors can change its inherited theme. Never observe
334
+ // the document subtree: canvas/tooltip/rendering mutations would trigger every chart.
335
+ for (let node: Element | null = scope ?? document.body ?? document.documentElement; node; node = node.parentElement) {
336
+ themeObserver.observe(node, { attributes: true, attributeFilter })
337
+ }
182
338
 
183
339
  const styleObserver = new MutationObserver(schedule)
184
340
  if (document.head) {
@@ -266,22 +422,23 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
266
422
  * components that build an ECharts option from token colours themselves (the
267
423
  * typed charts), so their palette follows light/dark like `EChart`'s theme.
268
424
  */
269
- function useChartTokens(): ChartTokens | null {
425
+ function useChartTokens(scope?: Element | null | RefObject<Element | null>): ChartTokens | null {
270
426
  const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
271
427
  // Compared here, not inside a `setTokens` updater: an updater that reads the DOM is
272
428
  // replayed by React on every rebase and returns a fresh object each time, so the state
273
429
  // never settles ("Maximum update depth exceeded" once the tokens change after mount).
274
430
  const tokensRef = useRef(tokens)
275
431
  useEffect(() => {
432
+ const target = scope && "current" in scope ? scope.current : scope
276
433
  const sync = () => {
277
- const next = readChartTokens()
434
+ const next = readChartTokens(target)
278
435
  if (sameChartTokens(tokensRef.current, next)) return
279
436
  tokensRef.current = next
280
437
  setTokens(next)
281
438
  }
282
439
  sync()
283
- return watchChartTokens(sync)
284
- }, [])
440
+ return watchChartTokens(sync, target)
441
+ }, [scope])
285
442
  return tokens
286
443
  }
287
444
 
@@ -293,7 +450,7 @@ const EMBEDDED_VAR_RE = /var\(\s*(--[\w-]+)\s*\)/g
293
450
  /**
294
451
  * Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
295
452
  * the resolved series tokens; `"positive"` / `"warning"` / `"negative"` / `"neutral"` (and the area
296
- * variants `"positive-fill"`, …) are read off the document root (`--positive`, …); `var(--token)` is read off the document root; any
453
+ * variants `"positive-fill"`, …) are read in the snapshot's inherited scope (`--positive`, …); `var(--token)` uses the same scope; any
297
454
  * other CSS colour is normalized like the tokens are. A colour that can't be
298
455
  * resolved comes back as `""`, which ECharts treats as "use the palette".
299
456
  */
@@ -304,12 +461,13 @@ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | nul
304
461
  // Meaning colours are the theme tokens of the same name: same path as `var(--positive)`.
305
462
  if (TONE_NAME_RE.test(color.trim())) {
306
463
  if (typeof document === "undefined") return ""
307
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue(`--${color.trim()}`)) ?? ""
464
+ const target = (tokens && tokenScopes.get(tokens)) ?? document.documentElement
465
+ return toCanvasColor(getComputedStyle(target).getPropertyValue(`--${color.trim()}`)) ?? ""
308
466
  }
309
467
  if (typeof document === "undefined") return color
310
468
  let value = color.trim()
311
469
  const cssVar = CSS_VAR_RE.exec(value)
312
- const styles = getComputedStyle(document.documentElement)
470
+ const styles = getComputedStyle((tokens && tokenScopes.get(tokens)) ?? document.documentElement)
313
471
  if (cssVar) {
314
472
  value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
315
473
  } else if (value.includes("var(")) {
@@ -355,7 +513,7 @@ function readChartSurface(el: Element | null): string | null {
355
513
  if (color) return color
356
514
  }
357
515
  }
358
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue("--background"))
516
+ return toCanvasColor(getComputedStyle(el ?? document.documentElement).getPropertyValue("--background"))
359
517
  }
360
518
 
361
519
  /** Widths are bucketed so a resize only rebuilds the option when the label budget can change. */
@@ -369,9 +527,10 @@ const WIDTH_BUCKET = 40
369
527
  */
370
528
  function useChartSurface<E extends Element>(
371
529
  forwarded: ForwardedRef<E>,
372
- tokens: ChartTokens | null,
373
- ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined } {
530
+ tokens?: ChartTokens | null,
531
+ ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined; tokens: ChartTokens | null } {
374
532
  const [node, setNode] = useState<E | null>(null)
533
+ const scopedTokens = useChartTokens(node)
375
534
  const [surface, setSurface] = useState<string | undefined>(undefined)
376
535
  const [width, setWidth] = useState<number | undefined>(undefined)
377
536
  const ref = useCallback(
@@ -385,7 +544,7 @@ function useChartSurface<E extends Element>(
385
544
  useEffect(() => {
386
545
  if (!node) return
387
546
  setSurface(readChartSurface(node) ?? undefined)
388
- }, [node, tokens])
547
+ }, [node, tokens, scopedTokens])
389
548
  useEffect(() => {
390
549
  if (!node || typeof ResizeObserver === "undefined") return
391
550
  const update = (w: number) => setWidth(Math.floor(w / WIDTH_BUCKET) * WIDTH_BUCKET)
@@ -394,11 +553,37 @@ function useChartSurface<E extends Element>(
394
553
  observer.observe(node)
395
554
  return () => observer.disconnect()
396
555
  }, [node])
397
- return { ref, surface, width }
556
+ return { ref, surface, width, tokens: scopedTokens }
398
557
  }
399
558
 
400
559
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-options.ts (internal to @squadbase/vantage) ---------- */
401
560
 
561
+ /** The built-in words of the option builders, by language. */
562
+ const CHART_TEXT = {
563
+ ja: {
564
+ target: "目標",
565
+ forecast: "予測",
566
+ total: "合計",
567
+ provisional: "速報",
568
+ noComparison: "比較なし",
569
+ share: (share: string) => `(${share})`,
570
+ targetRate: (rate: string) => `(達成率 ${rate})`,
571
+ provisionalTitle: (title: string, label: string) => `${title}(${label})`,
572
+ },
573
+ en: {
574
+ target: "Target",
575
+ forecast: "Forecast",
576
+ total: "Total",
577
+ provisional: "Preliminary",
578
+ noComparison: "No comparison",
579
+ share: (share: string) => ` (${share})`,
580
+ targetRate: (rate: string) => ` (${rate} achieved)`,
581
+ provisionalTitle: (title: string, label: string) => `${title} (${label})`,
582
+ },
583
+ } satisfies Record<UiLanguage, unknown>
584
+
585
+ const chartText = (locale?: string) => CHART_TEXT[uiLanguage(resolveLocale(locale))]
586
+
402
587
  type CartesianChartKind = "area" | "bar" | "line"
403
588
  type CartesianStackType = "default" | "stacked" | "percent"
404
589
  type CartesianCurveType = "linear" | "smooth" | "step"
@@ -583,7 +768,7 @@ interface CartesianChartConfig {
583
768
  * all ticks read in one unit (¥0.5億 / ¥1億). Default: `axisFormatter` for every tick.
584
769
  */
585
770
  axisTicks?: (top: number) => (value: number) => string
586
- /** Locale of the time-axis labels. Default `"ja-JP"`. */
771
+ /** Locale of the time-axis labels and the built-in words (total, target, forecast). Default the page's (`<html lang>`). */
587
772
  locale?: string
588
773
  type?: CartesianStackType
589
774
  /** Bar only. `"vertical"` lays the categories down the y axis (horizontal bars). */
@@ -821,11 +1006,12 @@ function timeAxisLabelStep(values: readonly unknown[], maxLabels: number = TIME_
821
1006
  */
822
1007
  function timeAxisLabels(
823
1008
  values: readonly unknown[],
824
- locale: string = DEFAULT_LOCALE,
1009
+ localeArg?: string,
825
1010
  shown: (i: number) => boolean = () => true,
826
1011
  ): string[] | null {
827
1012
  const parsed = parseTimeIndex(values)
828
1013
  if (!parsed) return null
1014
+ const locale = resolveLocale(localeArg)
829
1015
  const { granularity, times } = parsed
830
1016
  let prevYear: number | null = null
831
1017
  return times.map((t, i) => {
@@ -848,9 +1034,10 @@ function timeAxisLabels(
848
1034
  }
849
1035
 
850
1036
  /** The tooltip titles for time-like x values (`2025年9月`, `2025年9月1日`), or `null` when they are not dates. */
851
- function timeTooltipLabels(values: readonly unknown[], locale: string = DEFAULT_LOCALE): string[] | null {
1037
+ function timeTooltipLabels(values: readonly unknown[], localeArg?: string): string[] | null {
852
1038
  const parsed = parseTimeIndex(values)
853
1039
  if (!parsed) return null
1040
+ const locale = resolveLocale(localeArg)
854
1041
  const options: Intl.DateTimeFormatOptions =
855
1042
  parsed.granularity === "year"
856
1043
  ? { year: "numeric" }
@@ -990,7 +1177,9 @@ function tooltipHtml(
990
1177
  rows: readonly TooltipRow[],
991
1178
  valueFormatter: (value: number) => string,
992
1179
  notes: readonly string[] = [],
1180
+ locale?: string,
993
1181
  ): string {
1182
+ const t = chartText(locale)
994
1183
  const lines = rows.map(
995
1184
  (row) =>
996
1185
  `<div style="display:flex;align-items:center;gap:8px;line-height:20px">` +
@@ -999,7 +1188,7 @@ function tooltipHtml(
999
1188
  `<span style="font-weight:600;font-variant-numeric:tabular-nums;margin-left:12px">${
1000
1189
  row.value === null ? EMPTY_VALUE : escapeHtml(valueFormatter(row.value))
1001
1190
  }${
1002
- row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px">(${escapeHtml(formatShare(row.share))})</span>`
1191
+ row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px">${escapeHtml(t.share(formatShare(row.share)))}</span>`
1003
1192
  }</span></div>` +
1004
1193
  (row.note === undefined
1005
1194
  ? ""
@@ -1013,8 +1202,10 @@ function tooltipHtml(
1013
1202
 
1014
1203
  /* ------------------------------------------------- per-bar targets / notes */
1015
1204
 
1016
- /** Default name of the per-bar target marker (legend, tooltip). */
1017
- const BAR_TARGET_LABEL = "目標"
1205
+ /** Default name of the per-bar target marker (legend, tooltip) in `locale`: "Target" / 「目標」. Default the page's locale. */
1206
+ function barTargetLabel(locale?: string): string {
1207
+ return chartText(locale).target
1208
+ }
1018
1209
 
1019
1210
  /** How far (px) the target marker reaches past each side of its bar. */
1020
1211
  const TARGET_MARKER_OVERHANG = 4
@@ -1065,16 +1256,17 @@ function achievementRate(value: number | null | undefined, target: number | null
1065
1256
  return (value / target) * 100
1066
1257
  }
1067
1258
 
1068
- /** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3%)」. `null` without a target. */
1259
+ /** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3%)」 / "Target $12M (85.3% achieved)". `null` without a target. */
1069
1260
  function barTargetNote(
1070
1261
  label: string,
1071
1262
  value: number | null,
1072
1263
  target: number | null,
1073
1264
  valueFormatter: (value: number) => string,
1265
+ locale?: string,
1074
1266
  ): string | null {
1075
1267
  if (target === null) return null
1076
1268
  const rate = achievementRate(value, target)
1077
- return `${label} ${valueFormatter(target)}${rate === null ? "" : `(達成率 ${formatShare(rate)})`}`
1269
+ return `${label} ${valueFormatter(target)}${rate === null ? "" : chartText(locale).targetRate(formatShare(rate))}`
1078
1270
  }
1079
1271
 
1080
1272
  /** Half the width of a bar in category-axis units, from `barCategoryGap` (`"20%"`; a px gap or none → ECharts' 20%). */
@@ -1166,11 +1358,12 @@ const TARGET_LEGEND_SERIES_ID = "__target-legend"
1166
1358
  /** A flat tick (8×8 legend box, aspect kept): the per-bar target's legend icon. */
1167
1359
  const TARGET_LEGEND_ICON = "path://M0,0 L100,0 L100,24 L0,24 Z"
1168
1360
 
1169
- const DEFAULT_FORECAST_LABEL = "予測"
1170
-
1171
- /** 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. */
1172
- function forecastBoundaryText(label: string = DEFAULT_FORECAST_LABEL): string {
1173
- return `${label} →`
1361
+ /**
1362
+ * 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.
1363
+ * Without a `label`, "Forecast" / 「予測」 in `locale` (default the page's).
1364
+ */
1365
+ function forecastBoundaryText(label?: string, locale?: string): string {
1366
+ return `${label ?? chartText(locale).forecast} →`
1174
1367
  }
1175
1368
 
1176
1369
  /** A comparison series: a thin, muted, solid line with no markers (60% of `--muted-foreground`). */
@@ -1210,8 +1403,10 @@ interface AxisExtent {
1210
1403
  max: number
1211
1404
  }
1212
1405
 
1213
- /** The label of a stack's total (highlight and tooltip). */
1214
- const STACK_TOTAL_LABEL = "合計"
1406
+ /** The label of a stack's total (highlight and tooltip) in `locale`: "Total" / 「合計」. Default the page's locale. */
1407
+ function stackTotalLabel(locale?: string): string {
1408
+ return chartText(locale).total
1409
+ }
1215
1410
 
1216
1411
  /** Per point, the sum of the stacked series; `null` where every series is missing. */
1217
1412
  function stackTotalsOf(values: readonly (readonly (number | null)[])[]): (number | null)[] {
@@ -1333,8 +1528,8 @@ function indexGrain(values: readonly unknown[]): PeriodGrain | null {
1333
1528
 
1334
1529
  /**
1335
1530
  * The tooltip line under the main series' row when a comparison series is drawn: the arrow, the signed change and the
1336
- * comparison's name, as `Delta` writes it ("↑ +12.4% 前年同月比"; a minus is U+2212). "比較なし 前年同月比" when
1337
- * either value is missing or the baseline is 0.
1531
+ * comparison's name, as `Delta` writes it ("↑ +12.4% vs. same month last year" / "↑ +12.4% 前年同月比"; a minus is U+2212).
1532
+ * "No comparison vs. same month last year" / "比較なし 前年同月比" when either value is missing or the baseline is 0.
1338
1533
  */
1339
1534
  function comparisonNote(
1340
1535
  current: number | null,
@@ -1342,16 +1537,18 @@ function comparisonNote(
1342
1537
  comparison: Pick<CartesianComparisonConfig, "vs" | "label">,
1343
1538
  grain?: PeriodGrain,
1344
1539
  format?: ValueFormat,
1345
- locale: string = DEFAULT_LOCALE,
1540
+ localeArg?: string,
1346
1541
  ): string {
1542
+ const locale = resolveLocale(localeArg)
1347
1543
  const result = computeComparison(
1348
1544
  current,
1349
1545
  comparison.vs === "custom"
1350
1546
  ? { previous, vs: "custom", label: comparison.label ?? "", grain }
1351
1547
  : { previous, vs: comparison.vs, label: comparison.label, grain },
1548
+ { locale },
1352
1549
  )
1353
1550
  const text = formatChange(result, defaultChangeDisplay(format), locale, format)
1354
- if (text === null) return `比較なし ${result.label}`
1551
+ if (text === null) return `${chartText(locale).noComparison} ${result.label}`
1355
1552
  const arrow = result.direction === "up" ? "↑" : result.direction === "down" ? "↓" : "→"
1356
1553
  return `${arrow} ${text.replace(/^-/, "−")} ${result.label}`
1357
1554
  }
@@ -1394,7 +1591,6 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1394
1591
  toneColors,
1395
1592
  valueFormatter,
1396
1593
  axisFormatter = valueFormatter,
1397
- locale = DEFAULT_LOCALE,
1398
1594
  type = "default",
1399
1595
  layout = "horizontal",
1400
1596
  curveType = "linear",
@@ -1431,7 +1627,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1431
1627
  chartOptions,
1432
1628
  reserveActions = false,
1433
1629
  } = cfg
1434
- const provisionalLabel = cfg.provisionalLabel ?? DEFAULT_PROVISIONAL_LABEL
1630
+ const locale = resolveLocale(cfg.locale)
1631
+ const text = chartText(locale)
1632
+ const provisionalLabel = cfg.provisionalLabel ?? text.provisional
1435
1633
 
1436
1634
  const stacked = kind !== "line" && type !== "default"
1437
1635
  const percent = kind !== "line" && type === "percent"
@@ -1580,9 +1778,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1580
1778
  // `barOptions.targetKey` (a target per bar) takes over from the single `target` line.
1581
1779
  const perBarTarget = kind === "bar" && Boolean(targetKey)
1582
1780
  const hasTarget = !perBarTarget && target !== undefined && Number.isFinite(target)
1583
- const targetText = hasTarget ? (targetLabel ?? `目標 ${axisFormatter(target)}`) : ""
1781
+ const targetText = hasTarget ? (targetLabel ?? `${text.target} ${axisFormatter(target)}`) : ""
1584
1782
  const highlightText = (value: number) =>
1585
- shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${STACK_TOTAL_LABEL} ` : ""}${axisFormatter(value)}`
1783
+ shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${text.total} ` : ""}${axisFormatter(value)}`
1586
1784
  /** Whether `p`'s label sits within the target's value band and under the target label (at the right end). */
1587
1785
  const nearTargetLabel = (p: HighlightPoint, side: "under" | "over") => {
1588
1786
  if (!hasTarget || !plotWidth || kind === "bar" || labels.length < 2) return false
@@ -1608,7 +1806,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1608
1806
  // One series (or a stack, against its total) can be compared to a target; grouped series and 100% stacks cannot.
1609
1807
  const barTargets: (number | null)[] | null =
1610
1808
  perBarTarget && targetKey && !percent && (stacked || categories.length === 1) ? barTargetValues(data, targetKey) : null
1611
- const barTargetName = targetLabel ?? BAR_TARGET_LABEL
1809
+ const barTargetName = targetLabel ?? text.target
1612
1810
  const barTargetColor = labelColor ?? guideColor
1613
1811
 
1614
1812
  const markPointFor = (c: number, color: string | undefined) =>
@@ -1822,7 +2020,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1822
2020
  }
1823
2021
 
1824
2022
  /* -- forecast: dashed continuation (+ optional band) of one series */
1825
- const forecastLabel = forecast?.label ?? DEFAULT_FORECAST_LABEL
2023
+ const forecastLabel = forecast?.label ?? text.forecast
1826
2024
  const forecastBandAlpha = isDarkSurface(labelBackground) ? FORECAST_BAND_ALPHA_DARK : FORECAST_BAND_ALPHA_LIGHT
1827
2025
  const forecastValues: (number | null)[] = []
1828
2026
  if (forecast) {
@@ -2298,7 +2496,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2298
2496
  })
2299
2497
  }
2300
2498
  if (stackTotals && i <= lastActual && rows.length > 1) {
2301
- rows.push({ name: STACK_TOTAL_LABEL, color: "transparent", value: stackTotals[i] ?? null })
2499
+ rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
2302
2500
  }
2303
2501
  const provisional = provStart !== -1 && i >= provStart && i <= lastActual
2304
2502
  if (provisional) {
@@ -2314,19 +2512,19 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2314
2512
  ...noteFor(at),
2315
2513
  })
2316
2514
  })
2317
- if (stackTotals && rows.length > 1) rows.push({ name: STACK_TOTAL_LABEL, color: "transparent", value: stackTotals[i] ?? null })
2515
+ if (stackTotals && rows.length > 1) rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
2318
2516
  }
2319
2517
  const title = tooltipTitles[i] ?? labels[i] ?? ""
2320
2518
  const lines: string[] = []
2321
2519
  if (barTargets && i <= lastActual) {
2322
2520
  const total = stackTotals ? (stackTotals[i] ?? null) : (values[0]?.[i] ?? null)
2323
- const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter)
2521
+ const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter, locale)
2324
2522
  if (note) lines.push(note)
2325
2523
  }
2326
2524
  for (const note of notes) {
2327
2525
  if (note.to === undefined ? note.from === i : note.from <= i && i <= note.to) lines.push(note.label)
2328
2526
  }
2329
- return tooltipHtml(provisional ? `${title}(${provisionalLabel})` : title, rows, valueFormatter, lines)
2527
+ return tooltipHtml(provisional ? text.provisionalTitle(title, provisionalLabel) : title, rows, valueFormatter, lines, locale)
2330
2528
  },
2331
2529
  },
2332
2530
  xAxis: horizontalBars ? valueAxis : categoryAxis,
@@ -2418,8 +2616,6 @@ interface DonutOptionSlice {
2418
2616
  /** How many of the largest slices get an outside label (「自然検索 41%」) by default. */
2419
2617
  const DONUT_OUTSIDE_LABELS = 2
2420
2618
 
2421
- const DEFAULT_TOTAL_LABEL = "合計"
2422
-
2423
2619
  const sliceValue = (value: number) => (Number.isFinite(value) && value > 0 ? value : 0)
2424
2620
 
2425
2621
  /** A slice's share of `total` as text: whole percent, `<1%` for a sliver, `0%` for nothing. */
@@ -2452,15 +2648,16 @@ function donutLegendItems(slices: readonly DonutOptionSlice[]): DonutLegendItem[
2452
2648
  }
2453
2649
 
2454
2650
  /**
2455
- * The default center label: what is totalled (「合計」), the formatted total
2651
+ * The default center label: what is totalled (「合計」 / "Total" in `locale`, default the page's), the formatted total
2456
2652
  * (`amount`, the big line), the unit (a small line under it) and both as one
2457
2653
  * string (`value`, 「9.2万 セッション」).
2458
2654
  */
2459
2655
  function donutCenterText(
2460
2656
  total: number,
2461
2657
  valueFormatter: (value: number) => string,
2462
- { totalLabel = DEFAULT_TOTAL_LABEL, unit }: { totalLabel?: string; unit?: string } = {},
2658
+ { totalLabel, unit, locale }: { totalLabel?: string; unit?: string; locale?: string } = {},
2463
2659
  ): { label: string; value: string; amount: string; unit?: string } {
2660
+ totalLabel ??= chartText(locale).total
2464
2661
  const amount = valueFormatter(total)
2465
2662
  return { label: totalLabel, value: unit ? `${amount} ${unit}` : amount, amount, ...(unit ? { unit } : {}) }
2466
2663
  }
@@ -2688,8 +2885,10 @@ function hasLabel(labels: Readonly<Record<string, string>> | undefined, key: str
2688
2885
 
2689
2886
  /* ------------------------------------------------------------ provisional */
2690
2887
 
2691
- /** The default label for points that are not final yet. */
2692
- const DEFAULT_PROVISIONAL_LABEL = "速報"
2888
+ /** The default label for points that are not final yet, in `locale`: "Preliminary" / 「速報」. Default the page's locale. */
2889
+ function defaultProvisionalLabel(locale?: string): string {
2890
+ return chartText(locale).provisional
2891
+ }
2693
2892
 
2694
2893
  /**
2695
2894
  * Position of the first provisional point: the first entry of `indexValues`
@@ -2827,9 +3026,10 @@ function resolveValueFormatter(
2827
3026
  function resolveAxisFormatter(
2828
3027
  valueFormatter: ValueFormatter | undefined,
2829
3028
  format: ValueFormat | undefined,
2830
- locale: string = DEFAULT_LOCALE,
3029
+ localeArg?: string,
2831
3030
  ): ValueFormatter {
2832
3031
  if (valueFormatter) return valueFormatter
3032
+ const locale = resolveLocale(localeArg)
2833
3033
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
2834
3034
  if (spec.type !== "compact" && spec.type !== "number" && spec.type !== "currency") return toValueFormatter(format, locale)
2835
3035
  const currency = "currency" in spec ? spec.currency : undefined
@@ -2838,40 +3038,75 @@ function resolveAxisFormatter(
2838
3038
  return (value) => formatValue(value, short, locale)
2839
3039
  }
2840
3040
 
2841
- /** The unit words of a ja number (`1.2万` / `3.4億` / `1兆`) and their sizes, largest first. */
2842
- const JA_UNITS = [
2843
- { char: "兆", size: 1e12 },
2844
- { char: "億", size: 1e8 },
2845
- { char: "万", size: 1e4 },
2846
- ] as const
3041
+ interface CompactUnit {
3042
+ token: string
3043
+ size: number
3044
+ }
2847
3045
 
2848
- const JA_NUMBER_TOKEN_RE = /\d[\d,]*(?:\.\d+)?(?:兆|億|万)?/
3046
+ const compactUnitCache = new Map<string, CompactUnit[] | null>()
3047
+
3048
+ /**
3049
+ * The compact-notation unit words of a locale and their sizes, largest first: `兆` / `億` / `万` in ja,
3050
+ * `T` / `B` / `M` / `K` in en. `null` when the locale's compact form puts anything between the number and its
3051
+ * word (`1,5 Mio.`), where a token cannot be swapped in place.
3052
+ */
3053
+ function compactUnits(locale: string): CompactUnit[] | null {
3054
+ const cached = compactUnitCache.get(locale)
3055
+ if (cached !== undefined) return cached
3056
+ let units: CompactUnit[] | null = []
3057
+ try {
3058
+ const compact = new Intl.NumberFormat(locale, { notation: "compact" })
3059
+ const seen = new Set<string>()
3060
+ for (let exp = 3; exp <= 15; exp++) {
3061
+ const parts = compact.formatToParts(10 ** exp)
3062
+ const at = parts.findIndex((p) => p.type === "compact")
3063
+ if (at === -1) continue
3064
+ if (parts[at - 1]?.type !== "integer" || at !== parts.length - 1) {
3065
+ units = null
3066
+ break
3067
+ }
3068
+ const token = parts[at]!.value
3069
+ if (seen.has(token)) continue
3070
+ seen.add(token)
3071
+ units.push({ token, size: 10 ** exp })
3072
+ }
3073
+ } catch {
3074
+ units = null
3075
+ }
3076
+ const sorted = units && units.length > 0 ? units.sort((a, b) => b.size - a.size) : null
3077
+ compactUnitCache.set(locale, sorted)
3078
+ return sorted
3079
+ }
3080
+
3081
+ const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
2849
3082
 
2850
3083
  /**
2851
3084
  * The value-axis tick formatter for an axis whose largest tick is `top`: every tick reads in the unit of
2852
- * that largest tick, so 万 and 億 never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`).
2853
- * Only the ja short forms (`compact` / `number` / `currency`, no `valueFormatter`) have units to align;
2854
- * any other format — and an axis whose largest tick is under 1万 — keeps `resolveAxisFormatter`'s output as it is.
3085
+ * that largest tick, so units never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`;
3086
+ * `$0.5M` / `$1M` / `$1.5M`, not `$500K` / `$1M`). Only the short forms (`compact` / `number` / `currency`,
3087
+ * no `valueFormatter`) have units to align; any other format — an axis whose largest tick is under the smallest
3088
+ * unit (1万 / 1K), or a locale whose compact form cannot be aligned in place — keeps `resolveAxisFormatter`'s output as it is.
2855
3089
  */
2856
3090
  function resolveAxisTickFormatter(
2857
3091
  valueFormatter: ValueFormatter | undefined,
2858
3092
  format: ValueFormat | undefined,
2859
- locale: string = DEFAULT_LOCALE,
3093
+ localeArg?: string,
2860
3094
  ): (top: number) => ValueFormatter {
3095
+ const locale = resolveLocale(localeArg)
2861
3096
  const base = resolveAxisFormatter(valueFormatter, format, locale)
2862
3097
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
2863
- const aligns =
2864
- !valueFormatter && locale.toLowerCase().startsWith("ja") && (spec.type === "compact" || spec.type === "number" || spec.type === "currency")
2865
- if (!aligns) return () => base
3098
+ const units = !valueFormatter && (spec.type === "compact" || spec.type === "number" || spec.type === "currency") ? compactUnits(locale) : null
3099
+ if (!units) return () => base
2866
3100
  const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 3 })
3101
+ const numberToken = new RegExp(`\\d[\\d,]*(?:\\.\\d+)?(?:${units.map((u) => escapeRegExp(u.token)).join("|")})?`)
2867
3102
  return (top) => {
2868
- const unit = JA_UNITS.find((u) => Math.abs(top) >= u.size)
3103
+ const unit = units.find((u) => Math.abs(top) >= u.size)
2869
3104
  if (!unit) return base
2870
3105
  return (value) => {
2871
3106
  const text = base(value)
2872
3107
  if (value === 0 || !Number.isFinite(value)) return text
2873
- const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.char}`
2874
- return text.replace(JA_NUMBER_TOKEN_RE, () => scaled)
3108
+ const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.token}`
3109
+ return text.replace(numberToken, () => scaled)
2875
3110
  }
2876
3111
  }
2877
3112
  }
@@ -2900,8 +3135,20 @@ function clamp(value: number, min: number, max: number): number {
2900
3135
  /** How many categories a chart colours individually; the rest fold into「その他」. */
2901
3136
  const MAX_CHART_SERIES = 5
2902
3137
 
2903
- /** The series name the folded categories get. */
2904
- const OTHER_SERIES_LABEL = "その他"
3138
+ /** The series name the folded categories get, by language. */
3139
+ const OTHER_SERIES_LABELS: Record<UiLanguage, string> = { ja: "その他", en: "Other" }
3140
+
3141
+ /** The series name the folded categories get in `locale` ("Other" / "その他"). Default the page's locale (`<html lang>`). */
3142
+ function otherSeriesLabel(locale?: string): string {
3143
+ return OTHER_SERIES_LABELS[uiLanguage(resolveLocale(locale))]
3144
+ }
3145
+
3146
+ const OTHER_NAMES = new Set(Object.values(OTHER_SERIES_LABELS))
3147
+
3148
+ /** Whether a category / row is the "other" bucket by name (「その他」 or "Other", whatever the page language). */
3149
+ function isOtherName(name: string | null | undefined): boolean {
3150
+ return typeof name === "string" && OTHER_NAMES.has(name)
3151
+ }
2905
3152
 
2906
3153
  interface AggregatedItems<Item extends { name: string; value: number }> {
2907
3154
  /** The `n` largest items, in their given order. All items when there are `n` or fewer. */
@@ -2921,7 +3168,7 @@ interface AggregatedItems<Item extends { name: string; value: number }> {
2921
3168
  function aggregateTopN<Item extends { name: string; value: number }>(
2922
3169
  items: readonly Item[],
2923
3170
  n: number = MAX_CHART_SERIES,
2924
- otherLabel: string = OTHER_SERIES_LABEL,
3171
+ otherLabel: string = otherSeriesLabel(),
2925
3172
  ): AggregatedItems<Item> {
2926
3173
  if (items.length <= n) return { top: [...items], rest: [], other: null }
2927
3174
  const ranked = items
@@ -2950,7 +3197,7 @@ function aggregateSeries(
2950
3197
  data: readonly Record<string, unknown>[],
2951
3198
  categories: readonly string[],
2952
3199
  n: number = MAX_CHART_SERIES,
2953
- otherLabel: string = OTHER_SERIES_LABEL,
3200
+ otherLabel: string = otherSeriesLabel(),
2954
3201
  ): AggregatedSeries {
2955
3202
  const totals = categories.map((name) => ({
2956
3203
  name,
@@ -2982,12 +3229,14 @@ function aggregateSeriesKeeping(
2982
3229
  categories: readonly string[],
2983
3230
  keep: string | undefined,
2984
3231
  n: number = MAX_CHART_SERIES,
3232
+ otherLabel: string = otherSeriesLabel(),
2985
3233
  ): AggregatedSeries {
2986
- if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n)
3234
+ if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n, otherLabel)
2987
3235
  const aggregated = aggregateSeries(
2988
3236
  data,
2989
3237
  categories.filter((c) => c !== keep),
2990
3238
  n,
3239
+ otherLabel,
2991
3240
  )
2992
3241
  return { ...aggregated, categories: [...aggregated.categories, keep] }
2993
3242
  }
@@ -3023,7 +3272,7 @@ function seriesColorsWithComparison(
3023
3272
  * Colour per drawn series: the record's token for that category, else the
3024
3273
  * array's token at the category's position in `allCategories` (cycled), else
3025
3274
  * the token palette by drawn position. `otherCategory` — and a category the
3026
- * data itself names「その他」(`OTHER_SERIES_LABEL`) — always gets `otherColor`.
3275
+ * data itself names「その他」/ "Other" (`OTHER_SERIES_LABELS`) — always gets `otherColor`.
3027
3276
  */
3028
3277
  function seriesColorsFor(
3029
3278
  categories: readonly string[],
@@ -3035,7 +3284,7 @@ function seriesColorsFor(
3035
3284
  const list = Array.isArray(colors) ? (colors as ChartSeriesColor[]) : undefined
3036
3285
  const record = !Array.isArray(colors) && colors ? (colors as Partial<Record<string, ChartSeriesColor>>) : undefined
3037
3286
  return categories.map((category, i) => {
3038
- if ((otherCategory !== null && category === otherCategory) || category === OTHER_SERIES_LABEL) return otherColor
3287
+ if ((otherCategory !== null && category === otherCategory) || isOtherName(category)) return otherColor
3039
3288
  const fromRecord = record?.[category]
3040
3289
  if (fromRecord) return fromRecord
3041
3290
  if (list && list.length > 0) {
@@ -3082,7 +3331,7 @@ interface DonutSlicesConfig {
3082
3331
  */
3083
3332
  function donutSlices(
3084
3333
  data: readonly Record<string, unknown>[],
3085
- { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = OTHER_SERIES_LABEL }: DonutSlicesConfig,
3334
+ { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = otherSeriesLabel() }: DonutSlicesConfig,
3086
3335
  ): DonutSlice[] {
3087
3336
  const items = data
3088
3337
  .map((row, i) => {
@@ -3095,7 +3344,8 @@ function donutSlices(
3095
3344
  const slices: DonutSlice[] = top.map((item, rank) => ({
3096
3345
  name: item.name,
3097
3346
  value: item.value,
3098
- color: palette[rank % palette.length]!,
3347
+ // A row the data itself names「その他」/ "Other" is the same remainder: grey, like the folded slice.
3348
+ color: isOtherName(item.name) ? otherColor : palette[rank % palette.length]!,
3099
3349
  isOther: false,
3100
3350
  row: item.row,
3101
3351
  }))
@@ -3168,7 +3418,7 @@ interface BarListRowsConfig extends BarListOptions {
3168
3418
  */
3169
3419
  function barListRows<Row extends object>(
3170
3420
  data: readonly Row[],
3171
- { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = OTHER_SERIES_LABEL, exclude }: BarListRowsConfig = {},
3421
+ { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = otherSeriesLabel(), exclude }: BarListRowsConfig = {},
3172
3422
  ): BarListRow<Row>[] {
3173
3423
  const items = data.map((row): BarListRow<Row> => {
3174
3424
  const cells = row as Record<string, unknown>
@@ -3214,19 +3464,21 @@ const BAR_LIST_OTHER_TINT = 30
3214
3464
 
3215
3465
  /**
3216
3466
  * The fill of each BarList row: all real rows in the same tint of `color`
3217
- * (`BAR_LIST_TINT`), the folded「その他」row in a tint of `otherColor`
3467
+ * (`BAR_LIST_TINT`), the folded「その他」row (and a row the data names「その他」/ "Other") in a tint of `otherColor`
3218
3468
  * (`BAR_LIST_OTHER_TINT`) — the palest fill, a remainder never outweighs a
3219
3469
  * ranked row. A legacy per-row `color` (or a threshold tone) replaces `color`
3220
3470
  * for that row. A tint, not the solid token, so the label printed over the bar
3221
3471
  * keeps its contrast.
3222
3472
  */
3223
3473
  function barListFills(
3224
- rows: readonly Pick<BarListRow, "isOther" | "color">[],
3474
+ rows: readonly (Pick<BarListRow, "isOther" | "color"> & { name?: string })[],
3225
3475
  color: ChartColor,
3226
3476
  otherColor: string,
3227
3477
  ): string[] {
3228
3478
  return rows.map((row) =>
3229
- row.isOther ? tintCss(otherColor, BAR_LIST_OTHER_TINT) : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
3479
+ row.isOther || isOtherName(row.name)
3480
+ ? tintCss(otherColor, BAR_LIST_OTHER_TINT)
3481
+ : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
3230
3482
  )
3231
3483
  }
3232
3484
 
@@ -3442,11 +3694,30 @@ interface ChartSummaryInput {
3442
3694
  format?: ValueFormat
3443
3695
  /** Wins over `format`. */
3444
3696
  valueFormatter?: ValueFormatter
3697
+ /** Language of the sentence. Default the page's locale (`<html lang>`). */
3698
+ locale?: string
3699
+ }
3700
+
3701
+ const SUMMARY_TEXT = {
3702
+ ja: {
3703
+ up: "、前の点より増加",
3704
+ down: "、前の点より減少",
3705
+ flat: "、前の点と横ばい",
3706
+ latest: (label: string, value: string) => `最新 ${label} は ${value}`,
3707
+ top: (name: string, share: string) => `最大は ${name}(${share})`,
3708
+ },
3709
+ en: {
3710
+ up: ", up from the previous point",
3711
+ down: ", down from the previous point",
3712
+ flat: ", flat from the previous point",
3713
+ latest: (label: string, value: string) => `Latest ${label}: ${value}`,
3714
+ top: (name: string, share: string) => `Largest: ${name} (${share})`,
3715
+ },
3445
3716
  }
3446
3717
 
3447
3718
  /**
3448
3719
  * One-sentence summary built from the rows, for `summary="auto"`:
3449
- * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」. The direction is
3720
+ * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」("<title>: Latest <index>: <value>, up from the previous point"). The direction is
3450
3721
  * computed from the last two non-empty points of the first series — never
3451
3722
  * written by hand, so it cannot contradict the data. `undefined` when there is
3452
3723
  * no usable point.
@@ -3462,26 +3733,29 @@ function buildChartSummary(input: ChartSummaryInput): string | undefined {
3462
3733
  }
3463
3734
  const latest = points[points.length - 1]
3464
3735
  if (!latest) return undefined
3465
- const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact")
3736
+ const locale = resolveLocale(input.locale)
3737
+ const t = SUMMARY_TEXT[uiLanguage(locale)]
3738
+ const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact", locale)
3466
3739
  const prev = points[points.length - 2]
3467
- const direction = !prev ? "" : latest.value > prev.value ? "、前の点より増加" : latest.value < prev.value ? "、前の点より減少" : "、前の点と横ばい"
3740
+ const direction = !prev ? "" : latest.value > prev.value ? t.up : latest.value < prev.value ? t.down : t.flat
3468
3741
  const head = input.title ? `${input.title}: ` : ""
3469
- return `${head}最新 ${latest.label} は ${text(latest.value)}${direction}`
3742
+ return `${head}${t.latest(latest.label, text(latest.value))}${direction}`
3470
3743
  }
3471
3744
 
3472
3745
  /**
3473
- * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」 from the slices
3474
- * (largest first). `undefined` when there is nothing to describe.
3746
+ * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」("<title>: Largest: <name> (<share>)") from the slices
3747
+ * (largest first). `undefined` when there is nothing to describe. `locale` picks the language; default the page's.
3475
3748
  */
3476
3749
  function buildShareSummary(
3477
3750
  slices: readonly { name: string; value: number }[],
3478
3751
  title?: string,
3752
+ locale?: string,
3479
3753
  ): string | undefined {
3480
3754
  const total = slices.reduce((sum, s) => sum + s.value, 0)
3481
3755
  const top = slices[0]
3482
3756
  if (!top || total <= 0) return undefined
3483
3757
  const share = `${Math.round((top.value / total) * 1000) / 10}%`
3484
- return `${title ? `${title}: ` : ""}最大は ${top.name}(${share})`
3758
+ return `${title ? `${title}: ` : ""}${SUMMARY_TEXT[uiLanguage(resolveLocale(locale))].top(top.name, share)}`
3485
3759
  }
3486
3760
 
3487
3761
  /* ---------- inlined from @squadbase/squadkit registry/lib/distribution.ts (internal to @squadbase/vantage) ---------- */
@@ -3660,7 +3934,7 @@ export interface HistogramChartProps
3660
3934
  max?: number
3661
3935
  /** しきい値の線(任意)。階級の途中にも引ける。 */
3662
3936
  threshold?: HistogramThreshold
3663
- /** 件数の単位。既定「件」。 */
3937
+ /** 件数の単位。既定は日本語のページで「件」、それ以外は単位なし。 */
3664
3938
  countUnit?: string
3665
3939
  /** 横軸の値の名前(データ表の先頭列)。既定「値」。例「解決日数」 */
3666
3940
  valueLabel?: string
@@ -3672,9 +3946,45 @@ export interface HistogramChartProps
3672
3946
 
3673
3947
  /* ------------------------------------------------------------------ model */
3674
3948
 
3949
+ const TEXT = {
3950
+ ja: {
3951
+ countUnit: "件",
3952
+ atOrAbove: (from: string) => `${from}以上`,
3953
+ range: (from: string, to: string) => `${from}〜${to}`,
3954
+ count: (n: number, unit: string) => `${n}${unit}`,
3955
+ countHeader: (unit: string) => `${unit}数`,
3956
+ share: "構成比",
3957
+ value: "値",
3958
+ valueInSentence: "値",
3959
+ threshold: "しきい値",
3960
+ peak: (total: number, bin: string, count: number, unit: string) => `${total}${unit}のうち最も多いのは ${bin}(${count}${unit})`,
3961
+ overThreshold: (name: string, value: string, count: string, share: string) => `${name}(${value})以上は ${count}(${share})`,
3962
+ sentenceJoin: "。",
3963
+ chart: (label: string) => `${label}のヒストグラム`,
3964
+ },
3965
+ en: {
3966
+ countUnit: "",
3967
+ atOrAbove: (from: string) => `${from}+`,
3968
+ range: (from: string, to: string) => `${from}–${to}`,
3969
+ count: (n: number, unit: string) => (unit ? `${n} ${unit}` : `${n}`),
3970
+ countHeader: () => "Count",
3971
+ share: "Share",
3972
+ value: "Value",
3973
+ valueInSentence: "values",
3974
+ threshold: "Threshold",
3975
+ peak: (total: number, bin: string, count: number, unit: string) => `Most common: ${bin} (${unit ? `${count} ${unit}` : count} of ${total})`,
3976
+ overThreshold: (name: string, value: string, count: string, share: string) => `At or above ${name} (${value}): ${count} (${share})`,
3977
+ sentenceJoin: ". ",
3978
+ chart: (label: string) => `Histogram of ${label}`,
3979
+ },
3980
+ }
3981
+
3982
+ const textOf = (locale?: string) => TEXT[uiLanguage(resolveLocale(locale))]
3983
+
3675
3984
  /** 階級の名前。`10〜15`、上限超えは `30以上`。 */
3676
- export function binLabel(bin: HistogramBin, formatter: ValueFormatter): string {
3677
- return bin.overflow ? `${formatter(bin.from)}以上` : `${formatter(bin.from)}〜${formatter(bin.to)}`
3985
+ export function binLabel(bin: HistogramBin, formatter: ValueFormatter, locale?: string): string {
3986
+ const t = textOf(locale)
3987
+ return bin.overflow ? t.atOrAbove(formatter(bin.from)) : t.range(formatter(bin.from), formatter(bin.to))
3678
3988
  }
3679
3989
 
3680
3990
  /** しきい値が来る位置を階級の添字(小数。階級の左端 = 添字 − 0.5)で返す。範囲の外は `null`。 */
@@ -3700,27 +4010,30 @@ const percent = (n: number, total: number) => (total === 0 ? "0%" : `${Math.roun
3700
4010
  export function buildHistogramSummary(
3701
4011
  bins: readonly HistogramBin[],
3702
4012
  formatter: ValueFormatter,
3703
- config: { total: number; countUnit: string; atOrAbove?: number; threshold?: HistogramThreshold },
4013
+ config: { total: number; countUnit: string; atOrAbove?: number; threshold?: HistogramThreshold; locale?: string },
3704
4014
  ): string | undefined {
3705
4015
  if (bins.length === 0 || config.total === 0) return undefined
4016
+ const t = textOf(config.locale)
4017
+ const count = (n: number) => t.count(n, config.countUnit)
3706
4018
  const peak = bins.reduce((a, b) => (b.count > a.count ? b : a))
3707
- const parts = [`${config.total}${config.countUnit}のうち最も多いのは ${binLabel(peak, formatter)}(${peak.count}${config.countUnit})`]
4019
+ const parts = [t.peak(config.total, binLabel(peak, formatter, config.locale), peak.count, config.countUnit)]
3708
4020
  if (config.threshold && config.atOrAbove !== undefined) {
3709
- const name = config.threshold.label ?? "しきい値"
3710
- parts.push(`${name}(${formatter(config.threshold.value)})以上は ${config.atOrAbove}${config.countUnit}(${percent(config.atOrAbove, config.total)})`)
4021
+ const name = config.threshold.label ?? t.threshold
4022
+ parts.push(t.overThreshold(name, formatter(config.threshold.value), count(config.atOrAbove), percent(config.atOrAbove, config.total)))
3711
4023
  }
3712
- return parts.join("。")
4024
+ return parts.join(t.sentenceJoin)
3713
4025
  }
3714
4026
 
3715
4027
  /** The screen-reader table: one row per class. */
3716
4028
  export function histogramDataTable(
3717
4029
  bins: readonly HistogramBin[],
3718
4030
  formatter: ValueFormatter,
3719
- config: { total: number; countUnit: string; valueLabel?: string },
4031
+ config: { total: number; countUnit: string; valueLabel?: string; locale?: string },
3720
4032
  ): ChartDataTable {
4033
+ const t = textOf(config.locale)
3721
4034
  return {
3722
- columns: [config.valueLabel ?? "値", `${config.countUnit}数`, "構成比"],
3723
- rows: bins.map((b) => [binLabel(b, formatter), `${b.count}`, percent(b.count, config.total)]),
4035
+ columns: [config.valueLabel ?? t.value, t.countHeader(config.countUnit), t.share],
4036
+ rows: bins.map((b) => [binLabel(b, formatter, config.locale), `${b.count}`, percent(b.count, config.total)]),
3724
4037
  }
3725
4038
  }
3726
4039
 
@@ -3783,13 +4096,17 @@ export interface HistogramOptionConfig {
3783
4096
  labelColor?: string
3784
4097
  lineColor?: string
3785
4098
  chartOptions?: EChartsOption
4099
+ /** Language of the class names, tooltip and threshold label. Default the page's locale (`<html lang>`). */
4100
+ locale?: string
3786
4101
  }
3787
4102
 
3788
4103
  const ROW_STYLE = "display:flex;align-items:center;gap:8px;line-height:20px"
3789
4104
 
3790
4105
  /** The ECharts option: gap-less bars over the classes, the overflow class in the neutral fill, an optional threshold line. */
3791
4106
  export function buildHistogramOption(config: HistogramOptionConfig): EChartsOption {
3792
- const { bins, total, colors, valueFormatter, threshold, showTooltip = true, countUnit = "件" } = config
4107
+ const { locale } = config
4108
+ const t = textOf(locale)
4109
+ const { bins, total, colors, valueFormatter, threshold, showTooltip = true, countUnit = t.countUnit } = config
3793
4110
  const pos = threshold ? thresholdPosition(bins, threshold.value) : null
3794
4111
  const option = {
3795
4112
  ...chartMotion(false),
@@ -3809,15 +4126,15 @@ export function buildHistogramOption(config: HistogramOptionConfig): EChartsOpti
3809
4126
  `<span style="flex:1;opacity:.8">${escapeHtml(name)}</span>` +
3810
4127
  `<span style="font-weight:500;font-variant-numeric:tabular-nums;margin-left:12px">${escapeHtml(text)}</span></div>`
3811
4128
  return (
3812
- `<div style="font-weight:500;margin-bottom:4px">${escapeHtml(binLabel(bin, valueFormatter))}</div>` +
3813
- line(`${countUnit}数`, `${bin.count}${countUnit}`) +
3814
- line("構成比", percent(bin.count, total))
4129
+ `<div style="font-weight:500;margin-bottom:4px">${escapeHtml(binLabel(bin, valueFormatter, locale))}</div>` +
4130
+ line(t.countHeader(countUnit), t.count(bin.count, countUnit)) +
4131
+ line(t.share, percent(bin.count, total))
3815
4132
  )
3816
4133
  },
3817
4134
  },
3818
4135
  xAxis: {
3819
4136
  type: "category" as const,
3820
- data: bins.map((b) => binLabel(b, valueFormatter)),
4137
+ data: bins.map((b) => binLabel(b, valueFormatter, locale)),
3821
4138
  axisTick: { show: false },
3822
4139
  axisLabel: { hideOverlap: true },
3823
4140
  },
@@ -3852,7 +4169,7 @@ export function buildHistogramOption(config: HistogramOptionConfig): EChartsOpti
3852
4169
  // [階級の添字, 0(y の位置は使わない), 階級の中での位置(左端 -0.5 〜 右端 0.5)]
3853
4170
  data: [thresholdPoint(bins, threshold.value)],
3854
4171
  renderItem: thresholdRenderer({
3855
- text: `${threshold.label ?? "しきい値"} ${valueFormatter(threshold.value)}`,
4172
+ text: `${threshold.label ?? t.threshold} ${valueFormatter(threshold.value)}`,
3856
4173
  lineColor: config.lineColor,
3857
4174
  labelColor: config.labelColor,
3858
4175
  }),
@@ -3875,7 +4192,7 @@ export const HistogramChart = forwardRef(function HistogramChart(
3875
4192
  start,
3876
4193
  max,
3877
4194
  threshold,
3878
- countUnit = "件",
4195
+ countUnit: countUnitProp,
3879
4196
  valueLabel,
3880
4197
  format,
3881
4198
  valueFormatter: valueFormatterProp,
@@ -3894,12 +4211,14 @@ export const HistogramChart = forwardRef(function HistogramChart(
3894
4211
  }: HistogramChartProps,
3895
4212
  ref: ForwardedRef<HTMLDivElement>,
3896
4213
  ) {
3897
- const tokens = useChartTokens()
3898
- const { ref: frameRef } = useChartSurface(ref, tokens)
4214
+ const { ref: frameRef, tokens } = useChartSurface(ref)
4215
+ const locale = useLocale()
4216
+ const t = TEXT[uiLanguage(locale)]
4217
+ const countUnit = countUnitProp ?? t.countUnit
3899
4218
  // Class names read in full (`10〜15日`); the format default is a plain number, not the compact form.
3900
4219
  const valueFormatter = useMemo(
3901
- () => (valueFormatterProp ?? (format === undefined ? toValueFormatter("number") : resolveValueFormatter(undefined, format))),
3902
- [valueFormatterProp, format],
4220
+ () => (valueFormatterProp ?? (format === undefined ? toValueFormatter("number", locale) : resolveValueFormatter(undefined, format, locale))),
4221
+ [valueFormatterProp, format, locale],
3903
4222
  )
3904
4223
  const bins = useMemo(() => histogramBins(values, { binWidth, start, max }), [values, binWidth, start, max])
3905
4224
  const total = useMemo(() => bins.reduce((sum, b) => sum + b.count, 0), [bins])
@@ -3920,10 +4239,14 @@ export const HistogramChart = forwardRef(function HistogramChart(
3920
4239
  labelColor: tokens?.foreground ?? undefined,
3921
4240
  lineColor: tokens?.mutedForeground ?? undefined,
3922
4241
  chartOptions,
4242
+ locale,
3923
4243
  }),
3924
- [bins, total, colors, valueFormatter, countUnit, threshold, showTooltip, tokens, chartOptions],
4244
+ [bins, total, colors, valueFormatter, countUnit, threshold, showTooltip, tokens, chartOptions, locale],
4245
+ )
4246
+ const dataTable = useMemo(
4247
+ () => histogramDataTable(bins, valueFormatter, { total, countUnit, valueLabel, locale }),
4248
+ [bins, valueFormatter, total, countUnit, valueLabel, locale],
3925
4249
  )
3926
- const dataTable = useMemo(() => histogramDataTable(bins, valueFormatter, { total, countUnit, valueLabel }), [bins, valueFormatter, total, countUnit, valueLabel])
3927
4250
  const resolvedSummary = useMemo(
3928
4251
  () =>
3929
4252
  summary === "auto"
@@ -3932,11 +4255,12 @@ export const HistogramChart = forwardRef(function HistogramChart(
3932
4255
  countUnit,
3933
4256
  threshold,
3934
4257
  atOrAbove: threshold ? countAtOrAbove(values, threshold.value, bins[0]?.from) : undefined,
4258
+ locale,
3935
4259
  })
3936
4260
  : summary,
3937
- [summary, bins, valueFormatter, total, countUnit, threshold, values],
4261
+ [summary, bins, valueFormatter, total, countUnit, threshold, values, locale],
3938
4262
  )
3939
- const label = ariaLabel ?? `${valueLabel ?? "値"}のヒストグラム`
4263
+ const label = ariaLabel ?? t.chart(valueLabel ?? t.valueInSentence)
3940
4264
  return (
3941
4265
  <ChartFrame
3942
4266
  {...chartRootProps(rest)}