@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
@@ -32,6 +32,7 @@ import { EChart } from "@squadbase/vantage/components"
32
32
  import type { Polarity } from "@squadbase/vantage/components"
33
33
  import { EMPTY_VALUE, MINUS_SIGN } from "@squadbase/vantage/components"
34
34
  import type { ChartCommonProps, NumericKeys, ValueFormatter } from "@squadbase/vantage/components"
35
+ import { useSyncExternalStore } from "react"
35
36
  import { createContext, useCallback, useEffect, useRef, useState, type RefObject } from "react"
36
37
  import type { ChartTone } from "@squadbase/vantage/components"
37
38
  import { type ReactNode } from "react"
@@ -41,6 +42,137 @@ import type { ChartThreshold, ChartAnnotation, ChartEventPayload, ChartForecast,
41
42
  import { formatValue, toValueFormatter } from "@squadbase/vantage/components"
42
43
  import type { ChartColor, ChartColors, ChartSeriesColor } from "@squadbase/vantage/components"
43
44
 
45
+ /* ---------- inlined from @squadbase/squadkit registry/lib/locale.ts (internal to @squadbase/vantage) ---------- */
46
+
47
+ /**
48
+ * The display language of the parts and the defaults that follow it.
49
+ *
50
+ * The language comes from `<html lang>`: a tag starting with `ja` reads Japanese, anything else English.
51
+ * Without a usable `lang` (missing, invalid, or no `document` — SSR, Node) the locale is `FALLBACK_LOCALE`
52
+ * (`en-US`). A `locale` passed explicitly (a prop, an argument) always wins.
53
+ *
54
+ * Parts read the locale through `useLocale` (`hooks/use-locale`) and pass it down; the pure helpers here
55
+ * and in `format` / `comparison` / `chart-*` take it as an argument and fall back to `documentLocale()`.
56
+ */
57
+
58
+ /** The language of the built-in wording: Japanese or English. */
59
+ type UiLanguage = "ja" | "en"
60
+
61
+ /** The locale used when `<html lang>` gives none (and on the server). */
62
+ const FALLBACK_LOCALE = "en-US"
63
+
64
+ /** `"ja"` when `locale` starts with `ja` (`ja`, `ja-JP`), else `"en"`. A missing locale is `"en"`. */
65
+ function uiLanguage(locale: string | null | undefined): UiLanguage {
66
+ return typeof locale === "string" && locale.trim().toLowerCase().startsWith("ja") ? "ja" : "en"
67
+ }
68
+
69
+ /** A BCP 47 tag in its canonical form (`en-gb` → `en-GB`), or `FALLBACK_LOCALE` when it is empty or invalid. */
70
+ function normalizeLocale(tag: string | null | undefined): string {
71
+ const value = tag?.trim()
72
+ if (!value) return FALLBACK_LOCALE
73
+ try {
74
+ return Intl.getCanonicalLocales(value)[0] ?? FALLBACK_LOCALE
75
+ } catch {
76
+ return FALLBACK_LOCALE
77
+ }
78
+ }
79
+
80
+ /**
81
+ * The locale of the page: `document.documentElement.lang`, canonicalized. `FALLBACK_LOCALE` when it is empty or
82
+ * invalid, or there is no `document`. Parts read it through `useLocale`, never while rendering.
83
+ */
84
+ function documentLocale(): string {
85
+ if (typeof document === "undefined") return FALLBACK_LOCALE
86
+ return normalizeLocale(document.documentElement?.getAttribute("lang"))
87
+ }
88
+
89
+ /** `locale` when given, else the page's locale (`documentLocale()`). */
90
+ function resolveLocale(locale?: string | null): string {
91
+ return locale || documentLocale()
92
+ }
93
+
94
+ /** Picks the wording table of the locale's language. */
95
+ function pickLocale<T>(table: { ja: T; en: T }, locale?: string | null): T {
96
+ return table[uiLanguage(resolveLocale(locale))]
97
+ }
98
+
99
+ /** The currency `format="currency"` uses without a `currency`: JPY for Japanese, USD otherwise. */
100
+ function defaultCurrency(locale?: string | null): string {
101
+ return uiLanguage(resolveLocale(locale)) === "ja" ? "JPY" : "USD"
102
+ }
103
+
104
+ /** The month a fiscal year starts in (1–12) when none is given: April for Japanese, January otherwise. */
105
+ function defaultFiscalYearStartMonth(locale?: string | null): number {
106
+ return uiLanguage(resolveLocale(locale)) === "ja" ? 4 : 1
107
+ }
108
+
109
+ /** A day of the week, 0 = Sunday … 6 = Saturday (the `Date#getDay` / date-fns numbering). */
110
+ type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6
111
+
112
+ /** Locales whose week starts on Sunday when the runtime has no week data. */
113
+ const SUNDAY_FIRST = new Set(["en", "en-US", "en-CA"])
114
+
115
+ /**
116
+ * The first day of the week: Monday for Japanese; otherwise the locale's convention from `Intl.Locale`
117
+ * (`en-US` Sunday, `en-GB` Monday). Without week data in the runtime: Sunday for `en` / `en-US` / `en-CA`,
118
+ * Monday for the rest.
119
+ */
120
+ function weekStartsOn(locale?: string | null): WeekDay {
121
+ const tag = normalizeLocale(resolveLocale(locale))
122
+ if (uiLanguage(tag) === "ja") return 1
123
+ try {
124
+ const info = new Intl.Locale(tag) as Intl.Locale & {
125
+ getWeekInfo?: () => { firstDay: number }
126
+ weekInfo?: { firstDay: number }
127
+ }
128
+ const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay
129
+ if (typeof firstDay === "number" && firstDay >= 1 && firstDay <= 7) return (firstDay % 7) as WeekDay
130
+ } catch {
131
+ // Fall through to the table below.
132
+ }
133
+ return SUNDAY_FIRST.has(tag) ? 0 : 1
134
+ }
135
+
136
+ /* ---------- inlined from @squadbase/squadkit registry/hooks/use-locale.ts (internal to @squadbase/vantage) ---------- */
137
+
138
+ const listeners = new Set<() => void>()
139
+ let observer: MutationObserver | null = null
140
+
141
+ /** One observer on `<html lang>` for every subscribed part. */
142
+ function subscribe(onChange: () => void): () => void {
143
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {}
144
+ listeners.add(onChange)
145
+ if (!observer) {
146
+ observer = new MutationObserver(() => {
147
+ for (const listener of [...listeners]) listener()
148
+ })
149
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] })
150
+ }
151
+ return () => {
152
+ listeners.delete(onChange)
153
+ if (listeners.size === 0 && observer) {
154
+ observer.disconnect()
155
+ observer = null
156
+ }
157
+ }
158
+ }
159
+
160
+ const serverLocale = () => FALLBACK_LOCALE
161
+
162
+ /**
163
+ * The page's locale (`<html lang>`), following changes made while the page runs. On the server and during
164
+ * hydration it is `FALLBACK_LOCALE` (`en-US`), so the markup matches; it switches right after.
165
+ */
166
+ function useDocumentLocale(): string {
167
+ return useSyncExternalStore(subscribe, documentLocale, serverLocale)
168
+ }
169
+
170
+ /** The locale a part renders in: its `locale` prop when given, else the page's (`useDocumentLocale`). */
171
+ function useLocale(locale?: string | null): string {
172
+ const page = useDocumentLocale()
173
+ return locale || page
174
+ }
175
+
44
176
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-theme.ts (internal to @squadbase/vantage) ---------- */
45
177
 
