@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
@@ -19,6 +19,7 @@ import { ChartFrame } from "@squadbase/vantage/components"
19
19
  import { EChart } from "@squadbase/vantage/components"
20
20
  import { toValueFormatter } from "@squadbase/vantage/components"
21
21
  import type { ChartCommonProps, ValueFormatter } from "@squadbase/vantage/components"
22
+ import { useSyncExternalStore } from "react"
22
23
  import { createContext, useCallback, useEffect, useRef, useState, type RefObject } from "react"
23
24
  import type { ChartTone } from "@squadbase/vantage/components"
24
25
  import { type ReactNode } from "react"
@@ -28,6 +29,137 @@ import type { ChartThreshold, ChartAnnotation, ChartEventPayload, ChartForecast,
28
29
  import { formatValue } from "@squadbase/vantage/components"
29
30
  import type { ChartColor, ChartColors, ChartSeriesColor } from "@squadbase/vantage/components"
30
31
 
32
+ /* ---------- inlined from @squadbase/squadkit registry/lib/locale.ts (internal to @squadbase/vantage) ---------- */
33
+
34
+ /**
35
+ * The display language of the parts and the defaults that follow it.
36
+ *
37
+ * The language comes from `<html lang>`: a tag starting with `ja` reads Japanese, anything else English.
38
+ * Without a usable `lang` (missing, invalid, or no `document` — SSR, Node) the locale is `FALLBACK_LOCALE`
39
+ * (`en-US`). A `locale` passed explicitly (a prop, an argument) always wins.
40
+ *
41
+ * Parts read the locale through `useLocale` (`hooks/use-locale`) and pass it down; the pure helpers here
42
+ * and in `format` / `comparison` / `chart-*` take it as an argument and fall back to `documentLocale()`.
43
+ */
44
+
45
+ /** The language of the built-in wording: Japanese or English. */
46
+ type UiLanguage = "ja" | "en"
47
+
48
+ /** The locale used when `<html lang>` gives none (and on the server). */
49
+ const FALLBACK_LOCALE = "en-US"
50
+
51
+ /** `"ja"` when `locale` starts with `ja` (`ja`, `ja-JP`), else `"en"`. A missing locale is `"en"`. */
52
+ function uiLanguage(locale: string | null | undefined): UiLanguage {
53
+ return typeof locale === "string" && locale.trim().toLowerCase().startsWith("ja") ? "ja" : "en"
54
+ }
55
+
56
+ /** A BCP 47 tag in its canonical form (`en-gb` → `en-GB`), or `FALLBACK_LOCALE` when it is empty or invalid. */
57
+ function normalizeLocale(tag: string | null | undefined): string {
58
+ const value = tag?.trim()
59
+ if (!value) return FALLBACK_LOCALE
60
+ try {
61
+ return Intl.getCanonicalLocales(value)[0] ?? FALLBACK_LOCALE
62
+ } catch {
63
+ return FALLBACK_LOCALE
64
+ }
65
+ }
66
+
67
+ /**
68
+ * The locale of the page: `document.documentElement.lang`, canonicalized. `FALLBACK_LOCALE` when it is empty or
69
+ * invalid, or there is no `document`. Parts read it through `useLocale`, never while rendering.
70
+ */
71
+ function documentLocale(): string {
72
+ if (typeof document === "undefined") return FALLBACK_LOCALE
73
+ return normalizeLocale(document.documentElement?.getAttribute("lang"))
74
+ }
75
+
76
+ /** `locale` when given, else the page's locale (`documentLocale()`). */
77
+ function resolveLocale(locale?: string | null): string {
78
+ return locale || documentLocale()
79
+ }
80
+
81
+ /** Picks the wording table of the locale's language. */
82
+ function pickLocale<T>(table: { ja: T; en: T }, locale?: string | null): T {
83
+ return table[uiLanguage(resolveLocale(locale))]
84
+ }
85
+
86
+ /** The currency `format="currency"` uses without a `currency`: JPY for Japanese, USD otherwise. */
87
+ function defaultCurrency(locale?: string | null): string {
88
+ return uiLanguage(resolveLocale(locale)) === "ja" ? "JPY" : "USD"
89
+ }
90
+
91
+ /** The month a fiscal year starts in (1–12) when none is given: April for Japanese, January otherwise. */
92
+ function defaultFiscalYearStartMonth(locale?: string | null): number {
93
+ return uiLanguage(resolveLocale(locale)) === "ja" ? 4 : 1
94
+ }
95
+
96
+ /** A day of the week, 0 = Sunday … 6 = Saturday (the `Date#getDay` / date-fns numbering). */
97
+ type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6
98
+
99
+ /** Locales whose week starts on Sunday when the runtime has no week data. */
100
+ const SUNDAY_FIRST = new Set(["en", "en-US", "en-CA"])
101
+
102
+ /**
103
+ * The first day of the week: Monday for Japanese; otherwise the locale's convention from `Intl.Locale`
104
+ * (`en-US` Sunday, `en-GB` Monday). Without week data in the runtime: Sunday for `en` / `en-US` / `en-CA`,
105
+ * Monday for the rest.
106
+ */
107
+ function weekStartsOn(locale?: string | null): WeekDay {
108
+ const tag = normalizeLocale(resolveLocale(locale))
109
+ if (uiLanguage(tag) === "ja") return 1
110
+ try {
111
+ const info = new Intl.Locale(tag) as Intl.Locale & {
112
+ getWeekInfo?: () => { firstDay: number }
113
+ weekInfo?: { firstDay: number }
114
+ }
115
+ const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay
116
+ if (typeof firstDay === "number" && firstDay >= 1 && firstDay <= 7) return (firstDay % 7) as WeekDay
117
+ } catch {
118
+ // Fall through to the table below.
119
+ }
120
+ return SUNDAY_FIRST.has(tag) ? 0 : 1
121
+ }
122
+
123
+ /* ---------- inlined from @squadbase/squadkit registry/hooks/use-locale.ts (internal to @squadbase/vantage) ---------- */
124
+
125
+ const listeners = new Set<() => void>()
126
+ let observer: MutationObserver | null = null
127
+
128
+ /** One observer on `<html lang>` for every subscribed part. */
129
+ function subscribe(onChange: () => void): () => void {
130
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {}
131
+ listeners.add(onChange)
132
+ if (!observer) {
133
+ observer = new MutationObserver(() => {
134
+ for (const listener of [...listeners]) listener()
135
+ })
136
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] })
137
+ }
138
+ return () => {
139
+ listeners.delete(onChange)
140
+ if (listeners.size === 0 && observer) {
141
+ observer.disconnect()
142
+ observer = null
143
+ }
144
+ }
145
+ }
146
+
147
+ const serverLocale = () => FALLBACK_LOCALE
148
+
149
+ /**
150
+ * The page's locale (`<html lang>`), following changes made while the page runs. On the server and during
151
+ * hydration it is `FALLBACK_LOCALE` (`en-US`), so the markup matches; it switches right after.
152
+ */
153
+ function useDocumentLocale(): string {
154
+ return useSyncExternalStore(subscribe, documentLocale, serverLocale)
155
+ }
156
+
157
+ /** The locale a part renders in: its `locale` prop when given, else the page's (`useDocumentLocale`). */
158
+ function useLocale(locale?: string | null): string {
159
+ const page = useDocumentLocale()
160
+ return locale || page
161
+ }
162
+
31
163
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-theme.ts (internal to @squadbase/vantage) ---------- */
32
164
 
