@squadbase/vantage 0.6.2-dev1.48b34e1 → 0.6.2-dev1.51e401a

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 (71) hide show
  1. package/dist/{chunk-57GHR5LN.js → chunk-3RJEOP3J.js} +29 -18
  2. package/dist/chunk-3RJEOP3J.js.map +1 -0
  3. package/dist/{chunk-QOXXOFGL.js → chunk-77CISBMG.js} +994 -464
  4. package/dist/chunk-77CISBMG.js.map +1 -0
  5. package/dist/{chunk-JXFCSF3K.js → chunk-EG4PX726.js} +12 -7
  6. package/dist/chunk-EG4PX726.js.map +1 -0
  7. package/dist/{chunk-2N72FKLB.js → chunk-NZ5QIIG4.js} +33 -11
  8. package/dist/chunk-NZ5QIIG4.js.map +1 -0
  9. package/dist/{chunk-7S52M2SL.js → chunk-QNOX7WLZ.js} +3 -3
  10. package/dist/{chunk-7S52M2SL.js.map → chunk-QNOX7WLZ.js.map} +1 -1
  11. package/dist/{chunk-PDOBHMUQ.js → chunk-ROHNXP7J.js} +72 -3
  12. package/dist/chunk-ROHNXP7J.js.map +1 -0
  13. package/dist/{chunk-6OTTGAPO.js → chunk-TRMHNMMZ.js} +24 -7
  14. package/dist/chunk-TRMHNMMZ.js.map +1 -0
  15. package/dist/components/index.d.ts +49 -29
  16. package/dist/components/index.js +5 -5
  17. package/dist/{delta-text-CvwBGJ95.d.ts → delta-text-CbrOaW83.d.ts} +4 -2
  18. package/dist/doc/index.d.ts +14 -14
  19. package/dist/doc/index.js +198 -84
  20. package/dist/doc/index.js.map +1 -1
  21. package/dist/{format-U0cQl069.d.ts → format-DihQkL1N.d.ts} +17 -11
  22. package/dist/slide/index.d.ts +2 -2
  23. package/dist/slide/index.js +95 -44
  24. package/dist/slide/index.js.map +1 -1
  25. package/dist/table/index.js +6 -6
  26. package/dist/ui/index.d.ts +1 -1
  27. package/dist/ui/index.js +5 -5
  28. package/package.json +2 -2
  29. package/registry/ui/app-shell.tsx +138 -1
  30. package/registry/ui/area-chart.tsx +344 -85
  31. package/registry/ui/attention-list.tsx +177 -15
  32. package/registry/ui/bar-chart.tsx +341 -85
  33. package/registry/ui/bar-list.tsx +332 -77
  34. package/registry/ui/box-plot-chart.tsx +405 -97
  35. package/registry/ui/category-bar.tsx +248 -40
  36. package/registry/ui/chart-frame.tsx +257 -46
  37. package/registry/ui/data-table.tsx +229 -37
  38. package/registry/ui/date-range-picker.tsx +190 -36
  39. package/registry/ui/delta.tsx +171 -12
  40. package/registry/ui/donut-chart.tsx +339 -84
  41. package/registry/ui/echart.tsx +142 -4
  42. package/registry/ui/filter-bar.tsx +179 -20
  43. package/registry/ui/funnel-steps.tsx +354 -80
  44. package/registry/ui/heatmap.tsx +293 -52
  45. package/registry/ui/histogram-chart.tsx +395 -97
  46. package/registry/ui/kpi-card.tsx +245 -44
  47. package/registry/ui/legend.tsx +330 -76
  48. package/registry/ui/line-chart.tsx +344 -85
  49. package/registry/ui/multi-select.tsx +159 -9
  50. package/registry/ui/page-shell.tsx +141 -3
  51. package/registry/ui/paired-chart.tsx +349 -90
  52. package/registry/ui/progress-circle.tsx +205 -39
  53. package/registry/ui/sample-data.tsx +153 -9
  54. package/registry/ui/scatter-chart.tsx +391 -100
  55. package/registry/ui/searchable-select.tsx +145 -7
  56. package/registry/ui/states.tsx +170 -13
  57. package/registry/ui/status-badge.tsx +193 -28
  58. package/registry/ui/tracker.tsx +205 -39
  59. package/registry/ui/trend-indicator.tsx +149 -6
  60. package/registry/ui/updated-at.tsx +170 -16
  61. package/registry/ui/waterfall-chart.tsx +415 -102
  62. package/skills/vantage-add-feature/SKILL.md +5 -0
  63. package/templates/AGENTS.md +10 -8
  64. package/templates/agents/api.md +7 -0
  65. package/templates/agents/page.md +36 -0
  66. package/dist/chunk-2N72FKLB.js.map +0 -1
  67. package/dist/chunk-57GHR5LN.js.map +0 -1
  68. package/dist/chunk-6OTTGAPO.js.map +0 -1
  69. package/dist/chunk-JXFCSF3K.js.map +0 -1
  70. package/dist/chunk-PDOBHMUQ.js.map +0 -1
  71. package/dist/chunk-QOXXOFGL.js.map +0 -1