46
178
  /**
@@ -439,6 +571,32 @@ function useChartSurface<E extends Element>(
439
571
 
440
572
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-options.ts (internal to @squadbase/vantage) ---------- */
441
573
 
574
+ /** The built-in words of the option builders, by language. */
575
+ const CHART_TEXT = {
576
+ ja: {
577
+ target: "目標",
578
+ forecast: "予測",
579
+ total: "合計",
580
+ provisional: "速報",
581
+ noComparison: "比較なし",
582
+ share: (share: string) => `(${share})`,
583
+ targetRate: (rate: string) => `(達成率 ${rate})`,
584
+ provisionalTitle: (title: string, label: string) => `${title}(${label})`,
585
+ },
586
+ en: {
587
+ target: "Target",
588
+ forecast: "Forecast",
589
+ total: "Total",
590
+ provisional: "Preliminary",
591
+ noComparison: "No comparison",
592
+ share: (share: string) => ` (${share})`,
593
+ targetRate: (rate: string) => ` (${rate} achieved)`,
594
+ provisionalTitle: (title: string, label: string) => `${title} (${label})`,
595
+ },
596
+ } satisfies Record<UiLanguage, unknown>
597
+
598
+ const chartText = (locale?: string) => CHART_TEXT[uiLanguage(resolveLocale(locale))]
599
+
442
600
  type CartesianChartKind = "area" | "bar" | "line"
443
601
  type CartesianStackType = "default" | "stacked" | "percent"
444
602
  type CartesianCurveType = "linear" | "smooth" | "step"
