@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
@@ -20,6 +20,7 @@ import { ChartFrame } from "@squadbase/vantage/components"
20
20
  import { EChart } from "@squadbase/vantage/components"
21
21
  import { EMPTY_VALUE } from "@squadbase/vantage/components"
22
22
  import type { ChartCommonProps, ValueFormatter } from "@squadbase/vantage/components"
23
+ import { useSyncExternalStore } from "react"
23
24
  import { createContext, useCallback, useEffect, useRef, useState, type RefObject } from "react"
24
25
  import type { ChartTone } from "@squadbase/vantage/components"
25
26
  import { type ReactNode } from "react"
@@ -29,6 +30,137 @@ import type { ChartThreshold, ChartAnnotation, ChartEventPayload, ChartForecast,
29
30
  import { formatValue, toValueFormatter } from "@squadbase/vantage/components"
30
31
  import type { ChartColor, ChartColors, ChartSeriesColor } from "@squadbase/vantage/components"
31
32
 
33
+ /* ---------- inlined from @squadbase/squadkit registry/lib/locale.ts (internal to @squadbase/vantage) ---------- */
34
+
35
+ /**
36
+ * The display language of the parts and the defaults that follow it.
37
+ *
38
+ * The language comes from `<html lang>`: a tag starting with `ja` reads Japanese, anything else English.
39
+ * Without a usable `lang` (missing, invalid, or no `document` — SSR, Node) the locale is `FALLBACK_LOCALE`
40
+ * (`en-US`). A `locale` passed explicitly (a prop, an argument) always wins.
41
+ *
42
+ * Parts read the locale through `useLocale` (`hooks/use-locale`) and pass it down; the pure helpers here
43
+ * and in `format` / `comparison` / `chart-*` take it as an argument and fall back to `documentLocale()`.
44
+ */
45
+
46
+ /** The language of the built-in wording: Japanese or English. */
47
+ type UiLanguage = "ja" | "en"
48
+
49
+ /** The locale used when `<html lang>` gives none (and on the server). */
50
+ const FALLBACK_LOCALE = "en-US"
51
+
52
+ /** `"ja"` when `locale` starts with `ja` (`ja`, `ja-JP`), else `"en"`. A missing locale is `"en"`. */
53
+ function uiLanguage(locale: string | null | undefined): UiLanguage {
54
+ return typeof locale === "string" && locale.trim().toLowerCase().startsWith("ja") ? "ja" : "en"
55
+ }
56
+
57
+ /** A BCP 47 tag in its canonical form (`en-gb` → `en-GB`), or `FALLBACK_LOCALE` when it is empty or invalid. */
58
+ function normalizeLocale(tag: string | null | undefined): string {
59
+ const value = tag?.trim()
60
+ if (!value) return FALLBACK_LOCALE
61
+ try {
62
+ return Intl.getCanonicalLocales(value)[0] ?? FALLBACK_LOCALE
63
+ } catch {
64
+ return FALLBACK_LOCALE
65
+ }
66
+ }
67
+
68
+ /**
69
+ * The locale of the page: `document.documentElement.lang`, canonicalized. `FALLBACK_LOCALE` when it is empty or
70
+ * invalid, or there is no `document`. Parts read it through `useLocale`, never while rendering.
71
+ */
72
+ function documentLocale(): string {
73
+ if (typeof document === "undefined") return FALLBACK_LOCALE
74
+ return normalizeLocale(document.documentElement?.getAttribute("lang"))
75
+ }
76
+
77
+ /** `locale` when given, else the page's locale (`documentLocale()`). */
78
+ function resolveLocale(locale?: string | null): string {
79
+ return locale || documentLocale()
80
+ }
81
+
82
+ /** Picks the wording table of the locale's language. */
83
+ function pickLocale<T>(table: { ja: T; en: T }, locale?: string | null): T {
84
+ return table[uiLanguage(resolveLocale(locale))]
85
+ }
86
+
87
+ /** The currency `format="currency"` uses without a `currency`: JPY for Japanese, USD otherwise. */
88
+ function defaultCurrency(locale?: string | null): string {
89
+ return uiLanguage(resolveLocale(locale)) === "ja" ? "JPY" : "USD"
90
+ }
91
+
92
+ /** The month a fiscal year starts in (1–12) when none is given: April for Japanese, January otherwise. */
93
+ function defaultFiscalYearStartMonth(locale?: string | null): number {
94
+ return uiLanguage(resolveLocale(locale)) === "ja" ? 4 : 1
95
+ }
96
+
97
+ /** A day of the week, 0 = Sunday … 6 = Saturday (the `Date#getDay` / date-fns numbering). */
98
+ type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6
99
+
100
+ /** Locales whose week starts on Sunday when the runtime has no week data. */
101
+ const SUNDAY_FIRST = new Set(["en", "en-US", "en-CA"])
102
+
103
+ /**
104
+ * The first day of the week: Monday for Japanese; otherwise the locale's convention from `Intl.Locale`
105
+ * (`en-US` Sunday, `en-GB` Monday). Without week data in the runtime: Sunday for `en` / `en-US` / `en-CA`,
106
+ * Monday for the rest.
107
+ */
108
+ function weekStartsOn(locale?: string | null): WeekDay {
109
+ const tag = normalizeLocale(resolveLocale(locale))
110
+ if (uiLanguage(tag) === "ja") return 1
111
+ try {
112
+ const info = new Intl.Locale(tag) as Intl.Locale & {
113
+ getWeekInfo?: () => { firstDay: number }
114
+ weekInfo?: { firstDay: number }
115
+ }
116
+ const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay
117
+ if (typeof firstDay === "number" && firstDay >= 1 && firstDay <= 7) return (firstDay % 7) as WeekDay
118
+ } catch {
119
+ // Fall through to the table below.
120
+ }
121
+ return SUNDAY_FIRST.has(tag) ? 0 : 1
122
+ }
123
+
124
+ /* ---------- inlined from @squadbase/squadkit registry/hooks/use-locale.ts (internal to @squadbase/vantage) ---------- */
125
+
126
+ const listeners = new Set<() => void>()
127
+ let observer: MutationObserver | null = null
128
+
129
+ /** One observer on `<html lang>` for every subscribed part. */
130
+ function subscribe(onChange: () => void): () => void {
131
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {}
132
+ listeners.add(onChange)
133
+ if (!observer) {
134
+ observer = new MutationObserver(() => {
135
+ for (const listener of [...listeners]) listener()
136
+ })
137
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] })
138
+ }
139
+ return () => {
140
+ listeners.delete(onChange)
141
+ if (listeners.size === 0 && observer) {
142
+ observer.disconnect()
143
+ observer = null
144
+ }
145
+ }
146
+ }
147
+
148
+ const serverLocale = () => FALLBACK_LOCALE
149
+
150
+ /**
151
+ * The page's locale (`<html lang>`), following changes made while the page runs. On the server and during
152
+ * hydration it is `FALLBACK_LOCALE` (`en-US`), so the markup matches; it switches right after.
153
+ */
154
+ function useDocumentLocale(): string {
155
+ return useSyncExternalStore(subscribe, documentLocale, serverLocale)
156
+ }
157
+
158
+ /** The locale a part renders in: its `locale` prop when given, else the page's (`useDocumentLocale`). */
159
+ function useLocale(locale?: string | null): string {
160
+ const page = useDocumentLocale()
161
+ return locale || page
162
+ }
163
+
32
164
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-theme.ts (internal to @squadbase/vantage) ---------- */
33
165
 