33
165
  /**
@@ -426,6 +558,32 @@ function useChartSurface<E extends Element>(
426
558
 
427
559
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-options.ts (internal to @squadbase/vantage) ---------- */
428
560
 
561
+ /** The built-in words of the option builders, by language. */
562
+ const CHART_TEXT = {
563
+ ja: {
564
+ target: "目標",
565
+ forecast: "予測",
566
+ total: "合計",
567
+ provisional: "速報",
568
+ noComparison: "比較なし",
569
+ share: (share: string) => `(${share})`,
570
+ targetRate: (rate: string) => `(達成率 ${rate})`,
571
+ provisionalTitle: (title: string, label: string) => `${title}(${label})`,
572
+ },
573
+ en: {
574
+ target: "Target",
575
+ forecast: "Forecast",
576
+ total: "Total",
577
+ provisional: "Preliminary",
578
+ noComparison: "No comparison",
579
+ share: (share: string) => ` (${share})`,
580
+ targetRate: (rate: string) => ` (${rate} achieved)`,
581
+ provisionalTitle: (title: string, label: string) => `${title} (${label})`,
582
+ },
583
+ } satisfies Record<UiLanguage, unknown>
584
+
585
+ const chartText = (locale?: string) => CHART_TEXT[uiLanguage(resolveLocale(locale))]
586
+
429
587
  type CartesianChartKind = "area" | "bar" | "line"
430
588
  type CartesianStackType = "default" | "stacked" | "percent"
431
589
  type CartesianCurveType = "linear" | "smooth" | "step"