@@ -12,50 +12,214 @@
12
12
  * `gray` are still accepted and map to positive / negative / warning / info /
13
13
  * neutral. Unknown statuses and tones fall back to `neutral`.
14
14
  *
15
- * The default label comes from `STATUS_VOCABULARY` (one table of status, 日本語ラベル and tone), so the same
16
- * state is worded the same everywhere (Tracker legends import it too):
15
+ * The default label comes from `STATUS_VOCABULARY` (one table of status, label and tone; the label follows the
16
+ * page's language, `<html lang>`), so the same state is worded the same everywhere (Tracker legends import it too):
17
17
  *
18
- * | status | ラベル | 色調 |
18
+ * | status | ラベル(ja / en) | 色調 |
19
19
  * |---|---|---|
20
- * | `success` | 正常 | positive |
21
- * | `partial` | 一部失敗 | warning |
22
- * | `failed` | 失敗 | negative |
23
- * | `delayed` | 遅延 | warning |
24
- * | `running` | 実行中 | info |
25
- * | `stopped` | 停止中 | neutral |
26
- * | `active` / `inactive` | 有効 / 無効 | positive / neutral |
27
- * | `warning` / `error` / `pending` | 警告 / エラー / 保留中 | warning / negative / info |
20
+ * | `success` | 正常 / Healthy | positive |
21
+ * | `partial` | 一部失敗 / Partially failed | warning |
22
+ * | `failed` | 失敗 / Failed | negative |
23
+ * | `delayed` | 遅延 / Delayed | warning |
24
+ * | `running` | 実行中 / Running | info |
25
+ * | `stopped` | 停止中 / Stopped | neutral |
26
+ * | `active` / `inactive` | 有効 / 無効、Active / Inactive | positive / neutral |
27
+ * | `warning` / `error` / `pending` | 警告 / エラー / 保留中、Warning / Error / Pending | warning / negative / info |
28
28
  */
29
29
  import { forwardRef } from "react"
30
30
 
31
31
  import { cn } from "@squadbase/vantage/ui"
32
32
  import { Badge } from "@squadbase/vantage/ui"
33
+ import { useSyncExternalStore } from "react"
34
+
35
+ /* ---------- inlined from @squadbase/squadkit registry/lib/locale.ts (internal to @squadbase/vantage) ---------- */
36
+
37
+ /**
38
+ * The display language of the parts and the defaults that follow it.
39
+ *
40
+ * The language comes from `<html lang>`: a tag starting with `ja` reads Japanese, anything else English.
41
+ * Without a usable `lang` (missing, invalid, or no `document` — SSR, Node) the locale is `FALLBACK_LOCALE`
42
+ * (`en-US`). A `locale` passed explicitly (a prop, an argument) always wins.
43
+ *
44
+ * Parts read the locale through `useLocale` (`hooks/use-locale`) and pass it down; the pure helpers here
45
+ * and in `format` / `comparison` / `chart-*` take it as an argument and fall back to `documentLocale()`.
46
+ */
47
+
48
+ /** The language of the built-in wording: Japanese or English. */
49
+ type UiLanguage = "ja" | "en"
50
+
51
+ /** The locale used when `<html lang>` gives none (and on the server). */
52
+ const FALLBACK_LOCALE = "en-US"
53
+
54
+ /** `"ja"` when `locale` starts with `ja` (`ja`, `ja-JP`), else `"en"`. A missing locale is `"en"`. */
55
+ function uiLanguage(locale: string | null | undefined): UiLanguage {
56
+ return typeof locale === "string" && locale.trim().toLowerCase().startsWith("ja") ? "ja" : "en"
57
+ }
58
+
59
+ /** A BCP 47 tag in its canonical form (`en-gb` → `en-GB`), or `FALLBACK_LOCALE` when it is empty or invalid. */
60
+ function normalizeLocale(tag: string | null | undefined): string {
61
+ const value = tag?.trim()
62
+ if (!value) return FALLBACK_LOCALE
63
+ try {
64
+ return Intl.getCanonicalLocales(value)[0] ?? FALLBACK_LOCALE
65
+ } catch {
66
+ return FALLBACK_LOCALE
67
+ }
68
+ }
69
+
70
+ /**
71
+ * The locale of the page: `document.documentElement.lang`, canonicalized. `FALLBACK_LOCALE` when it is empty or
72
+ * invalid, or there is no `document`. Parts read it through `useLocale`, never while rendering.
73
+ */
74
+ function documentLocale(): string {
75
+ if (typeof document === "undefined") return FALLBACK_LOCALE
76
+ return normalizeLocale(document.documentElement?.getAttribute("lang"))
77
+ }
78
+
79
+ /** `locale` when given, else the page's locale (`documentLocale()`). */
80
+ function resolveLocale(locale?: string | null): string {
81
+ return locale || documentLocale()
82
+ }
83
+
84
+ /** Picks the wording table of the locale's language. */
85
+ function pickLocale<T>(table: { ja: T; en: T }, locale?: string | null): T {
86
+ return table[uiLanguage(resolveLocale(locale))]
87
+ }
88
+
89
+ /** The currency `format="currency"` uses without a `currency`: JPY for Japanese, USD otherwise. */
90
+ function defaultCurrency(locale?: string | null): string {
91
+ return uiLanguage(resolveLocale(locale)) === "ja" ? "JPY" : "USD"
92
+ }
93
+
94
+ /** The month a fiscal year starts in (1–12) when none is given: April for Japanese, January otherwise. */
95
+ function defaultFiscalYearStartMonth(locale?: string | null): number {
96
+ return uiLanguage(resolveLocale(locale)) === "ja" ? 4 : 1
97
+ }
98
+
99
+ /** A day of the week, 0 = Sunday … 6 = Saturday (the `Date#getDay` / date-fns numbering). */
100
+ type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6
101
+
102
+ /** Locales whose week starts on Sunday when the runtime has no week data. */
103
+ const SUNDAY_FIRST = new Set(["en", "en-US", "en-CA"])
104
+
105
+ /**
106
+ * The first day of the week: Monday for Japanese; otherwise the locale's convention from `Intl.Locale`
107
+ * (`en-US` Sunday, `en-GB` Monday). Without week data in the runtime: Sunday for `en` / `en-US` / `en-CA`,
108
+ * Monday for the rest.
109
+ */
110
+ function weekStartsOn(locale?: string | null): WeekDay {
111
+ const tag = normalizeLocale(resolveLocale(locale))
112
+ if (uiLanguage(tag) === "ja") return 1
113
+ try {
114
+ const info = new Intl.Locale(tag) as Intl.Locale & {
115
+ getWeekInfo?: () => { firstDay: number }
116
+ weekInfo?: { firstDay: number }
117
+ }
118
+ const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay
119
+ if (typeof firstDay === "number" && firstDay >= 1 && firstDay <= 7) return (firstDay % 7) as WeekDay
120
+ } catch {
121
+ // Fall through to the table below.
122
+ }
123
+ return SUNDAY_FIRST.has(tag) ? 0 : 1
124
+ }
125
+
126
+ /* ---------- inlined from @squadbase/squadkit registry/hooks/use-locale.ts (internal to @squadbase/vantage) ---------- */
127
+
128
+ const listeners = new Set<() => void>()
129
+ let observer: MutationObserver | null = null
130
+
131
+ /** One observer on `<html lang>` for every subscribed part. */
132
+ function subscribe(onChange: () => void): () => void {
133
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {}
134
+ listeners.add(onChange)
135
+ if (!observer) {
136
+ observer = new MutationObserver(() => {
137
+ for (const listener of [...listeners]) listener()
138
+ })
139
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] })
140
+ }
141
+ return () => {
142
+ listeners.delete(onChange)
143
+ if (listeners.size === 0 && observer) {
144
+ observer.disconnect()
145
+ observer = null
146
+ }
147
+ }
148
+ }
149
+
150
+ const serverLocale = () => FALLBACK_LOCALE
151
+
152
+ /**
153
+ * The page's locale (`<html lang>`), following changes made while the page runs. On the server and during
154
+ * hydration it is `FALLBACK_LOCALE` (`en-US`), so the markup matches; it switches right after.
155
+ */
156
+ function useDocumentLocale(): string {
157
+ return useSyncExternalStore(subscribe, documentLocale, serverLocale)
158
+ }
159
+
160
+ /** The locale a part renders in: its `locale` prop when given, else the page's (`useDocumentLocale`). */
161
+ function useLocale(locale?: string | null): string {
162
+ const page = useDocumentLocale()
163
+ return locale || page
164
+ }
165
+
166
+ /* ---------- end of inlined code ---------- */
33
167
 