@@ -623,7 +781,7 @@ interface CartesianChartConfig {
623
781
  * all ticks read in one unit (¥0.5億 / ¥1億). Default: `axisFormatter` for every tick.
624
782
  */
625
783
  axisTicks?: (top: number) => (value: number) => string
626
- /** Locale of the time-axis labels. Default `"ja-JP"`. */
784
+ /** Locale of the time-axis labels and the built-in words (total, target, forecast). Default the page's (`<html lang>`). */
627
785
  locale?: string
628
786
  type?: CartesianStackType
629
787
  /** Bar only. `"vertical"` lays the categories down the y axis (horizontal bars). */
@@ -861,11 +1019,12 @@ function timeAxisLabelStep(values: readonly unknown[], maxLabels: number = TIME_
861
1019
  */
862
1020
  function timeAxisLabels(
863
1021
  values: readonly unknown[],
864
- locale: string = DEFAULT_LOCALE,
1022
+ localeArg?: string,
865
1023
  shown: (i: number) => boolean = () => true,
866
1024
  ): string[] | null {
867
1025
  const parsed = parseTimeIndex(values)
868
1026
  if (!parsed) return null
1027
+ const locale = resolveLocale(localeArg)
869
1028
  const { granularity, times } = parsed
870
1029
  let prevYear: number | null = null
871
1030
  return times.map((t, i) => {
@@ -888,9 +1047,10 @@ function timeAxisLabels(
888
1047
  }
889
1048
 
890
1049
  /** The tooltip titles for time-like x values (`2025年9月`, `2025年9月1日`), or `null` when they are not dates. */
891
- function timeTooltipLabels(values: readonly unknown[], locale: string = DEFAULT_LOCALE): string[] | null {
1050
+ function timeTooltipLabels(values: readonly unknown[], localeArg?: string): string[] | null {
892
1051
  const parsed = parseTimeIndex(values)
893
1052
  if (!parsed) return null
1053
+ const locale = resolveLocale(localeArg)
894
1054
  const options: Intl.DateTimeFormatOptions =
895
1055
  parsed.granularity === "year"
896
1056
  ? { year: "numeric" }
@@ -1030,7 +1190,9 @@ function tooltipHtml(
1030
1190
  rows: readonly TooltipRow[],
1031
1191
  valueFormatter: (value: number) => string,
1032
1192
  notes: readonly string[] = [],
1193
+ locale?: string,
1033
1194
  ): string {
1195
+ const t = chartText(locale)
1034
1196
  const lines = rows.map(
1035
1197
  (row) =>
1036
1198
  `<div style="display:flex;align-items:center;gap:8px;line-height:20px">` +
@@ -1039,7 +1201,7 @@ function tooltipHtml(
1039
1201
  `<span style="font-weight:600;font-variant-numeric:tabular-nums;margin-left:12px">${
1040
1202
  row.value === null ? EMPTY_VALUE : escapeHtml(valueFormatter(row.value))
1041
1203
  }${
1042
- row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px">(${escapeHtml(formatShare(row.share))})</span>`
1204
+ row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px">${escapeHtml(t.share(formatShare(row.share)))}</span>`
1043
1205
  }</span></div>` +
1044
1206
  (row.note === undefined
1045
1207
  ? ""
@@ -1053,8 +1215,10 @@ function tooltipHtml(
1053
1215
 
1054
1216
  /* ------------------------------------------------- per-bar targets / notes */
1055
1217
 
1056
- /** Default name of the per-bar target marker (legend, tooltip). */
1057
- const BAR_TARGET_LABEL = "目標"
1218
+ /** Default name of the per-bar target marker (legend, tooltip) in `locale`: "Target" / 「目標」. Default the page's locale. */
1219
+ function barTargetLabel(locale?: string): string {
1220
+ return chartText(locale).target
1221
+ }
1058
1222
 
1059
1223
  /** How far (px) the target marker reaches past each side of its bar. */
1060
1224
  const TARGET_MARKER_OVERHANG = 4
@@ -1105,16 +1269,17 @@ function achievementRate(value: number | null | undefined, target: number | null
1105
1269
  return (value / target) * 100
1106
1270
  }
1107
1271
 
1108
- /** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3%)」. `null` without a target. */
1272
+ /** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3%)」 / "Target $12M (85.3% achieved)". `null` without a target. */
1109
1273
  function barTargetNote(
1110
1274
  label: string,
1111
1275
  value: number | null,
1112
1276
  target: number | null,
1113
1277
  valueFormatter: (value: number) => string,
1278
+ locale?: string,
1114
1279
  ): string | null {
1115
1280
  if (target === null) return null
1116
1281
  const rate = achievementRate(value, target)
1117
- return `${label} ${valueFormatter(target)}${rate === null ? "" : `(達成率 ${formatShare(rate)})`}`
1282
+ return `${label} ${valueFormatter(target)}${rate === null ? "" : chartText(locale).targetRate(formatShare(rate))}`
1118
1283
  }
1119
1284
 
1120
1285
  /** Half the width of a bar in category-axis units, from `barCategoryGap` (`"20%"`; a px gap or none → ECharts' 20%). */
@@ -1206,11 +1371,12 @@ const TARGET_LEGEND_SERIES_ID = "__target-legend"
1206
1371
  /** A flat tick (8×8 legend box, aspect kept): the per-bar target's legend icon. */
1207
1372
  const TARGET_LEGEND_ICON = "path://M0,0 L100,0 L100,24 L0,24 Z"
1208
1373
 
1209
- const DEFAULT_FORECAST_LABEL = "予測"
1210
-
1211
- /** 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. */
1212
- function forecastBoundaryText(label: string = DEFAULT_FORECAST_LABEL): string {
1213
- return `${label} →`
1374
+ /**
1375
+ * The label at the top of a forecast's boundary line: the forecast's name (as in the legend) and an arrow toward the forecast side.
1376
+ * Without a `label`, "Forecast" / 「予測」 in `locale` (default the page's).
1377
+ */
1378
+ function forecastBoundaryText(label?: string, locale?: string): string {
1379
+ return `${label ?? chartText(locale).forecast} →`
1214
1380
  }
1215
1381
 
1216
1382
  /** A comparison series: a thin, muted, solid line with no markers (60% of `--muted-foreground`). */
@@ -1250,8 +1416,10 @@ interface AxisExtent {
1250
1416
  max: number
1251
1417
  }
1252
1418
 
1253
- /** The label of a stack's total (highlight and tooltip). */
1254
- const STACK_TOTAL_LABEL = "合計"
1419
+ /** The label of a stack's total (highlight and tooltip) in `locale`: "Total" / 「合計」. Default the page's locale. */
1420
+ function stackTotalLabel(locale?: string): string {
1421
+ return chartText(locale).total
1422
+ }
1255
1423
 
1256
1424
  /** Per point, the sum of the stacked series; `null` where every series is missing. */
1257
1425
  function stackTotalsOf(values: readonly (readonly (number | null)[])[]): (number | null)[] {
@@ -1373,8 +1541,8 @@ function indexGrain(values: readonly unknown[]): PeriodGrain | null {
1373
1541
 
1374
1542
  /**
1375
1543
  * The tooltip line under the main series' row when a comparison series is drawn: the arrow, the signed change and the
1376
- * comparison's name, as `Delta` writes it ("↑ +12.4% 前年同月比"; a minus is U+2212). "比較なし 前年同月比" when
1377
- * either value is missing or the baseline is 0.
1544
+ * comparison's name, as `Delta` writes it ("↑ +12.4% vs. same month last year" / "↑ +12.4% 前年同月比"; a minus is U+2212).
1545
+ * "No comparison vs. same month last year" / "比較なし 前年同月比" when either value is missing or the baseline is 0.
1378
1546
  */
1379
1547
  function comparisonNote(
1380
1548
  current: number | null,
@@ -1382,16 +1550,18 @@ function comparisonNote(
1382
1550
  comparison: Pick<CartesianComparisonConfig, "vs" | "label">,
1383
1551
  grain?: PeriodGrain,
1384
1552
  format?: ValueFormat,
1385
- locale: string = DEFAULT_LOCALE,
1553
+ localeArg?: string,
1386
1554
  ): string {
1555
+ const locale = resolveLocale(localeArg)
1387
1556
  const result = computeComparison(
1388
1557
  current,
1389
1558
  comparison.vs === "custom"
1390
1559
  ? { previous, vs: "custom", label: comparison.label ?? "", grain }
1391
1560
  : { previous, vs: comparison.vs, label: comparison.label, grain },
1561
+ { locale },
1392
1562
  )
1393
1563
  const text = formatChange(result, defaultChangeDisplay(format), locale, format)
1394
- if (text === null) return `比較なし ${result.label}`
1564
+ if (text === null) return `${chartText(locale).noComparison} ${result.label}`
1395
1565
  const arrow = result.direction === "up" ? "↑" : result.direction === "down" ? "↓" : "→"
1396
1566
  return `${arrow} ${text.replace(/^-/, "−")} ${result.label}`
1397
1567
  }
@@ -1434,7 +1604,6 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1434
1604
  toneColors,
1435
1605
  valueFormatter,
1436
1606
  axisFormatter = valueFormatter,
1437
- locale = DEFAULT_LOCALE,
1438
1607
  type = "default",
1439
1608
  layout = "horizontal",
1440
1609
  curveType = "linear",
@@ -1471,7 +1640,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1471
1640
  chartOptions,
1472
1641
  reserveActions = false,
1473
1642
  } = cfg
1474
- const provisionalLabel = cfg.provisionalLabel ?? DEFAULT_PROVISIONAL_LABEL
1643
+ const locale = resolveLocale(cfg.locale)
1644
+ const text = chartText(locale)
1645
+ const provisionalLabel = cfg.provisionalLabel ?? text.provisional
1475
1646
 
1476
1647
  const stacked = kind !== "line" && type !== "default"
1477
1648
  const percent = kind !== "line" && type === "percent"
@@ -1620,9 +1791,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1620
1791
  // `barOptions.targetKey` (a target per bar) takes over from the single `target` line.
1621
1792
  const perBarTarget = kind === "bar" && Boolean(targetKey)
1622
1793
  const hasTarget = !perBarTarget && target !== undefined && Number.isFinite(target)
1623
- const targetText = hasTarget ? (targetLabel ?? `目標 ${axisFormatter(target)}`) : ""
1794
+ const targetText = hasTarget ? (targetLabel ?? `${text.target} ${axisFormatter(target)}`) : ""
1624
1795
  const highlightText = (value: number) =>
1625
- shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${STACK_TOTAL_LABEL} ` : ""}${axisFormatter(value)}`
1796
+ shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${text.total} ` : ""}${axisFormatter(value)}`
1626
1797
  /** Whether `p`'s label sits within the target's value band and under the target label (at the right end). */
1627
1798
  const nearTargetLabel = (p: HighlightPoint, side: "under" | "over") => {
1628
1799
  if (!hasTarget || !plotWidth || kind === "bar" || labels.length < 2) return false
@@ -1648,7 +1819,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1648
1819
  // One series (or a stack, against its total) can be compared to a target; grouped series and 100% stacks cannot.
1649
1820
  const barTargets: (number | null)[] | null =
1650
1821
  perBarTarget && targetKey && !percent && (stacked || categories.length === 1) ? barTargetValues(data, targetKey) : null
1651
- const barTargetName = targetLabel ?? BAR_TARGET_LABEL
1822
+ const barTargetName = targetLabel ?? text.target
1652
1823
  const barTargetColor = labelColor ?? guideColor
1653
1824
 
1654
1825
  const markPointFor = (c: number, color: string | undefined) =>
@@ -1862,7 +2033,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1862
2033
  }
1863
2034
 
1864
2035
  /* -- forecast: dashed continuation (+ optional band) of one series */
1865
- const forecastLabel = forecast?.label ?? DEFAULT_FORECAST_LABEL
2036
+ const forecastLabel = forecast?.label ?? text.forecast
1866
2037
  const forecastBandAlpha = isDarkSurface(labelBackground) ? FORECAST_BAND_ALPHA_DARK : FORECAST_BAND_ALPHA_LIGHT
1867
2038
  const forecastValues: (number | null)[] = []
1868
2039
  if (forecast) {
@@ -2338,7 +2509,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2338
2509
  })
2339
2510
  }
2340
2511
  if (stackTotals && i <= lastActual && rows.length > 1) {
2341
- rows.push({ name: STACK_TOTAL_LABEL, color: "transparent", value: stackTotals[i] ?? null })
2512
+ rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
2342
2513
  }
2343
2514
  const provisional = provStart !== -1 && i >= provStart && i <= lastActual
2344
2515
  if (provisional) {
@@ -2354,19 +2525,19 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2354
2525
  ...noteFor(at),
2355
2526
  })
2356
2527
  })
2357
- if (stackTotals && rows.length > 1) rows.push({ name: STACK_TOTAL_LABEL, color: "transparent", value: stackTotals[i] ?? null })
2528
+ if (stackTotals && rows.length > 1) rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
2358
2529
  }
2359
2530
  const title = tooltipTitles[i] ?? labels[i] ?? ""
2360
2531
  const lines: string[] = []
2361
2532
  if (barTargets && i <= lastActual) {
2362
2533
  const total = stackTotals ? (stackTotals[i] ?? null) : (values[0]?.[i] ?? null)
2363
- const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter)
2534
+ const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter, locale)
2364
2535
  if (note) lines.push(note)
2365
2536
  }
2366
2537
  for (const note of notes) {
2367
2538
  if (note.to === undefined ? note.from === i : note.from <= i && i <= note.to) lines.push(note.label)
2368
2539
  }
2369
- return tooltipHtml(provisional ? `${title}(${provisionalLabel})` : title, rows, valueFormatter, lines)
2540
+ return tooltipHtml(provisional ? text.provisionalTitle(title, provisionalLabel) : title, rows, valueFormatter, lines, locale)
2370
2541
  },
2371
2542
  },
2372
2543
  xAxis: horizontalBars ? valueAxis : categoryAxis,
@@ -2458,8 +2629,6 @@ interface DonutOptionSlice {
2458
2629
  /** How many of the largest slices get an outside label (「自然検索 41%」) by default. */
2459
2630
  const DONUT_OUTSIDE_LABELS = 2
2460
2631
 
2461
- const DEFAULT_TOTAL_LABEL = "合計"
2462
-
2463
2632
  const sliceValue = (value: number) => (Number.isFinite(value) && value > 0 ? value : 0)
2464
2633
 
2465
2634
  /** A slice's share of `total` as text: whole percent, `<1%` for a sliver, `0%` for nothing. */
@@ -2492,15 +2661,16 @@ function donutLegendItems(slices: readonly DonutOptionSlice[]): DonutLegendItem[
2492
2661
  }
2493
2662
 
2494
2663
  /**
2495
- * The default center label: what is totalled (「合計」), the formatted total
2664
+ * The default center label: what is totalled (「合計」 / "Total" in `locale`, default the page's), the formatted total
2496
2665
  * (`amount`, the big line), the unit (a small line under it) and both as one
2497
2666
  * string (`value`, 「9.2万 セッション」).
2498
2667
  */
2499
2668
  function donutCenterText(
2500
2669
  total: number,
2501
2670
  valueFormatter: (value: number) => string,
2502
- { totalLabel = DEFAULT_TOTAL_LABEL, unit }: { totalLabel?: string; unit?: string } = {},
2671
+ { totalLabel, unit, locale }: { totalLabel?: string; unit?: string; locale?: string } = {},
2503
2672
  ): { label: string; value: string; amount: string; unit?: string } {
2673
+ totalLabel ??= chartText(locale).total
2504
2674
  const amount = valueFormatter(total)
2505
2675
  return { label: totalLabel, value: unit ? `${amount} ${unit}` : amount, amount, ...(unit ? { unit } : {}) }
2506
2676
  }
@@ -2728,8 +2898,10 @@ function hasLabel(labels: Readonly<Record<string, string>> | undefined, key: str
2728
2898
 
2729
2899
  /* ------------------------------------------------------------ provisional */
2730
2900
 
2731
- /** The default label for points that are not final yet. */
2732
- const DEFAULT_PROVISIONAL_LABEL = "速報"
2901
+ /** The default label for points that are not final yet, in `locale`: "Preliminary" / 「速報」. Default the page's locale. */
2902
+ function defaultProvisionalLabel(locale?: string): string {
2903
+ return chartText(locale).provisional
2904
+ }
2733
2905
 
2734
2906
  /**
2735
2907
  * Position of the first provisional point: the first entry of `indexValues`
@@ -2867,9 +3039,10 @@ function resolveValueFormatter(
2867
3039
  function resolveAxisFormatter(
2868
3040
  valueFormatter: ValueFormatter | undefined,
2869
3041
  format: ValueFormat | undefined,
2870
- locale: string = DEFAULT_LOCALE,
3042
+ localeArg?: string,
2871
3043
  ): ValueFormatter {
2872
3044
  if (valueFormatter) return valueFormatter
3045
+ const locale = resolveLocale(localeArg)
2873
3046
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
2874
3047
  if (spec.type !== "compact" && spec.type !== "number" && spec.type !== "currency") return toValueFormatter(format, locale)
2875
3048
  const currency = "currency" in spec ? spec.currency : undefined
@@ -2878,40 +3051,75 @@ function resolveAxisFormatter(
2878
3051
  return (value) => formatValue(value, short, locale)
2879
3052
  }
2880
3053
 
2881
- /** The unit words of a ja number (`1.2万` / `3.4億` / `1兆`) and their sizes, largest first. */
2882
- const JA_UNITS = [
2883
- { char: "兆", size: 1e12 },
2884
- { char: "億", size: 1e8 },
2885
- { char: "万", size: 1e4 },
2886
- ] as const
3054
+ interface CompactUnit {
3055
+ token: string
3056
+ size: number
3057
+ }
3058
+
3059
+ const compactUnitCache = new Map<string, CompactUnit[] | null>()
3060
+
3061
+ /**
3062
+ * The compact-notation unit words of a locale and their sizes, largest first: `兆` / `億` / `万` in ja,
3063
+ * `T` / `B` / `M` / `K` in en. `null` when the locale's compact form puts anything between the number and its
3064
+ * word (`1,5 Mio.`), where a token cannot be swapped in place.
3065
+ */
3066
+ function compactUnits(locale: string): CompactUnit[] | null {
3067
+ const cached = compactUnitCache.get(locale)
3068
+ if (cached !== undefined) return cached
3069
+ let units: CompactUnit[] | null = []
3070
+ try {
3071
+ const compact = new Intl.NumberFormat(locale, { notation: "compact" })
3072
+ const seen = new Set<string>()
3073
+ for (let exp = 3; exp <= 15; exp++) {
3074
+ const parts = compact.formatToParts(10 ** exp)
3075
+ const at = parts.findIndex((p) => p.type === "compact")
3076
+ if (at === -1) continue
3077
+ if (parts[at - 1]?.type !== "integer" || at !== parts.length - 1) {
3078
+ units = null
3079
+ break
3080
+ }
3081
+ const token = parts[at]!.value
3082
+ if (seen.has(token)) continue
3083
+ seen.add(token)
3084
+ units.push({ token, size: 10 ** exp })
3085
+ }
3086
+ } catch {
3087
+ units = null
3088
+ }
3089
+ const sorted = units && units.length > 0 ? units.sort((a, b) => b.size - a.size) : null
3090
+ compactUnitCache.set(locale, sorted)
3091
+ return sorted
3092
+ }
2887
3093
 
2888
- const JA_NUMBER_TOKEN_RE = /\d[\d,]*(?:\.\d+)?(?:兆|億|万)?/
3094
+ const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
2889
3095
 
2890
3096
  /**
2891
3097
  * The value-axis tick formatter for an axis whose largest tick is `top`: every tick reads in the unit of
2892
- * that largest tick, so 万 and 億 never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`).
2893
- * Only the ja short forms (`compact` / `number` / `currency`, no `valueFormatter`) have units to align;
2894
- * any other format — and an axis whose largest tick is under 1万 — keeps `resolveAxisFormatter`'s output as it is.
3098
+ * that largest tick, so units never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`;
3099
+ * `$0.5M` / `$1M` / `$1.5M`, not `$500K` / `$1M`). Only the short forms (`compact` / `number` / `currency`,
3100
+ * no `valueFormatter`) have units to align; any other format — an axis whose largest tick is under the smallest
3101
+ * unit (1万 / 1K), or a locale whose compact form cannot be aligned in place — keeps `resolveAxisFormatter`'s output as it is.
2895
3102
  */
2896
3103
  function resolveAxisTickFormatter(
2897
3104
  valueFormatter: ValueFormatter | undefined,
2898
3105
  format: ValueFormat | undefined,
2899
- locale: string = DEFAULT_LOCALE,
3106
+ localeArg?: string,
2900
3107
  ): (top: number) => ValueFormatter {
3108
+ const locale = resolveLocale(localeArg)
2901
3109
  const base = resolveAxisFormatter(valueFormatter, format, locale)
2902
3110
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
2903
- const aligns =
2904
- !valueFormatter && locale.toLowerCase().startsWith("ja") && (spec.type === "compact" || spec.type === "number" || spec.type === "currency")
2905
- if (!aligns) return () => base
3111
+ const units = !valueFormatter && (spec.type === "compact" || spec.type === "number" || spec.type === "currency") ? compactUnits(locale) : null
3112
+ if (!units) return () => base
2906
3113
  const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 3 })
3114
+ const numberToken = new RegExp(`\\d[\\d,]*(?:\\.\\d+)?(?:${units.map((u) => escapeRegExp(u.token)).join("|")})?`)
2907
3115
  return (top) => {
2908
- const unit = JA_UNITS.find((u) => Math.abs(top) >= u.size)
3116
+ const unit = units.find((u) => Math.abs(top) >= u.size)
2909
3117
  if (!unit) return base
2910
3118
  return (value) => {
2911
3119
  const text = base(value)
2912
3120
  if (value === 0 || !Number.isFinite(value)) return text
2913
- const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.char}`
2914
- return text.replace(JA_NUMBER_TOKEN_RE, () => scaled)
3121
+ const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.token}`
3122
+ return text.replace(numberToken, () => scaled)
2915
3123
  }
2916
3124
  }
2917
3125
  }
@@ -2940,8 +3148,20 @@ function clamp(value: number, min: number, max: number): number {
2940
3148
  /** How many categories a chart colours individually; the rest fold into「その他」. */
2941
3149
  const MAX_CHART_SERIES = 5
2942
3150
 
2943
- /** The series name the folded categories get. */
2944
- const OTHER_SERIES_LABEL = "その他"
3151
+ /** The series name the folded categories get, by language. */
3152
+ const OTHER_SERIES_LABELS: Record<UiLanguage, string> = { ja: "その他", en: "Other" }
3153
+
3154
+ /** The series name the folded categories get in `locale` ("Other" / "その他"). Default the page's locale (`<html lang>`). */
3155
+ function otherSeriesLabel(locale?: string): string {
3156
+ return OTHER_SERIES_LABELS[uiLanguage(resolveLocale(locale))]
3157
+ }
3158
+
3159
+ const OTHER_NAMES = new Set(Object.values(OTHER_SERIES_LABELS))
3160
+
3161
+ /** Whether a category / row is the "other" bucket by name (「その他」 or "Other", whatever the page language). */
3162
+ function isOtherName(name: string | null | undefined): boolean {
3163
+ return typeof name === "string" && OTHER_NAMES.has(name)
3164
+ }
2945
3165
 
2946
3166
  interface AggregatedItems<Item extends { name: string; value: number }> {
2947
3167
  /** The `n` largest items, in their given order. All items when there are `n` or fewer. */
@@ -2961,7 +3181,7 @@ interface AggregatedItems<Item extends { name: string; value: number }> {
2961
3181
  function aggregateTopN<Item extends { name: string; value: number }>(
2962
3182
  items: readonly Item[],
2963
3183
  n: number = MAX_CHART_SERIES,
2964
- otherLabel: string = OTHER_SERIES_LABEL,
3184
+ otherLabel: string = otherSeriesLabel(),
2965
3185
  ): AggregatedItems<Item> {
2966
3186
  if (items.length <= n) return { top: [...items], rest: [], other: null }
2967
3187
  const ranked = items
@@ -2990,7 +3210,7 @@ function aggregateSeries(
2990
3210
  data: readonly Record<string, unknown>[],
2991
3211
  categories: readonly string[],
2992
3212
  n: number = MAX_CHART_SERIES,
2993
- otherLabel: string = OTHER_SERIES_LABEL,
3213
+ otherLabel: string = otherSeriesLabel(),
2994
3214
  ): AggregatedSeries {
2995
3215
  const totals = categories.map((name) => ({
2996
3216
  name,
@@ -3022,12 +3242,14 @@ function aggregateSeriesKeeping(
3022
3242
  categories: readonly string[],
3023
3243
  keep: string | undefined,
3024
3244
  n: number = MAX_CHART_SERIES,
3245
+ otherLabel: string = otherSeriesLabel(),
3025
3246
  ): AggregatedSeries {
3026
- if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n)
3247
+ if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n, otherLabel)
3027
3248
  const aggregated = aggregateSeries(
3028
3249
  data,
3029
3250
  categories.filter((c) => c !== keep),
3030
3251
  n,
3252
+ otherLabel,
3031
3253
  )
3032
3254
  return { ...aggregated, categories: [...aggregated.categories, keep] }
3033
3255
  }
@@ -3063,7 +3285,7 @@ function seriesColorsWithComparison(
3063
3285
  * Colour per drawn series: the record's token for that category, else the
3064
3286
  * array's token at the category's position in `allCategories` (cycled), else
3065
3287
  * the token palette by drawn position. `otherCategory` — and a category the
3066
- * data itself names「その他」(`OTHER_SERIES_LABEL`) — always gets `otherColor`.
3288
+ * data itself names「その他」/ "Other" (`OTHER_SERIES_LABELS`) — always gets `otherColor`.
3067
3289
  */
3068
3290
  function seriesColorsFor(
3069
3291
  categories: readonly string[],
@@ -3075,7 +3297,7 @@ function seriesColorsFor(
3075
3297
  const list = Array.isArray(colors) ? (colors as ChartSeriesColor[]) : undefined
3076
3298
  const record = !Array.isArray(colors) && colors ? (colors as Partial<Record<string, ChartSeriesColor>>) : undefined
3077
3299
  return categories.map((category, i) => {
3078
- if ((otherCategory !== null && category === otherCategory) || category === OTHER_SERIES_LABEL) return otherColor
3300
+ if ((otherCategory !== null && category === otherCategory) || isOtherName(category)) return otherColor
3079
3301
  const fromRecord = record?.[category]
3080
3302
  if (fromRecord) return fromRecord
3081
3303
  if (list && list.length > 0) {
@@ -3122,7 +3344,7 @@ interface DonutSlicesConfig {
3122
3344
  */
3123
3345
  function donutSlices(
3124
3346
  data: readonly Record<string, unknown>[],
3125
- { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = OTHER_SERIES_LABEL }: DonutSlicesConfig,
3347
+ { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = otherSeriesLabel() }: DonutSlicesConfig,
3126
3348
  ): DonutSlice[] {
3127
3349
  const items = data
3128
3350
  .map((row, i) => {
@@ -3135,7 +3357,8 @@ function donutSlices(
3135
3357
  const slices: DonutSlice[] = top.map((item, rank) => ({
3136
3358
  name: item.name,
3137
3359
  value: item.value,
3138
- color: palette[rank % palette.length]!,
3360
+ // A row the data itself names「その他」/ "Other" is the same remainder: grey, like the folded slice.
3361
+ color: isOtherName(item.name) ? otherColor : palette[rank % palette.length]!,
3139
3362
  isOther: false,
3140
3363
  row: item.row,
3141
3364
  }))
@@ -3208,7 +3431,7 @@ interface BarListRowsConfig extends BarListOptions {
3208
3431
  */
3209
3432
  function barListRows<Row extends object>(
3210
3433
  data: readonly Row[],
3211
- { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = OTHER_SERIES_LABEL, exclude }: BarListRowsConfig = {},
3434
+ { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = otherSeriesLabel(), exclude }: BarListRowsConfig = {},
3212
3435
  ): BarListRow<Row>[] {
3213
3436
  const items = data.map((row): BarListRow<Row> => {
3214
3437
  const cells = row as Record<string, unknown>
@@ -3254,19 +3477,21 @@ const BAR_LIST_OTHER_TINT = 30
3254
3477
 
3255
3478
  /**
3256
3479
  * The fill of each BarList row: all real rows in the same tint of `color`
3257
- * (`BAR_LIST_TINT`), the folded「その他」row in a tint of `otherColor`
3480
+ * (`BAR_LIST_TINT`), the folded「その他」row (and a row the data names「その他」/ "Other") in a tint of `otherColor`
3258
3481
  * (`BAR_LIST_OTHER_TINT`) — the palest fill, a remainder never outweighs a
3259
3482
  * ranked row. A legacy per-row `color` (or a threshold tone) replaces `color`
3260
3483
  * for that row. A tint, not the solid token, so the label printed over the bar
3261
3484
  * keeps its contrast.
3262
3485
  */
3263
3486
  function barListFills(
3264
- rows: readonly Pick<BarListRow, "isOther" | "color">[],
3487
+ rows: readonly (Pick<BarListRow, "isOther" | "color"> & { name?: string })[],
3265
3488
  color: ChartColor,
3266
3489
  otherColor: string,
3267
3490
  ): string[] {
3268
3491
  return rows.map((row) =>
3269
- row.isOther ? tintCss(otherColor, BAR_LIST_OTHER_TINT) : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
3492
+ row.isOther || isOtherName(row.name)
3493
+ ? tintCss(otherColor, BAR_LIST_OTHER_TINT)
3494
+ : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
3270
3495
  )
3271
3496
  }
3272
3497
 
@@ -3482,11 +3707,30 @@ interface ChartSummaryInput {
3482
3707
  format?: ValueFormat
3483
3708
  /** Wins over `format`. */
3484
3709
  valueFormatter?: ValueFormatter
3710
+ /** Language of the sentence. Default the page's locale (`<html lang>`). */
3711
+ locale?: string
3712
+ }
3713
+
3714
+ const SUMMARY_TEXT = {
3715
+ ja: {
3716
+ up: "、前の点より増加",
3717
+ down: "、前の点より減少",
3718
+ flat: "、前の点と横ばい",
3719
+ latest: (label: string, value: string) => `最新 ${label} は ${value}`,
3720
+ top: (name: string, share: string) => `最大は ${name}(${share})`,
3721
+ },
3722
+ en: {
3723
+ up: ", up from the previous point",
3724
+ down: ", down from the previous point",
3725
+ flat: ", flat from the previous point",
3726
+ latest: (label: string, value: string) => `Latest ${label}: ${value}`,
3727
+ top: (name: string, share: string) => `Largest: ${name} (${share})`,
3728
+ },
3485
3729
  }
3486
3730
 
3487
3731
  /**
3488
3732
  * One-sentence summary built from the rows, for `summary="auto"`:
3489
- * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」. The direction is
3733
+ * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」("<title>: Latest <index>: <value>, up from the previous point"). The direction is
3490
3734
  * computed from the last two non-empty points of the first series — never
3491
3735
  * written by hand, so it cannot contradict the data. `undefined` when there is
3492
3736
  * no usable point.
@@ -3502,26 +3746,29 @@ function buildChartSummary(input: ChartSummaryInput): string | undefined {
3502
3746
  }
3503
3747
  const latest = points[points.length - 1]
3504
3748
  if (!latest) return undefined
3505
- const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact")
3749
+ const locale = resolveLocale(input.locale)
3750
+ const t = SUMMARY_TEXT[uiLanguage(locale)]
3751
+ const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact", locale)
3506
3752
  const prev = points[points.length - 2]
3507
- const direction = !prev ? "" : latest.value > prev.value ? "、前の点より増加" : latest.value < prev.value ? "、前の点より減少" : "、前の点と横ばい"
3753
+ const direction = !prev ? "" : latest.value > prev.value ? t.up : latest.value < prev.value ? t.down : t.flat
3508
3754
  const head = input.title ? `${input.title}: ` : ""
3509
- return `${head}最新 ${latest.label} は ${text(latest.value)}${direction}`
3755
+ return `${head}${t.latest(latest.label, text(latest.value))}${direction}`
3510
3756
  }
3511
3757
 
3512
3758
  /**
3513
- * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」 from the slices
3514
- * (largest first). `undefined` when there is nothing to describe.
3759
+ * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」("<title>: Largest: <name> (<share>)") from the slices
3760
+ * (largest first). `undefined` when there is nothing to describe. `locale` picks the language; default the page's.
3515
3761
  */
3516
3762
  function buildShareSummary(
3517
3763
  slices: readonly { name: string; value: number }[],
3518
3764
  title?: string,
3765
+ locale?: string,
3519
3766
  ): string | undefined {
3520
3767
  const total = slices.reduce((sum, s) => sum + s.value, 0)
3521
3768
  const top = slices[0]
3522
3769
  if (!top || total <= 0) return undefined
3523
3770
  const share = `${Math.round((top.value / total) * 1000) / 10}%`
3524
- return `${title ? `${title}: ` : ""}最大は ${top.name}(${share})`
3771
+ return `${title ? `${title}: ` : ""}${SUMMARY_TEXT[uiLanguage(resolveLocale(locale))].top(top.name, share)}`
3525
3772
  }
3526
3773
 
3527
3774
  /* ---------- end of inlined code ---------- */
@@ -3588,6 +3835,47 @@ export interface WaterfallChartProps<T extends Record<string, unknown> = Record<
3588
3835
 
3589
3836
  /* ------------------------------------------------------------------ model */
3590
3837
 
3838
+ const TEXT = {
3839
+ ja: {
3840
+ net: "純増",
3841
+ total: "合計",
3842
+ change: "増減",
3843
+ cumulative: "累計",
3844
+ cumulativeChange: "累計増減",
3845
+ item: "項目",
3846
+ start: "開始",
3847
+ end: "終了",
3848
+ fromTo: (from: string, to: string, net: string) => `${from} → ${to}(${net})`,
3849
+ from: (from: string, net: string) => `${from} から ${net}`,
3850
+ to: (to: string, net: string) => `${to}(${net})`,
3851
+ netOnly: (net: string) => `増減の合計 ${net}`,
3852
+ increase: (name: string, value: string) => `最大の増加は ${name}(${value})`,
3853
+ decrease: (name: string, value: string) => `最大の減少は ${name}(${value})`,
3854
+ sentenceJoin: "。",
3855
+ chart: (label: string) => `${label}のウォーターフォールチャート`,
3856
+ },
3857
+ en: {
3858
+ net: "Net change",
3859
+ total: "Total",
3860
+ change: "Change",
3861
+ cumulative: "Cumulative",
3862
+ cumulativeChange: "Cumulative change",
3863
+ item: "Item",
3864
+ start: "Start",
3865
+ end: "End",
3866
+ fromTo: (from: string, to: string, net: string) => `${from} → ${to} (${net})`,
3867
+ from: (from: string, net: string) => `${net} from ${from}`,
3868
+ to: (to: string, net: string) => `${to} (${net})`,
3869
+ netOnly: (net: string) => `Net change ${net}`,
3870
+ increase: (name: string, value: string) => `Largest increase: ${name} (${value})`,
3871
+ decrease: (name: string, value: string) => `Largest decrease: ${name} (${value})`,
3872
+ sentenceJoin: ". ",
3873
+ chart: (label: string) => `Waterfall chart of ${label}`,
3874
+ },
3875
+ }
3876
+
3877
+ const textOf = (locale?: string) => TEXT[uiLanguage(resolveLocale(locale))]
3878
+
3591
3879
  export type WaterfallKind = "total" | "increase" | "decrease" | "flat" | "gap"
3592
3880
 
3593
3881
  export interface WaterfallStep {
@@ -3609,7 +3897,7 @@ export interface WaterfallStep {
3609
3897
  */
3610
3898
  export function waterfallSteps(
3611
3899
  data: readonly Record<string, unknown>[],
3612
- config: { index: string; value: string; totals?: readonly string[]; mode?: WaterfallMode },
3900
+ config: { index: string; value: string; totals?: readonly string[]; mode?: WaterfallMode; locale?: string },
3613
3901
  ): WaterfallStep[] {
3614
3902
  const totals = new Set(config.totals ?? [])
3615
3903
  const delta = config.mode === "delta"
@@ -3630,13 +3918,15 @@ export function waterfallSteps(
3630
3918
  })
3631
3919
  // `delta`: the net change closes the plot as a total bar from 0 (series colour 1), so the moves add up to something.
3632
3920
  if (delta && steps.some((s) => s.kind !== "gap")) {
3633
- steps.push({ name: DELTA_TOTAL_NAME, kind: "total", value: running, start: 0, end: running, row: {} })
3921
+ steps.push({ name: deltaTotalName(config.locale), kind: "total", value: running, start: 0, end: running, row: {} })
3634
3922
  }
3635
3923
  return steps
3636
3924
  }
3637
3925
 
3638
- /** The name of the net-change bar that `mode="delta"` appends. */
3639
- export const DELTA_TOTAL_NAME = "純増"
3926
+ /** The name of the net-change bar that `mode="delta"` appends, in `locale` ("Net change" / 「純増」). Default the page's locale. */
3927
+ export function deltaTotalName(locale?: string): string {
3928
+ return textOf(locale).net
3929
+ }
3640
3930
 
3641
3931
  /** What `mode="delta"` leaves out of the bars: the opening / closing totals and the net change of the moves. */
3642
3932
  export interface WaterfallDeltaEnds {
@@ -3674,16 +3964,22 @@ export function waterfallDeltaEnds(
3674
3964
  }
3675
3965
 
3676
3966
  /** The note above a delta plot: `前期 ¥2.2億 → 当期 ¥2.2億(+¥130万)`; without the totals' values, the net change alone. */
3677
- export function waterfallDeltaNote(ends: WaterfallDeltaEnds, formatter: ValueFormatter, labels?: Record<string, string>): string {
3967
+ export function waterfallDeltaNote(
3968
+ ends: WaterfallDeltaEnds,
3969
+ formatter: ValueFormatter,
3970
+ labels?: Record<string, string>,
3971
+ locale?: string,
3972
+ ): string {
3973
+ const t = textOf(locale)
3678
3974
  const net = signedText(ends.net, formatter)
3679
3975
  const { opening, closing } = ends
3680
3976
  const side = (e: { name: string; value: number | null } | null) => (e && e.value !== null ? `${labelOf(e.name, labels)} ${formatter(e.value)}` : null)
3681
3977
  const from = side(opening)
3682
3978
  const to = side(closing)
3683
- if (from && to) return `${from} → ${to}(${net})`
3684
- if (from) return `${from} から ${net}`
3685
- if (to) return `${to}(${net})`
3686
- return `増減の合計 ${net}`
3979
+ if (from && to) return t.fromTo(from, to, net)
3980
+ if (from) return t.from(from, net)
3981
+ if (to) return t.to(to, net)
3982
+ return t.netOnly(net)
3687
3983
  }
3688
3984
 
3689
3985
  export interface WaterfallStack {
@@ -3767,7 +4063,9 @@ export function buildWaterfallSummary(
3767
4063
  title?: string,
3768
4064
  labels?: Record<string, string>,
3769
4065
  delta?: WaterfallDeltaEnds,
4066
+ locale?: string,
3770
4067
  ): string | undefined {
4068
+ const t = textOf(locale)
3771
4069
  const real = steps.filter((s) => s.kind !== "gap")
3772
4070
  const first = real[0]
3773
4071
  const last = real[real.length - 1]
@@ -3777,7 +4075,7 @@ export function buildWaterfallSummary(
3777
4075
  // A first step starts at 0, so its end is its value; a total's end is the total. `delta`: the note (totals and net change) instead.
3778
4076
  const parts = [
3779
4077
  delta
3780
- ? waterfallDeltaNote(delta, formatter, labels)
4078
+ ? waterfallDeltaNote(delta, formatter, labels, locale)
3781
4079
  : real.length === 1
3782
4080
  ? `${name(first)} ${formatter(first.end)}`
3783
4081
  : `${name(first)} ${formatter(first.end)} → ${name(last)} ${formatter(last.end)}`,
@@ -3788,9 +4086,9 @@ export function buildWaterfallSummary(
3788
4086
  if (s.kind === "increase" && (!up || s.value! > up.value!)) up = s
3789
4087
  if (s.kind === "decrease" && (!down || s.value! < down.value!)) down = s
3790
4088
  }
3791
- if (up) parts.push(`最大の増加は ${name(up)}(${signedText(up.value!, formatter)})`)
3792
- if (down) parts.push(`最大の減少は ${name(down)}(${signedText(down.value!, formatter)})`)
3793
- return `${head}${parts.join("。")}`
4089
+ if (up) parts.push(t.increase(name(up), signedText(up.value!, formatter)))
4090
+ if (down) parts.push(t.decrease(name(down), signedText(down.value!, formatter)))
4091
+ return `${head}${parts.join(t.sentenceJoin)}`
3794
4092
  }
3795
4093
 
3796
4094
  /** The screen-reader table: one row per step with its change and the running total. */
@@ -3800,19 +4098,21 @@ export function waterfallDataTable(
3800
4098
  formatter: ValueFormatter,
3801
4099
  labels?: Record<string, string>,
3802
4100
  delta?: WaterfallDeltaEnds,
4101
+ locale?: string,
3803
4102
  ): ChartDataTable {
4103
+ const t = textOf(locale)
3804
4104
  const label = (key: string) => labelOf(key, labels)
3805
4105
  const rows = steps.map((s) => [
3806
4106
  label(s.name),
3807
- s.kind === "total" ? (delta ? DELTA_TOTAL_NAME : "合計") : s.value === null ? EMPTY_VALUE : signedText(s.value, formatter),
4107
+ s.kind === "total" ? (delta ? t.net : t.total) : s.value === null ? EMPTY_VALUE : signedText(s.value, formatter),
3808
4108
  s.kind === "gap" ? EMPTY_VALUE : delta ? signedText(s.end, formatter) : formatter(s.end),
3809
4109
  ])
3810
4110
  // `delta`: the totals are not bars, so the opening / closing values are rows of their own (they are not in `steps`).
3811
4111
  const end = (e: WaterfallDeltaEnds["opening"], role: string) =>
3812
4112
  e && e.value !== null ? [[label(e.name), role, formatter(e.value)]] : []
3813
4113
  return {
3814
- columns: [labels?.[names.index] ?? "項目", "増減", delta ? "累計増減" : "累計"],
3815
- rows: delta ? [...end(delta.opening, "開始"), ...rows, ...end(delta.closing, "終了")] : rows,
4114
+ columns: [labels?.[names.index] ?? t.item, t.change, delta ? t.cumulativeChange : t.cumulative],
4115
+ rows: delta ? [...end(delta.opening, t.start), ...rows, ...end(delta.closing, t.end)] : rows,
3816
4116
  }
3817
4117
  }
3818
4118
 
@@ -3847,6 +4147,8 @@ export interface WaterfallOptionConfig {
3847
4147
  noteColor?: string
3848
4148
  /** Colour of the 1px line joining the bars' tops (the border token). `undefined` draws no connector. */
3849
4149
  connectorColor?: string
4150
+ /** Language of the tooltip. Default the page's locale (`<html lang>`). */
4151
+ locale?: string
3850
4152
  }
3851
4153
 
3852
4154
  /** The bar fills 45% of its category band. ECharts reads `barCategoryGap` as a share of the band (not of the bar), so the gap is the other 55%; a gap over 100% leaves the bar at its 1px minimum. */
@@ -3987,6 +4289,7 @@ const ROW_STYLE = "display:flex;align-items:center;gap:8px;line-height:20px"
3987
4289
  /** The ECharts option: a transparent base plus the part above and below 0, coloured per step. */
3988
4290
  export function buildWaterfallOption(config: WaterfallOptionConfig): EChartsOption {
3989
4291
  const { steps, colors, polarity, labels, valueFormatter, showValues = true, showTooltip = true } = config
4292
+ const t = textOf(config.locale)
3990
4293
  const delta = config.mode === "delta"
3991
4294
  const axisFormatter = config.axisFormatter ?? valueFormatter
3992
4295
  const noteWidth = config.plotWidth ? Math.max(120, config.plotWidth - 16) : undefined
@@ -4068,9 +4371,13 @@ export function buildWaterfallOption(config: WaterfallOptionConfig): EChartsOpti
4068
4371
  `<span style="font-weight:500;font-variant-numeric:tabular-nums;margin-left:12px">${escapeHtml(text)}</span></div>`
4069
4372
  const rows =
4070
4373
  step.kind === "total"
4071
- ? line(delta ? DELTA_TOTAL_NAME : "合計", delta ? signedText(step.end, valueFormatter) : valueFormatter(step.end))
4072
- : line("増減", waterfallStepText(step, valueFormatter)) +
4073
- (step.kind === "gap" ? "" : delta ? line("累計増減", signedText(step.end, valueFormatter)) : line("累計", valueFormatter(step.end)))
4374
+ ? line(delta ? t.net : t.total, delta ? signedText(step.end, valueFormatter) : valueFormatter(step.end))
4375
+ : line(t.change, waterfallStepText(step, valueFormatter)) +
4376
+ (step.kind === "gap"
4377
+ ? ""
4378
+ : delta
4379
+ ? line(t.cumulativeChange, signedText(step.end, valueFormatter))
4380
+ : line(t.cumulative, valueFormatter(step.end)))
4074
4381
  return `<div style="font-weight:500;margin-bottom:4px">${escapeHtml(labelOf(step.name, labels))}</div>${rows}`
4075
4382
  },
4076
4383
  },
@@ -4170,11 +4477,15 @@ function WaterfallChartImpl<T extends Record<string, unknown>>(
4170
4477
  const valueKey: string = categories[0] ?? ""
4171
4478
  const { ref: frameRef, width: plotWidth, tokens } = useChartSurface(ref)
4172
4479
  // Value labels and the tooltip read in full (as `format` says); ticks read short.
4173
- const valueFormatter = useMemo(() => resolveValueFormatter(valueFormatterProp, format), [valueFormatterProp, format])
4174
- const axisFormatter = useMemo(() => resolveAxisFormatter(valueFormatterProp, format), [valueFormatterProp, format])
4175
- const steps = useMemo(() => waterfallSteps(data, { index, value: valueKey, totals, mode }), [data, index, valueKey, totals, mode])
4480
+ const locale = useLocale()
4481
+ const valueFormatter = useMemo(() => resolveValueFormatter(valueFormatterProp, format, locale), [valueFormatterProp, format, locale])
4482
+ const axisFormatter = useMemo(() => resolveAxisFormatter(valueFormatterProp, format, locale), [valueFormatterProp, format, locale])
4483
+ const steps = useMemo(
4484
+ () => waterfallSteps(data, { index, value: valueKey, totals, mode, locale }),
4485
+ [data, index, valueKey, totals, mode, locale],
4486
+ )
4176
4487
  const ends = useMemo(() => (mode === "delta" ? waterfallDeltaEnds(data, { index, value: valueKey, totals }) : undefined), [data, index, valueKey, totals, mode])
4177
- const deltaNote = useMemo(() => (ends ? waterfallDeltaNote(ends, valueFormatter, labels) : undefined), [ends, valueFormatter, labels])
4488
+ const deltaNote = useMemo(() => (ends ? waterfallDeltaNote(ends, valueFormatter, labels, locale) : undefined), [ends, valueFormatter, labels, locale])
4178
4489
  const colors = useMemo(() => {
4179
4490
  const fills = resolveFillColors(tokens)
4180
4491
  const [total = ""] = resolveChartColors(["chart-1"], tokens)
@@ -4207,18 +4518,20 @@ function WaterfallChartImpl<T extends Record<string, unknown>>(
4207
4518
  minValue,
4208
4519
  maxValue,
4209
4520
  yAxisWidth,
4521
+ locale,
4210
4522
  }),
4211
- [steps, colors, polarity, labels, valueFormatter, axisFormatter, showValues, showTooltip, tokens, mode, deltaNote, plotWidth, chartOptions, minValue, maxValue, yAxisWidth],
4523
+ [steps, colors, polarity, labels, valueFormatter, axisFormatter, showValues, showTooltip, tokens, mode, deltaNote, plotWidth, chartOptions, minValue, maxValue, yAxisWidth, locale],
4212
4524
  )
4213
4525
  const dataTable = useMemo(
4214
- () => waterfallDataTable(steps, { index, value: valueKey }, valueFormatter, labels, ends),
4215
- [steps, index, valueKey, valueFormatter, labels, ends],
4526
+ () => waterfallDataTable(steps, { index, value: valueKey }, valueFormatter, labels, ends, locale),
4527
+ [steps, index, valueKey, valueFormatter, labels, ends, locale],
4216
4528
  )
4217
4529
  const resolvedSummary = useMemo(
4218
- () => (summary === "auto" ? buildWaterfallSummary(steps, valueFormatter, title, labels, ends) : summary),
4219
- [summary, steps, valueFormatter, title, labels, ends],
4530
+ () => (summary === "auto" ? buildWaterfallSummary(steps, valueFormatter, title, labels, ends, locale) : summary),
4531
+ [summary, steps, valueFormatter, title, labels, ends, locale],
4220
4532
  )
4221
- const label = ariaLabel ?? `${labelOf(valueKey, labels) || "増減"}のウォーターフォールチャート`
4533
+ const t = textOf(locale)
4534
+ const label = ariaLabel ?? t.chart(labelOf(valueKey, labels) || t.change.toLowerCase())
4222
4535
  return (
4223
4536
  <ChartFrame
4224
4537
  {...chartRootProps(rest)}