34
166
  /**
@@ -427,6 +559,32 @@ function useChartSurface<E extends Element>(
427
559
 
428
560
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-options.ts (internal to @squadbase/vantage) ---------- */
429
561
 
562
+ /** The built-in words of the option builders, by language. */
563
+ const CHART_TEXT = {
564
+ ja: {
565
+ target: "目標",
566
+ forecast: "予測",
567
+ total: "合計",
568
+ provisional: "速報",
569
+ noComparison: "比較なし",
570
+ share: (share: string) => `(${share})`,
571
+ targetRate: (rate: string) => `(達成率 ${rate})`,
572
+ provisionalTitle: (title: string, label: string) => `${title}(${label})`,
573
+ },
574
+ en: {
575
+ target: "Target",
576
+ forecast: "Forecast",
577
+ total: "Total",
578
+ provisional: "Preliminary",
579
+ noComparison: "No comparison",
580
+ share: (share: string) => ` (${share})`,
581
+ targetRate: (rate: string) => ` (${rate} achieved)`,
582
+ provisionalTitle: (title: string, label: string) => `${title} (${label})`,
583
+ },
584
+ } satisfies Record<UiLanguage, unknown>
585
+
586
+ const chartText = (locale?: string) => CHART_TEXT[uiLanguage(resolveLocale(locale))]
587
+
430
588
  type CartesianChartKind = "area" | "bar" | "line"
431
589
  type CartesianStackType = "default" | "stacked" | "percent"
432
590
  type CartesianCurveType = "linear" | "smooth" | "step"