34
168
  type StatusTone = "positive" | "negative" | "warning" | "info" | "neutral"
35
169
 
170
+ const STATUS_LABELS = {
171
+ ja: {
172
+ success: "正常",
173
+ partial: "一部失敗",
174
+ failed: "失敗",
175
+ delayed: "遅延",
176
+ running: "実行中",
177
+ stopped: "停止中",
178
+ active: "有効",
179
+ inactive: "無効",
180
+ warning: "警告",
181
+ error: "エラー",
182
+ pending: "保留中",
183
+ },
184
+ en: {
185
+ success: "Healthy",
186
+ partial: "Partially failed",
187
+ failed: "Failed",
188
+ delayed: "Delayed",
189
+ running: "Running",
190
+ stopped: "Stopped",
191
+ active: "Active",
192
+ inactive: "Inactive",
193
+ warning: "Warning",
194
+ error: "Error",
195
+ pending: "Pending",
196
+ },
197
+ } as const
198
+
36
199
  /**
37
- * 状態の語彙の正本: status → 日本語ラベルと色調。StatusBadge の既定の表示と、Tracker の凡例など
38
- * 同じ状態を語る他の場所が、同じ語を使うためにここから参照する。
200
+ * 状態の語彙の正本: status → ラベルと色調。StatusBadge の既定の表示と、Tracker の凡例など
201
+ * 同じ状態を語る他の場所が、同じ語を使うためにここから参照する。`label` は英語(フォールバック)で、
202
+ * ページの言語に合わせた語は `statusLabel(status, locale)` で引く。
39
203
  */
