@squadbase/vantage 0.6.2-dev1.54bde93 → 0.6.2-dev1.5c79ef5

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 (87) hide show
  1. package/dist/{build-A23M4GCH.js → build-U7GO26HR.js} +3 -3
  2. package/dist/{build-A23M4GCH.js.map → build-U7GO26HR.js.map} +1 -1
  3. package/dist/{check-FBHT6W6J.js → check-MRVAIHG5.js} +3 -3
  4. package/dist/{check-FBHT6W6J.js.map → check-MRVAIHG5.js.map} +1 -1
  5. package/dist/{chunk-57GHR5LN.js → chunk-3RJEOP3J.js} +29 -18
  6. package/dist/chunk-3RJEOP3J.js.map +1 -0
  7. package/dist/{chunk-QOXXOFGL.js → chunk-77CISBMG.js} +994 -464
  8. package/dist/chunk-77CISBMG.js.map +1 -0
  9. package/dist/{chunk-JXFCSF3K.js → chunk-EG4PX726.js} +12 -7
  10. package/dist/chunk-EG4PX726.js.map +1 -0
  11. package/dist/{chunk-2N72FKLB.js → chunk-NZ5QIIG4.js} +33 -11
  12. package/dist/chunk-NZ5QIIG4.js.map +1 -0
  13. package/dist/{chunk-5C2SEDPH.js → chunk-PJ4SPXHS.js} +4 -4
  14. package/dist/{chunk-5C2SEDPH.js.map → chunk-PJ4SPXHS.js.map} +1 -1
  15. package/dist/{chunk-7S52M2SL.js → chunk-QNOX7WLZ.js} +3 -3
  16. package/dist/{chunk-7S52M2SL.js.map → chunk-QNOX7WLZ.js.map} +1 -1
  17. package/dist/{chunk-PDOBHMUQ.js → chunk-ROHNXP7J.js} +72 -3
  18. package/dist/chunk-ROHNXP7J.js.map +1 -0
  19. package/dist/{chunk-6OTTGAPO.js → chunk-TRMHNMMZ.js} +24 -7
  20. package/dist/chunk-TRMHNMMZ.js.map +1 -0
  21. package/dist/cli.js +4 -4
  22. package/dist/components/index.d.ts +49 -29
  23. package/dist/components/index.js +5 -5
  24. package/dist/{delta-text-CvwBGJ95.d.ts → delta-text-CbrOaW83.d.ts} +4 -2
  25. package/dist/{dev-H4I2JL63.js → dev-O42JFQ64.js} +3 -3
  26. package/dist/{dev-H4I2JL63.js.map → dev-O42JFQ64.js.map} +1 -1
  27. package/dist/doc/index.d.ts +14 -14
  28. package/dist/doc/index.js +198 -84
  29. package/dist/doc/index.js.map +1 -1
  30. package/dist/{doctor-B2O3GJDU.js → doctor-YD3XAAOT.js} +3 -3
  31. package/dist/{doctor-B2O3GJDU.js.map → doctor-YD3XAAOT.js.map} +1 -1
  32. package/dist/{format-U0cQl069.d.ts → format-DihQkL1N.d.ts} +17 -11
  33. package/dist/slide/index.d.ts +2 -2
  34. package/dist/slide/index.js +95 -44
  35. package/dist/slide/index.js.map +1 -1
  36. package/dist/table/index.js +6 -6
  37. package/dist/ui/index.d.ts +1 -1
  38. package/dist/ui/index.js +5 -5
  39. package/docs/en/agent-skills.md +1 -1
  40. package/docs/ja/agent-skills.md +1 -1
  41. package/package.json +2 -2
  42. package/registry/ui/app-shell.tsx +138 -1
  43. package/registry/ui/area-chart.tsx +344 -85
  44. package/registry/ui/attention-list.tsx +177 -15
  45. package/registry/ui/bar-chart.tsx +341 -85
  46. package/registry/ui/bar-list.tsx +332 -77
  47. package/registry/ui/box-plot-chart.tsx +405 -97
  48. package/registry/ui/category-bar.tsx +248 -40
  49. package/registry/ui/chart-frame.tsx +257 -46
  50. package/registry/ui/data-table.tsx +229 -37
  51. package/registry/ui/date-range-picker.tsx +190 -36
  52. package/registry/ui/delta.tsx +171 -12
  53. package/registry/ui/donut-chart.tsx +339 -84
  54. package/registry/ui/echart.tsx +142 -4
  55. package/registry/ui/filter-bar.tsx +179 -20
  56. package/registry/ui/funnel-steps.tsx +354 -80
  57. package/registry/ui/heatmap.tsx +293 -52
  58. package/registry/ui/histogram-chart.tsx +395 -97
  59. package/registry/ui/kpi-card.tsx +245 -44
  60. package/registry/ui/legend.tsx +330 -76
  61. package/registry/ui/line-chart.tsx +344 -85
  62. package/registry/ui/multi-select.tsx +159 -9
  63. package/registry/ui/page-shell.tsx +141 -3
  64. package/registry/ui/paired-chart.tsx +349 -90
  65. package/registry/ui/progress-circle.tsx +205 -39
  66. package/registry/ui/sample-data.tsx +153 -9
  67. package/registry/ui/scatter-chart.tsx +391 -100
  68. package/registry/ui/searchable-select.tsx +145 -7
  69. package/registry/ui/states.tsx +170 -13
  70. package/registry/ui/status-badge.tsx +193 -28
  71. package/registry/ui/tracker.tsx +205 -39
  72. package/registry/ui/trend-indicator.tsx +149 -6
  73. package/registry/ui/updated-at.tsx +170 -16
  74. package/registry/ui/waterfall-chart.tsx +415 -102
  75. package/skills/vantage-design/SKILL.md +25 -27
  76. package/skills/vantage-design/references/extract.md +10 -13
  77. package/skills/vantage-design/references/format.md +12 -173
  78. package/skills/vantage-design/references/presets.md +0 -10
  79. package/templates/AGENTS.md +29 -15
  80. package/templates/agents/page.md +1 -1
  81. package/templates/agents/slide.md +7 -1
  82. package/dist/chunk-2N72FKLB.js.map +0 -1
  83. package/dist/chunk-57GHR5LN.js.map +0 -1
  84. package/dist/chunk-6OTTGAPO.js.map +0 -1
  85. package/dist/chunk-JXFCSF3K.js.map +0 -1
  86. package/dist/chunk-PDOBHMUQ.js.map +0 -1
  87. package/dist/chunk-QOXXOFGL.js.map +0 -1
@@ -29,6 +29,7 @@ import { ChartFrame } from "@squadbase/vantage/components"
29
29
  import { EChart } from "@squadbase/vantage/components"
30
30
  import { EMPTY_VALUE, type ValueFormat } from "@squadbase/vantage/components"
31
31
  import type { ChartCommonProps, ChartEmptyState, ChartSeriesColor, NumericKeys, ValueFormatter } from "@squadbase/vantage/components"
32
+ import { useSyncExternalStore } from "react"
32
33
  import { createContext, useCallback, useEffect, useRef, useState, type RefObject } from "react"
33
34
  import type { ChartTone } from "@squadbase/vantage/components"
34
35
  import { type ReactNode } from "react"