@@ -611,7 +769,7 @@ interface CartesianChartConfig {
611
769
  * all ticks read in one unit (¥0.5億 / ¥1億). Default: `axisFormatter` for every tick.
612
770
  */
613
771
  axisTicks?: (top: number) => (value: number) => string
614
- /** Locale of the time-axis labels. Default `"ja-JP"`. */
772
+ /** Locale of the time-axis labels and the built-in words (total, target, forecast). Default the page's (`<html lang>`). */
615
773
  locale?: string
616
774
  type?: CartesianStackType
617
775
  /** Bar only. `"vertical"` lays the categories down the y axis (horizontal bars). */
@@ -849,11 +1007,12 @@ function timeAxisLabelStep(values: readonly unknown[], maxLabels: number = TIME_
849
1007
  */
850
1008
  function timeAxisLabels(
851
1009
  values: readonly unknown[],
852
- locale: string = DEFAULT_LOCALE,
1010
+ localeArg?: string,
853
1011
  shown: (i: number) => boolean = () => true,
854
1012
  ): string[] | null {
855
1013
  const parsed = parseTimeIndex(values)
856
1014
  if (!parsed) return null
1015
+ const locale = resolveLocale(localeArg)
857
1016
  const { granularity, times } = parsed
858
1017
  let prevYear: number | null = null
859
1018
  return times.map((t, i) => {
@@ -876,9 +1035,10 @@ function timeAxisLabels(
876
1035
  }
877
1036
 
878
1037
  /** The tooltip titles for time-like x values (`2025年9月`, `2025年9月1日`), or `null` when they are not dates. */
879
- function timeTooltipLabels(values: readonly unknown[], locale: string = DEFAULT_LOCALE): string[] | null {
1038
+ function timeTooltipLabels(values: readonly unknown[], localeArg?: string): string[] | null {
880
1039
  const parsed = parseTimeIndex(values)
881
1040
  if (!parsed) return null
1041
+ const locale = resolveLocale(localeArg)
882
1042
  const options: Intl.DateTimeFormatOptions =
883
1043
  parsed.granularity === "year"
884
1044
  ? { year: "numeric" }
@@ -1018,7 +1178,9 @@ function tooltipHtml(
1018
1178
  rows: readonly TooltipRow[],
1019
1179
  valueFormatter: (value: number) => string,
1020
1180
  notes: readonly string[] = [],
1181
+ locale?: string,
1021
1182
  ): string {
1183
+ const t = chartText(locale)
1022
1184
  const lines = rows.map(
1023
1185
  (row) =>
1024
1186
  `<div style="display:flex;align-items:center;gap:8px;line-height:20px">` +
@@ -1027,7 +1189,7 @@ function tooltipHtml(
1027
1189
  `<span style="font-weight:600;font-variant-numeric:tabular-nums;margin-left:12px">${
1028
1190
  row.value === null ? EMPTY_VALUE : escapeHtml(valueFormatter(row.value))
1029
1191
  }${
1030
- row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px">(${escapeHtml(formatShare(row.share))})</span>`
1192
+ row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px">${escapeHtml(t.share(formatShare(row.share)))}</span>`
1031
1193
  }</span></div>` +
1032
1194
  (row.note === undefined
1033
1195
  ? ""
@@ -1041,8 +1203,10 @@ function tooltipHtml(
1041
1203
 
1042
1204
  /* ------------------------------------------------- per-bar targets / notes */
1043
1205
 
1044
- /** Default name of the per-bar target marker (legend, tooltip). */
1045
- const BAR_TARGET_LABEL = "目標"
1206
+ /** Default name of the per-bar target marker (legend, tooltip) in `locale`: "Target" / 「目標」. Default the page's locale. */
1207
+ function barTargetLabel(locale?: string): string {
1208
+ return chartText(locale).target
1209
+ }
1046
1210
 
1047
1211
  /** How far (px) the target marker reaches past each side of its bar. */
1048
1212
  const TARGET_MARKER_OVERHANG = 4
@@ -1093,16 +1257,17 @@ function achievementRate(value: number | null | undefined, target: number | null
1093
1257
  return (value / target) * 100
1094
1258
  }
1095
1259
 
1096
- /** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3%)」. `null` without a target. */
1260
+ /** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3%)」 / "Target $12M (85.3% achieved)". `null` without a target. */
1097
1261
  function barTargetNote(
1098
1262
  label: string,
1099
1263
  value: number | null,
1100
1264
  target: number | null,
1101
1265
  valueFormatter: (value: number) => string,
1266
+ locale?: string,
1102
1267
  ): string | null {
1103
1268
  if (target === null) return null
1104
1269
  const rate = achievementRate(value, target)
1105
- return `${label} ${valueFormatter(target)}${rate === null ? "" : `(達成率 ${formatShare(rate)})`}`
1270
+ return `${label} ${valueFormatter(target)}${rate === null ? "" : chartText(locale).targetRate(formatShare(rate))}`
1106
1271
  }
1107
1272
 
1108
1273
  /** Half the width of a bar in category-axis units, from `barCategoryGap` (`"20%"`; a px gap or none → ECharts' 20%). */
@@ -1194,11 +1359,12 @@ const TARGET_LEGEND_SERIES_ID = "__target-legend"
1194
1359
  /** A flat tick (8×8 legend box, aspect kept): the per-bar target's legend icon. */
1195
1360
  const TARGET_LEGEND_ICON = "path://M0,0 L100,0 L100,24 L0,24 Z"
1196
1361
 
1197
- const DEFAULT_FORECAST_LABEL = "予測"
1198
-
1199
- /** 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. */
1200
- function forecastBoundaryText(label: string = DEFAULT_FORECAST_LABEL): string {
1201
- return `${label} →`
1362
+ /**
1363
+ * The label at the top of a forecast's boundary line: the forecast's name (as in the legend) and an arrow toward the forecast side.
1364
+ * Without a `label`, "Forecast" / 「予測」 in `locale` (default the page's).
1365
+ */
1366
+ function forecastBoundaryText(label?: string, locale?: string): string {
1367
+ return `${label ?? chartText(locale).forecast} →`
1202
1368
  }
1203
1369
 
1204
1370
  /** A comparison series: a thin, muted, solid line with no markers (60% of `--muted-foreground`). */
@@ -1238,8 +1404,10 @@ interface AxisExtent {
1238
1404
  max: number
1239
1405
  }
1240
1406
 
1241
- /** The label of a stack's total (highlight and tooltip). */
1242
- const STACK_TOTAL_LABEL = "合計"
1407
+ /** The label of a stack's total (highlight and tooltip) in `locale`: "Total" / 「合計」. Default the page's locale. */
1408
+ function stackTotalLabel(locale?: string): string {
1409
+ return chartText(locale).total
1410
+ }
1243
1411
 
1244
1412
  /** Per point, the sum of the stacked series; `null` where every series is missing. */
1245
1413
  function stackTotalsOf(values: readonly (readonly (number | null)[])[]): (number | null)[] {
@@ -1361,8 +1529,8 @@ function indexGrain(values: readonly unknown[]): PeriodGrain | null {
1361
1529
 
1362
1530
  /**
1363
1531
  * The tooltip line under the main series' row when a comparison series is drawn: the arrow, the signed change and the
1364
- * comparison's name, as `Delta` writes it ("↑ +12.4% 前年同月比"; a minus is U+2212). "比較なし 前年同月比" when
1365
- * either value is missing or the baseline is 0.
1532
+ * comparison's name, as `Delta` writes it ("↑ +12.4% vs. same month last year" / "↑ +12.4% 前年同月比"; a minus is U+2212).
1533
+ * "No comparison vs. same month last year" / "比較なし 前年同月比" when either value is missing or the baseline is 0.
1366
1534
  */
1367
1535
  function comparisonNote(
1368
1536
  current: number | null,
@@ -1370,16 +1538,18 @@ function comparisonNote(
1370
1538
  comparison: Pick<CartesianComparisonConfig, "vs" | "label">,
1371
1539
  grain?: PeriodGrain,
1372
1540
  format?: ValueFormat,
1373
- locale: string = DEFAULT_LOCALE,
1541
+ localeArg?: string,
1374
1542
  ): string {
1543
+ const locale = resolveLocale(localeArg)
1375
1544
  const result = computeComparison(
1376
1545
  current,
1377
1546
  comparison.vs === "custom"
1378
1547
  ? { previous, vs: "custom", label: comparison.label ?? "", grain }
1379
1548
  : { previous, vs: comparison.vs, label: comparison.label, grain },
1549
+ { locale },
1380
1550
  )
1381
1551
  const text = formatChange(result, defaultChangeDisplay(format), locale, format)
1382
- if (text === null) return `比較なし ${result.label}`
1552
+ if (text === null) return `${chartText(locale).noComparison} ${result.label}`
1383
1553
  const arrow = result.direction === "up" ? "↑" : result.direction === "down" ? "↓" : "→"
1384
1554
  return `${arrow} ${text.replace(/^-/, "−")} ${result.label}`
1385
1555
  }
@@ -1422,7 +1592,6 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1422
1592
  toneColors,
1423
1593
  valueFormatter,
1424
1594
  axisFormatter = valueFormatter,
1425
- locale = DEFAULT_LOCALE,
1426
1595
  type = "default",
1427
1596
  layout = "horizontal",
1428
1597
  curveType = "linear",
@@ -1459,7 +1628,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1459
1628
  chartOptions,
1460
1629
  reserveActions = false,
1461
1630
  } = cfg
1462
- const provisionalLabel = cfg.provisionalLabel ?? DEFAULT_PROVISIONAL_LABEL
1631
+ const locale = resolveLocale(cfg.locale)
1632
+ const text = chartText(locale)
1633
+ const provisionalLabel = cfg.provisionalLabel ?? text.provisional
1463
1634
 
1464
1635
  const stacked = kind !== "line" && type !== "default"
1465
1636
  const percent = kind !== "line" && type === "percent"
@@ -1608,9 +1779,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1608
1779
  // `barOptions.targetKey` (a target per bar) takes over from the single `target` line.
1609
1780
  const perBarTarget = kind === "bar" && Boolean(targetKey)
1610
1781
  const hasTarget = !perBarTarget && target !== undefined && Number.isFinite(target)
1611
- const targetText = hasTarget ? (targetLabel ?? `目標 ${axisFormatter(target)}`) : ""
1782
+ const targetText = hasTarget ? (targetLabel ?? `${text.target} ${axisFormatter(target)}`) : ""
1612
1783
  const highlightText = (value: number) =>
1613
- shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${STACK_TOTAL_LABEL} ` : ""}${axisFormatter(value)}`
1784
+ shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${text.total} ` : ""}${axisFormatter(value)}`
1614
1785
  /** Whether `p`'s label sits within the target's value band and under the target label (at the right end). */
1615
1786
  const nearTargetLabel = (p: HighlightPoint, side: "under" | "over") => {
1616
1787
  if (!hasTarget || !plotWidth || kind === "bar" || labels.length < 2) return false
@@ -1636,7 +1807,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1636
1807
  // One series (or a stack, against its total) can be compared to a target; grouped series and 100% stacks cannot.
1637
1808
  const barTargets: (number | null)[] | null =
1638
1809
  perBarTarget && targetKey && !percent && (stacked || categories.length === 1) ? barTargetValues(data, targetKey) : null
1639
- const barTargetName = targetLabel ?? BAR_TARGET_LABEL
1810
+ const barTargetName = targetLabel ?? text.target
1640
1811
  const barTargetColor = labelColor ?? guideColor
1641
1812
 
1642
1813
  const markPointFor = (c: number, color: string | undefined) =>
@@ -1850,7 +2021,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1850
2021
  }
1851
2022
 
1852
2023
  /* -- forecast: dashed continuation (+ optional band) of one series */
1853
- const forecastLabel = forecast?.label ?? DEFAULT_FORECAST_LABEL
2024
+ const forecastLabel = forecast?.label ?? text.forecast
1854
2025
  const forecastBandAlpha = isDarkSurface(labelBackground) ? FORECAST_BAND_ALPHA_DARK : FORECAST_BAND_ALPHA_LIGHT
1855
2026
  const forecastValues: (number | null)[] = []
1856
2027
  if (forecast) {
@@ -2326,7 +2497,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2326
2497
  })
2327
2498
  }
2328
2499
  if (stackTotals && i <= lastActual && rows.length > 1) {
2329
- rows.push({ name: STACK_TOTAL_LABEL, color: "transparent", value: stackTotals[i] ?? null })
2500
+ rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
2330
2501
  }
2331
2502
  const provisional = provStart !== -1 && i >= provStart && i <= lastActual
2332
2503
  if (provisional) {
@@ -2342,19 +2513,19 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2342
2513
  ...noteFor(at),
2343
2514
  })
2344
2515
  })
2345
- if (stackTotals && rows.length > 1) rows.push({ name: STACK_TOTAL_LABEL, color: "transparent", value: stackTotals[i] ?? null })
2516
+ if (stackTotals && rows.length > 1) rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
2346
2517
  }
2347
2518
  const title = tooltipTitles[i] ?? labels[i] ?? ""
2348
2519
  const lines: string[] = []
2349
2520
  if (barTargets && i <= lastActual) {
2350
2521
  const total = stackTotals ? (stackTotals[i] ?? null) : (values[0]?.[i] ?? null)
2351
- const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter)
2522
+ const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter, locale)
2352
2523
  if (note) lines.push(note)
2353
2524
  }
2354
2525
  for (const note of notes) {
2355
2526
  if (note.to === undefined ? note.from === i : note.from <= i && i <= note.to) lines.push(note.label)
2356
2527
  }
2357
- return tooltipHtml(provisional ? `${title}(${provisionalLabel})` : title, rows, valueFormatter, lines)
2528
+ return tooltipHtml(provisional ? text.provisionalTitle(title, provisionalLabel) : title, rows, valueFormatter, lines, locale)
2358
2529
  },
2359
2530
  },
2360
2531
  xAxis: horizontalBars ? valueAxis : categoryAxis,
@@ -2446,8 +2617,6 @@ interface DonutOptionSlice {
2446
2617
  /** How many of the largest slices get an outside label (「自然検索 41%」) by default. */
2447
2618
  const DONUT_OUTSIDE_LABELS = 2
2448
2619
 
2449
- const DEFAULT_TOTAL_LABEL = "合計"
2450
-
2451
2620
  const sliceValue = (value: number) => (Number.isFinite(value) && value > 0 ? value : 0)
2452
2621
 
2453
2622
  /** A slice's share of `total` as text: whole percent, `<1%` for a sliver, `0%` for nothing. */
@@ -2480,15 +2649,16 @@ function donutLegendItems(slices: readonly DonutOptionSlice[]): DonutLegendItem[
2480
2649
  }
2481
2650
 
2482
2651
  /**
2483
- * The default center label: what is totalled (「合計」), the formatted total
2652
+ * The default center label: what is totalled (「合計」 / "Total" in `locale`, default the page's), the formatted total
2484
2653
  * (`amount`, the big line), the unit (a small line under it) and both as one
2485
2654
  * string (`value`, 「9.2万 セッション」).
2486
2655
  */
2487
2656
  function donutCenterText(
2488
2657
  total: number,
2489
2658
  valueFormatter: (value: number) => string,
2490
- { totalLabel = DEFAULT_TOTAL_LABEL, unit }: { totalLabel?: string; unit?: string } = {},
2659
+ { totalLabel, unit, locale }: { totalLabel?: string; unit?: string; locale?: string } = {},
2491
2660
  ): { label: string; value: string; amount: string; unit?: string } {
2661
+ totalLabel ??= chartText(locale).total
2492
2662
  const amount = valueFormatter(total)
2493
2663
  return { label: totalLabel, value: unit ? `${amount} ${unit}` : amount, amount, ...(unit ? { unit } : {}) }
2494
2664
  }
@@ -2716,8 +2886,10 @@ function hasLabel(labels: Readonly<Record<string, string>> | undefined, key: str
2716
2886
 
2717
2887
  /* ------------------------------------------------------------ provisional */
2718
2888
 
2719
- /** The default label for points that are not final yet. */
2720
- const DEFAULT_PROVISIONAL_LABEL = "速報"
2889
+ /** The default label for points that are not final yet, in `locale`: "Preliminary" / 「速報」. Default the page's locale. */
2890
+ function defaultProvisionalLabel(locale?: string): string {
2891
+ return chartText(locale).provisional
2892
+ }
2721
2893
 
2722
2894
  /**
2723
2895
  * Position of the first provisional point: the first entry of `indexValues`
@@ -2855,9 +3027,10 @@ function resolveValueFormatter(
2855
3027
  function resolveAxisFormatter(
2856
3028
  valueFormatter: ValueFormatter | undefined,
2857
3029
  format: ValueFormat | undefined,
2858
- locale: string = DEFAULT_LOCALE,
3030
+ localeArg?: string,
2859
3031
  ): ValueFormatter {
2860
3032
  if (valueFormatter) return valueFormatter
3033
+ const locale = resolveLocale(localeArg)
2861
3034
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
2862
3035
  if (spec.type !== "compact" && spec.type !== "number" && spec.type !== "currency") return toValueFormatter(format, locale)
2863
3036
  const currency = "currency" in spec ? spec.currency : undefined
@@ -2866,40 +3039,75 @@ function resolveAxisFormatter(
2866
3039
  return (value) => formatValue(value, short, locale)
2867
3040
  }
2868
3041
 
2869
- /** The unit words of a ja number (`1.2万` / `3.4億` / `1兆`) and their sizes, largest first. */
2870
- const JA_UNITS = [
2871
- { char: "兆", size: 1e12 },
2872
- { char: "億", size: 1e8 },
2873
- { char: "万", size: 1e4 },
2874
- ] as const
3042
+ interface CompactUnit {
3043
+ token: string
3044
+ size: number
3045
+ }
3046
+
3047
+ const compactUnitCache = new Map<string, CompactUnit[] | null>()
3048
+
3049
+ /**
3050
+ * The compact-notation unit words of a locale and their sizes, largest first: `兆` / `億` / `万` in ja,
3051
+ * `T` / `B` / `M` / `K` in en. `null` when the locale's compact form puts anything between the number and its
3052
+ * word (`1,5 Mio.`), where a token cannot be swapped in place.
3053
+ */
3054
+ function compactUnits(locale: string): CompactUnit[] | null {
3055
+ const cached = compactUnitCache.get(locale)
3056
+ if (cached !== undefined) return cached
3057
+ let units: CompactUnit[] | null = []
3058
+ try {
3059
+ const compact = new Intl.NumberFormat(locale, { notation: "compact" })
3060
+ const seen = new Set<string>()
3061
+ for (let exp = 3; exp <= 15; exp++) {
3062
+ const parts = compact.formatToParts(10 ** exp)
3063
+ const at = parts.findIndex((p) => p.type === "compact")
3064
+ if (at === -1) continue
3065
+ if (parts[at - 1]?.type !== "integer" || at !== parts.length - 1) {
3066
+ units = null
3067
+ break
3068
+ }
3069
+ const token = parts[at]!.value
3070
+ if (seen.has(token)) continue
3071
+ seen.add(token)
3072
+ units.push({ token, size: 10 ** exp })
3073
+ }
3074
+ } catch {
3075
+ units = null
3076
+ }
3077
+ const sorted = units && units.length > 0 ? units.sort((a, b) => b.size - a.size) : null
3078
+ compactUnitCache.set(locale, sorted)
3079
+ return sorted
3080
+ }
2875
3081
 
2876
- const JA_NUMBER_TOKEN_RE = /\d[\d,]*(?:\.\d+)?(?:兆|億|万)?/
3082
+ const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
2877
3083
 
2878
3084
  /**
2879
3085
  * The value-axis tick formatter for an axis whose largest tick is `top`: every tick reads in the unit of
2880
- * that largest tick, so 万 and 億 never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`).
2881
- * Only the ja short forms (`compact` / `number` / `currency`, no `valueFormatter`) have units to align;
2882
- * any other format — and an axis whose largest tick is under 1万 — keeps `resolveAxisFormatter`'s output as it is.
3086
+ * that largest tick, so units never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`;
3087
+ * `$0.5M` / `$1M` / `$1.5M`, not `$500K` / `$1M`). Only the short forms (`compact` / `number` / `currency`,
3088
+ * no `valueFormatter`) have units to align; any other format — an axis whose largest tick is under the smallest
3089
+ * unit (1万 / 1K), or a locale whose compact form cannot be aligned in place — keeps `resolveAxisFormatter`'s output as it is.
2883
3090
  */
2884
3091
  function resolveAxisTickFormatter(
2885
3092
  valueFormatter: ValueFormatter | undefined,
2886
3093
  format: ValueFormat | undefined,
2887
- locale: string = DEFAULT_LOCALE,
3094
+ localeArg?: string,
2888
3095
  ): (top: number) => ValueFormatter {
3096
+ const locale = resolveLocale(localeArg)
2889
3097
  const base = resolveAxisFormatter(valueFormatter, format, locale)
2890
3098
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
2891
- const aligns =
2892
- !valueFormatter && locale.toLowerCase().startsWith("ja") && (spec.type === "compact" || spec.type === "number" || spec.type === "currency")
2893
- if (!aligns) return () => base
3099
+ const units = !valueFormatter && (spec.type === "compact" || spec.type === "number" || spec.type === "currency") ? compactUnits(locale) : null
3100
+ if (!units) return () => base
2894
3101
  const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 3 })
3102
+ const numberToken = new RegExp(`\\d[\\d,]*(?:\\.\\d+)?(?:${units.map((u) => escapeRegExp(u.token)).join("|")})?`)
2895
3103
  return (top) => {
2896
- const unit = JA_UNITS.find((u) => Math.abs(top) >= u.size)
3104
+ const unit = units.find((u) => Math.abs(top) >= u.size)
2897
3105
  if (!unit) return base
2898
3106
  return (value) => {
2899
3107
  const text = base(value)
2900
3108
  if (value === 0 || !Number.isFinite(value)) return text
2901
- const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.char}`
2902
- return text.replace(JA_NUMBER_TOKEN_RE, () => scaled)
3109
+ const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.token}`
3110
+ return text.replace(numberToken, () => scaled)
2903
3111
  }
2904
3112
  }
2905
3113
  }
@@ -2928,8 +3136,20 @@ function clamp(value: number, min: number, max: number): number {
2928
3136
  /** How many categories a chart colours individually; the rest fold into「その他」. */
2929
3137
  const MAX_CHART_SERIES = 5
2930
3138
 
2931
- /** The series name the folded categories get. */
2932
- const OTHER_SERIES_LABEL = "その他"
3139
+ /** The series name the folded categories get, by language. */
3140
+ const OTHER_SERIES_LABELS: Record<UiLanguage, string> = { ja: "その他", en: "Other" }
3141
+
3142
+ /** The series name the folded categories get in `locale` ("Other" / "その他"). Default the page's locale (`<html lang>`). */
3143
+ function otherSeriesLabel(locale?: string): string {
3144
+ return OTHER_SERIES_LABELS[uiLanguage(resolveLocale(locale))]
3145
+ }
3146
+
3147
+ const OTHER_NAMES = new Set(Object.values(OTHER_SERIES_LABELS))
3148
+
3149
+ /** Whether a category / row is the "other" bucket by name (「その他」 or "Other", whatever the page language). */
3150
+ function isOtherName(name: string | null | undefined): boolean {
3151
+ return typeof name === "string" && OTHER_NAMES.has(name)
3152
+ }
2933
3153
 
2934
3154
  interface AggregatedItems<Item extends { name: string; value: number }> {
2935
3155
  /** The `n` largest items, in their given order. All items when there are `n` or fewer. */
@@ -2949,7 +3169,7 @@ interface AggregatedItems<Item extends { name: string; value: number }> {
2949
3169
  function aggregateTopN<Item extends { name: string; value: number }>(
2950
3170
  items: readonly Item[],
2951
3171
  n: number = MAX_CHART_SERIES,
2952
- otherLabel: string = OTHER_SERIES_LABEL,
3172
+ otherLabel: string = otherSeriesLabel(),
2953
3173
  ): AggregatedItems<Item> {
2954
3174
  if (items.length <= n) return { top: [...items], rest: [], other: null }
2955
3175
  const ranked = items
@@ -2978,7 +3198,7 @@ function aggregateSeries(
2978
3198
  data: readonly Record<string, unknown>[],
2979
3199
  categories: readonly string[],
2980
3200
  n: number = MAX_CHART_SERIES,
2981
- otherLabel: string = OTHER_SERIES_LABEL,
3201
+ otherLabel: string = otherSeriesLabel(),
2982
3202
  ): AggregatedSeries {
2983
3203
  const totals = categories.map((name) => ({
2984
3204
  name,
@@ -3010,12 +3230,14 @@ function aggregateSeriesKeeping(
3010
3230
  categories: readonly string[],
3011
3231
  keep: string | undefined,
3012
3232
  n: number = MAX_CHART_SERIES,
3233
+ otherLabel: string = otherSeriesLabel(),
3013
3234
  ): AggregatedSeries {
3014
- if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n)
3235
+ if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n, otherLabel)
3015
3236
  const aggregated = aggregateSeries(
3016
3237
  data,
3017
3238
  categories.filter((c) => c !== keep),
3018
3239
  n,
3240
+ otherLabel,
3019
3241
  )
3020
3242
  return { ...aggregated, categories: [...aggregated.categories, keep] }
3021
3243
  }
@@ -3051,7 +3273,7 @@ function seriesColorsWithComparison(
3051
3273
  * Colour per drawn series: the record's token for that category, else the
3052
3274
  * array's token at the category's position in `allCategories` (cycled), else
3053
3275
  * the token palette by drawn position. `otherCategory` — and a category the
3054
- * data itself names「その他」(`OTHER_SERIES_LABEL`) — always gets `otherColor`.
3276
+ * data itself names「その他」/ "Other" (`OTHER_SERIES_LABELS`) — always gets `otherColor`.
3055
3277
  */
3056
3278
  function seriesColorsFor(
3057
3279
  categories: readonly string[],
@@ -3063,7 +3285,7 @@ function seriesColorsFor(
3063
3285
  const list = Array.isArray(colors) ? (colors as ChartSeriesColor[]) : undefined
3064
3286
  const record = !Array.isArray(colors) && colors ? (colors as Partial<Record<string, ChartSeriesColor>>) : undefined
3065
3287
  return categories.map((category, i) => {
3066
- if ((otherCategory !== null && category === otherCategory) || category === OTHER_SERIES_LABEL) return otherColor
3288
+ if ((otherCategory !== null && category === otherCategory) || isOtherName(category)) return otherColor
3067
3289
  const fromRecord = record?.[category]
3068
3290
  if (fromRecord) return fromRecord
3069
3291
  if (list && list.length > 0) {
@@ -3110,7 +3332,7 @@ interface DonutSlicesConfig {
3110
3332
  */
3111
3333
  function donutSlices(
3112
3334
  data: readonly Record<string, unknown>[],
3113
- { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = OTHER_SERIES_LABEL }: DonutSlicesConfig,
3335
+ { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = otherSeriesLabel() }: DonutSlicesConfig,
3114
3336
  ): DonutSlice[] {
3115
3337
  const items = data
3116
3338
  .map((row, i) => {
@@ -3123,7 +3345,8 @@ function donutSlices(
3123
3345
  const slices: DonutSlice[] = top.map((item, rank) => ({
3124
3346
  name: item.name,
3125
3347
  value: item.value,
3126
- color: palette[rank % palette.length]!,
3348
+ // A row the data itself names「その他」/ "Other" is the same remainder: grey, like the folded slice.
3349
+ color: isOtherName(item.name) ? otherColor : palette[rank % palette.length]!,
3127
3350
  isOther: false,
3128
3351
  row: item.row,
3129
3352
  }))
@@ -3196,7 +3419,7 @@ interface BarListRowsConfig extends BarListOptions {
3196
3419
  */
3197
3420
  function barListRows<Row extends object>(
3198
3421
  data: readonly Row[],
3199
- { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = OTHER_SERIES_LABEL, exclude }: BarListRowsConfig = {},
3422
+ { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = otherSeriesLabel(), exclude }: BarListRowsConfig = {},
3200
3423
  ): BarListRow<Row>[] {
3201
3424
  const items = data.map((row): BarListRow<Row> => {
3202
3425
  const cells = row as Record<string, unknown>
@@ -3242,19 +3465,21 @@ const BAR_LIST_OTHER_TINT = 30
3242
3465
 
3243
3466
  /**
3244
3467
  * The fill of each BarList row: all real rows in the same tint of `color`
3245
- * (`BAR_LIST_TINT`), the folded「その他」row in a tint of `otherColor`
3468
+ * (`BAR_LIST_TINT`), the folded「その他」row (and a row the data names「その他」/ "Other") in a tint of `otherColor`
3246
3469
  * (`BAR_LIST_OTHER_TINT`) — the palest fill, a remainder never outweighs a
3247
3470
  * ranked row. A legacy per-row `color` (or a threshold tone) replaces `color`
3248
3471
  * for that row. A tint, not the solid token, so the label printed over the bar
3249
3472
  * keeps its contrast.
3250
3473
  */
3251
3474
  function barListFills(
3252
- rows: readonly Pick<BarListRow, "isOther" | "color">[],
3475
+ rows: readonly (Pick<BarListRow, "isOther" | "color"> & { name?: string })[],
3253
3476
  color: ChartColor,
3254
3477
  otherColor: string,
3255
3478
  ): string[] {
3256
3479
  return rows.map((row) =>
3257
- row.isOther ? tintCss(otherColor, BAR_LIST_OTHER_TINT) : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
3480
+ row.isOther || isOtherName(row.name)
3481
+ ? tintCss(otherColor, BAR_LIST_OTHER_TINT)
3482
+ : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
3258
3483
  )
3259
3484
  }
3260
3485
 
@@ -3470,11 +3695,30 @@ interface ChartSummaryInput {
3470
3695
  format?: ValueFormat
3471
3696
  /** Wins over `format`. */
3472
3697
  valueFormatter?: ValueFormatter
3698
+ /** Language of the sentence. Default the page's locale (`<html lang>`). */
3699
+ locale?: string
3700
+ }
3701
+
3702
+ const SUMMARY_TEXT = {
3703
+ ja: {
3704
+ up: "、前の点より増加",
3705
+ down: "、前の点より減少",
3706
+ flat: "、前の点と横ばい",
3707
+ latest: (label: string, value: string) => `最新 ${label} は ${value}`,
3708
+ top: (name: string, share: string) => `最大は ${name}(${share})`,
3709
+ },
3710
+ en: {
3711
+ up: ", up from the previous point",
3712
+ down: ", down from the previous point",
3713
+ flat: ", flat from the previous point",
3714
+ latest: (label: string, value: string) => `Latest ${label}: ${value}`,
3715
+ top: (name: string, share: string) => `Largest: ${name} (${share})`,
3716
+ },
3473
3717
  }
3474
3718
 
3475
3719
  /**
3476
3720
  * One-sentence summary built from the rows, for `summary="auto"`:
3477
- * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」. The direction is
3721
+ * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」("<title>: Latest <index>: <value>, up from the previous point"). The direction is
3478
3722
  * computed from the last two non-empty points of the first series — never
3479
3723
  * written by hand, so it cannot contradict the data. `undefined` when there is
3480
3724
  * no usable point.
@@ -3490,26 +3734,29 @@ function buildChartSummary(input: ChartSummaryInput): string | undefined {
3490
3734
  }
3491
3735
  const latest = points[points.length - 1]
3492
3736
  if (!latest) return undefined
3493
- const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact")
3737
+ const locale = resolveLocale(input.locale)
3738
+ const t = SUMMARY_TEXT[uiLanguage(locale)]
3739
+ const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact", locale)
3494
3740
  const prev = points[points.length - 2]
3495
- const direction = !prev ? "" : latest.value > prev.value ? "、前の点より増加" : latest.value < prev.value ? "、前の点より減少" : "、前の点と横ばい"
3741
+ const direction = !prev ? "" : latest.value > prev.value ? t.up : latest.value < prev.value ? t.down : t.flat
3496
3742
  const head = input.title ? `${input.title}: ` : ""
3497
- return `${head}最新 ${latest.label} は ${text(latest.value)}${direction}`
3743
+ return `${head}${t.latest(latest.label, text(latest.value))}${direction}`
3498
3744
  }
3499
3745
 
3500
3746
  /**
3501
- * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」 from the slices
3502
- * (largest first). `undefined` when there is nothing to describe.
3747
+ * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」("<title>: Largest: <name> (<share>)") from the slices
3748
+ * (largest first). `undefined` when there is nothing to describe. `locale` picks the language; default the page's.
3503
3749
  */
3504
3750
  function buildShareSummary(
3505
3751
  slices: readonly { name: string; value: number }[],
3506
3752
  title?: string,
3753
+ locale?: string,
3507
3754
  ): string | undefined {
3508
3755
  const total = slices.reduce((sum, s) => sum + s.value, 0)
3509
3756
  const top = slices[0]
3510
3757
  if (!top || total <= 0) return undefined
3511
3758
  const share = `${Math.round((top.value / total) * 1000) / 10}%`
3512
- return `${title ? `${title}: ` : ""}最大は ${top.name}(${share})`
3759
+ return `${title ? `${title}: ` : ""}${SUMMARY_TEXT[uiLanguage(resolveLocale(locale))].top(top.name, share)}`
3513
3760
  }
3514
3761
 
3515
3762
  /* ---------- inlined from @squadbase/squadkit registry/lib/distribution.ts (internal to @squadbase/vantage) ---------- */
@@ -3691,6 +3938,51 @@ export interface BoxPlotChartProps extends Omit<ChartCommonProps, "data" | "inde
3691
3938
 
3692
3939
  /* ------------------------------------------------------------------ model */
3693
3940
 
3941
+ const TEXT = {
3942
+ ja: {
3943
+ value: "値",
3944
+ valueInSentence: "値",
3945
+ medianOf: (name: string, label: string, median: string) => `${name}の${label}の中央値は ${median}`,
3946
+ medianRange: (label: string, top: string, topMedian: string, low: string, lowMedian: string) =>
3947
+ `${label}の中央値が最も高いのは ${top}(${topMedian})、最も低いのは ${low}(${lowMedian})`,
3948
+ outliers: (n: number) => `外れ値は ${n} 件`,
3949
+ sentenceJoin: "。",
3950
+ columns: ["群", "件数", "最小(ひげ)", "第1四分位", "中央値", "第3四分位", "最大(ひげ)", "外れ値"],
3951
+ listJoin: "、",
3952
+ none: "なし",
3953
+ tooltipTitle: (name: string, label: string, count: number) => `${name}(${label}・${count}件)`,
3954
+ max: "最大(ひげ)",
3955
+ q3: "第3四分位",
3956
+ median: "中央値",
3957
+ q1: "第1四分位",
3958
+ min: "最小(ひげ)",
3959
+ outlierRow: "外れ値",
3960
+ chart: (label: string) => `${label}の箱ひげ図`,
3961
+ },
3962
+ en: {
3963
+ value: "Value",
3964
+ valueInSentence: "value",
3965
+ medianOf: (name: string, label: string, median: string) => `Median ${label} for ${name}: ${median}`,
3966
+ medianRange: (label: string, top: string, topMedian: string, low: string, lowMedian: string) =>
3967
+ `Highest median ${label}: ${top} (${topMedian}); lowest: ${low} (${lowMedian})`,
3968
+ outliers: (n: number) => (n === 1 ? "1 outlier" : `${n} outliers`),
3969
+ sentenceJoin: ". ",
3970
+ columns: ["Group", "Count", "Min (whisker)", "Q1", "Median", "Q3", "Max (whisker)", "Outliers"],
3971
+ listJoin: ", ",
3972
+ none: "None",
3973
+ tooltipTitle: (name: string, label: string, count: number) => `${name} (${label}, n=${count})`,
3974
+ max: "Max (whisker)",
3975
+ q3: "Q3",
3976
+ median: "Median",
3977
+ q1: "Q1",
3978
+ min: "Min (whisker)",
3979
+ outlierRow: "Outliers",
3980
+ chart: (label: string) => `Box plot of ${label}`,
3981
+ },
3982
+ }
3983
+
3984
+ const textOf = (locale?: string) => TEXT[uiLanguage(resolveLocale(locale))]
3985
+
3694
3986
  export interface BoxPlotEntry {
3695
3987
  name: string
3696
3988
  stats: BoxStats | null
@@ -3701,27 +3993,38 @@ export function boxPlotEntries(groups: readonly BoxPlotGroup[], whisker?: number
3701
3993
  return groups.map((g) => ({ name: g.name, stats: boxStats(g.values, whisker) }))
3702
3994
  }
3703
3995
 
3704
- /** `summary="auto"`: the groups with the highest / lowest median and the outlier count. `undefined` with no usable group. */
3705
- export function buildBoxPlotSummary(entries: readonly BoxPlotEntry[], formatter: ValueFormatter, valueLabel = "値"): string | undefined {
3996
+ /**
3997
+ * `summary="auto"`: the groups with the highest / lowest median and the outlier count. `undefined` with no usable group.
3998
+ * `locale` picks the language; default the page's (`<html lang>`).
3999
+ */
4000
+ export function buildBoxPlotSummary(
4001
+ entries: readonly BoxPlotEntry[],
4002
+ formatter: ValueFormatter,
4003
+ valueLabel?: string,
4004
+ locale?: string,
4005
+ ): string | undefined {
4006
+ const t = textOf(locale)
4007
+ const label = valueLabel ?? t.valueInSentence
3706
4008
  const real = entries.filter((e): e is BoxPlotEntry & { stats: BoxStats } => e.stats !== null)
3707
4009
  if (real.length === 0) return undefined
3708
4010
  const top = real.reduce((a, b) => (b.stats.median > a.stats.median ? b : a))
3709
4011
  const low = real.reduce((a, b) => (b.stats.median < a.stats.median ? b : a))
3710
4012
  const parts = [
3711
4013
  real.length === 1
3712
- ? `${top.name}の${valueLabel}の中央値は ${formatter(top.stats.median)}`
3713
- : `${valueLabel}の中央値が最も高いのは ${top.name}(${formatter(top.stats.median)})、最も低いのは ${low.name}(${formatter(low.stats.median)})`,
4014
+ ? t.medianOf(top.name, label, formatter(top.stats.median))
4015
+ : t.medianRange(label, top.name, formatter(top.stats.median), low.name, formatter(low.stats.median)),
3714
4016
  ]
3715
4017
  const outliers = real.reduce((sum, e) => sum + e.stats.outliers.length, 0)
3716
- if (outliers > 0) parts.push(`外れ値は ${outliers} 件`)
3717
- return parts.join("。")
4018
+ if (outliers > 0) parts.push(t.outliers(outliers))
4019
+ return parts.join(t.sentenceJoin)
3718
4020
  }
3719
4021
 
3720
4022
  /** The screen-reader table: one row per group with n, the quartiles, the whisker ends and the outliers. */
3721
- export function boxPlotDataTable(entries: readonly BoxPlotEntry[], formatter: ValueFormatter): ChartDataTable {
4023
+ export function boxPlotDataTable(entries: readonly BoxPlotEntry[], formatter: ValueFormatter, locale?: string): ChartDataTable {
4024
+ const t = textOf(locale)
3722
4025
  const f = (n: number | undefined) => (n === undefined ? EMPTY_VALUE : formatter(n))
3723
4026
  return {
3724
- columns: ["群", "件数", "最小(ひげ)", "第1四分位", "中央値", "第3四分位", "最大(ひげ)", "外れ値"],
4027
+ columns: [...t.columns],
3725
4028
  rows: entries.map(({ name, stats: s }) => [
3726
4029
  name,
3727
4030
  s ? `${s.count}` : EMPTY_VALUE,
@@ -3730,7 +4033,7 @@ export function boxPlotDataTable(entries: readonly BoxPlotEntry[], formatter: Va
3730
4033
  f(s?.median),
3731
4034
  f(s?.q3),
3732
4035
  f(s?.whiskerHigh),
3733
- s ? (s.outliers.length > 0 ? s.outliers.map(formatter).join("、") : "なし") : EMPTY_VALUE,
4036
+ s ? (s.outliers.length > 0 ? s.outliers.map(formatter).join(t.listJoin) : t.none) : EMPTY_VALUE,
3734
4037
  ]),
3735
4038
  }
3736
4039
  }
@@ -3747,6 +4050,8 @@ export interface BoxPlotOptionConfig {
3747
4050
  showTooltip?: boolean
3748
4051
  labelColor?: string
3749
4052
  chartOptions?: EChartsOption
4053
+ /** Language of the tooltip. Default the page's locale (`<html lang>`). */
4054
+ locale?: string
3750
4055
  }
3751
4056
 
3752
4057
  const ROW_STYLE = "display:flex;align-items:center;gap:8px;line-height:20px"
@@ -3792,7 +4097,8 @@ export function boxPlotRenderer(color: string, outliers: readonly (readonly numb
3792
4097
 
3793
4098
  /** The ECharts option: one `custom` series, a box per group. Groups with no value draw nothing. */
3794
4099
  export function buildBoxPlotOption(config: BoxPlotOptionConfig): EChartsOption {
3795
- const { entries, valueFormatter, showTooltip = true, valueLabel = "値" } = config
4100
+ const t = textOf(config.locale)
4101
+ const { entries, valueFormatter, showTooltip = true, valueLabel = t.value } = config
3796
4102
  const axisFormatter = config.axisFormatter ?? valueFormatter
3797
4103
  const color = config.color || "#888"
3798
4104
  // [index, whisker low, Q1, median, Q3, whisker high, min, max]: the last two stretch the value axis over the outliers.
@@ -3813,13 +4119,13 @@ export function buildBoxPlotOption(config: BoxPlotOptionConfig): EChartsOption {
3813
4119
  `<div style="${ROW_STYLE}"><span style="flex:1;opacity:.8">${escapeHtml(name)}</span>` +
3814
4120
  `<span style="font-weight:500;font-variant-numeric:tabular-nums;margin-left:12px">${escapeHtml(text)}</span></div>`
3815
4121
  return (
3816
- `<div style="font-weight:500;margin-bottom:4px">${escapeHtml(entry.name)}(${escapeHtml(valueLabel)}・${s.count}件)</div>` +
3817
- line("最大(ひげ)", valueFormatter(s.whiskerHigh)) +
3818
- line("第3四分位", valueFormatter(s.q3)) +
3819
- line("中央値", valueFormatter(s.median)) +
3820
- line("第1四分位", valueFormatter(s.q1)) +
3821
- line("最小(ひげ)", valueFormatter(s.whiskerLow)) +
3822
- (s.outliers.length > 0 ? line("外れ値", s.outliers.map(valueFormatter).join("、")) : "")
4122
+ `<div style="font-weight:500;margin-bottom:4px">${escapeHtml(t.tooltipTitle(entry.name, valueLabel, s.count))}</div>` +
4123
+ line(t.max, valueFormatter(s.whiskerHigh)) +
4124
+ line(t.q3, valueFormatter(s.q3)) +
4125
+ line(t.median, valueFormatter(s.median)) +
4126
+ line(t.q1, valueFormatter(s.q1)) +
4127
+ line(t.min, valueFormatter(s.whiskerLow)) +
4128
+ (s.outliers.length > 0 ? line(t.outlierRow, s.outliers.map(valueFormatter).join(t.listJoin)) : "")
3823
4129
  )
3824
4130
  },
3825
4131
  },
@@ -3871,19 +4177,21 @@ export const BoxPlotChart = forwardRef(function BoxPlotChart(
3871
4177
  ref: ForwardedRef<HTMLDivElement>,
3872
4178
  ) {
3873
4179
  const { ref: frameRef, tokens } = useChartSurface(ref)
3874
- const valueFormatter = useMemo(() => resolveValueFormatter(valueFormatterProp, format), [valueFormatterProp, format])
4180
+ const locale = useLocale()
4181
+ const t = TEXT[uiLanguage(locale)]
4182
+ const valueFormatter = useMemo(() => resolveValueFormatter(valueFormatterProp, format, locale), [valueFormatterProp, format, locale])
3875
4183
  const entries = useMemo(() => boxPlotEntries(groups, whisker), [groups, whisker])
3876
4184
  const [color = ""] = useMemo(() => resolveChartColors(["chart-1"], tokens), [tokens])
3877
4185
  const option = useMemo(
3878
- () => buildBoxPlotOption({ entries, color, valueFormatter, valueLabel, showTooltip, labelColor: tokens?.foreground ?? undefined, chartOptions }),
3879
- [entries, color, valueFormatter, valueLabel, showTooltip, tokens, chartOptions],
4186
+ () => buildBoxPlotOption({ entries, color, valueFormatter, valueLabel, showTooltip, labelColor: tokens?.foreground ?? undefined, chartOptions, locale }),
4187
+ [entries, color, valueFormatter, valueLabel, showTooltip, tokens, chartOptions, locale],
3880
4188
  )
3881
- const dataTable = useMemo(() => boxPlotDataTable(entries, valueFormatter), [entries, valueFormatter])
4189
+ const dataTable = useMemo(() => boxPlotDataTable(entries, valueFormatter, locale), [entries, valueFormatter, locale])
3882
4190
  const resolvedSummary = useMemo(
3883
- () => (summary === "auto" ? buildBoxPlotSummary(entries, valueFormatter, valueLabel) : summary),
3884
- [summary, entries, valueFormatter, valueLabel],
4191
+ () => (summary === "auto" ? buildBoxPlotSummary(entries, valueFormatter, valueLabel, locale) : summary),
4192
+ [summary, entries, valueFormatter, valueLabel, locale],
3885
4193
  )
3886
- const label = ariaLabel ?? `${valueLabel ?? "値"}の箱ひげ図`
4194
+ const label = ariaLabel ?? t.chart(valueLabel ?? t.valueInSentence)
3887
4195
  return (
3888
4196
  <ChartFrame
3889
4197
  {...chartRootProps(rest)}