40
204
  export const STATUS_VOCABULARY = {
41
- success: { label: "正常", tone: "positive" },
42
- partial: { label: "一部失敗", tone: "warning" },
43
- failed: { label: "失敗", tone: "negative" },
44
- delayed: { label: "遅延", tone: "warning" },
45
- running: { label: "実行中", tone: "info" },
46
- stopped: { label: "停止中", tone: "neutral" },
47
- active: { label: "有効", tone: "positive" },
48
- inactive: { label: "無効", tone: "neutral" },
49
- warning: { label: "警告", tone: "warning" },
50
- error: { label: "エラー", tone: "negative" },
51
- pending: { label: "保留中", tone: "info" },
205
+ success: { label: STATUS_LABELS.en.success, tone: "positive" },
206
+ partial: { label: STATUS_LABELS.en.partial, tone: "warning" },
207
+ failed: { label: STATUS_LABELS.en.failed, tone: "negative" },
208
+ delayed: { label: STATUS_LABELS.en.delayed, tone: "warning" },
209
+ running: { label: STATUS_LABELS.en.running, tone: "info" },
210
+ stopped: { label: STATUS_LABELS.en.stopped, tone: "neutral" },
211
+ active: { label: STATUS_LABELS.en.active, tone: "positive" },
212
+ inactive: { label: STATUS_LABELS.en.inactive, tone: "neutral" },
213
+ warning: { label: STATUS_LABELS.en.warning, tone: "warning" },
214
+ error: { label: STATUS_LABELS.en.error, tone: "negative" },
215
+ pending: { label: STATUS_LABELS.en.pending, tone: "info" },
52
216
  } as const satisfies Record<string, { label: string; tone: StatusTone }>
53
217
 
54
218
  export type StatusKey = keyof typeof STATUS_VOCABULARY
55
219
 
