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