@@ -38,6 +39,137 @@ import type { ChartThreshold, ChartAnnotation, ChartEventPayload, ChartForecast,
38
39
  import { formatValue, toValueFormatter } from "@squadbase/vantage/components"
39
40
  import type { ChartColor, ChartColors } from "@squadbase/vantage/components"
40
41
 
42
+ /* ---------- inlined from @squadbase/squadkit registry/lib/locale.ts (internal to @squadbase/vantage) ---------- */
43
+
44
+ /**
45
+ * The display language of the parts and the defaults that follow it.
46
+ *
47
+ * The language comes from `<html lang>`: a tag starting with `ja` reads Japanese, anything else English.
48
+ * Without a usable `lang` (missing, invalid, or no `document` — SSR, Node) the locale is `FALLBACK_LOCALE`
49
+ * (`en-US`). A `locale` passed explicitly (a prop, an argument) always wins.
50
+ *
51
+ * Parts read the locale through `useLocale` (`hooks/use-locale`) and pass it down; the pure helpers here
52
+ * and in `format` / `comparison` / `chart-*` take it as an argument and fall back to `documentLocale()`.
53
+ */
54
+
55
+ /** The language of the built-in wording: Japanese or English. */
56
+ type UiLanguage = "ja" | "en"
57
+
58
+ /** The locale used when `<html lang>` gives none (and on the server). */
59
+ const FALLBACK_LOCALE = "en-US"
60
+
61
+ /** `"ja"` when `locale` starts with `ja` (`ja`, `ja-JP`), else `"en"`. A missing locale is `"en"`. */
62
+ function uiLanguage(locale: string | null | undefined): UiLanguage {
63
+ return typeof locale === "string" && locale.trim().toLowerCase().startsWith("ja") ? "ja" : "en"
64
+ }
65
+
66
+ /** A BCP 47 tag in its canonical form (`en-gb` → `en-GB`), or `FALLBACK_LOCALE` when it is empty or invalid. */
67
+ function normalizeLocale(tag: string | null | undefined): string {
68
+ const value = tag?.trim()
69
+ if (!value) return FALLBACK_LOCALE
70
+ try {
71
+ return Intl.getCanonicalLocales(value)[0] ?? FALLBACK_LOCALE
72
+ } catch {
73
+ return FALLBACK_LOCALE
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The locale of the page: `document.documentElement.lang`, canonicalized. `FALLBACK_LOCALE` when it is empty or
79
+ * invalid, or there is no `document`. Parts read it through `useLocale`, never while rendering.
80
+ */
81
+ function documentLocale(): string {
82
+ if (typeof document === "undefined") return FALLBACK_LOCALE
83
+ return normalizeLocale(document.documentElement?.getAttribute("lang"))
84
+ }
85
+
86
+ /** `locale` when given, else the page's locale (`documentLocale()`). */
87
+ function resolveLocale(locale?: string | null): string {
88
+ return locale || documentLocale()
89
+ }
90
+
91
+ /** Picks the wording table of the locale's language. */
92
+ function pickLocale<T>(table: { ja: T; en: T }, locale?: string | null): T {
93
+ return table[uiLanguage(resolveLocale(locale))]
94
+ }
95
+
96
+ /** The currency `format="currency"` uses without a `currency`: JPY for Japanese, USD otherwise. */
97
+ function defaultCurrency(locale?: string | null): string {
98
+ return uiLanguage(resolveLocale(locale)) === "ja" ? "JPY" : "USD"
99
+ }
100
+
101
+ /** The month a fiscal year starts in (1–12) when none is given: April for Japanese, January otherwise. */
102
+ function defaultFiscalYearStartMonth(locale?: string | null): number {
103
+ return uiLanguage(resolveLocale(locale)) === "ja" ? 4 : 1
104
+ }
105
+
106
+ /** A day of the week, 0 = Sunday … 6 = Saturday (the `Date#getDay` / date-fns numbering). */
107
+ type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6
108
+
109
+ /** Locales whose week starts on Sunday when the runtime has no week data. */
110
+ const SUNDAY_FIRST = new Set(["en", "en-US", "en-CA"])
111
+
112
+ /**
113
+ * The first day of the week: Monday for Japanese; otherwise the locale's convention from `Intl.Locale`
114
+ * (`en-US` Sunday, `en-GB` Monday). Without week data in the runtime: Sunday for `en` / `en-US` / `en-CA`,
115
+ * Monday for the rest.
116
+ */
117
+ function weekStartsOn(locale?: string | null): WeekDay {
118
+ const tag = normalizeLocale(resolveLocale(locale))
119
+ if (uiLanguage(tag) === "ja") return 1
120
+ try {
121
+ const info = new Intl.Locale(tag) as Intl.Locale & {
122
+ getWeekInfo?: () => { firstDay: number }
123
+ weekInfo?: { firstDay: number }
124
+ }
125
+ const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay
126
+ if (typeof firstDay === "number" && firstDay >= 1 && firstDay <= 7) return (firstDay % 7) as WeekDay
127
+ } catch {
128
+ // Fall through to the table below.
129
+ }
130
+ return SUNDAY_FIRST.has(tag) ? 0 : 1
131
+ }
132
+
133
+ /* ---------- inlined from @squadbase/squadkit registry/hooks/use-locale.ts (internal to @squadbase/vantage) ---------- */
134
+
135
+ const listeners = new Set<() => void>()
136
+ let observer: MutationObserver | null = null
137
+
138
+ /** One observer on `<html lang>` for every subscribed part. */
139
+ function subscribe(onChange: () => void): () => void {
140
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {}
141
+ listeners.add(onChange)
142
+ if (!observer) {
143
+ observer = new MutationObserver(() => {
144
+ for (const listener of [...listeners]) listener()
145
+ })
146
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] })
147
+ }
148
+ return () => {
149
+ listeners.delete(onChange)
150
+ if (listeners.size === 0 && observer) {
151
+ observer.disconnect()
152
+ observer = null
153
+ }
154
+ }
155
+ }
156
+
157
+ const serverLocale = () => FALLBACK_LOCALE
158
+
159
+ /**
160
+ * The page's locale (`<html lang>`), following changes made while the page runs. On the server and during
161
+ * hydration it is `FALLBACK_LOCALE` (`en-US`), so the markup matches; it switches right after.
162
+ */
163
+ function useDocumentLocale(): string {
164
+ return useSyncExternalStore(subscribe, documentLocale, serverLocale)
165
+ }
166
+
167
+ /** The locale a part renders in: its `locale` prop when given, else the page's (`useDocumentLocale`). */
168
+ function useLocale(locale?: string | null): string {
169
+ const page = useDocumentLocale()
170
+ return locale || page
171
+ }
172
+
41
173
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-theme.ts (internal to @squadbase/vantage) ---------- */
42
174
 
43
175
  /**
@@ -436,6 +568,32 @@ function useChartSurface<E extends Element>(
436
568
 
437
569
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-options.ts (internal to @squadbase/vantage) ---------- */
438
570
 
571
+ /** The built-in words of the option builders, by language. */
572
+ const CHART_TEXT = {
573
+ ja: {
574
+ target: "目標",
575
+ forecast: "予測",
576
+ total: "合計",
577
+ provisional: "速報",
578
+ noComparison: "比較なし",
579
+ share: (share: string) => `(${share})`,
580
+ targetRate: (rate: string) => `(達成率 ${rate})`,
581
+ provisionalTitle: (title: string, label: string) => `${title}(${label})`,
582
+ },
583
+ en: {
584
+ target: "Target",
585
+ forecast: "Forecast",
586
+ total: "Total",
587
+ provisional: "Preliminary",
588
+ noComparison: "No comparison",
589
+ share: (share: string) => ` (${share})`,
590
+ targetRate: (rate: string) => ` (${rate} achieved)`,
591
+ provisionalTitle: (title: string, label: string) => `${title} (${label})`,
592
+ },
593
+ } satisfies Record<UiLanguage, unknown>
594
+
595
+ const chartText = (locale?: string) => CHART_TEXT[uiLanguage(resolveLocale(locale))]
596
+
439
597
  type CartesianChartKind = "area" | "bar" | "line"
440
598
  type CartesianStackType = "default" | "stacked" | "percent"
441
599
  type CartesianCurveType = "linear" | "smooth" | "step"