@@ -610,7 +768,7 @@ interface CartesianChartConfig {
610
768
  * all ticks read in one unit (¥0.5億 / ¥1億). Default: `axisFormatter` for every tick.
611
769
  */
612
770
  axisTicks?: (top: number) => (value: number) => string
613
- /** Locale of the time-axis labels. Default `"ja-JP"`. */
771
+ /** Locale of the time-axis labels and the built-in words (total, target, forecast). Default the page's (`<html lang>`). */
614
772
  locale?: string
615
773
  type?: CartesianStackType
616
774
  /** Bar only. `"vertical"` lays the categories down the y axis (horizontal bars). */
@@ -848,11 +1006,12 @@ function timeAxisLabelStep(values: readonly unknown[], maxLabels: number = TIME_
848
1006
  */
849
1007
  function timeAxisLabels(
850
1008
  values: readonly unknown[],
851
- locale: string = DEFAULT_LOCALE,
1009
+ localeArg?: string,
852
1010
  shown: (i: number) => boolean = () => true,
853
1011
  ): string[] | null {
854
1012
  const parsed = parseTimeIndex(values)
855
1013
  if (!parsed) return null
1014
+ const locale = resolveLocale(localeArg)
856
1015
  const { granularity, times } = parsed
857
1016
  let prevYear: number | null = null
858
1017
  return times.map((t, i) => {
@@ -875,9 +1034,10 @@ function timeAxisLabels(
875
1034
  }
876
1035
 
877
1036
  /** The tooltip titles for time-like x values (`2025年9月`, `2025年9月1日`), or `null` when they are not dates. */
878
- function timeTooltipLabels(values: readonly unknown[], locale: string = DEFAULT_LOCALE): string[] | null {
1037
+ function timeTooltipLabels(values: readonly unknown[], localeArg?: string): string[] | null {
879
1038
  const parsed = parseTimeIndex(values)
880
1039
  if (!parsed) return null
1040
+ const locale = resolveLocale(localeArg)
881
1041
  const options: Intl.DateTimeFormatOptions =
882
1042
  parsed.granularity === "year"
883
1043
  ? { year: "numeric" }
@@ -1017,7 +1177,9 @@ function tooltipHtml(
1017
1177
  rows: readonly TooltipRow[],
1018
1178
  valueFormatter: (value: number) => string,
1019
1179
  notes: readonly string[] = [],
1180
+ locale?: string,
1020
1181
  ): string {
1182
+ const t = chartText(locale)
1021
1183
  const lines = rows.map(
1022
1184
  (row) =>
1023
1185
  `<div style="display:flex;align-items:center;gap:8px;line-height:20px">` +
@@ -1026,7 +1188,7 @@ function tooltipHtml(
1026
1188
  `<span style="font-weight:600;font-variant-numeric:tabular-nums;margin-left:12px">${
1027
1189
  row.value === null ? EMPTY_VALUE : escapeHtml(valueFormatter(row.value))
1028
1190
  }${
1029
- row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px">(${escapeHtml(formatShare(row.share))})</span>`
1191
+ row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px">${escapeHtml(t.share(formatShare(row.share)))}</span>`
1030
1192
  }</span></div>` +
1031
1193
  (row.note === undefined
1032
1194
  ? ""
@@ -1040,8 +1202,10 @@ function tooltipHtml(
1040
1202
 
1041
1203
  /* ------------------------------------------------- per-bar targets / notes */
1042
1204
 
1043
- /** Default name of the per-bar target marker (legend, tooltip). */
1044
- const BAR_TARGET_LABEL = "目標"
1205
+ /** Default name of the per-bar target marker (legend, tooltip) in `locale`: "Target" / 「目標」. Default the page's locale. */
1206
+ function barTargetLabel(locale?: string): string {
1207
+ return chartText(locale).target
1208
+ }
1045
1209
 
1046
1210
  /** How far (px) the target marker reaches past each side of its bar. */
1047
1211
  const TARGET_MARKER_OVERHANG = 4
@@ -1092,16 +1256,17 @@ function achievementRate(value: number | null | undefined, target: number | null
1092
1256
  return (value / target) * 100
1093
1257
  }
1094
1258
 
1095
- /** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3%)」. `null` without a target. */
1259
+ /** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3%)」 / "Target $12M (85.3% achieved)". `null` without a target. */
1096
1260
  function barTargetNote(
1097
1261
  label: string,
1098
1262
  value: number | null,
1099
1263
  target: number | null,
1100
1264
  valueFormatter: (value: number) => string,
1265
+ locale?: string,
1101
1266
  ): string | null {
1102
1267
  if (target === null) return null
1103
1268
  const rate = achievementRate(value, target)
1104
- return `${label} ${valueFormatter(target)}${rate === null ? "" : `(達成率 ${formatShare(rate)})`}`
1269
+ return `${label} ${valueFormatter(target)}${rate === null ? "" : chartText(locale).targetRate(formatShare(rate))}`
1105
1270
  }
1106
1271
 
1107
1272
  /** Half the width of a bar in category-axis units, from `barCategoryGap` (`"20%"`; a px gap or none → ECharts' 20%). */
@@ -1193,11 +1358,12 @@ const TARGET_LEGEND_SERIES_ID = "__target-legend"
1193
1358
  /** A flat tick (8×8 legend box, aspect kept): the per-bar target's legend icon. */
1194
1359
  const TARGET_LEGEND_ICON = "path://M0,0 L100,0 L100,24 L0,24 Z"
1195
1360
 
1196
- const DEFAULT_FORECAST_LABEL = "予測"
1197
-
1198
- /** 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. */
1199
- function forecastBoundaryText(label: string = DEFAULT_FORECAST_LABEL): string {
1200
- return `${label} →`
1361
+ /**
1362
+ * 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.
1363
+ * Without a `label`, "Forecast" / 「予測」 in `locale` (default the page's).
1364
+ */
1365
+ function forecastBoundaryText(label?: string, locale?: string): string {
1366
+ return `${label ?? chartText(locale).forecast} →`
1201
1367
  }
1202
1368
 
1203
1369
  /** A comparison series: a thin, muted, solid line with no markers (60% of `--muted-foreground`). */
@@ -1237,8 +1403,10 @@ interface AxisExtent {
1237
1403
  max: number
1238
1404
  }
1239
1405
 
1240
- /** The label of a stack's total (highlight and tooltip). */
1241
- const STACK_TOTAL_LABEL = "合計"
1406
+ /** The label of a stack's total (highlight and tooltip) in `locale`: "Total" / 「合計」. Default the page's locale. */
1407
+ function stackTotalLabel(locale?: string): string {
1408
+ return chartText(locale).total
1409
+ }
1242
1410
 
1243
1411
  /** Per point, the sum of the stacked series; `null` where every series is missing. */
1244
1412
  function stackTotalsOf(values: readonly (readonly (number | null)[])[]): (number | null)[] {
@@ -1360,8 +1528,8 @@ function indexGrain(values: readonly unknown[]): PeriodGrain | null {
1360
1528
 
1361
1529
  /**
1362
1530
  * The tooltip line under the main series' row when a comparison series is drawn: the arrow, the signed change and the
1363
- * comparison's name, as `Delta` writes it ("↑ +12.4% 前年同月比"; a minus is U+2212). "比較なし 前年同月比" when
1364
- * either value is missing or the baseline is 0.
1531
+ * comparison's name, as `Delta` writes it ("↑ +12.4% vs. same month last year" / "↑ +12.4% 前年同月比"; a minus is U+2212).
1532
+ * "No comparison vs. same month last year" / "比較なし 前年同月比" when either value is missing or the baseline is 0.
1365
1533
  */
1366
1534
  function comparisonNote(
1367
1535
  current: number | null,
@@ -1369,16 +1537,18 @@ function comparisonNote(
1369
1537
  comparison: Pick<CartesianComparisonConfig, "vs" | "label">,
1370
1538
  grain?: PeriodGrain,
1371
1539
  format?: ValueFormat,
1372
- locale: string = DEFAULT_LOCALE,
1540
+ localeArg?: string,
1373
1541
  ): string {
1542
+ const locale = resolveLocale(localeArg)
1374
1543
  const result = computeComparison(
1375
1544
  current,
1376
1545
  comparison.vs === "custom"
1377
1546
  ? { previous, vs: "custom", label: comparison.label ?? "", grain }
1378
1547
  : { previous, vs: comparison.vs, label: comparison.label, grain },
1548
+ { locale },
1379
1549
  )
1380
1550
  const text = formatChange(result, defaultChangeDisplay(format), locale, format)
1381
- if (text === null) return `比較なし ${result.label}`
1551
+ if (text === null) return `${chartText(locale).noComparison} ${result.label}`
1382
1552
  const arrow = result.direction === "up" ? "↑" : result.direction === "down" ? "↓" : "→"
1383
1553
  return `${arrow} ${text.replace(/^-/, "−")} ${result.label}`
1384
1554
  }
@@ -1421,7 +1591,6 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1421
1591
  toneColors,
1422
1592
  valueFormatter,
1423
1593
  axisFormatter = valueFormatter,
1424
- locale = DEFAULT_LOCALE,
1425
1594
  type = "default",
1426
1595
  layout = "horizontal",
1427
1596
  curveType = "linear",
@@ -1458,7 +1627,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1458
1627
  chartOptions,
1459
1628
  reserveActions = false,
1460
1629
  } = cfg
1461
- const provisionalLabel = cfg.provisionalLabel ?? DEFAULT_PROVISIONAL_LABEL
1630
+ const locale = resolveLocale(cfg.locale)
1631
+ const text = chartText(locale)
1632
+ const provisionalLabel = cfg.provisionalLabel ?? text.provisional
1462
1633
 
1463
1634
  const stacked = kind !== "line" && type !== "default"
1464
1635
  const percent = kind !== "line" && type === "percent"
@@ -1607,9 +1778,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1607
1778
  // `barOptions.targetKey` (a target per bar) takes over from the single `target` line.
1608
1779
  const perBarTarget = kind === "bar" && Boolean(targetKey)
1609
1780
  const hasTarget = !perBarTarget && target !== undefined && Number.isFinite(target)
1610
- const targetText = hasTarget ? (targetLabel ?? `目標 ${axisFormatter(target)}`) : ""
1781
+ const targetText = hasTarget ? (targetLabel ?? `${text.target} ${axisFormatter(target)}`) : ""
1611
1782
  const highlightText = (value: number) =>
1612
- shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${STACK_TOTAL_LABEL} ` : ""}${axisFormatter(value)}`
1783
+ shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${text.total} ` : ""}${axisFormatter(value)}`
1613
1784
  /** Whether `p`'s label sits within the target's value band and under the target label (at the right end). */
1614
1785
  const nearTargetLabel = (p: HighlightPoint, side: "under" | "over") => {
1615
1786
  if (!hasTarget || !plotWidth || kind === "bar" || labels.length < 2) return false
@@ -1635,7 +1806,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1635
1806
  // One series (or a stack, against its total) can be compared to a target; grouped series and 100% stacks cannot.
1636
1807
  const barTargets: (number | null)[] | null =
1637
1808
  perBarTarget && targetKey && !percent && (stacked || categories.length === 1) ? barTargetValues(data, targetKey) : null
1638
- const barTargetName = targetLabel ?? BAR_TARGET_LABEL
1809
+ const barTargetName = targetLabel ?? text.target
1639
1810
  const barTargetColor = labelColor ?? guideColor
1640
1811
 
1641
1812
  const markPointFor = (c: number, color: string | undefined) =>
@@ -1849,7 +2020,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1849
2020
  }
1850
2021
 
1851
2022
  /* -- forecast: dashed continuation (+ optional band) of one series */
1852
- const forecastLabel = forecast?.label ?? DEFAULT_FORECAST_LABEL
2023
+ const forecastLabel = forecast?.label ?? text.forecast
1853
2024
  const forecastBandAlpha = isDarkSurface(labelBackground) ? FORECAST_BAND_ALPHA_DARK : FORECAST_BAND_ALPHA_LIGHT
1854
2025
  const forecastValues: (number | null)[] = []
1855
2026
  if (forecast) {
@@ -2325,7 +2496,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2325
2496
  })
2326
2497
  }
2327
2498
  if (stackTotals && i <= lastActual && rows.length > 1) {
2328
- rows.push({ name: STACK_TOTAL_LABEL, color: "transparent", value: stackTotals[i] ?? null })
2499
+ rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
2329
2500
  }
2330
2501
  const provisional = provStart !== -1 && i >= provStart && i <= lastActual
2331
2502
  if (provisional) {
@@ -2341,19 +2512,19 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2341
2512
  ...noteFor(at),
2342
2513
  })
2343
2514
  })
2344
- if (stackTotals && rows.length > 1) rows.push({ name: STACK_TOTAL_LABEL, color: "transparent", value: stackTotals[i] ?? null })
2515
+ if (stackTotals && rows.length > 1) rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
2345
2516
  }
2346
2517
  const title = tooltipTitles[i] ?? labels[i] ?? ""
2347
2518
  const lines: string[] = []
2348
2519
  if (barTargets && i <= lastActual) {
2349
2520
  const total = stackTotals ? (stackTotals[i] ?? null) : (values[0]?.[i] ?? null)
2350
- const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter)
2521
+ const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter, locale)
2351
2522
  if (note) lines.push(note)
2352
2523
  }
2353
2524
  for (const note of notes) {
2354
2525
  if (note.to === undefined ? note.from === i : note.from <= i && i <= note.to) lines.push(note.label)
2355
2526
  }
2356
- return tooltipHtml(provisional ? `${title}(${provisionalLabel})` : title, rows, valueFormatter, lines)
2527
+ return tooltipHtml(provisional ? text.provisionalTitle(title, provisionalLabel) : title, rows, valueFormatter, lines, locale)
2357
2528
  },
2358
2529
  },
2359
2530
  xAxis: horizontalBars ? valueAxis : categoryAxis,
@@ -2445,8 +2616,6 @@ interface DonutOptionSlice {
2445
2616
  /** How many of the largest slices get an outside label (「自然検索 41%」) by default. */
2446
2617
  const DONUT_OUTSIDE_LABELS = 2
2447
2618
 
2448
- const DEFAULT_TOTAL_LABEL = "合計"
2449
-
2450
2619
  const sliceValue = (value: number) => (Number.isFinite(value) && value > 0 ? value : 0)
2451
2620
 
2452
2621
  /** A slice's share of `total` as text: whole percent, `<1%` for a sliver, `0%` for nothing. */
@@ -2479,15 +2648,16 @@ function donutLegendItems(slices: readonly DonutOptionSlice[]): DonutLegendItem[
2479
2648
  }
2480
2649
 
2481
2650
  /**
2482
- * The default center label: what is totalled (「合計」), the formatted total
2651
+ * The default center label: what is totalled (「合計」 / "Total" in `locale`, default the page's), the formatted total
2483
2652
  * (`amount`, the big line), the unit (a small line under it) and both as one
2484
2653
  * string (`value`, 「9.2万 セッション」).
2485
2654
  */
2486
2655
  function donutCenterText(
2487
2656
  total: number,
2488
2657
  valueFormatter: (value: number) => string,
2489
- { totalLabel = DEFAULT_TOTAL_LABEL, unit }: { totalLabel?: string; unit?: string } = {},
2658
+ { totalLabel, unit, locale }: { totalLabel?: string; unit?: string; locale?: string } = {},
2490
2659
  ): { label: string; value: string; amount: string; unit?: string } {
2660
+ totalLabel ??= chartText(locale).total
2491
2661
  const amount = valueFormatter(total)
2492
2662
  return { label: totalLabel, value: unit ? `${amount} ${unit}` : amount, amount, ...(unit ? { unit } : {}) }
2493
2663
  }
@@ -2715,8 +2885,10 @@ function hasLabel(labels: Readonly<Record<string, string>> | undefined, key: str
2715
2885
 
2716
2886
  /* ------------------------------------------------------------ provisional */
2717
2887
 
2718
- /** The default label for points that are not final yet. */
2719
- const DEFAULT_PROVISIONAL_LABEL = "速報"
2888
+ /** The default label for points that are not final yet, in `locale`: "Preliminary" / 「速報」. Default the page's locale. */
2889
+ function defaultProvisionalLabel(locale?: string): string {
2890
+ return chartText(locale).provisional
2891
+ }
2720
2892
 
2721
2893
  /**
2722
2894
  * Position of the first provisional point: the first entry of `indexValues`
@@ -2854,9 +3026,10 @@ function resolveValueFormatter(
2854
3026
  function resolveAxisFormatter(
2855
3027
  valueFormatter: ValueFormatter | undefined,
2856
3028
  format: ValueFormat | undefined,
2857
- locale: string = DEFAULT_LOCALE,
3029
+ localeArg?: string,
2858
3030
  ): ValueFormatter {
2859
3031
  if (valueFormatter) return valueFormatter
3032
+ const locale = resolveLocale(localeArg)
2860
3033
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
2861
3034
  if (spec.type !== "compact" && spec.type !== "number" && spec.type !== "currency") return toValueFormatter(format, locale)
2862
3035
  const currency = "currency" in spec ? spec.currency : undefined
@@ -2865,40 +3038,75 @@ function resolveAxisFormatter(
2865
3038
  return (value) => formatValue(value, short, locale)
2866
3039
  }
2867
3040
 
2868
- /** The unit words of a ja number (`1.2万` / `3.4億` / `1兆`) and their sizes, largest first. */
2869
- const JA_UNITS = [
2870
- { char: "兆", size: 1e12 },
2871
- { char: "億", size: 1e8 },
2872
- { char: "万", size: 1e4 },
2873
- ] as const
3041
+ interface CompactUnit {
3042
+ token: string
3043
+ size: number
3044
+ }
2874
3045
 
2875
- const JA_NUMBER_TOKEN_RE = /\d[\d,]*(?:\.\d+)?(?:兆|億|万)?/
3046
+ const compactUnitCache = new Map<string, CompactUnit[] | null>()
3047
+
3048
+ /**
3049
+ * The compact-notation unit words of a locale and their sizes, largest first: `兆` / `億` / `万` in ja,
3050
+ * `T` / `B` / `M` / `K` in en. `null` when the locale's compact form puts anything between the number and its
3051
+ * word (`1,5 Mio.`), where a token cannot be swapped in place.
3052
+ */
3053
+ function compactUnits(locale: string): CompactUnit[] | null {
3054
+ const cached = compactUnitCache.get(locale)
3055
+ if (cached !== undefined) return cached
3056
+ let units: CompactUnit[] | null = []
3057
+ try {
3058
+ const compact = new Intl.NumberFormat(locale, { notation: "compact" })
3059
+ const seen = new Set<string>()
3060
+ for (let exp = 3; exp <= 15; exp++) {
3061
+ const parts = compact.formatToParts(10 ** exp)
3062
+ const at = parts.findIndex((p) => p.type === "compact")
3063
+ if (at === -1) continue
3064
+ if (parts[at - 1]?.type !== "integer" || at !== parts.length - 1) {
3065
+ units = null
3066
+ break
3067
+ }
3068
+ const token = parts[at]!.value
3069
+ if (seen.has(token)) continue
3070
+ seen.add(token)
3071
+ units.push({ token, size: 10 ** exp })
3072
+ }
3073
+ } catch {
3074
+ units = null
3075
+ }
3076
+ const sorted = units && units.length > 0 ? units.sort((a, b) => b.size - a.size) : null
3077
+ compactUnitCache.set(locale, sorted)
3078
+ return sorted
3079
+ }
3080
+
3081
+ const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
2876
3082
 
2877
3083
  /**
2878
3084
  * The value-axis tick formatter for an axis whose largest tick is `top`: every tick reads in the unit of
2879
- * that largest tick, so 万 and 億 never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`).
2880
- * Only the ja short forms (`compact` / `number` / `currency`, no `valueFormatter`) have units to align;
2881
- * any other format — and an axis whose largest tick is under 1万 — keeps `resolveAxisFormatter`'s output as it is.
3085
+ * that largest tick, so units never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`;
3086
+ * `$0.5M` / `$1M` / `$1.5M`, not `$500K` / `$1M`). Only the short forms (`compact` / `number` / `currency`,
3087
+ * no `valueFormatter`) have units to align; any other format — an axis whose largest tick is under the smallest
3088
+ * unit (1万 / 1K), or a locale whose compact form cannot be aligned in place — keeps `resolveAxisFormatter`'s output as it is.
2882
3089
  */
2883
3090
  function resolveAxisTickFormatter(
2884
3091
  valueFormatter: ValueFormatter | undefined,
2885
3092
  format: ValueFormat | undefined,
2886
- locale: string = DEFAULT_LOCALE,
3093
+ localeArg?: string,
2887
3094
  ): (top: number) => ValueFormatter {
3095
+ const locale = resolveLocale(localeArg)
2888
3096
  const base = resolveAxisFormatter(valueFormatter, format, locale)
2889
3097
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
2890
- const aligns =
2891
- !valueFormatter && locale.toLowerCase().startsWith("ja") && (spec.type === "compact" || spec.type === "number" || spec.type === "currency")
2892
- if (!aligns) return () => base
3098
+ const units = !valueFormatter && (spec.type === "compact" || spec.type === "number" || spec.type === "currency") ? compactUnits(locale) : null
3099
+ if (!units) return () => base
2893
3100
  const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 3 })
3101
+ const numberToken = new RegExp(`\\d[\\d,]*(?:\\.\\d+)?(?:${units.map((u) => escapeRegExp(u.token)).join("|")})?`)
2894
3102
  return (top) => {
2895
- const unit = JA_UNITS.find((u) => Math.abs(top) >= u.size)
3103
+ const unit = units.find((u) => Math.abs(top) >= u.size)
2896
3104
  if (!unit) return base
2897
3105
  return (value) => {
2898
3106
  const text = base(value)
2899
3107
  if (value === 0 || !Number.isFinite(value)) return text
2900
- const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.char}`
2901
- return text.replace(JA_NUMBER_TOKEN_RE, () => scaled)
3108
+ const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.token}`
3109
+ return text.replace(numberToken, () => scaled)
2902
3110
  }
2903
3111
  }
2904
3112
  }
@@ -2927,8 +3135,20 @@ function clamp(value: number, min: number, max: number): number {
2927
3135
  /** How many categories a chart colours individually; the rest fold into「その他」. */
2928
3136
  const MAX_CHART_SERIES = 5
2929
3137
 
2930
- /** The series name the folded categories get. */
2931
- const OTHER_SERIES_LABEL = "その他"
3138
+ /** The series name the folded categories get, by language. */
3139
+ const OTHER_SERIES_LABELS: Record<UiLanguage, string> = { ja: "その他", en: "Other" }
3140
+
3141
+ /** The series name the folded categories get in `locale` ("Other" / "その他"). Default the page's locale (`<html lang>`). */
3142
+ function otherSeriesLabel(locale?: string): string {
3143
+ return OTHER_SERIES_LABELS[uiLanguage(resolveLocale(locale))]
3144
+ }
3145
+
3146
+ const OTHER_NAMES = new Set(Object.values(OTHER_SERIES_LABELS))
3147
+
3148
+ /** Whether a category / row is the "other" bucket by name (「その他」 or "Other", whatever the page language). */
3149
+ function isOtherName(name: string | null | undefined): boolean {
3150
+ return typeof name === "string" && OTHER_NAMES.has(name)
3151
+ }
2932
3152
 
2933
3153
  interface AggregatedItems<Item extends { name: string; value: number }> {
2934
3154
  /** The `n` largest items, in their given order. All items when there are `n` or fewer. */
@@ -2948,7 +3168,7 @@ interface AggregatedItems<Item extends { name: string; value: number }> {
2948
3168
  function aggregateTopN<Item extends { name: string; value: number }>(
2949
3169
  items: readonly Item[],
2950
3170
  n: number = MAX_CHART_SERIES,
2951
- otherLabel: string = OTHER_SERIES_LABEL,
3171
+ otherLabel: string = otherSeriesLabel(),
2952
3172
  ): AggregatedItems<Item> {
2953
3173
  if (items.length <= n) return { top: [...items], rest: [], other: null }
2954
3174
  const ranked = items
@@ -2977,7 +3197,7 @@ function aggregateSeries(
2977
3197
  data: readonly Record<string, unknown>[],
2978
3198
  categories: readonly string[],
2979
3199
  n: number = MAX_CHART_SERIES,
2980
- otherLabel: string = OTHER_SERIES_LABEL,
3200
+ otherLabel: string = otherSeriesLabel(),
2981
3201
  ): AggregatedSeries {
2982
3202
  const totals = categories.map((name) => ({
2983
3203
  name,
@@ -3009,12 +3229,14 @@ function aggregateSeriesKeeping(
3009
3229
  categories: readonly string[],
3010
3230
  keep: string | undefined,
3011
3231
  n: number = MAX_CHART_SERIES,
3232
+ otherLabel: string = otherSeriesLabel(),
3012
3233
  ): AggregatedSeries {
3013
- if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n)
3234
+ if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n, otherLabel)
3014
3235
  const aggregated = aggregateSeries(
3015
3236
  data,
3016
3237
  categories.filter((c) => c !== keep),
3017
3238
  n,
3239
+ otherLabel,
3018
3240
  )
3019
3241
  return { ...aggregated, categories: [...aggregated.categories, keep] }
3020
3242
  }
@@ -3050,7 +3272,7 @@ function seriesColorsWithComparison(
3050
3272
  * Colour per drawn series: the record's token for that category, else the
3051
3273
  * array's token at the category's position in `allCategories` (cycled), else
3052
3274
  * the token palette by drawn position. `otherCategory` — and a category the
3053
- * data itself names「その他」(`OTHER_SERIES_LABEL`) — always gets `otherColor`.
3275
+ * data itself names「その他」/ "Other" (`OTHER_SERIES_LABELS`) — always gets `otherColor`.
3054
3276
  */
3055
3277
  function seriesColorsFor(
3056
3278
  categories: readonly string[],
@@ -3062,7 +3284,7 @@ function seriesColorsFor(
3062
3284
  const list = Array.isArray(colors) ? (colors as ChartSeriesColor[]) : undefined
3063
3285
  const record = !Array.isArray(colors) && colors ? (colors as Partial<Record<string, ChartSeriesColor>>) : undefined
3064
3286
  return categories.map((category, i) => {
3065
- if ((otherCategory !== null && category === otherCategory) || category === OTHER_SERIES_LABEL) return otherColor
3287
+ if ((otherCategory !== null && category === otherCategory) || isOtherName(category)) return otherColor
3066
3288
  const fromRecord = record?.[category]
3067
3289
  if (fromRecord) return fromRecord
3068
3290
  if (list && list.length > 0) {
@@ -3109,7 +3331,7 @@ interface DonutSlicesConfig {
3109
3331
  */
3110
3332
  function donutSlices(
3111
3333
  data: readonly Record<string, unknown>[],
3112
- { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = OTHER_SERIES_LABEL }: DonutSlicesConfig,
3334
+ { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = otherSeriesLabel() }: DonutSlicesConfig,
3113
3335
  ): DonutSlice[] {
3114
3336
  const items = data
3115
3337
  .map((row, i) => {
@@ -3122,7 +3344,8 @@ function donutSlices(
3122
3344
  const slices: DonutSlice[] = top.map((item, rank) => ({
3123
3345
  name: item.name,
3124
3346
  value: item.value,
3125
- color: palette[rank % palette.length]!,
3347
+ // A row the data itself names「その他」/ "Other" is the same remainder: grey, like the folded slice.
3348
+ color: isOtherName(item.name) ? otherColor : palette[rank % palette.length]!,
3126
3349
  isOther: false,
3127
3350
  row: item.row,
3128
3351
  }))
@@ -3195,7 +3418,7 @@ interface BarListRowsConfig extends BarListOptions {
3195
3418
  */
3196
3419
  function barListRows<Row extends object>(
3197
3420
  data: readonly Row[],
3198
- { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = OTHER_SERIES_LABEL, exclude }: BarListRowsConfig = {},
3421
+ { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = otherSeriesLabel(), exclude }: BarListRowsConfig = {},
3199
3422
  ): BarListRow<Row>[] {
3200
3423
  const items = data.map((row): BarListRow<Row> => {
3201
3424
  const cells = row as Record<string, unknown>
@@ -3241,19 +3464,21 @@ const BAR_LIST_OTHER_TINT = 30
3241
3464
 
3242
3465
  /**
3243
3466
  * The fill of each BarList row: all real rows in the same tint of `color`
3244
- * (`BAR_LIST_TINT`), the folded「その他」row in a tint of `otherColor`
3467
+ * (`BAR_LIST_TINT`), the folded「その他」row (and a row the data names「その他」/ "Other") in a tint of `otherColor`
3245
3468
  * (`BAR_LIST_OTHER_TINT`) — the palest fill, a remainder never outweighs a
3246
3469
  * ranked row. A legacy per-row `color` (or a threshold tone) replaces `color`
3247
3470
  * for that row. A tint, not the solid token, so the label printed over the bar
3248
3471
  * keeps its contrast.
3249
3472
  */
3250
3473
  function barListFills(
3251
- rows: readonly Pick<BarListRow, "isOther" | "color">[],
3474
+ rows: readonly (Pick<BarListRow, "isOther" | "color"> & { name?: string })[],
3252
3475
  color: ChartColor,
3253
3476
  otherColor: string,
3254
3477
  ): string[] {
3255
3478
  return rows.map((row) =>
3256
- row.isOther ? tintCss(otherColor, BAR_LIST_OTHER_TINT) : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
3479
+ row.isOther || isOtherName(row.name)
3480
+ ? tintCss(otherColor, BAR_LIST_OTHER_TINT)
3481
+ : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
3257
3482
  )
3258
3483
  }
3259
3484
 
@@ -3469,11 +3694,30 @@ interface ChartSummaryInput {
3469
3694
  format?: ValueFormat
3470
3695
  /** Wins over `format`. */
3471
3696
  valueFormatter?: ValueFormatter
3697
+ /** Language of the sentence. Default the page's locale (`<html lang>`). */
3698
+ locale?: string
3699
+ }
3700
+
3701
+ const SUMMARY_TEXT = {
3702
+ ja: {
3703
+ up: "、前の点より増加",
3704
+ down: "、前の点より減少",
3705
+ flat: "、前の点と横ばい",
3706
+ latest: (label: string, value: string) => `最新 ${label} は ${value}`,
3707
+ top: (name: string, share: string) => `最大は ${name}(${share})`,
3708
+ },
3709
+ en: {
3710
+ up: ", up from the previous point",
3711
+ down: ", down from the previous point",
3712
+ flat: ", flat from the previous point",
3713
+ latest: (label: string, value: string) => `Latest ${label}: ${value}`,
3714
+ top: (name: string, share: string) => `Largest: ${name} (${share})`,
3715
+ },
3472
3716
  }
3473
3717
 
3474
3718
  /**
3475
3719
  * One-sentence summary built from the rows, for `summary="auto"`:
3476
- * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」. The direction is
3720
+ * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」("<title>: Latest <index>: <value>, up from the previous point"). The direction is
3477
3721
  * computed from the last two non-empty points of the first series — never
3478
3722
  * written by hand, so it cannot contradict the data. `undefined` when there is
3479
3723
  * no usable point.
@@ -3489,26 +3733,29 @@ function buildChartSummary(input: ChartSummaryInput): string | undefined {
3489
3733
  }
3490
3734
  const latest = points[points.length - 1]
3491
3735
  if (!latest) return undefined
3492
- const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact")
3736
+ const locale = resolveLocale(input.locale)
3737
+ const t = SUMMARY_TEXT[uiLanguage(locale)]
3738
+ const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact", locale)
3493
3739
  const prev = points[points.length - 2]
3494
- const direction = !prev ? "" : latest.value > prev.value ? "、前の点より増加" : latest.value < prev.value ? "、前の点より減少" : "、前の点と横ばい"
3740
+ const direction = !prev ? "" : latest.value > prev.value ? t.up : latest.value < prev.value ? t.down : t.flat
3495
3741
  const head = input.title ? `${input.title}: ` : ""
3496
- return `${head}最新 ${latest.label} は ${text(latest.value)}${direction}`
3742
+ return `${head}${t.latest(latest.label, text(latest.value))}${direction}`
3497
3743
  }
3498
3744
 
3499
3745
  /**
3500
- * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」 from the slices
3501
- * (largest first). `undefined` when there is nothing to describe.
3746
+ * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」("<title>: Largest: <name> (<share>)") from the slices
3747
+ * (largest first). `undefined` when there is nothing to describe. `locale` picks the language; default the page's.
3502
3748
  */
3503
3749
  function buildShareSummary(
3504
3750
  slices: readonly { name: string; value: number }[],
3505
3751
  title?: string,
3752
+ locale?: string,
3506
3753
  ): string | undefined {
3507
3754
  const total = slices.reduce((sum, s) => sum + s.value, 0)
3508
3755
  const top = slices[0]
3509
3756
  if (!top || total <= 0) return undefined
3510
3757
  const share = `${Math.round((top.value / total) * 1000) / 10}%`
3511
- return `${title ? `${title}: ` : ""}最大は ${top.name}(${share})`
3758
+ return `${title ? `${title}: ` : ""}${SUMMARY_TEXT[uiLanguage(resolveLocale(locale))].top(top.name, share)}`
3512
3759
  }
3513
3760
 
3514
3761
  /* ---------- inlined from @squadbase/squadkit registry/lib/distribution.ts (internal to @squadbase/vantage) ---------- */
@@ -3687,7 +3934,7 @@ export interface HistogramChartProps
3687
3934
  max?: number
3688
3935
  /** しきい値の線(任意)。階級の途中にも引ける。 */
3689
3936
  threshold?: HistogramThreshold
3690
- /** 件数の単位。既定「件」。 */
3937
+ /** 件数の単位。既定は日本語のページで「件」、それ以外は単位なし。 */
3691
3938
  countUnit?: string
3692
3939
  /** 横軸の値の名前(データ表の先頭列)。既定「値」。例「解決日数」 */
3693
3940
  valueLabel?: string
@@ -3699,9 +3946,45 @@ export interface HistogramChartProps
3699
3946
 
3700
3947
  /* ------------------------------------------------------------------ model */
3701
3948
 
3949
+ const TEXT = {
3950
+ ja: {
3951
+ countUnit: "件",
3952
+ atOrAbove: (from: string) => `${from}以上`,
3953
+ range: (from: string, to: string) => `${from}〜${to}`,
3954
+ count: (n: number, unit: string) => `${n}${unit}`,
3955
+ countHeader: (unit: string) => `${unit}数`,
3956
+ share: "構成比",
3957
+ value: "値",
3958
+ valueInSentence: "値",
3959
+ threshold: "しきい値",
3960
+ peak: (total: number, bin: string, count: number, unit: string) => `${total}${unit}のうち最も多いのは ${bin}(${count}${unit})`,
3961
+ overThreshold: (name: string, value: string, count: string, share: string) => `${name}(${value})以上は ${count}(${share})`,
3962
+ sentenceJoin: "。",
3963
+ chart: (label: string) => `${label}のヒストグラム`,
3964
+ },
3965
+ en: {
3966
+ countUnit: "",
3967
+ atOrAbove: (from: string) => `${from}+`,
3968
+ range: (from: string, to: string) => `${from}–${to}`,
3969
+ count: (n: number, unit: string) => (unit ? `${n} ${unit}` : `${n}`),
3970
+ countHeader: () => "Count",
3971
+ share: "Share",
3972
+ value: "Value",
3973
+ valueInSentence: "values",
3974
+ threshold: "Threshold",
3975
+ peak: (total: number, bin: string, count: number, unit: string) => `Most common: ${bin} (${unit ? `${count} ${unit}` : count} of ${total})`,
3976
+ overThreshold: (name: string, value: string, count: string, share: string) => `At or above ${name} (${value}): ${count} (${share})`,
3977
+ sentenceJoin: ". ",
3978
+ chart: (label: string) => `Histogram of ${label}`,
3979
+ },
3980
+ }
3981
+
3982
+ const textOf = (locale?: string) => TEXT[uiLanguage(resolveLocale(locale))]
3983
+
3702
3984
  /** 階級の名前。`10〜15`、上限超えは `30以上`。 */
3703
- export function binLabel(bin: HistogramBin, formatter: ValueFormatter): string {
3704
- return bin.overflow ? `${formatter(bin.from)}以上` : `${formatter(bin.from)}〜${formatter(bin.to)}`
3985
+ export function binLabel(bin: HistogramBin, formatter: ValueFormatter, locale?: string): string {
3986
+ const t = textOf(locale)
3987
+ return bin.overflow ? t.atOrAbove(formatter(bin.from)) : t.range(formatter(bin.from), formatter(bin.to))
3705
3988
  }
3706
3989
 
3707
3990
  /** しきい値が来る位置を階級の添字(小数。階級の左端 = 添字 − 0.5)で返す。範囲の外は `null`。 */
@@ -3727,27 +4010,30 @@ const percent = (n: number, total: number) => (total === 0 ? "0%" : `${Math.roun
3727
4010
  export function buildHistogramSummary(
3728
4011
  bins: readonly HistogramBin[],
3729
4012
  formatter: ValueFormatter,
3730
- config: { total: number; countUnit: string; atOrAbove?: number; threshold?: HistogramThreshold },
4013
+ config: { total: number; countUnit: string; atOrAbove?: number; threshold?: HistogramThreshold; locale?: string },
3731
4014
  ): string | undefined {
3732
4015
  if (bins.length === 0 || config.total === 0) return undefined
4016
+ const t = textOf(config.locale)
4017
+ const count = (n: number) => t.count(n, config.countUnit)
3733
4018
  const peak = bins.reduce((a, b) => (b.count > a.count ? b : a))
3734
- const parts = [`${config.total}${config.countUnit}のうち最も多いのは ${binLabel(peak, formatter)}(${peak.count}${config.countUnit})`]
4019
+ const parts = [t.peak(config.total, binLabel(peak, formatter, config.locale), peak.count, config.countUnit)]
3735
4020
  if (config.threshold && config.atOrAbove !== undefined) {
3736
- const name = config.threshold.label ?? "しきい値"
3737
- parts.push(`${name}(${formatter(config.threshold.value)})以上は ${config.atOrAbove}${config.countUnit}(${percent(config.atOrAbove, config.total)})`)
4021
+ const name = config.threshold.label ?? t.threshold
4022
+ parts.push(t.overThreshold(name, formatter(config.threshold.value), count(config.atOrAbove), percent(config.atOrAbove, config.total)))
3738
4023
  }
3739
- return parts.join("。")
4024
+ return parts.join(t.sentenceJoin)
3740
4025
  }
3741
4026
 
3742
4027
  /** The screen-reader table: one row per class. */
3743
4028
  export function histogramDataTable(
3744
4029
  bins: readonly HistogramBin[],
3745
4030
  formatter: ValueFormatter,
3746
- config: { total: number; countUnit: string; valueLabel?: string },
4031
+ config: { total: number; countUnit: string; valueLabel?: string; locale?: string },
3747
4032
  ): ChartDataTable {
4033
+ const t = textOf(config.locale)
3748
4034
  return {
3749
- columns: [config.valueLabel ?? "値", `${config.countUnit}数`, "構成比"],
3750
- rows: bins.map((b) => [binLabel(b, formatter), `${b.count}`, percent(b.count, config.total)]),
4035
+ columns: [config.valueLabel ?? t.value, t.countHeader(config.countUnit), t.share],
4036
+ rows: bins.map((b) => [binLabel(b, formatter, config.locale), `${b.count}`, percent(b.count, config.total)]),
3751
4037
  }
3752
4038
  }
3753
4039
 
@@ -3810,13 +4096,17 @@ export interface HistogramOptionConfig {
3810
4096
  labelColor?: string
3811
4097
  lineColor?: string
3812
4098
  chartOptions?: EChartsOption
4099
+ /** Language of the class names, tooltip and threshold label. Default the page's locale (`<html lang>`). */
4100
+ locale?: string
3813
4101
  }
3814
4102
 
3815
4103
  const ROW_STYLE = "display:flex;align-items:center;gap:8px;line-height:20px"
3816
4104
 
3817
4105
  /** The ECharts option: gap-less bars over the classes, the overflow class in the neutral fill, an optional threshold line. */
3818
4106
  export function buildHistogramOption(config: HistogramOptionConfig): EChartsOption {
3819
- const { bins, total, colors, valueFormatter, threshold, showTooltip = true, countUnit = "件" } = config
4107
+ const { locale } = config
4108
+ const t = textOf(locale)
4109
+ const { bins, total, colors, valueFormatter, threshold, showTooltip = true, countUnit = t.countUnit } = config
3820
4110
  const pos = threshold ? thresholdPosition(bins, threshold.value) : null
3821
4111
  const option = {
3822
4112
  ...chartMotion(false),
@@ -3836,15 +4126,15 @@ export function buildHistogramOption(config: HistogramOptionConfig): EChartsOpti
3836
4126
  `<span style="flex:1;opacity:.8">${escapeHtml(name)}</span>` +
3837
4127
  `<span style="font-weight:500;font-variant-numeric:tabular-nums;margin-left:12px">${escapeHtml(text)}</span></div>`
3838
4128
  return (
3839
- `<div style="font-weight:500;margin-bottom:4px">${escapeHtml(binLabel(bin, valueFormatter))}</div>` +
3840
- line(`${countUnit}数`, `${bin.count}${countUnit}`) +
3841
- line("構成比", percent(bin.count, total))
4129
+ `<div style="font-weight:500;margin-bottom:4px">${escapeHtml(binLabel(bin, valueFormatter, locale))}</div>` +
4130
+ line(t.countHeader(countUnit), t.count(bin.count, countUnit)) +
4131
+ line(t.share, percent(bin.count, total))
3842
4132
  )
3843
4133
  },
3844
4134
  },
3845
4135
  xAxis: {
3846
4136
  type: "category" as const,
3847
- data: bins.map((b) => binLabel(b, valueFormatter)),
4137
+ data: bins.map((b) => binLabel(b, valueFormatter, locale)),
3848
4138
  axisTick: { show: false },
3849
4139
  axisLabel: { hideOverlap: true },
3850
4140
  },
@@ -3879,7 +4169,7 @@ export function buildHistogramOption(config: HistogramOptionConfig): EChartsOpti
3879
4169
  // [階級の添字, 0(y の位置は使わない), 階級の中での位置(左端 -0.5 〜 右端 0.5)]
3880
4170
  data: [thresholdPoint(bins, threshold.value)],
3881
4171
  renderItem: thresholdRenderer({
3882
- text: `${threshold.label ?? "しきい値"} ${valueFormatter(threshold.value)}`,
4172
+ text: `${threshold.label ?? t.threshold} ${valueFormatter(threshold.value)}`,
3883
4173
  lineColor: config.lineColor,
3884
4174
  labelColor: config.labelColor,
3885
4175
  }),
@@ -3902,7 +4192,7 @@ export const HistogramChart = forwardRef(function HistogramChart(
3902
4192
  start,
3903
4193
  max,
3904
4194
  threshold,
3905
- countUnit = "件",
4195
+ countUnit: countUnitProp,
3906
4196
  valueLabel,
3907
4197
  format,
3908
4198
  valueFormatter: valueFormatterProp,
@@ -3922,10 +4212,13 @@ export const HistogramChart = forwardRef(function HistogramChart(
3922
4212
  ref: ForwardedRef<HTMLDivElement>,
3923
4213
  ) {
3924
4214
  const { ref: frameRef, tokens } = useChartSurface(ref)
4215
+ const locale = useLocale()
4216
+ const t = TEXT[uiLanguage(locale)]
4217
+ const countUnit = countUnitProp ?? t.countUnit
3925
4218
  // Class names read in full (`10〜15日`); the format default is a plain number, not the compact form.
3926
4219
  const valueFormatter = useMemo(
3927
- () => (valueFormatterProp ?? (format === undefined ? toValueFormatter("number") : resolveValueFormatter(undefined, format))),
3928
- [valueFormatterProp, format],
4220
+ () => (valueFormatterProp ?? (format === undefined ? toValueFormatter("number", locale) : resolveValueFormatter(undefined, format, locale))),
4221
+ [valueFormatterProp, format, locale],
3929
4222
  )
3930
4223
  const bins = useMemo(() => histogramBins(values, { binWidth, start, max }), [values, binWidth, start, max])
3931
4224
  const total = useMemo(() => bins.reduce((sum, b) => sum + b.count, 0), [bins])
@@ -3946,10 +4239,14 @@ export const HistogramChart = forwardRef(function HistogramChart(
3946
4239
  labelColor: tokens?.foreground ?? undefined,
3947
4240
  lineColor: tokens?.mutedForeground ?? undefined,
3948
4241
  chartOptions,
4242
+ locale,
3949
4243
  }),
3950
- [bins, total, colors, valueFormatter, countUnit, threshold, showTooltip, tokens, chartOptions],
4244
+ [bins, total, colors, valueFormatter, countUnit, threshold, showTooltip, tokens, chartOptions, locale],
4245
+ )
4246
+ const dataTable = useMemo(
4247
+ () => histogramDataTable(bins, valueFormatter, { total, countUnit, valueLabel, locale }),
4248
+ [bins, valueFormatter, total, countUnit, valueLabel, locale],
3951
4249
  )
3952
- const dataTable = useMemo(() => histogramDataTable(bins, valueFormatter, { total, countUnit, valueLabel }), [bins, valueFormatter, total, countUnit, valueLabel])
3953
4250
  const resolvedSummary = useMemo(
3954
4251
  () =>
3955
4252
  summary === "auto"
@@ -3958,11 +4255,12 @@ export const HistogramChart = forwardRef(function HistogramChart(
3958
4255
  countUnit,
3959
4256
  threshold,
3960
4257
  atOrAbove: threshold ? countAtOrAbove(values, threshold.value, bins[0]?.from) : undefined,
4258
+ locale,
3961
4259
  })
3962
4260
  : summary,
3963
- [summary, bins, valueFormatter, total, countUnit, threshold, values],
4261
+ [summary, bins, valueFormatter, total, countUnit, threshold, values, locale],
3964
4262
  )
3965
- const label = ariaLabel ?? `${valueLabel ?? "値"}のヒストグラム`
4263
+ const label = ariaLabel ?? t.chart(valueLabel ?? t.valueInSentence)
3966
4264
  return (
3967
4265
  <ChartFrame
3968
4266
  {...chartRootProps(rest)}