56
- /** 状態の日本語ラベル。語彙に無い status は渡された文字列のまま返す。 */
57
- export function statusLabel(status: string): string {
58
- return Object.hasOwn(STATUS_VOCABULARY, status) ? STATUS_VOCABULARY[status as StatusKey].label : status
220
+ /** 状態のラベル(`locale` の言語。既定はページのロケール)。語彙に無い status は渡された文字列のまま返す。 */
221
+ export function statusLabel(status: string, locale?: string): string {
222
+ return Object.hasOwn(STATUS_VOCABULARY, status) ? STATUS_LABELS[uiLanguage(resolveLocale(locale))][status as StatusKey] : status
59
223
  }
60
224
 
61
225
  const defaultColorMap: Record<string, string> = Object.fromEntries(
@@ -101,9 +265,10 @@ export const StatusBadge = forwardRef<HTMLSpanElement, StatusBadgeProps>(functio
101
265
  { status, label, colorMap, className },
102
266
  ref,
103
267
  ) {
268
+ const locale = useLocale()
104
269
  const mergedColorMap = { ...defaultColorMap, ...colorMap }
105
270
  const style = toneStyles[resolveTone(mergedColorMap[status])]
106
- const displayLabel = label ?? statusLabel(status)
271
+ const displayLabel = label ?? statusLabel(status, locale)
107
272
 
108
273
  return (
109
274
  <Badge
@@ -16,9 +16,100 @@ import { forwardRef, useCallback, useRef, type ComponentProps } from "react"
16
16
  import { cn } from "@squadbase/vantage/ui"
17
17
  import { Tooltip, TooltipContent, TooltipTrigger } from "@squadbase/vantage/ui"
18
18
  import type { ChartColor } from "@squadbase/vantage/components"
19
- import { DEFAULT_LOCALE, EMPTY_VALUE, formatValue, toValueFormatter, type ValueFormat } from "@squadbase/vantage/components"
19
+ import { EMPTY_VALUE, formatValue, toValueFormatter, type ValueFormat } from "@squadbase/vantage/components"
20
20
  import type { ChartColors, ChartSeriesColor, ChartTone, ValueFormatter } from "@squadbase/vantage/components"
21
21
 
22
+ /* ---------- inlined from @squadbase/squadkit registry/lib/locale.ts (internal to @squadbase/vantage) ---------- */
23
+
24
+ /**
25
+ * The display language of the parts and the defaults that follow it.
26
+ *
27
+ * The language comes from `<html lang>`: a tag starting with `ja` reads Japanese, anything else English.
28
+ * Without a usable `lang` (missing, invalid, or no `document` — SSR, Node) the locale is `FALLBACK_LOCALE`
29
+ * (`en-US`). A `locale` passed explicitly (a prop, an argument) always wins.
30
+ *
31
+ * Parts read the locale through `useLocale` (`hooks/use-locale`) and pass it down; the pure helpers here
32
+ * and in `format` / `comparison` / `chart-*` take it as an argument and fall back to `documentLocale()`.
33
+ */
34
+
35
+ /** The language of the built-in wording: Japanese or English. */
36
+ type UiLanguage = "ja" | "en"
37
+
38
+ /** The locale used when `<html lang>` gives none (and on the server). */
39
+ const FALLBACK_LOCALE = "en-US"
40
+
41
+ /** `"ja"` when `locale` starts with `ja` (`ja`, `ja-JP`), else `"en"`. A missing locale is `"en"`. */
42
+ function uiLanguage(locale: string | null | undefined): UiLanguage {
43
+ return typeof locale === "string" && locale.trim().toLowerCase().startsWith("ja") ? "ja" : "en"
44
+ }
45
+
46
+ /** A BCP 47 tag in its canonical form (`en-gb` → `en-GB`), or `FALLBACK_LOCALE` when it is empty or invalid. */
47
+ function normalizeLocale(tag: string | null | undefined): string {
48
+ const value = tag?.trim()
49
+ if (!value) return FALLBACK_LOCALE
50
+ try {
51
+ return Intl.getCanonicalLocales(value)[0] ?? FALLBACK_LOCALE
52
+ } catch {
53
+ return FALLBACK_LOCALE
54
+ }
55
+ }
56
+
57
+ /**
58
+ * The locale of the page: `document.documentElement.lang`, canonicalized. `FALLBACK_LOCALE` when it is empty or
59
+ * invalid, or there is no `document`. Parts read it through `useLocale`, never while rendering.
60
+ */
61
+ function documentLocale(): string {
62
+ if (typeof document === "undefined") return FALLBACK_LOCALE
63
+ return normalizeLocale(document.documentElement?.getAttribute("lang"))
64
+ }
65
+
66
+ /** `locale` when given, else the page's locale (`documentLocale()`). */
67
+ function resolveLocale(locale?: string | null): string {
68
+ return locale || documentLocale()
69
+ }
70
+
71
+ /** Picks the wording table of the locale's language. */
72
+ function pickLocale<T>(table: { ja: T; en: T }, locale?: string | null): T {
73
+ return table[uiLanguage(resolveLocale(locale))]
74
+ }
75
+
76
+ /** The currency `format="currency"` uses without a `currency`: JPY for Japanese, USD otherwise. */
77
+ function defaultCurrency(locale?: string | null): string {
78
+ return uiLanguage(resolveLocale(locale)) === "ja" ? "JPY" : "USD"
79
+ }
80
+
81
+ /** The month a fiscal year starts in (1–12) when none is given: April for Japanese, January otherwise. */
82
+ function defaultFiscalYearStartMonth(locale?: string | null): number {
83
+ return uiLanguage(resolveLocale(locale)) === "ja" ? 4 : 1
84
+ }
85
+
86
+ /** A day of the week, 0 = Sunday … 6 = Saturday (the `Date#getDay` / date-fns numbering). */
87
+ type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6
88
+
89
+ /** Locales whose week starts on Sunday when the runtime has no week data. */
90
+ const SUNDAY_FIRST = new Set(["en", "en-US", "en-CA"])
91
+
92
+ /**
93
+ * The first day of the week: Monday for Japanese; otherwise the locale's convention from `Intl.Locale`
94
+ * (`en-US` Sunday, `en-GB` Monday). Without week data in the runtime: Sunday for `en` / `en-US` / `en-CA`,
95
+ * Monday for the rest.
96
+ */
97
+ function weekStartsOn(locale?: string | null): WeekDay {
98
+ const tag = normalizeLocale(resolveLocale(locale))
99
+ if (uiLanguage(tag) === "ja") return 1
100
+ try {
101
+ const info = new Intl.Locale(tag) as Intl.Locale & {
102
+ getWeekInfo?: () => { firstDay: number }
103
+ weekInfo?: { firstDay: number }
104
+ }
105
+ const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay
106
+ if (typeof firstDay === "number" && firstDay >= 1 && firstDay <= 7) return (firstDay % 7) as WeekDay
107
+ } catch {
108
+ // Fall through to the table below.
109
+ }
110
+ return SUNDAY_FIRST.has(tag) ? 0 : 1
111
+ }
112
+
22
113
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-utils.ts (internal to @squadbase/vantage) ---------- */
23
114
 
24
115
  const ROOT_PROP_KEYS = new Set(["id", "style", "role", "tabIndex", "lang", "hidden"])
@@ -126,9 +217,10 @@ function resolveValueFormatter(
126
217
  function resolveAxisFormatter(
127
218
  valueFormatter: ValueFormatter | undefined,
128
219
  format: ValueFormat | undefined,
129
- locale: string = DEFAULT_LOCALE,
220
+ localeArg?: string,
130
221
  ): ValueFormatter {
131
222
  if (valueFormatter) return valueFormatter
223
+ const locale = resolveLocale(localeArg)
132
224
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
133
225
  if (spec.type !== "compact" && spec.type !== "number" && spec.type !== "currency") return toValueFormatter(format, locale)
134
226
  const currency = "currency" in spec ? spec.currency : undefined
@@ -137,40 +229,75 @@ function resolveAxisFormatter(
137
229
  return (value) => formatValue(value, short, locale)
138
230
  }
139
231
 
140
- /** The unit words of a ja number (`1.2万` / `3.4億` / `1兆`) and their sizes, largest first. */
141
- const JA_UNITS = [
142
- { char: "兆", size: 1e12 },
143
- { char: "億", size: 1e8 },
144
- { char: "万", size: 1e4 },
145
- ] as const
232
+ interface CompactUnit {
233
+ token: string
234
+ size: number
235
+ }
146
236
 
147
- const JA_NUMBER_TOKEN_RE = /\d[\d,]*(?:\.\d+)?(?:兆|億|万)?/
237
+ const compactUnitCache = new Map<string, CompactUnit[] | null>()
238
+
239
+ /**
240
+ * The compact-notation unit words of a locale and their sizes, largest first: `兆` / `億` / `万` in ja,
241
+ * `T` / `B` / `M` / `K` in en. `null` when the locale's compact form puts anything between the number and its
242
+ * word (`1,5 Mio.`), where a token cannot be swapped in place.
243
+ */
244
+ function compactUnits(locale: string): CompactUnit[] | null {
245
+ const cached = compactUnitCache.get(locale)
246
+ if (cached !== undefined) return cached
247
+ let units: CompactUnit[] | null = []
248
+ try {
249
+ const compact = new Intl.NumberFormat(locale, { notation: "compact" })
250
+ const seen = new Set<string>()
251
+ for (let exp = 3; exp <= 15; exp++) {
252
+ const parts = compact.formatToParts(10 ** exp)
253
+ const at = parts.findIndex((p) => p.type === "compact")
254
+ if (at === -1) continue
255
+ if (parts[at - 1]?.type !== "integer" || at !== parts.length - 1) {
256
+ units = null
257
+ break
258
+ }
259
+ const token = parts[at]!.value
260
+ if (seen.has(token)) continue
261
+ seen.add(token)
262
+ units.push({ token, size: 10 ** exp })
263
+ }
264
+ } catch {
265
+ units = null
266
+ }
267
+ const sorted = units && units.length > 0 ? units.sort((a, b) => b.size - a.size) : null
268
+ compactUnitCache.set(locale, sorted)
269
+ return sorted
270
+ }
271
+
272
+ const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
148
273
 
149
274
  /**
150
275
  * The value-axis tick formatter for an axis whose largest tick is `top`: every tick reads in the unit of
151
- * that largest tick, so 万 and 億 never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`).
152
- * Only the ja short forms (`compact` / `number` / `currency`, no `valueFormatter`) have units to align;
153
- * any other format — and an axis whose largest tick is under 1万 — keeps `resolveAxisFormatter`'s output as it is.
276
+ * that largest tick, so units never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`;
277
+ * `$0.5M` / `$1M` / `$1.5M`, not `$500K` / `$1M`). Only the short forms (`compact` / `number` / `currency`,
278
+ * no `valueFormatter`) have units to align; any other format — an axis whose largest tick is under the smallest
279
+ * unit (1万 / 1K), or a locale whose compact form cannot be aligned in place — keeps `resolveAxisFormatter`'s output as it is.
154
280
  */
155
281
  function resolveAxisTickFormatter(
156
282
  valueFormatter: ValueFormatter | undefined,
157
283
  format: ValueFormat | undefined,
158
- locale: string = DEFAULT_LOCALE,
284
+ localeArg?: string,
159
285
  ): (top: number) => ValueFormatter {
286
+ const locale = resolveLocale(localeArg)
160
287
  const base = resolveAxisFormatter(valueFormatter, format, locale)
161
288
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
162
- const aligns =
163
- !valueFormatter && locale.toLowerCase().startsWith("ja") && (spec.type === "compact" || spec.type === "number" || spec.type === "currency")
164
- if (!aligns) return () => base
289
+ const units = !valueFormatter && (spec.type === "compact" || spec.type === "number" || spec.type === "currency") ? compactUnits(locale) : null
290
+ if (!units) return () => base
165
291
  const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 3 })
292
+ const numberToken = new RegExp(`\\d[\\d,]*(?:\\.\\d+)?(?:${units.map((u) => escapeRegExp(u.token)).join("|")})?`)
166
293
  return (top) => {
167
- const unit = JA_UNITS.find((u) => Math.abs(top) >= u.size)
294
+ const unit = units.find((u) => Math.abs(top) >= u.size)
168
295
  if (!unit) return base
169
296
  return (value) => {
170
297
  const text = base(value)
171
298
  if (value === 0 || !Number.isFinite(value)) return text
172
- const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.char}`
173
- return text.replace(JA_NUMBER_TOKEN_RE, () => scaled)
299
+ const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.token}`
300
+ return text.replace(numberToken, () => scaled)
174
301
  }
175
302
  }
176
303
  }
@@ -199,8 +326,20 @@ function clamp(value: number, min: number, max: number): number {
199
326
  /** How many categories a chart colours individually; the rest fold into「その他」. */
200
327
  const MAX_CHART_SERIES = 5
201
328
 
202
- /** The series name the folded categories get. */
203
- const OTHER_SERIES_LABEL = "その他"
329
+ /** The series name the folded categories get, by language. */
330
+ const OTHER_SERIES_LABELS: Record<UiLanguage, string> = { ja: "その他", en: "Other" }
331
+
332
+ /** The series name the folded categories get in `locale` ("Other" / "その他"). Default the page's locale (`<html lang>`). */
333
+ function otherSeriesLabel(locale?: string): string {
334
+ return OTHER_SERIES_LABELS[uiLanguage(resolveLocale(locale))]
335
+ }
336
+
337
+ const OTHER_NAMES = new Set(Object.values(OTHER_SERIES_LABELS))
338
+
339
+ /** Whether a category / row is the "other" bucket by name (「その他」 or "Other", whatever the page language). */
340
+ function isOtherName(name: string | null | undefined): boolean {
341
+ return typeof name === "string" && OTHER_NAMES.has(name)
342
+ }
204
343
 
205
344
  interface AggregatedItems<Item extends { name: string; value: number }> {
206
345
  /** The `n` largest items, in their given order. All items when there are `n` or fewer. */
@@ -220,7 +359,7 @@ interface AggregatedItems<Item extends { name: string; value: number }> {
220
359
  function aggregateTopN<Item extends { name: string; value: number }>(
221
360
  items: readonly Item[],
222
361
  n: number = MAX_CHART_SERIES,
223
- otherLabel: string = OTHER_SERIES_LABEL,
362
+ otherLabel: string = otherSeriesLabel(),
224
363
  ): AggregatedItems<Item> {
225
364
  if (items.length <= n) return { top: [...items], rest: [], other: null }
226
365
  const ranked = items
@@ -249,7 +388,7 @@ function aggregateSeries(
249
388
  data: readonly Record<string, unknown>[],
250
389
  categories: readonly string[],
251
390
  n: number = MAX_CHART_SERIES,
252
- otherLabel: string = OTHER_SERIES_LABEL,
391
+ otherLabel: string = otherSeriesLabel(),
253
392
  ): AggregatedSeries {
254
393
  const totals = categories.map((name) => ({
255
394
  name,
@@ -281,12 +420,14 @@ function aggregateSeriesKeeping(
281
420
  categories: readonly string[],
282
421
  keep: string | undefined,
283
422
  n: number = MAX_CHART_SERIES,
423
+ otherLabel: string = otherSeriesLabel(),
284
424
  ): AggregatedSeries {
285
- if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n)
425
+ if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n, otherLabel)
286
426
  const aggregated = aggregateSeries(
287
427
  data,
288
428
  categories.filter((c) => c !== keep),
289
429
  n,
430
+ otherLabel,
290
431
  )
291
432
  return { ...aggregated, categories: [...aggregated.categories, keep] }
292
433
  }
@@ -322,7 +463,7 @@ function seriesColorsWithComparison(
322
463
  * Colour per drawn series: the record's token for that category, else the
323
464
  * array's token at the category's position in `allCategories` (cycled), else
324
465
  * the token palette by drawn position. `otherCategory` — and a category the
325
- * data itself names「その他」(`OTHER_SERIES_LABEL`) — always gets `otherColor`.
466
+ * data itself names「その他」/ "Other" (`OTHER_SERIES_LABELS`) — always gets `otherColor`.
326
467
  */
327
468
  function seriesColorsFor(
328
469
  categories: readonly string[],
@@ -334,7 +475,7 @@ function seriesColorsFor(
334
475
  const list = Array.isArray(colors) ? (colors as ChartSeriesColor[]) : undefined
335
476
  const record = !Array.isArray(colors) && colors ? (colors as Partial<Record<string, ChartSeriesColor>>) : undefined
336
477
  return categories.map((category, i) => {
337
- if ((otherCategory !== null && category === otherCategory) || category === OTHER_SERIES_LABEL) return otherColor
478
+ if ((otherCategory !== null && category === otherCategory) || isOtherName(category)) return otherColor
338
479
  const fromRecord = record?.[category]
339
480
  if (fromRecord) return fromRecord
340
481
  if (list && list.length > 0) {
@@ -381,7 +522,7 @@ interface DonutSlicesConfig {
381
522
  */
382
523
  function donutSlices(
383
524
  data: readonly Record<string, unknown>[],
384
- { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = OTHER_SERIES_LABEL }: DonutSlicesConfig,
525
+ { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = otherSeriesLabel() }: DonutSlicesConfig,
385
526
  ): DonutSlice[] {
386
527
  const items = data
387
528
  .map((row, i) => {
@@ -394,7 +535,8 @@ function donutSlices(
394
535
  const slices: DonutSlice[] = top.map((item, rank) => ({
395
536
  name: item.name,
396
537
  value: item.value,
397
- color: palette[rank % palette.length]!,
538
+ // A row the data itself names「その他」/ "Other" is the same remainder: grey, like the folded slice.
539
+ color: isOtherName(item.name) ? otherColor : palette[rank % palette.length]!,
398
540
  isOther: false,
399
541
  row: item.row,
400
542
  }))
@@ -467,7 +609,7 @@ interface BarListRowsConfig extends BarListOptions {
467
609
  */
468
610
  function barListRows<Row extends object>(
469
611
  data: readonly Row[],
470
- { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = OTHER_SERIES_LABEL, exclude }: BarListRowsConfig = {},
612
+ { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = otherSeriesLabel(), exclude }: BarListRowsConfig = {},
471
613
  ): BarListRow<Row>[] {
472
614
  const items = data.map((row): BarListRow<Row> => {
473
615
  const cells = row as Record<string, unknown>
@@ -513,19 +655,21 @@ const BAR_LIST_OTHER_TINT = 30
513
655
 
514
656
  /**
515
657
  * The fill of each BarList row: all real rows in the same tint of `color`
516
- * (`BAR_LIST_TINT`), the folded「その他」row in a tint of `otherColor`
658
+ * (`BAR_LIST_TINT`), the folded「その他」row (and a row the data names「その他」/ "Other") in a tint of `otherColor`
517
659
  * (`BAR_LIST_OTHER_TINT`) — the palest fill, a remainder never outweighs a
518
660
  * ranked row. A legacy per-row `color` (or a threshold tone) replaces `color`
519
661
  * for that row. A tint, not the solid token, so the label printed over the bar
520
662
  * keeps its contrast.
521
663
  */
522
664
  function barListFills(
523
- rows: readonly Pick<BarListRow, "isOther" | "color">[],
665
+ rows: readonly (Pick<BarListRow, "isOther" | "color"> & { name?: string })[],
524
666
  color: ChartColor,
525
667
  otherColor: string,
526
668
  ): string[] {
527
669
  return rows.map((row) =>
528
- row.isOther ? tintCss(otherColor, BAR_LIST_OTHER_TINT) : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
670
+ row.isOther || isOtherName(row.name)
671
+ ? tintCss(otherColor, BAR_LIST_OTHER_TINT)
672
+ : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
529
673
  )
530
674
  }
531
675
 
@@ -741,11 +885,30 @@ interface ChartSummaryInput {
741
885
  format?: ValueFormat
742
886
  /** Wins over `format`. */
743
887
  valueFormatter?: ValueFormatter
888
+ /** Language of the sentence. Default the page's locale (`<html lang>`). */
889
+ locale?: string
890
+ }
891
+
892
+ const SUMMARY_TEXT = {
893
+ ja: {
894
+ up: "、前の点より増加",
895
+ down: "、前の点より減少",
896
+ flat: "、前の点と横ばい",
897
+ latest: (label: string, value: string) => `最新 ${label} は ${value}`,
898
+ top: (name: string, share: string) => `最大は ${name}(${share})`,
899
+ },
900
+ en: {
901
+ up: ", up from the previous point",
902
+ down: ", down from the previous point",
903
+ flat: ", flat from the previous point",
904
+ latest: (label: string, value: string) => `Latest ${label}: ${value}`,
905
+ top: (name: string, share: string) => `Largest: ${name} (${share})`,
906
+ },
744
907
  }
745
908
 
746
909
  /**
747
910
  * One-sentence summary built from the rows, for `summary="auto"`:
748
- * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」. The direction is
911
+ * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」("<title>: Latest <index>: <value>, up from the previous point"). The direction is
749
912
  * computed from the last two non-empty points of the first series — never
750
913
  * written by hand, so it cannot contradict the data. `undefined` when there is
751
914
  * no usable point.
@@ -761,26 +924,29 @@ function buildChartSummary(input: ChartSummaryInput): string | undefined {
761
924
  }
762
925
  const latest = points[points.length - 1]
763
926
  if (!latest) return undefined
764
- const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact")
927
+ const locale = resolveLocale(input.locale)
928
+ const t = SUMMARY_TEXT[uiLanguage(locale)]
929
+ const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact", locale)
765
930
  const prev = points[points.length - 2]
766
- const direction = !prev ? "" : latest.value > prev.value ? "、前の点より増加" : latest.value < prev.value ? "、前の点より減少" : "、前の点と横ばい"
931
+ const direction = !prev ? "" : latest.value > prev.value ? t.up : latest.value < prev.value ? t.down : t.flat
767
932
  const head = input.title ? `${input.title}: ` : ""
768
- return `${head}最新 ${latest.label} は ${text(latest.value)}${direction}`
933
+ return `${head}${t.latest(latest.label, text(latest.value))}${direction}`
769
934
  }
770
935
 
771
936
  /**
772
- * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」 from the slices
773
- * (largest first). `undefined` when there is nothing to describe.
937
+ * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」("<title>: Largest: <name> (<share>)") from the slices
938
+ * (largest first). `undefined` when there is nothing to describe. `locale` picks the language; default the page's.
774
939
  */
775
940
  function buildShareSummary(
776
941
  slices: readonly { name: string; value: number }[],
777
942
  title?: string,
943
+ locale?: string,
778
944
  ): string | undefined {
779
945
  const total = slices.reduce((sum, s) => sum + s.value, 0)
780
946
  const top = slices[0]
781
947
  if (!top || total <= 0) return undefined
782
948
  const share = `${Math.round((top.value / total) * 1000) / 10}%`
783
- return `${title ? `${title}: ` : ""}最大は ${top.name}(${share})`
949
+ return `${title ? `${title}: ` : ""}${SUMMARY_TEXT[uiLanguage(resolveLocale(locale))].top(top.name, share)}`
784
950
  }
785
951
 
786
952
  /* ---------- end of inlined code ---------- */