@@ -620,7 +778,7 @@ interface CartesianChartConfig {
620
778
  * all ticks read in one unit (¥0.5億 / ¥1億). Default: `axisFormatter` for every tick.
621
779
  */
622
780
  axisTicks?: (top: number) => (value: number) => string
623
- /** Locale of the time-axis labels. Default `"ja-JP"`. */
781
+ /** Locale of the time-axis labels and the built-in words (total, target, forecast). Default the page's (`<html lang>`). */
624
782
  locale?: string
625
783
  type?: CartesianStackType
626
784
  /** Bar only. `"vertical"` lays the categories down the y axis (horizontal bars). */
@@ -858,11 +1016,12 @@ function timeAxisLabelStep(values: readonly unknown[], maxLabels: number = TIME_
858
1016
  */
859
1017
  function timeAxisLabels(
860
1018
  values: readonly unknown[],
861
- locale: string = DEFAULT_LOCALE,
1019
+ localeArg?: string,
862
1020
  shown: (i: number) => boolean = () => true,
863
1021
  ): string[] | null {
864
1022
  const parsed = parseTimeIndex(values)
865
1023
  if (!parsed) return null
1024
+ const locale = resolveLocale(localeArg)
866
1025
  const { granularity, times } = parsed
867
1026
  let prevYear: number | null = null
868
1027
  return times.map((t, i) => {
@@ -885,9 +1044,10 @@ function timeAxisLabels(
885
1044
  }
886
1045
 
887
1046
  /** The tooltip titles for time-like x values (`2025年9月`, `2025年9月1日`), or `null` when they are not dates. */
888
- function timeTooltipLabels(values: readonly unknown[], locale: string = DEFAULT_LOCALE): string[] | null {
1047
+ function timeTooltipLabels(values: readonly unknown[], localeArg?: string): string[] | null {
889
1048
  const parsed = parseTimeIndex(values)
890
1049
  if (!parsed) return null
1050
+ const locale = resolveLocale(localeArg)
891
1051
  const options: Intl.DateTimeFormatOptions =
892
1052
  parsed.granularity === "year"
893
1053
  ? { year: "numeric" }
@@ -1027,7 +1187,9 @@ function tooltipHtml(
1027
1187
  rows: readonly TooltipRow[],
1028
1188
  valueFormatter: (value: number) => string,
1029
1189
  notes: readonly string[] = [],
1190
+ locale?: string,
1030
1191
  ): string {
1192
+ const t = chartText(locale)
1031
1193
  const lines = rows.map(
1032
1194
  (row) =>
1033
1195
  `<div style="display:flex;align-items:center;gap:8px;line-height:20px">` +
@@ -1036,7 +1198,7 @@ function tooltipHtml(
1036
1198
  `<span style="font-weight:600;font-variant-numeric:tabular-nums;margin-left:12px">${
1037
1199
  row.value === null ? EMPTY_VALUE : escapeHtml(valueFormatter(row.value))
1038
1200
  }${
1039
- row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px">(${escapeHtml(formatShare(row.share))})</span>`
1201
+ row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px">${escapeHtml(t.share(formatShare(row.share)))}</span>`
1040
1202
  }</span></div>` +
1041
1203
  (row.note === undefined
1042
1204
  ? ""
@@ -1050,8 +1212,10 @@ function tooltipHtml(
1050
1212
 
1051
1213
  /* ------------------------------------------------- per-bar targets / notes */
1052
1214
 
1053
- /** Default name of the per-bar target marker (legend, tooltip). */
1054
- const BAR_TARGET_LABEL = "目標"
1215
+ /** Default name of the per-bar target marker (legend, tooltip) in `locale`: "Target" / 「目標」. Default the page's locale. */
1216
+ function barTargetLabel(locale?: string): string {
1217
+ return chartText(locale).target
1218
+ }
1055
1219
 
1056
1220
  /** How far (px) the target marker reaches past each side of its bar. */
1057
1221
  const TARGET_MARKER_OVERHANG = 4
@@ -1102,16 +1266,17 @@ function achievementRate(value: number | null | undefined, target: number | null
1102
1266
  return (value / target) * 100
1103
1267
  }
1104
1268
 
1105
- /** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3%)」. `null` without a target. */
1269
+ /** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3%)」 / "Target $12M (85.3% achieved)". `null` without a target. */
1106
1270
  function barTargetNote(
1107
1271
  label: string,
1108
1272
  value: number | null,
1109
1273
  target: number | null,
1110
1274
  valueFormatter: (value: number) => string,
1275
+ locale?: string,
1111
1276
  ): string | null {
1112
1277
  if (target === null) return null
1113
1278
  const rate = achievementRate(value, target)
1114
- return `${label} ${valueFormatter(target)}${rate === null ? "" : `(達成率 ${formatShare(rate)})`}`
1279
+ return `${label} ${valueFormatter(target)}${rate === null ? "" : chartText(locale).targetRate(formatShare(rate))}`
1115
1280
  }
1116
1281
 
1117
1282
  /** Half the width of a bar in category-axis units, from `barCategoryGap` (`"20%"`; a px gap or none → ECharts' 20%). */
@@ -1203,11 +1368,12 @@ const TARGET_LEGEND_SERIES_ID = "__target-legend"
1203
1368
  /** A flat tick (8×8 legend box, aspect kept): the per-bar target's legend icon. */
1204
1369
  const TARGET_LEGEND_ICON = "path://M0,0 L100,0 L100,24 L0,24 Z"
1205
1370
 
1206
- const DEFAULT_FORECAST_LABEL = "予測"
1207
-
1208
- /** 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. */
1209
- function forecastBoundaryText(label: string = DEFAULT_FORECAST_LABEL): string {
1210
- return `${label} →`
1371
+ /**
1372
+ * 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.
1373
+ * Without a `label`, "Forecast" / 「予測」 in `locale` (default the page's).
1374
+ */
1375
+ function forecastBoundaryText(label?: string, locale?: string): string {
1376
+ return `${label ?? chartText(locale).forecast} →`
1211
1377
  }
1212
1378
 
1213
1379
  /** A comparison series: a thin, muted, solid line with no markers (60% of `--muted-foreground`). */
@@ -1247,8 +1413,10 @@ interface AxisExtent {
1247
1413
  max: number
1248
1414
  }
1249
1415
 
1250
- /** The label of a stack's total (highlight and tooltip). */
1251
- const STACK_TOTAL_LABEL = "合計"
1416
+ /** The label of a stack's total (highlight and tooltip) in `locale`: "Total" / 「合計」. Default the page's locale. */
1417
+ function stackTotalLabel(locale?: string): string {
1418
+ return chartText(locale).total
1419
+ }
1252
1420
 
1253
1421
  /** Per point, the sum of the stacked series; `null` where every series is missing. */
1254
1422
  function stackTotalsOf(values: readonly (readonly (number | null)[])[]): (number | null)[] {
@@ -1370,8 +1538,8 @@ function indexGrain(values: readonly unknown[]): PeriodGrain | null {
1370
1538
 
1371
1539
  /**
1372
1540
  * The tooltip line under the main series' row when a comparison series is drawn: the arrow, the signed change and the
1373
- * comparison's name, as `Delta` writes it ("↑ +12.4% 前年同月比"; a minus is U+2212). "比較なし 前年同月比" when
1374
- * either value is missing or the baseline is 0.
1541
+ * comparison's name, as `Delta` writes it ("↑ +12.4% vs. same month last year" / "↑ +12.4% 前年同月比"; a minus is U+2212).
1542
+ * "No comparison vs. same month last year" / "比較なし 前年同月比" when either value is missing or the baseline is 0.
1375
1543
  */
1376
1544
  function comparisonNote(
1377
1545
  current: number | null,
@@ -1379,16 +1547,18 @@ function comparisonNote(
1379
1547
  comparison: Pick<CartesianComparisonConfig, "vs" | "label">,
1380
1548
  grain?: PeriodGrain,
1381
1549
  format?: ValueFormat,
1382
- locale: string = DEFAULT_LOCALE,
1550
+ localeArg?: string,
1383
1551
  ): string {
1552
+ const locale = resolveLocale(localeArg)
1384
1553
  const result = computeComparison(
1385
1554
  current,
1386
1555
  comparison.vs === "custom"
1387
1556
  ? { previous, vs: "custom", label: comparison.label ?? "", grain }
1388
1557
  : { previous, vs: comparison.vs, label: comparison.label, grain },
1558
+ { locale },
1389
1559
  )
1390
1560
  const text = formatChange(result, defaultChangeDisplay(format), locale, format)
1391
- if (text === null) return `比較なし ${result.label}`
1561
+ if (text === null) return `${chartText(locale).noComparison} ${result.label}`
1392
1562
  const arrow = result.direction === "up" ? "↑" : result.direction === "down" ? "↓" : "→"
1393
1563
  return `${arrow} ${text.replace(/^-/, "−")} ${result.label}`
1394
1564
  }
@@ -1431,7 +1601,6 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1431
1601
  toneColors,
1432
1602
  valueFormatter,
1433
1603
  axisFormatter = valueFormatter,
1434
- locale = DEFAULT_LOCALE,
1435
1604
  type = "default",
1436
1605
  layout = "horizontal",
1437
1606
  curveType = "linear",
@@ -1468,7 +1637,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1468
1637
  chartOptions,
1469
1638
  reserveActions = false,
1470
1639
  } = cfg
1471
- const provisionalLabel = cfg.provisionalLabel ?? DEFAULT_PROVISIONAL_LABEL
1640
+ const locale = resolveLocale(cfg.locale)
1641
+ const text = chartText(locale)
1642
+ const provisionalLabel = cfg.provisionalLabel ?? text.provisional
1472
1643
 
1473
1644
  const stacked = kind !== "line" && type !== "default"
1474
1645
  const percent = kind !== "line" && type === "percent"
@@ -1617,9 +1788,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1617
1788
  // `barOptions.targetKey` (a target per bar) takes over from the single `target` line.
1618
1789
  const perBarTarget = kind === "bar" && Boolean(targetKey)
1619
1790
  const hasTarget = !perBarTarget && target !== undefined && Number.isFinite(target)
1620
- const targetText = hasTarget ? (targetLabel ?? `目標 ${axisFormatter(target)}`) : ""
1791
+ const targetText = hasTarget ? (targetLabel ?? `${text.target} ${axisFormatter(target)}`) : ""
1621
1792
  const highlightText = (value: number) =>
1622
- shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${STACK_TOTAL_LABEL} ` : ""}${axisFormatter(value)}`
1793
+ shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${text.total} ` : ""}${axisFormatter(value)}`
1623
1794
  /** Whether `p`'s label sits within the target's value band and under the target label (at the right end). */
1624
1795
  const nearTargetLabel = (p: HighlightPoint, side: "under" | "over") => {
1625
1796
  if (!hasTarget || !plotWidth || kind === "bar" || labels.length < 2) return false
@@ -1645,7 +1816,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1645
1816
  // One series (or a stack, against its total) can be compared to a target; grouped series and 100% stacks cannot.
1646
1817
  const barTargets: (number | null)[] | null =
1647
1818
  perBarTarget && targetKey && !percent && (stacked || categories.length === 1) ? barTargetValues(data, targetKey) : null
1648
- const barTargetName = targetLabel ?? BAR_TARGET_LABEL
1819
+ const barTargetName = targetLabel ?? text.target
1649
1820
  const barTargetColor = labelColor ?? guideColor
1650
1821
 
1651
1822
  const markPointFor = (c: number, color: string | undefined) =>
@@ -1859,7 +2030,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1859
2030
  }
1860
2031
 
1861
2032
  /* -- forecast: dashed continuation (+ optional band) of one series */
1862
- const forecastLabel = forecast?.label ?? DEFAULT_FORECAST_LABEL
2033
+ const forecastLabel = forecast?.label ?? text.forecast
1863
2034
  const forecastBandAlpha = isDarkSurface(labelBackground) ? FORECAST_BAND_ALPHA_DARK : FORECAST_BAND_ALPHA_LIGHT
1864
2035
  const forecastValues: (number | null)[] = []
1865
2036
  if (forecast) {
@@ -2335,7 +2506,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2335
2506
  })
2336
2507
  }
2337
2508
  if (stackTotals && i <= lastActual && rows.length > 1) {
2338
- rows.push({ name: STACK_TOTAL_LABEL, color: "transparent", value: stackTotals[i] ?? null })
2509
+ rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
2339
2510
  }
2340
2511
  const provisional = provStart !== -1 && i >= provStart && i <= lastActual
2341
2512
  if (provisional) {
@@ -2351,19 +2522,19 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2351
2522
  ...noteFor(at),
2352
2523
  })
2353
2524
  })
2354
- if (stackTotals && rows.length > 1) rows.push({ name: STACK_TOTAL_LABEL, color: "transparent", value: stackTotals[i] ?? null })
2525
+ if (stackTotals && rows.length > 1) rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
2355
2526
  }
2356
2527
  const title = tooltipTitles[i] ?? labels[i] ?? ""
2357
2528
  const lines: string[] = []
2358
2529
  if (barTargets && i <= lastActual) {
2359
2530
  const total = stackTotals ? (stackTotals[i] ?? null) : (values[0]?.[i] ?? null)
2360
- const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter)
2531
+ const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter, locale)
2361
2532
  if (note) lines.push(note)
2362
2533
  }
2363
2534
  for (const note of notes) {
2364
2535
  if (note.to === undefined ? note.from === i : note.from <= i && i <= note.to) lines.push(note.label)
2365
2536
  }
2366
- return tooltipHtml(provisional ? `${title}(${provisionalLabel})` : title, rows, valueFormatter, lines)
2537
+ return tooltipHtml(provisional ? text.provisionalTitle(title, provisionalLabel) : title, rows, valueFormatter, lines, locale)
2367
2538
  },
2368
2539
  },
2369
2540
  xAxis: horizontalBars ? valueAxis : categoryAxis,
@@ -2455,8 +2626,6 @@ interface DonutOptionSlice {
2455
2626
  /** How many of the largest slices get an outside label (「自然検索 41%」) by default. */
2456
2627
  const DONUT_OUTSIDE_LABELS = 2
2457
2628
 
2458
- const DEFAULT_TOTAL_LABEL = "合計"
2459
-
2460
2629
  const sliceValue = (value: number) => (Number.isFinite(value) && value > 0 ? value : 0)
2461
2630
 
2462
2631
  /** A slice's share of `total` as text: whole percent, `<1%` for a sliver, `0%` for nothing. */
@@ -2489,15 +2658,16 @@ function donutLegendItems(slices: readonly DonutOptionSlice[]): DonutLegendItem[
2489
2658
  }
2490
2659
 
2491
2660
  /**
2492
- * The default center label: what is totalled (「合計」), the formatted total
2661
+ * The default center label: what is totalled (「合計」 / "Total" in `locale`, default the page's), the formatted total
2493
2662
  * (`amount`, the big line), the unit (a small line under it) and both as one
2494
2663
  * string (`value`, 「9.2万 セッション」).
2495
2664
  */
2496
2665
  function donutCenterText(
2497
2666
  total: number,
2498
2667
  valueFormatter: (value: number) => string,
2499
- { totalLabel = DEFAULT_TOTAL_LABEL, unit }: { totalLabel?: string; unit?: string } = {},
2668
+ { totalLabel, unit, locale }: { totalLabel?: string; unit?: string; locale?: string } = {},
2500
2669
  ): { label: string; value: string; amount: string; unit?: string } {
2670
+ totalLabel ??= chartText(locale).total
2501
2671
  const amount = valueFormatter(total)
2502
2672
  return { label: totalLabel, value: unit ? `${amount} ${unit}` : amount, amount, ...(unit ? { unit } : {}) }
2503
2673
  }
@@ -2725,8 +2895,10 @@ function hasLabel(labels: Readonly<Record<string, string>> | undefined, key: str
2725
2895
 
2726
2896
  /* ------------------------------------------------------------ provisional */
2727
2897
 
2728
- /** The default label for points that are not final yet. */
2729
- const DEFAULT_PROVISIONAL_LABEL = "速報"
2898
+ /** The default label for points that are not final yet, in `locale`: "Preliminary" / 「速報」. Default the page's locale. */
2899
+ function defaultProvisionalLabel(locale?: string): string {
2900
+ return chartText(locale).provisional
2901
+ }
2730
2902
 
2731
2903
  /**
2732
2904
  * Position of the first provisional point: the first entry of `indexValues`
@@ -2864,9 +3036,10 @@ function resolveValueFormatter(
2864
3036
  function resolveAxisFormatter(
2865
3037
  valueFormatter: ValueFormatter | undefined,
2866
3038
  format: ValueFormat | undefined,
2867
- locale: string = DEFAULT_LOCALE,
3039
+ localeArg?: string,
2868
3040
  ): ValueFormatter {
2869
3041
  if (valueFormatter) return valueFormatter
3042
+ const locale = resolveLocale(localeArg)
2870
3043
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
2871
3044
  if (spec.type !== "compact" && spec.type !== "number" && spec.type !== "currency") return toValueFormatter(format, locale)
2872
3045
  const currency = "currency" in spec ? spec.currency : undefined
@@ -2875,40 +3048,75 @@ function resolveAxisFormatter(
2875
3048
  return (value) => formatValue(value, short, locale)
2876
3049
  }
2877
3050
 
2878
- /** The unit words of a ja number (`1.2万` / `3.4億` / `1兆`) and their sizes, largest first. */
2879
- const JA_UNITS = [
2880
- { char: "兆", size: 1e12 },
2881
- { char: "億", size: 1e8 },
2882
- { char: "万", size: 1e4 },
2883
- ] as const
3051
+ interface CompactUnit {
3052
+ token: string
3053
+ size: number
3054
+ }
2884
3055
 
2885
- const JA_NUMBER_TOKEN_RE = /\d[\d,]*(?:\.\d+)?(?:兆|億|万)?/
3056
+ const compactUnitCache = new Map<string, CompactUnit[] | null>()
3057
+
3058
+ /**
3059
+ * The compact-notation unit words of a locale and their sizes, largest first: `兆` / `億` / `万` in ja,
3060
+ * `T` / `B` / `M` / `K` in en. `null` when the locale's compact form puts anything between the number and its
3061
+ * word (`1,5 Mio.`), where a token cannot be swapped in place.
3062
+ */
3063
+ function compactUnits(locale: string): CompactUnit[] | null {
3064
+ const cached = compactUnitCache.get(locale)
3065
+ if (cached !== undefined) return cached
3066
+ let units: CompactUnit[] | null = []
3067
+ try {
3068
+ const compact = new Intl.NumberFormat(locale, { notation: "compact" })
3069
+ const seen = new Set<string>()
3070
+ for (let exp = 3; exp <= 15; exp++) {
3071
+ const parts = compact.formatToParts(10 ** exp)
3072
+ const at = parts.findIndex((p) => p.type === "compact")
3073
+ if (at === -1) continue
3074
+ if (parts[at - 1]?.type !== "integer" || at !== parts.length - 1) {
3075
+ units = null
3076
+ break
3077
+ }
3078
+ const token = parts[at]!.value
3079
+ if (seen.has(token)) continue
3080
+ seen.add(token)
3081
+ units.push({ token, size: 10 ** exp })
3082
+ }
3083
+ } catch {
3084
+ units = null
3085
+ }
3086
+ const sorted = units && units.length > 0 ? units.sort((a, b) => b.size - a.size) : null
3087
+ compactUnitCache.set(locale, sorted)
3088
+ return sorted
3089
+ }
3090
+
3091
+ const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
2886
3092
 
2887
3093
  /**
2888
3094
  * The value-axis tick formatter for an axis whose largest tick is `top`: every tick reads in the unit of
2889
- * that largest tick, so 万 and 億 never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`).
2890
- * Only the ja short forms (`compact` / `number` / `currency`, no `valueFormatter`) have units to align;
2891
- * any other format — and an axis whose largest tick is under 1万 — keeps `resolveAxisFormatter`'s output as it is.
3095
+ * that largest tick, so units never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`;
3096
+ * `$0.5M` / `$1M` / `$1.5M`, not `$500K` / `$1M`). Only the short forms (`compact` / `number` / `currency`,
3097
+ * no `valueFormatter`) have units to align; any other format — an axis whose largest tick is under the smallest
3098
+ * unit (1万 / 1K), or a locale whose compact form cannot be aligned in place — keeps `resolveAxisFormatter`'s output as it is.
2892
3099
  */
2893
3100
  function resolveAxisTickFormatter(
2894
3101
  valueFormatter: ValueFormatter | undefined,
2895
3102
  format: ValueFormat | undefined,
2896
- locale: string = DEFAULT_LOCALE,
3103
+ localeArg?: string,
2897
3104
  ): (top: number) => ValueFormatter {
3105
+ const locale = resolveLocale(localeArg)
2898
3106
  const base = resolveAxisFormatter(valueFormatter, format, locale)
2899
3107
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
2900
- const aligns =
2901
- !valueFormatter && locale.toLowerCase().startsWith("ja") && (spec.type === "compact" || spec.type === "number" || spec.type === "currency")
2902
- if (!aligns) return () => base
3108
+ const units = !valueFormatter && (spec.type === "compact" || spec.type === "number" || spec.type === "currency") ? compactUnits(locale) : null
3109
+ if (!units) return () => base
2903
3110
  const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 3 })
3111
+ const numberToken = new RegExp(`\\d[\\d,]*(?:\\.\\d+)?(?:${units.map((u) => escapeRegExp(u.token)).join("|")})?`)
2904
3112
  return (top) => {
2905
- const unit = JA_UNITS.find((u) => Math.abs(top) >= u.size)
3113
+ const unit = units.find((u) => Math.abs(top) >= u.size)
2906
3114
  if (!unit) return base
2907
3115
  return (value) => {
2908
3116
  const text = base(value)
2909
3117
  if (value === 0 || !Number.isFinite(value)) return text
2910
- const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.char}`
2911
- return text.replace(JA_NUMBER_TOKEN_RE, () => scaled)
3118
+ const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.token}`
3119
+ return text.replace(numberToken, () => scaled)
2912
3120
  }
2913
3121
  }
2914
3122
  }
@@ -2937,8 +3145,20 @@ function clamp(value: number, min: number, max: number): number {
2937
3145
  /** How many categories a chart colours individually; the rest fold into「その他」. */
2938
3146
  const MAX_CHART_SERIES = 5
2939
3147
 
2940
- /** The series name the folded categories get. */
2941
- const OTHER_SERIES_LABEL = "その他"
3148
+ /** The series name the folded categories get, by language. */
3149
+ const OTHER_SERIES_LABELS: Record<UiLanguage, string> = { ja: "その他", en: "Other" }
3150
+
3151
+ /** The series name the folded categories get in `locale` ("Other" / "その他"). Default the page's locale (`<html lang>`). */
3152
+ function otherSeriesLabel(locale?: string): string {
3153
+ return OTHER_SERIES_LABELS[uiLanguage(resolveLocale(locale))]
3154
+ }
3155
+
3156
+ const OTHER_NAMES = new Set(Object.values(OTHER_SERIES_LABELS))
3157
+
3158
+ /** Whether a category / row is the "other" bucket by name (「その他」 or "Other", whatever the page language). */
3159
+ function isOtherName(name: string | null | undefined): boolean {
3160
+ return typeof name === "string" && OTHER_NAMES.has(name)
3161
+ }
2942
3162
 
2943
3163
  interface AggregatedItems<Item extends { name: string; value: number }> {
2944
3164
  /** The `n` largest items, in their given order. All items when there are `n` or fewer. */
@@ -2958,7 +3178,7 @@ interface AggregatedItems<Item extends { name: string; value: number }> {
2958
3178
  function aggregateTopN<Item extends { name: string; value: number }>(
2959
3179
  items: readonly Item[],
2960
3180
  n: number = MAX_CHART_SERIES,
2961
- otherLabel: string = OTHER_SERIES_LABEL,
3181
+ otherLabel: string = otherSeriesLabel(),
2962
3182
  ): AggregatedItems<Item> {
2963
3183
  if (items.length <= n) return { top: [...items], rest: [], other: null }
2964
3184
  const ranked = items
@@ -2987,7 +3207,7 @@ function aggregateSeries(
2987
3207
  data: readonly Record<string, unknown>[],
2988
3208
  categories: readonly string[],
2989
3209
  n: number = MAX_CHART_SERIES,
2990
- otherLabel: string = OTHER_SERIES_LABEL,
3210
+ otherLabel: string = otherSeriesLabel(),
2991
3211
  ): AggregatedSeries {
2992
3212
  const totals = categories.map((name) => ({
2993
3213
  name,
@@ -3019,12 +3239,14 @@ function aggregateSeriesKeeping(
3019
3239
  categories: readonly string[],
3020
3240
  keep: string | undefined,
3021
3241
  n: number = MAX_CHART_SERIES,
3242
+ otherLabel: string = otherSeriesLabel(),
3022
3243
  ): AggregatedSeries {
3023
- if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n)
3244
+ if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n, otherLabel)
3024
3245
  const aggregated = aggregateSeries(
3025
3246
  data,
3026
3247
  categories.filter((c) => c !== keep),
3027
3248
  n,
3249
+ otherLabel,
3028
3250
  )
3029
3251
  return { ...aggregated, categories: [...aggregated.categories, keep] }
3030
3252
  }
@@ -3060,7 +3282,7 @@ function seriesColorsWithComparison(
3060
3282
  * Colour per drawn series: the record's token for that category, else the
3061
3283
  * array's token at the category's position in `allCategories` (cycled), else
3062
3284
  * the token palette by drawn position. `otherCategory` — and a category the
3063
- * data itself names「その他」(`OTHER_SERIES_LABEL`) — always gets `otherColor`.
3285
+ * data itself names「その他」/ "Other" (`OTHER_SERIES_LABELS`) — always gets `otherColor`.
3064
3286
  */
3065
3287
  function seriesColorsFor(
3066
3288
  categories: readonly string[],
@@ -3072,7 +3294,7 @@ function seriesColorsFor(
3072
3294
  const list = Array.isArray(colors) ? (colors as ChartSeriesColor[]) : undefined
3073
3295
  const record = !Array.isArray(colors) && colors ? (colors as Partial<Record<string, ChartSeriesColor>>) : undefined
3074
3296
  return categories.map((category, i) => {
3075
- if ((otherCategory !== null && category === otherCategory) || category === OTHER_SERIES_LABEL) return otherColor
3297
+ if ((otherCategory !== null && category === otherCategory) || isOtherName(category)) return otherColor
3076
3298
  const fromRecord = record?.[category]
3077
3299
  if (fromRecord) return fromRecord
3078
3300
  if (list && list.length > 0) {
@@ -3119,7 +3341,7 @@ interface DonutSlicesConfig {
3119
3341
  */
3120
3342
  function donutSlices(
3121
3343
  data: readonly Record<string, unknown>[],
3122
- { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = OTHER_SERIES_LABEL }: DonutSlicesConfig,
3344
+ { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = otherSeriesLabel() }: DonutSlicesConfig,
3123
3345
  ): DonutSlice[] {
3124
3346
  const items = data
3125
3347
  .map((row, i) => {
@@ -3132,7 +3354,8 @@ function donutSlices(
3132
3354
  const slices: DonutSlice[] = top.map((item, rank) => ({
3133
3355
  name: item.name,
3134
3356
  value: item.value,
3135
- color: palette[rank % palette.length]!,
3357
+ // A row the data itself names「その他」/ "Other" is the same remainder: grey, like the folded slice.
3358
+ color: isOtherName(item.name) ? otherColor : palette[rank % palette.length]!,
3136
3359
  isOther: false,
3137
3360
  row: item.row,
3138
3361
  }))
@@ -3205,7 +3428,7 @@ interface BarListRowsConfig extends BarListOptions {
3205
3428
  */
3206
3429
  function barListRows<Row extends object>(
3207
3430
  data: readonly Row[],
3208
- { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = OTHER_SERIES_LABEL, exclude }: BarListRowsConfig = {},
3431
+ { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = otherSeriesLabel(), exclude }: BarListRowsConfig = {},
3209
3432
  ): BarListRow<Row>[] {
3210
3433
  const items = data.map((row): BarListRow<Row> => {
3211
3434
  const cells = row as Record<string, unknown>
@@ -3251,19 +3474,21 @@ const BAR_LIST_OTHER_TINT = 30
3251
3474
 
3252
3475
  /**
3253
3476
  * The fill of each BarList row: all real rows in the same tint of `color`
3254
- * (`BAR_LIST_TINT`), the folded「その他」row in a tint of `otherColor`
3477
+ * (`BAR_LIST_TINT`), the folded「その他」row (and a row the data names「その他」/ "Other") in a tint of `otherColor`
3255
3478
  * (`BAR_LIST_OTHER_TINT`) — the palest fill, a remainder never outweighs a
3256
3479
  * ranked row. A legacy per-row `color` (or a threshold tone) replaces `color`
3257
3480
  * for that row. A tint, not the solid token, so the label printed over the bar
3258
3481
  * keeps its contrast.
3259
3482
  */
3260
3483
  function barListFills(
3261
- rows: readonly Pick<BarListRow, "isOther" | "color">[],
3484
+ rows: readonly (Pick<BarListRow, "isOther" | "color"> & { name?: string })[],
3262
3485
  color: ChartColor,
3263
3486
  otherColor: string,
3264
3487
  ): string[] {
3265
3488
  return rows.map((row) =>
3266
- row.isOther ? tintCss(otherColor, BAR_LIST_OTHER_TINT) : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
3489
+ row.isOther || isOtherName(row.name)
3490
+ ? tintCss(otherColor, BAR_LIST_OTHER_TINT)
3491
+ : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
3267
3492
  )
3268
3493
  }
3269
3494
 
@@ -3479,11 +3704,30 @@ interface ChartSummaryInput {
3479
3704
  format?: ValueFormat
3480
3705
  /** Wins over `format`. */
3481
3706
  valueFormatter?: ValueFormatter
3707
+ /** Language of the sentence. Default the page's locale (`<html lang>`). */
3708
+ locale?: string
3709
+ }
3710
+
3711
+ const SUMMARY_TEXT = {
3712
+ ja: {
3713
+ up: "、前の点より増加",
3714
+ down: "、前の点より減少",
3715
+ flat: "、前の点と横ばい",
3716
+ latest: (label: string, value: string) => `最新 ${label} は ${value}`,
3717
+ top: (name: string, share: string) => `最大は ${name}(${share})`,
3718
+ },
3719
+ en: {
3720
+ up: ", up from the previous point",
3721
+ down: ", down from the previous point",
3722
+ flat: ", flat from the previous point",
3723
+ latest: (label: string, value: string) => `Latest ${label}: ${value}`,
3724
+ top: (name: string, share: string) => `Largest: ${name} (${share})`,
3725
+ },
3482
3726
  }
3483
3727
 
3484
3728
  /**
3485
3729
  * One-sentence summary built from the rows, for `summary="auto"`:
3486
- * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」. The direction is
3730
+ * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」("<title>: Latest <index>: <value>, up from the previous point"). The direction is
3487
3731
  * computed from the last two non-empty points of the first series — never
3488
3732
  * written by hand, so it cannot contradict the data. `undefined` when there is
3489
3733
  * no usable point.
@@ -3499,26 +3743,29 @@ function buildChartSummary(input: ChartSummaryInput): string | undefined {
3499
3743
  }
3500
3744
  const latest = points[points.length - 1]
3501
3745
  if (!latest) return undefined
3502
- const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact")
3746
+ const locale = resolveLocale(input.locale)
3747
+ const t = SUMMARY_TEXT[uiLanguage(locale)]
3748
+ const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact", locale)
3503
3749
  const prev = points[points.length - 2]
3504
- const direction = !prev ? "" : latest.value > prev.value ? "、前の点より増加" : latest.value < prev.value ? "、前の点より減少" : "、前の点と横ばい"
3750
+ const direction = !prev ? "" : latest.value > prev.value ? t.up : latest.value < prev.value ? t.down : t.flat
3505
3751
  const head = input.title ? `${input.title}: ` : ""
3506
- return `${head}最新 ${latest.label} は ${text(latest.value)}${direction}`
3752
+ return `${head}${t.latest(latest.label, text(latest.value))}${direction}`
3507
3753
  }
3508
3754
 
3509
3755
  /**
3510
- * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」 from the slices
3511
- * (largest first). `undefined` when there is nothing to describe.
3756
+ * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」("<title>: Largest: <name> (<share>)") from the slices
3757
+ * (largest first). `undefined` when there is nothing to describe. `locale` picks the language; default the page's.
3512
3758
  */
3513
3759
  function buildShareSummary(
3514
3760
  slices: readonly { name: string; value: number }[],
3515
3761
  title?: string,
3762
+ locale?: string,
3516
3763
  ): string | undefined {
3517
3764
  const total = slices.reduce((sum, s) => sum + s.value, 0)
3518
3765
  const top = slices[0]
3519
3766
  if (!top || total <= 0) return undefined
3520
3767
  const share = `${Math.round((top.value / total) * 1000) / 10}%`
3521
- return `${title ? `${title}: ` : ""}最大は ${top.name}(${share})`
3768
+ return `${title ? `${title}: ` : ""}${SUMMARY_TEXT[uiLanguage(resolveLocale(locale))].top(top.name, share)}`
3522
3769
  }
3523
3770
 
3524
3771
  /* ---------- end of inlined code ---------- */
@@ -3656,12 +3903,49 @@ export interface ScatterSeries {
3656
3903
  points: ScatterPoint[]
3657
3904
  }
3658
3905
 
3906
+ const TEXT = {
3907
+ ja: {
3908
+ who: (name: string, value: string) => `${name}(${value})`,
3909
+ summary: (count: number, y: string, who: string) => `${count}点。${y}が最大なのは ${who}`,
3910
+ name: "名前",
3911
+ series: "系列",
3912
+ x: "横軸",
3913
+ y: "縦軸",
3914
+ size: "大きさ",
3915
+ pair: (y: string, x: string) => `${y}×${x}`,
3916
+ first: (pair: string, count: number) => `${pair}(先頭 ${count} 点)`,
3917
+ guide: (label: string, axis: string, value: string) => `${label}(${axis} ${value})`,
3918
+ caption: (base: string, guides: readonly string[]) => `${base}。基準線: ${guides.join("、")}`,
3919
+ chart: (y: string, x: string) => `${y}×${x}の散布図`,
3920
+ },
3921
+ en: {
3922
+ who: (name: string, value: string) => `${name} (${value})`,
3923
+ summary: (count: number, y: string, who: string) => `${count === 1 ? "1 point" : `${count} points`}. Highest ${y}: ${who}`,
3924
+ name: "Name",
3925
+ series: "Series",
3926
+ x: "X",
3927
+ y: "Y",
3928
+ size: "Size",
3929
+ pair: (y: string, x: string) => `${y} × ${x}`,
3930
+ first: (pair: string, count: number) => `${pair} (first ${count} points)`,
3931
+ guide: (label: string, axis: string, value: string) => `${label} (${axis} ${value})`,
3932
+ caption: (base: string, guides: readonly string[]) => `${base}. Reference lines: ${guides.join(", ")}`,
3933
+ chart: (y: string, x: string) => `Scatter plot of ${y} × ${x}`,
3934
+ },
3935
+ }
3936
+
3937
+ const textOf = (locale?: string) => TEXT[uiLanguage(resolveLocale(locale))]
3938
+
3659
3939
  /**
3660
3940
  * Points → series. Series keep their order of first appearance (that order is the
3661
3941
  * colour order). More than `max` of them: the `max` with the most points are kept
3662
3942
  * and the rest fold into one trailing「その他」series.
3663
3943
  */
3664
- export function scatterSeries(points: readonly ScatterPoint[], max: number = MAX_CHART_SERIES): ScatterSeries[] {
3944
+ export function scatterSeries(
3945
+ points: readonly ScatterPoint[],
3946
+ max: number = MAX_CHART_SERIES,
3947
+ otherLabel: string = otherSeriesLabel(),
3948
+ ): ScatterSeries[] {
3665
3949
  const order: string[] = []
3666
3950
  const counts = new Map<string, number>()
3667
3951
  for (const p of points) {
@@ -3676,7 +3960,7 @@ export function scatterSeries(points: readonly ScatterPoint[], max: number = MAX
3676
3960
  const result: ScatterSeries[] = order
3677
3961
  .filter((name) => kept.has(name))
3678
3962
  .map((name) => ({ name, isOther: false, points: points.filter((p) => p.series === name) }))
3679
- result.push({ name: OTHER_SERIES_LABEL, isOther: true, points: points.filter((p) => !kept.has(p.series)) })
3963
+ result.push({ name: otherLabel, isOther: true, points: points.filter((p) => !kept.has(p.series)) })
3680
3964
  return result
3681
3965
  }
3682
3966
 
@@ -3703,20 +3987,22 @@ function pointTitle(point: ScatterPoint, labels?: Record<string, string>): strin
3703
3987
  return labelOf(point.name || point.series, labels)
3704
3988
  }
3705
3989
 
3706
- /** `summary="auto"`: the point count and the highest y. `undefined` with no points. */
3990
+ /** `summary="auto"`: the point count and the highest y. `undefined` with no points. `locale` picks the language; default the page's. */
3707
3991
  export function buildScatterSummary(
3708
3992
  points: readonly ScatterPoint[],
3709
3993
  yName: string,
3710
3994
  yFormatter: ValueFormatter,
3711
3995
  title?: string,
3712
3996
  labels?: Record<string, string>,
3997
+ locale?: string,
3713
3998
  ): string | undefined {
3714
3999
  if (points.length === 0) return undefined
4000
+ const t = textOf(locale)
3715
4001
  let best = points[0]!
3716
4002
  for (const p of points) if (p.y > best.y) best = p
3717
4003
  const head = title ? `${title}: ` : ""
3718
- const who = best.name ? `${labelOf(best.name, labels)}(${yFormatter(best.y)})` : yFormatter(best.y)
3719
- return `${head}${points.length}点。${labelOf(yName, labels)}が最大なのは ${who}`
4004
+ const who = best.name ? t.who(labelOf(best.name, labels), yFormatter(best.y)) : yFormatter(best.y)
4005
+ return `${head}${t.summary(points.length, labelOf(yName, labels), who)}`
3720
4006
  }
3721
4007
 
3722
4008
  /** Cap of the screen-reader table (a scatter can hold thousands of dots). */
@@ -3734,17 +4020,20 @@ export function scatterDataTable(
3734
4020
  formatters: { x: ValueFormatter; y: ValueFormatter; size: ValueFormatter }
3735
4021
  labels?: Record<string, string>
3736
4022
  referenceLines?: readonly ScatterReferenceLine[]
4023
+ /** Language of the headers and caption. Default the page's locale (`<html lang>`). */
4024
+ locale?: string
3737
4025
  },
3738
4026
  ): ChartDataTable {
4027
+ const t = textOf(config.locale)
3739
4028
  const label = (key: string) => labelOf(key, config.labels)
3740
- // Column headers: the caller's `labels`, else a plain Japanese name (not the data key).
4029
+ // Column headers: the caller's `labels`, else a plain name (not the data key).
3741
4030
  const column = (key: string, fallback: string) => config.labels?.[key] ?? fallback
3742
4031
  const columns = [
3743
- ...(config.index ? [column(config.index, "名前")] : []),
3744
- ...(config.series ? [column(config.series, "系列")] : []),
3745
- column(config.x, "横軸"),
3746
- column(config.y, "縦軸"),
3747
- ...(config.size ? [column(config.size, "大きさ")] : []),
4032
+ ...(config.index ? [column(config.index, t.name)] : []),
4033
+ ...(config.series ? [column(config.series, t.series)] : []),
4034
+ column(config.x, t.x),
4035
+ column(config.y, t.y),
4036
+ ...(config.size ? [column(config.size, t.size)] : []),
3748
4037
  ]
3749
4038
  const shown = points.slice(0, SCATTER_TABLE_MAX_ROWS)
3750
4039
  const rows = shown.map((p, i) => {
@@ -3758,15 +4047,16 @@ export function scatterDataTable(
3758
4047
  // The first column is the row header; without an `index` column, number the dots.
3759
4048
  return config.index ? cells : [String(i + 1), ...cells]
3760
4049
  })
3761
- const base = points.length > shown.length ? `${label(config.y)}×${label(config.x)}(先頭 ${shown.length} 点)` : `${label(config.y)}×${label(config.x)}`
4050
+ const pair = t.pair(label(config.y), label(config.x))
4051
+ const base = points.length > shown.length ? t.first(pair, shown.length) : pair
3762
4052
  // The guide lines carry information the dots do not: read them out with the table.
3763
4053
  const guides = (config.referenceLines ?? []).map((line) => {
3764
4054
  const key = line.axis === "x" ? config.x : config.y
3765
4055
  const text = config.formatters[line.axis](line.value)
3766
- return line.label ? `${line.label}(${label(key)} ${text})` : `${label(key)} ${text}`
4056
+ return line.label ? t.guide(line.label, label(key), text) : `${label(key)} ${text}`
3767
4057
  })
3768
4058
  return {
3769
- caption: guides.length > 0 ? `${base}。基準線: ${guides.join("、")}` : base,
4059
+ caption: guides.length > 0 ? t.caption(base, guides) : base,
3770
4060
  columns: config.index ? columns : ["#", ...columns],
3771
4061
  rows,
3772
4062
  }
@@ -4118,27 +4408,28 @@ function ScatterChartImpl<T extends Record<string, unknown>>(
4118
4408
  ref: ForwardedRef<HTMLDivElement>,
4119
4409
  ) {
4120
4410
  const { ref: frameRef, width: plotWidth, tokens } = useChartSurface(ref)
4411
+ const locale = useLocale()
4121
4412
  // Tooltips and the table read in full; axis ticks read short.
4122
4413
  const formatters = useMemo(
4123
4414
  () => ({
4124
- x: resolveValueFormatter(valueFormatter, xFormat ?? format),
4125
- y: resolveValueFormatter(valueFormatter, yFormat ?? format),
4126
- size: resolveValueFormatter(valueFormatter, sizeFormat ?? format),
4415
+ x: resolveValueFormatter(valueFormatter, xFormat ?? format, locale),
4416
+ y: resolveValueFormatter(valueFormatter, yFormat ?? format, locale),
4417
+ size: resolveValueFormatter(valueFormatter, sizeFormat ?? format, locale),
4127
4418
  }),
4128
- [valueFormatter, xFormat, yFormat, sizeFormat, format],
4419
+ [valueFormatter, xFormat, yFormat, sizeFormat, format, locale],
4129
4420
  )
4130
4421
  const axisFormatters = useMemo(
4131
4422
  () => ({
4132
- x: resolveAxisFormatter(valueFormatter, xFormat ?? format),
4133
- y: resolveAxisFormatter(valueFormatter, yFormat ?? format),
4423
+ x: resolveAxisFormatter(valueFormatter, xFormat ?? format, locale),
4424
+ y: resolveAxisFormatter(valueFormatter, yFormat ?? format, locale),
4134
4425
  }),
4135
- [valueFormatter, xFormat, yFormat, format],
4426
+ [valueFormatter, xFormat, yFormat, format, locale],
4136
4427
  )
4137
4428
  const points = useMemo(
4138
4429
  () => scatterPoints(data, { x, y, size, index, series: seriesKey, labelKey }),
4139
4430
  [data, x, y, size, index, seriesKey, labelKey],
4140
4431
  )
4141
- const series = useMemo(() => scatterSeries(points), [points])
4432
+ const series = useMemo(() => scatterSeries(points, undefined, otherSeriesLabel(locale)), [points, locale])
4142
4433
  const palette = useMemo(
4143
4434
  () => resolveChartColors(scatterSeriesColors(series, colors).map(chartFillName), tokens),
4144
4435
  [series, colors, tokens],
@@ -4175,14 +4466,14 @@ function ScatterChartImpl<T extends Record<string, unknown>>(
4175
4466
  [series, palette, x, y, size, seriesKey, seriesLabel, highlightList, referenceLines, labelKey, tokens, labels, formatters, axisFormatters, fitToData, showLegend, showTooltip, actions, plotWidth, chartOptions, minValue, maxValue, xMinValue, xMaxValue],
4176
4467
  )
4177
4468
  const dataTable = useMemo(
4178
- () => scatterDataTable(points, { x, y, size, index: index ?? labelKey, series: seriesKey, formatters, labels, referenceLines }),
4179
- [points, x, y, size, index, labelKey, seriesKey, formatters, labels, referenceLines],
4469
+ () => scatterDataTable(points, { x, y, size, index: index ?? labelKey, series: seriesKey, formatters, labels, referenceLines, locale }),
4470
+ [points, x, y, size, index, labelKey, seriesKey, formatters, labels, referenceLines, locale],
4180
4471
  )
4181
4472
  const resolvedSummary = useMemo(
4182
- () => (summary === "auto" ? buildScatterSummary(points, y, formatters.y, title, labels) : summary),
4183
- [summary, points, y, formatters, title, labels],
4473
+ () => (summary === "auto" ? buildScatterSummary(points, y, formatters.y, title, labels, locale) : summary),
4474
+ [summary, points, y, formatters, title, labels, locale],
4184
4475
  )
4185
- const label = ariaLabel ?? `${labelOf(y, labels)}×${labelOf(x, labels)}の散布図`
4476
+ const label = ariaLabel ?? textOf(locale).chart(labelOf(y, labels), labelOf(x, labels))
4186
4477
  return (
4187
4478
  <ChartFrame
4188
4479
  {...chartRootProps(rest)}