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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/{chunk-57GHR5LN.js → chunk-3RJEOP3J.js} +29 -18
  2. package/dist/chunk-3RJEOP3J.js.map +1 -0
  3. package/dist/{chunk-QOXXOFGL.js → chunk-77CISBMG.js} +994 -464
  4. package/dist/chunk-77CISBMG.js.map +1 -0
  5. package/dist/{chunk-JXFCSF3K.js → chunk-EG4PX726.js} +12 -7
  6. package/dist/chunk-EG4PX726.js.map +1 -0
  7. package/dist/{chunk-2N72FKLB.js → chunk-NZ5QIIG4.js} +33 -11
  8. package/dist/chunk-NZ5QIIG4.js.map +1 -0
  9. package/dist/{chunk-7S52M2SL.js → chunk-QNOX7WLZ.js} +3 -3
  10. package/dist/{chunk-7S52M2SL.js.map → chunk-QNOX7WLZ.js.map} +1 -1
  11. package/dist/{chunk-PDOBHMUQ.js → chunk-ROHNXP7J.js} +72 -3
  12. package/dist/chunk-ROHNXP7J.js.map +1 -0
  13. package/dist/{chunk-6OTTGAPO.js → chunk-TRMHNMMZ.js} +24 -7
  14. package/dist/chunk-TRMHNMMZ.js.map +1 -0
  15. package/dist/components/index.d.ts +49 -29
  16. package/dist/components/index.js +5 -5
  17. package/dist/{delta-text-CvwBGJ95.d.ts → delta-text-CbrOaW83.d.ts} +4 -2
  18. package/dist/doc/index.d.ts +14 -14
  19. package/dist/doc/index.js +198 -84
  20. package/dist/doc/index.js.map +1 -1
  21. package/dist/{format-U0cQl069.d.ts → format-DihQkL1N.d.ts} +17 -11
  22. package/dist/slide/index.d.ts +2 -2
  23. package/dist/slide/index.js +95 -44
  24. package/dist/slide/index.js.map +1 -1
  25. package/dist/table/index.js +6 -6
  26. package/dist/ui/index.d.ts +1 -1
  27. package/dist/ui/index.js +5 -5
  28. package/package.json +2 -2
  29. package/registry/ui/app-shell.tsx +138 -1
  30. package/registry/ui/area-chart.tsx +344 -85
  31. package/registry/ui/attention-list.tsx +177 -15
  32. package/registry/ui/bar-chart.tsx +341 -85
  33. package/registry/ui/bar-list.tsx +332 -77
  34. package/registry/ui/box-plot-chart.tsx +405 -97
  35. package/registry/ui/category-bar.tsx +248 -40
  36. package/registry/ui/chart-frame.tsx +257 -46
  37. package/registry/ui/data-table.tsx +229 -37
  38. package/registry/ui/date-range-picker.tsx +190 -36
  39. package/registry/ui/delta.tsx +171 -12
  40. package/registry/ui/donut-chart.tsx +339 -84
  41. package/registry/ui/echart.tsx +142 -4
  42. package/registry/ui/filter-bar.tsx +179 -20
  43. package/registry/ui/funnel-steps.tsx +354 -80
  44. package/registry/ui/heatmap.tsx +293 -52
  45. package/registry/ui/histogram-chart.tsx +395 -97
  46. package/registry/ui/kpi-card.tsx +245 -44
  47. package/registry/ui/legend.tsx +330 -76
  48. package/registry/ui/line-chart.tsx +344 -85
  49. package/registry/ui/multi-select.tsx +159 -9
  50. package/registry/ui/page-shell.tsx +141 -3
  51. package/registry/ui/paired-chart.tsx +349 -90
  52. package/registry/ui/progress-circle.tsx +205 -39
  53. package/registry/ui/sample-data.tsx +153 -9
  54. package/registry/ui/scatter-chart.tsx +391 -100
  55. package/registry/ui/searchable-select.tsx +145 -7
  56. package/registry/ui/states.tsx +170 -13
  57. package/registry/ui/status-badge.tsx +193 -28
  58. package/registry/ui/tracker.tsx +205 -39
  59. package/registry/ui/trend-indicator.tsx +149 -6
  60. package/registry/ui/updated-at.tsx +170 -16
  61. package/registry/ui/waterfall-chart.tsx +415 -102
  62. package/skills/vantage-add-feature/SKILL.md +5 -0
  63. package/templates/AGENTS.md +10 -8
  64. package/templates/agents/api.md +7 -0
  65. package/templates/agents/page.md +36 -0
  66. package/dist/chunk-2N72FKLB.js.map +0 -1
  67. package/dist/chunk-57GHR5LN.js.map +0 -1
  68. package/dist/chunk-6OTTGAPO.js.map +0 -1
  69. package/dist/chunk-JXFCSF3K.js.map +0 -1
  70. package/dist/chunk-PDOBHMUQ.js.map +0 -1
  71. package/dist/chunk-QOXXOFGL.js.map +0 -1
@@ -22,14 +22,147 @@ import { forwardRef, useEffect, useMemo, useState, type ForwardedRef, type React
22
22
  import { ChartFrame } from "@squadbase/vantage/components"
23
23
  import { EChart } from "@squadbase/vantage/components"
24
24
  import type { ChartCommonProps, ChartEventPayload, ChartSeriesColor } from "@squadbase/vantage/components"
25
+ import { useSyncExternalStore } from "react"
25
26
  import { createContext, useCallback, useRef, type RefObject } from "react"
26
27
  import type { ChartTone } from "@squadbase/vantage/components"
27
- import { DEFAULT_LOCALE, EMPTY_VALUE, formatValue, toValueFormatter, type ValueFormat } from "@squadbase/vantage/components"
28
+ import { EMPTY_VALUE, formatValue, toValueFormatter, type ValueFormat } from "@squadbase/vantage/components"
28
29
  import type { ChartColor, ChartColors, ValueFormatter } from "@squadbase/vantage/components"
29
30
  import type { EChartsOption } from "@squadbase/vantage/components"
30
31
  import { computeComparison, defaultChangeDisplay, formatChange, toneForValue, type ComparisonKind, type PeriodGrain } from "@squadbase/vantage/components"
32
+ import { DEFAULT_LOCALE } from "@squadbase/vantage/components"
31
33
  import type { ChartThreshold, ChartAnnotation, ChartForecast, ChartForecastRow, ChartHighlight, NumericKeys } from "@squadbase/vantage/components"
32
34
 
35
+ /* ---------- inlined from @squadbase/squadkit registry/lib/locale.ts (internal to @squadbase/vantage) ---------- */
36
+
37
+ /**
38
+ * The display language of the parts and the defaults that follow it.
39
+ *
40
+ * The language comes from `<html lang>`: a tag starting with `ja` reads Japanese, anything else English.
41
+ * Without a usable `lang` (missing, invalid, or no `document` — SSR, Node) the locale is `FALLBACK_LOCALE`
42
+ * (`en-US`). A `locale` passed explicitly (a prop, an argument) always wins.
43
+ *
44
+ * Parts read the locale through `useLocale` (`hooks/use-locale`) and pass it down; the pure helpers here
45
+ * and in `format` / `comparison` / `chart-*` take it as an argument and fall back to `documentLocale()`.
46
+ */
47
+
48
+ /** The language of the built-in wording: Japanese or English. */
49
+ type UiLanguage = "ja" | "en"
50
+
51
+ /** The locale used when `<html lang>` gives none (and on the server). */
52
+ const FALLBACK_LOCALE = "en-US"
53
+
54
+ /** `"ja"` when `locale` starts with `ja` (`ja`, `ja-JP`), else `"en"`. A missing locale is `"en"`. */
55
+ function uiLanguage(locale: string | null | undefined): UiLanguage {
56
+ return typeof locale === "string" && locale.trim().toLowerCase().startsWith("ja") ? "ja" : "en"
57
+ }
58
+
59
+ /** A BCP 47 tag in its canonical form (`en-gb` → `en-GB`), or `FALLBACK_LOCALE` when it is empty or invalid. */
60
+ function normalizeLocale(tag: string | null | undefined): string {
61
+ const value = tag?.trim()
62
+ if (!value) return FALLBACK_LOCALE
63
+ try {
64
+ return Intl.getCanonicalLocales(value)[0] ?? FALLBACK_LOCALE
65
+ } catch {
66
+ return FALLBACK_LOCALE
67
+ }
68
+ }
69
+
70
+ /**
71
+ * The locale of the page: `document.documentElement.lang`, canonicalized. `FALLBACK_LOCALE` when it is empty or
72
+ * invalid, or there is no `document`. Parts read it through `useLocale`, never while rendering.
73
+ */
74
+ function documentLocale(): string {
75
+ if (typeof document === "undefined") return FALLBACK_LOCALE
76
+ return normalizeLocale(document.documentElement?.getAttribute("lang"))
77
+ }
78
+
79
+ /** `locale` when given, else the page's locale (`documentLocale()`). */
80
+ function resolveLocale(locale?: string | null): string {
81
+ return locale || documentLocale()
82
+ }
83
+
84
+ /** Picks the wording table of the locale's language. */
85
+ function pickLocale<T>(table: { ja: T; en: T }, locale?: string | null): T {
86
+ return table[uiLanguage(resolveLocale(locale))]
87
+ }
88
+
89
+ /** The currency `format="currency"` uses without a `currency`: JPY for Japanese, USD otherwise. */
90
+ function defaultCurrency(locale?: string | null): string {
91
+ return uiLanguage(resolveLocale(locale)) === "ja" ? "JPY" : "USD"
92
+ }
93
+
94
+ /** The month a fiscal year starts in (1–12) when none is given: April for Japanese, January otherwise. */
95
+ function defaultFiscalYearStartMonth(locale?: string | null): number {
96
+ return uiLanguage(resolveLocale(locale)) === "ja" ? 4 : 1
97
+ }
98
+
99
+ /** A day of the week, 0 = Sunday … 6 = Saturday (the `Date#getDay` / date-fns numbering). */
100
+ type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6
101
+
102
+ /** Locales whose week starts on Sunday when the runtime has no week data. */
103
+ const SUNDAY_FIRST = new Set(["en", "en-US", "en-CA"])
104
+
105
+ /**
106
+ * The first day of the week: Monday for Japanese; otherwise the locale's convention from `Intl.Locale`
107
+ * (`en-US` Sunday, `en-GB` Monday). Without week data in the runtime: Sunday for `en` / `en-US` / `en-CA`,
108
+ * Monday for the rest.
109
+ */
110
+ function weekStartsOn(locale?: string | null): WeekDay {
111
+ const tag = normalizeLocale(resolveLocale(locale))
112
+ if (uiLanguage(tag) === "ja") return 1
113
+ try {
114
+ const info = new Intl.Locale(tag) as Intl.Locale & {
115
+ getWeekInfo?: () => { firstDay: number }
116
+ weekInfo?: { firstDay: number }
117
+ }
118
+ const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay
119
+ if (typeof firstDay === "number" && firstDay >= 1 && firstDay <= 7) return (firstDay % 7) as WeekDay
120
+ } catch {
121
+ // Fall through to the table below.
122
+ }
123
+ return SUNDAY_FIRST.has(tag) ? 0 : 1
124
+ }
125
+
126
+ /* ---------- inlined from @squadbase/squadkit registry/hooks/use-locale.ts (internal to @squadbase/vantage) ---------- */
127
+
128
+ const listeners = new Set<() => void>()
129
+ let observer: MutationObserver | null = null
130
+
131
+ /** One observer on `<html lang>` for every subscribed part. */
132
+ function subscribe(onChange: () => void): () => void {
133
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {}
134
+ listeners.add(onChange)
135
+ if (!observer) {
136
+ observer = new MutationObserver(() => {
137
+ for (const listener of [...listeners]) listener()
138
+ })
139
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] })
140
+ }
141
+ return () => {
142
+ listeners.delete(onChange)
143
+ if (listeners.size === 0 && observer) {
144
+ observer.disconnect()
145
+ observer = null
146
+ }
147
+ }
148
+ }
149
+
150
+ const serverLocale = () => FALLBACK_LOCALE
151
+
152
+ /**
153
+ * The page's locale (`<html lang>`), following changes made while the page runs. On the server and during
154
+ * hydration it is `FALLBACK_LOCALE` (`en-US`), so the markup matches; it switches right after.
155
+ */
156
+ function useDocumentLocale(): string {
157
+ return useSyncExternalStore(subscribe, documentLocale, serverLocale)
158
+ }
159
+
160
+ /** The locale a part renders in: its `locale` prop when given, else the page's (`useDocumentLocale`). */
161
+ function useLocale(locale?: string | null): string {
162
+ const page = useDocumentLocale()
163
+ return locale || page
164
+ }
165
+
33
166
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-theme.ts (internal to @squadbase/vantage) ---------- */
34
167
 
35
168
  /**
@@ -533,9 +666,10 @@ function resolveValueFormatter(
533
666
  function resolveAxisFormatter(
534
667
  valueFormatter: ValueFormatter | undefined,
535
668
  format: ValueFormat | undefined,
536
- locale: string = DEFAULT_LOCALE,
669
+ localeArg?: string,
537
670
  ): ValueFormatter {
538
671
  if (valueFormatter) return valueFormatter
672
+ const locale = resolveLocale(localeArg)
539
673
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
540
674
  if (spec.type !== "compact" && spec.type !== "number" && spec.type !== "currency") return toValueFormatter(format, locale)
541
675
  const currency = "currency" in spec ? spec.currency : undefined
@@ -544,40 +678,75 @@ function resolveAxisFormatter(
544
678
  return (value) => formatValue(value, short, locale)
545
679
  }
546
680
 
547
- /** The unit words of a ja number (`1.2万` / `3.4億` / `1兆`) and their sizes, largest first. */
548
- const JA_UNITS = [
549
- { char: "兆", size: 1e12 },
550
- { char: "億", size: 1e8 },
551
- { char: "万", size: 1e4 },
552
- ] as const
681
+ interface CompactUnit {
682
+ token: string
683
+ size: number
684
+ }
553
685
 
554
- const JA_NUMBER_TOKEN_RE = /\d[\d,]*(?:\.\d+)?(?:兆|億|万)?/
686
+ const compactUnitCache = new Map<string, CompactUnit[] | null>()
687
+
688
+ /**
689
+ * The compact-notation unit words of a locale and their sizes, largest first: `兆` / `億` / `万` in ja,
690
+ * `T` / `B` / `M` / `K` in en. `null` when the locale's compact form puts anything between the number and its
691
+ * word (`1,5 Mio.`), where a token cannot be swapped in place.
692
+ */
693
+ function compactUnits(locale: string): CompactUnit[] | null {
694
+ const cached = compactUnitCache.get(locale)
695
+ if (cached !== undefined) return cached
696
+ let units: CompactUnit[] | null = []
697
+ try {
698
+ const compact = new Intl.NumberFormat(locale, { notation: "compact" })
699
+ const seen = new Set<string>()
700
+ for (let exp = 3; exp <= 15; exp++) {
701
+ const parts = compact.formatToParts(10 ** exp)
702
+ const at = parts.findIndex((p) => p.type === "compact")
703
+ if (at === -1) continue
704
+ if (parts[at - 1]?.type !== "integer" || at !== parts.length - 1) {
705
+ units = null
706
+ break
707
+ }
708
+ const token = parts[at]!.value
709
+ if (seen.has(token)) continue
710
+ seen.add(token)
711
+ units.push({ token, size: 10 ** exp })
712
+ }
713
+ } catch {
714
+ units = null
715
+ }
716
+ const sorted = units && units.length > 0 ? units.sort((a, b) => b.size - a.size) : null
717
+ compactUnitCache.set(locale, sorted)
718
+ return sorted
719
+ }
720
+
721
+ const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
555
722
 
556
723
  /**
557
724
  * The value-axis tick formatter for an axis whose largest tick is `top`: every tick reads in the unit of
558
- * that largest tick, so 万 and 億 never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`).
559
- * Only the ja short forms (`compact` / `number` / `currency`, no `valueFormatter`) have units to align;
560
- * any other format — and an axis whose largest tick is under 1万 — keeps `resolveAxisFormatter`'s output as it is.
725
+ * that largest tick, so units never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`;
726
+ * `$0.5M` / `$1M` / `$1.5M`, not `$500K` / `$1M`). Only the short forms (`compact` / `number` / `currency`,
727
+ * no `valueFormatter`) have units to align; any other format — an axis whose largest tick is under the smallest
728
+ * unit (1万 / 1K), or a locale whose compact form cannot be aligned in place — keeps `resolveAxisFormatter`'s output as it is.
561
729
  */
562
730
  function resolveAxisTickFormatter(
563
731
  valueFormatter: ValueFormatter | undefined,
564
732
  format: ValueFormat | undefined,
565
- locale: string = DEFAULT_LOCALE,
733
+ localeArg?: string,
566
734
  ): (top: number) => ValueFormatter {
735
+ const locale = resolveLocale(localeArg)
567
736
  const base = resolveAxisFormatter(valueFormatter, format, locale)
568
737
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
569
- const aligns =
570
- !valueFormatter && locale.toLowerCase().startsWith("ja") && (spec.type === "compact" || spec.type === "number" || spec.type === "currency")
571
- if (!aligns) return () => base
738
+ const units = !valueFormatter && (spec.type === "compact" || spec.type === "number" || spec.type === "currency") ? compactUnits(locale) : null
739
+ if (!units) return () => base
572
740
  const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 3 })
741
+ const numberToken = new RegExp(`\\d[\\d,]*(?:\\.\\d+)?(?:${units.map((u) => escapeRegExp(u.token)).join("|")})?`)
573
742
  return (top) => {
574
- const unit = JA_UNITS.find((u) => Math.abs(top) >= u.size)
743
+ const unit = units.find((u) => Math.abs(top) >= u.size)
575
744
  if (!unit) return base
576
745
  return (value) => {
577
746
  const text = base(value)
578
747
  if (value === 0 || !Number.isFinite(value)) return text
579
- const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.char}`
580
- return text.replace(JA_NUMBER_TOKEN_RE, () => scaled)
748
+ const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.token}`
749
+ return text.replace(numberToken, () => scaled)
581
750
  }
582
751
  }
583
752
  }
@@ -606,8 +775,20 @@ function clamp(value: number, min: number, max: number): number {
606
775
  /** How many categories a chart colours individually; the rest fold into「その他」. */
607
776
  const MAX_CHART_SERIES = 5
608
777
 
609
- /** The series name the folded categories get. */
610
- const OTHER_SERIES_LABEL = "その他"
778
+ /** The series name the folded categories get, by language. */
779
+ const OTHER_SERIES_LABELS: Record<UiLanguage, string> = { ja: "その他", en: "Other" }
780
+
781
+ /** The series name the folded categories get in `locale` ("Other" / "その他"). Default the page's locale (`<html lang>`). */
782
+ function otherSeriesLabel(locale?: string): string {
783
+ return OTHER_SERIES_LABELS[uiLanguage(resolveLocale(locale))]
784
+ }
785
+
786
+ const OTHER_NAMES = new Set(Object.values(OTHER_SERIES_LABELS))
787
+
788
+ /** Whether a category / row is the "other" bucket by name (「その他」 or "Other", whatever the page language). */
789
+ function isOtherName(name: string | null | undefined): boolean {
790
+ return typeof name === "string" && OTHER_NAMES.has(name)
791
+ }
611
792
 
612
793
  interface AggregatedItems<Item extends { name: string; value: number }> {
613
794
  /** The `n` largest items, in their given order. All items when there are `n` or fewer. */
@@ -627,7 +808,7 @@ interface AggregatedItems<Item extends { name: string; value: number }> {
627
808
  function aggregateTopN<Item extends { name: string; value: number }>(
628
809
  items: readonly Item[],
629
810
  n: number = MAX_CHART_SERIES,
630
- otherLabel: string = OTHER_SERIES_LABEL,
811
+ otherLabel: string = otherSeriesLabel(),
631
812
  ): AggregatedItems<Item> {
632
813
  if (items.length <= n) return { top: [...items], rest: [], other: null }
633
814
  const ranked = items
@@ -656,7 +837,7 @@ function aggregateSeries(
656
837
  data: readonly Record<string, unknown>[],
657
838
  categories: readonly string[],
658
839
  n: number = MAX_CHART_SERIES,
659
- otherLabel: string = OTHER_SERIES_LABEL,
840
+ otherLabel: string = otherSeriesLabel(),
660
841
  ): AggregatedSeries {
661
842
  const totals = categories.map((name) => ({
662
843
  name,
@@ -688,12 +869,14 @@ function aggregateSeriesKeeping(
688
869
  categories: readonly string[],
689
870
  keep: string | undefined,
690
871
  n: number = MAX_CHART_SERIES,
872
+ otherLabel: string = otherSeriesLabel(),
691
873
  ): AggregatedSeries {
692
- if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n)
874
+ if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n, otherLabel)
693
875
  const aggregated = aggregateSeries(
694
876
  data,
695
877
  categories.filter((c) => c !== keep),
696
878
  n,
879
+ otherLabel,
697
880
  )
698
881
  return { ...aggregated, categories: [...aggregated.categories, keep] }
699
882
  }
@@ -729,7 +912,7 @@ function seriesColorsWithComparison(
729
912
  * Colour per drawn series: the record's token for that category, else the
730
913
  * array's token at the category's position in `allCategories` (cycled), else
731
914
  * the token palette by drawn position. `otherCategory` — and a category the
732
- * data itself names「その他」(`OTHER_SERIES_LABEL`) — always gets `otherColor`.
915
+ * data itself names「その他」/ "Other" (`OTHER_SERIES_LABELS`) — always gets `otherColor`.
733
916
  */
734
917
  function seriesColorsFor(
735
918
  categories: readonly string[],
@@ -741,7 +924,7 @@ function seriesColorsFor(
741
924
  const list = Array.isArray(colors) ? (colors as ChartSeriesColor[]) : undefined
742
925
  const record = !Array.isArray(colors) && colors ? (colors as Partial<Record<string, ChartSeriesColor>>) : undefined
743
926
  return categories.map((category, i) => {
744
- if ((otherCategory !== null && category === otherCategory) || category === OTHER_SERIES_LABEL) return otherColor
927
+ if ((otherCategory !== null && category === otherCategory) || isOtherName(category)) return otherColor
745
928
  const fromRecord = record?.[category]
746
929
  if (fromRecord) return fromRecord
747
930
  if (list && list.length > 0) {
@@ -788,7 +971,7 @@ interface DonutSlicesConfig {
788
971
  */
789
972
  function donutSlices(
790
973
  data: readonly Record<string, unknown>[],
791
- { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = OTHER_SERIES_LABEL }: DonutSlicesConfig,
974
+ { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = otherSeriesLabel() }: DonutSlicesConfig,
792
975
  ): DonutSlice[] {
793
976
  const items = data
794
977
  .map((row, i) => {
@@ -801,7 +984,8 @@ function donutSlices(
801
984
  const slices: DonutSlice[] = top.map((item, rank) => ({
802
985
  name: item.name,
803
986
  value: item.value,
804
- color: palette[rank % palette.length]!,
987
+ // A row the data itself names「その他」/ "Other" is the same remainder: grey, like the folded slice.
988
+ color: isOtherName(item.name) ? otherColor : palette[rank % palette.length]!,
805
989
  isOther: false,
806
990
  row: item.row,
807
991
  }))
@@ -874,7 +1058,7 @@ interface BarListRowsConfig extends BarListOptions {
874
1058
  */
875
1059
  function barListRows<Row extends object>(
876
1060
  data: readonly Row[],
877
- { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = OTHER_SERIES_LABEL, exclude }: BarListRowsConfig = {},
1061
+ { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = otherSeriesLabel(), exclude }: BarListRowsConfig = {},
878
1062
  ): BarListRow<Row>[] {
879
1063
  const items = data.map((row): BarListRow<Row> => {
880
1064
  const cells = row as Record<string, unknown>
@@ -920,19 +1104,21 @@ const BAR_LIST_OTHER_TINT = 30
920
1104
 
921
1105
  /**
922
1106
  * The fill of each BarList row: all real rows in the same tint of `color`
923
- * (`BAR_LIST_TINT`), the folded「その他」row in a tint of `otherColor`
1107
+ * (`BAR_LIST_TINT`), the folded「その他」row (and a row the data names「その他」/ "Other") in a tint of `otherColor`
924
1108
  * (`BAR_LIST_OTHER_TINT`) — the palest fill, a remainder never outweighs a
925
1109
  * ranked row. A legacy per-row `color` (or a threshold tone) replaces `color`
926
1110
  * for that row. A tint, not the solid token, so the label printed over the bar
927
1111
  * keeps its contrast.
928
1112
  */
929
1113
  function barListFills(
930
- rows: readonly Pick<BarListRow, "isOther" | "color">[],
1114
+ rows: readonly (Pick<BarListRow, "isOther" | "color"> & { name?: string })[],
931
1115
  color: ChartColor,
932
1116
  otherColor: string,
933
1117
  ): string[] {
934
1118
  return rows.map((row) =>
935
- row.isOther ? tintCss(otherColor, BAR_LIST_OTHER_TINT) : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
1119
+ row.isOther || isOtherName(row.name)
1120
+ ? tintCss(otherColor, BAR_LIST_OTHER_TINT)
1121
+ : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
936
1122
  )
937
1123
  }
938
1124
 
@@ -1148,11 +1334,30 @@ interface ChartSummaryInput {
1148
1334
  format?: ValueFormat
1149
1335
  /** Wins over `format`. */
1150
1336
  valueFormatter?: ValueFormatter
1337
+ /** Language of the sentence. Default the page's locale (`<html lang>`). */
1338
+ locale?: string
1339
+ }
1340
+
1341
+ const SUMMARY_TEXT = {
1342
+ ja: {
1343
+ up: "、前の点より増加",
1344
+ down: "、前の点より減少",
1345
+ flat: "、前の点と横ばい",
1346
+ latest: (label: string, value: string) => `最新 ${label} は ${value}`,
1347
+ top: (name: string, share: string) => `最大は ${name}(${share})`,
1348
+ },
1349
+ en: {
1350
+ up: ", up from the previous point",
1351
+ down: ", down from the previous point",
1352
+ flat: ", flat from the previous point",
1353
+ latest: (label: string, value: string) => `Latest ${label}: ${value}`,
1354
+ top: (name: string, share: string) => `Largest: ${name} (${share})`,
1355
+ },
1151
1356
  }
1152
1357
 
1153
1358
  /**
1154
1359
  * One-sentence summary built from the rows, for `summary="auto"`:
1155
- * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」. The direction is
1360
+ * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」("<title>: Latest <index>: <value>, up from the previous point"). The direction is
1156
1361
  * computed from the last two non-empty points of the first series — never
1157
1362
  * written by hand, so it cannot contradict the data. `undefined` when there is
1158
1363
  * no usable point.
@@ -1168,30 +1373,59 @@ function buildChartSummary(input: ChartSummaryInput): string | undefined {
1168
1373
  }
1169
1374
  const latest = points[points.length - 1]
1170
1375
  if (!latest) return undefined
1171
- const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact")
1376
+ const locale = resolveLocale(input.locale)
1377
+ const t = SUMMARY_TEXT[uiLanguage(locale)]
1378
+ const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact", locale)
1172
1379
  const prev = points[points.length - 2]
1173
- const direction = !prev ? "" : latest.value > prev.value ? "、前の点より増加" : latest.value < prev.value ? "、前の点より減少" : "、前の点と横ばい"
1380
+ const direction = !prev ? "" : latest.value > prev.value ? t.up : latest.value < prev.value ? t.down : t.flat
1174
1381
  const head = input.title ? `${input.title}: ` : ""
1175
- return `${head}最新 ${latest.label} は ${text(latest.value)}${direction}`
1382
+ return `${head}${t.latest(latest.label, text(latest.value))}${direction}`
1176
1383
  }
1177
1384
 
1178
1385
  /**
1179
- * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」 from the slices
1180
- * (largest first). `undefined` when there is nothing to describe.
1386
+ * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」("<title>: Largest: <name> (<share>)") from the slices
1387
+ * (largest first). `undefined` when there is nothing to describe. `locale` picks the language; default the page's.
1181
1388
  */
1182
1389
  function buildShareSummary(
1183
1390
  slices: readonly { name: string; value: number }[],
1184
1391
  title?: string,
1392
+ locale?: string,
1185
1393
  ): string | undefined {
1186
1394
  const total = slices.reduce((sum, s) => sum + s.value, 0)
1187
1395
  const top = slices[0]
1188
1396
  if (!top || total <= 0) return undefined
1189
1397
  const share = `${Math.round((top.value / total) * 1000) / 10}%`
1190
- return `${title ? `${title}: ` : ""}最大は ${top.name}(${share})`
1398
+ return `${title ? `${title}: ` : ""}${SUMMARY_TEXT[uiLanguage(resolveLocale(locale))].top(top.name, share)}`
1191
1399
  }
1192
1400
 
1193
1401
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-options.ts (internal to @squadbase/vantage) ---------- */
1194
1402
 
1403
+ /** The built-in words of the option builders, by language. */
1404
+ const CHART_TEXT = {
1405
+ ja: {
1406
+ target: "目標",
1407
+ forecast: "予測",
1408
+ total: "合計",
1409
+ provisional: "速報",
1410
+ noComparison: "比較なし",
1411
+ share: (share: string) => `(${share})`,
1412
+ targetRate: (rate: string) => `(達成率 ${rate})`,
1413
+ provisionalTitle: (title: string, label: string) => `${title}(${label})`,
1414
+ },
1415
+ en: {
1416
+ target: "Target",
1417
+ forecast: "Forecast",
1418
+ total: "Total",
1419
+ provisional: "Preliminary",
1420
+ noComparison: "No comparison",
1421
+ share: (share: string) => ` (${share})`,
1422
+ targetRate: (rate: string) => ` (${rate} achieved)`,
1423
+ provisionalTitle: (title: string, label: string) => `${title} (${label})`,
1424
+ },
1425
+ } satisfies Record<UiLanguage, unknown>
1426
+
1427
+ const chartText = (locale?: string) => CHART_TEXT[uiLanguage(resolveLocale(locale))]
1428
+
1195
1429
  type CartesianChartKind = "area" | "bar" | "line"
1196
1430
  type CartesianStackType = "default" | "stacked" | "percent"
1197
1431
  type CartesianCurveType = "linear" | "smooth" | "step"
@@ -1376,7 +1610,7 @@ interface CartesianChartConfig {
1376
1610
  * all ticks read in one unit (¥0.5億 / ¥1億). Default: `axisFormatter` for every tick.
1377
1611
  */
1378
1612
  axisTicks?: (top: number) => (value: number) => string
1379
- /** Locale of the time-axis labels. Default `"ja-JP"`. */
1613
+ /** Locale of the time-axis labels and the built-in words (total, target, forecast). Default the page's (`<html lang>`). */
1380
1614
  locale?: string
1381
1615
  type?: CartesianStackType
1382
1616
  /** Bar only. `"vertical"` lays the categories down the y axis (horizontal bars). */
@@ -1614,11 +1848,12 @@ function timeAxisLabelStep(values: readonly unknown[], maxLabels: number = TIME_
1614
1848
  */
1615
1849
  function timeAxisLabels(
1616
1850
  values: readonly unknown[],
1617
- locale: string = DEFAULT_LOCALE,
1851
+ localeArg?: string,
1618
1852
  shown: (i: number) => boolean = () => true,
1619
1853
  ): string[] | null {
1620
1854
  const parsed = parseTimeIndex(values)
1621
1855
  if (!parsed) return null
1856
+ const locale = resolveLocale(localeArg)
1622
1857
  const { granularity, times } = parsed
1623
1858
  let prevYear: number | null = null
1624
1859
  return times.map((t, i) => {
@@ -1641,9 +1876,10 @@ function timeAxisLabels(
1641
1876
  }
1642
1877
 
1643
1878
  /** The tooltip titles for time-like x values (`2025年9月`, `2025年9月1日`), or `null` when they are not dates. */
1644
- function timeTooltipLabels(values: readonly unknown[], locale: string = DEFAULT_LOCALE): string[] | null {
1879
+ function timeTooltipLabels(values: readonly unknown[], localeArg?: string): string[] | null {
1645
1880
  const parsed = parseTimeIndex(values)
1646
1881
  if (!parsed) return null
1882
+ const locale = resolveLocale(localeArg)
1647
1883
  const options: Intl.DateTimeFormatOptions =
1648
1884
  parsed.granularity === "year"
1649
1885
  ? { year: "numeric" }
@@ -1783,7 +2019,9 @@ function tooltipHtml(
1783
2019
  rows: readonly TooltipRow[],
1784
2020
  valueFormatter: (value: number) => string,
1785
2021
  notes: readonly string[] = [],
2022
+ locale?: string,
1786
2023
  ): string {
2024
+ const t = chartText(locale)
1787
2025
  const lines = rows.map(
1788
2026
  (row) =>
1789
2027
  `<div style="display:flex;align-items:center;gap:8px;line-height:20px">` +
@@ -1792,7 +2030,7 @@ function tooltipHtml(
1792
2030
  `<span style="font-weight:600;font-variant-numeric:tabular-nums;margin-left:12px">${
1793
2031
  row.value === null ? EMPTY_VALUE : escapeHtml(valueFormatter(row.value))
1794
2032
  }${
1795
- row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px">(${escapeHtml(formatShare(row.share))})</span>`
2033
+ row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px">${escapeHtml(t.share(formatShare(row.share)))}</span>`
1796
2034
  }</span></div>` +
1797
2035
  (row.note === undefined
1798
2036
  ? ""
@@ -1806,8 +2044,10 @@ function tooltipHtml(
1806
2044
 
1807
2045
  /* ------------------------------------------------- per-bar targets / notes */
1808
2046
 
1809
- /** Default name of the per-bar target marker (legend, tooltip). */
1810
- const BAR_TARGET_LABEL = "目標"
2047
+ /** Default name of the per-bar target marker (legend, tooltip) in `locale`: "Target" / 「目標」. Default the page's locale. */
2048
+ function barTargetLabel(locale?: string): string {
2049
+ return chartText(locale).target
2050
+ }
1811
2051
 
1812
2052
  /** How far (px) the target marker reaches past each side of its bar. */
1813
2053
  const TARGET_MARKER_OVERHANG = 4
@@ -1858,16 +2098,17 @@ function achievementRate(value: number | null | undefined, target: number | null
1858
2098
  return (value / target) * 100
1859
2099
  }
1860
2100
 
1861
- /** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3%)」. `null` without a target. */
2101
+ /** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3%)」 / "Target $12M (85.3% achieved)". `null` without a target. */
1862
2102
  function barTargetNote(
1863
2103
  label: string,
1864
2104
  value: number | null,
1865
2105
  target: number | null,
1866
2106
  valueFormatter: (value: number) => string,
2107
+ locale?: string,
1867
2108
  ): string | null {
1868
2109
  if (target === null) return null
1869
2110
  const rate = achievementRate(value, target)
1870
- return `${label} ${valueFormatter(target)}${rate === null ? "" : `(達成率 ${formatShare(rate)})`}`
2111
+ return `${label} ${valueFormatter(target)}${rate === null ? "" : chartText(locale).targetRate(formatShare(rate))}`
1871
2112
  }
1872
2113
 
1873
2114
  /** Half the width of a bar in category-axis units, from `barCategoryGap` (`"20%"`; a px gap or none → ECharts' 20%). */
@@ -1959,11 +2200,12 @@ const TARGET_LEGEND_SERIES_ID = "__target-legend"
1959
2200
  /** A flat tick (8×8 legend box, aspect kept): the per-bar target's legend icon. */
1960
2201
  const TARGET_LEGEND_ICON = "path://M0,0 L100,0 L100,24 L0,24 Z"
1961
2202
 
1962
- const DEFAULT_FORECAST_LABEL = "予測"
1963
-
1964
- /** 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. */
1965
- function forecastBoundaryText(label: string = DEFAULT_FORECAST_LABEL): string {
1966
- return `${label} →`
2203
+ /**
2204
+ * 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.
2205
+ * Without a `label`, "Forecast" / 「予測」 in `locale` (default the page's).
2206
+ */
2207
+ function forecastBoundaryText(label?: string, locale?: string): string {
2208
+ return `${label ?? chartText(locale).forecast} →`
1967
2209
  }
1968
2210
 
1969
2211
  /** A comparison series: a thin, muted, solid line with no markers (60% of `--muted-foreground`). */
@@ -2003,8 +2245,10 @@ interface AxisExtent {
2003
2245
  max: number
2004
2246
  }
2005
2247
 
2006
- /** The label of a stack's total (highlight and tooltip). */
2007
- const STACK_TOTAL_LABEL = "合計"
2248
+ /** The label of a stack's total (highlight and tooltip) in `locale`: "Total" / 「合計」. Default the page's locale. */
2249
+ function stackTotalLabel(locale?: string): string {
2250
+ return chartText(locale).total
2251
+ }
2008
2252
 
2009
2253
  /** Per point, the sum of the stacked series; `null` where every series is missing. */
2010
2254
  function stackTotalsOf(values: readonly (readonly (number | null)[])[]): (number | null)[] {
@@ -2126,8 +2370,8 @@ function indexGrain(values: readonly unknown[]): PeriodGrain | null {
2126
2370
 
2127
2371
  /**
2128
2372
  * The tooltip line under the main series' row when a comparison series is drawn: the arrow, the signed change and the
2129
- * comparison's name, as `Delta` writes it ("↑ +12.4% 前年同月比"; a minus is U+2212). "比較なし 前年同月比" when
2130
- * either value is missing or the baseline is 0.
2373
+ * comparison's name, as `Delta` writes it ("↑ +12.4% vs. same month last year" / "↑ +12.4% 前年同月比"; a minus is U+2212).
2374
+ * "No comparison vs. same month last year" / "比較なし 前年同月比" when either value is missing or the baseline is 0.
2131
2375
  */
2132
2376
  function comparisonNote(
2133
2377
  current: number | null,
@@ -2135,16 +2379,18 @@ function comparisonNote(
2135
2379
  comparison: Pick<CartesianComparisonConfig, "vs" | "label">,
2136
2380
  grain?: PeriodGrain,
2137
2381
  format?: ValueFormat,
2138
- locale: string = DEFAULT_LOCALE,
2382
+ localeArg?: string,
2139
2383
  ): string {
2384
+ const locale = resolveLocale(localeArg)
2140
2385
  const result = computeComparison(
2141
2386
  current,
2142
2387
  comparison.vs === "custom"
2143
2388
  ? { previous, vs: "custom", label: comparison.label ?? "", grain }
2144
2389
  : { previous, vs: comparison.vs, label: comparison.label, grain },
2390
+ { locale },
2145
2391
  )
2146
2392
  const text = formatChange(result, defaultChangeDisplay(format), locale, format)
2147
- if (text === null) return `比較なし ${result.label}`
2393
+ if (text === null) return `${chartText(locale).noComparison} ${result.label}`
2148
2394
  const arrow = result.direction === "up" ? "↑" : result.direction === "down" ? "↓" : "→"
2149
2395
  return `${arrow} ${text.replace(/^-/, "−")} ${result.label}`
2150
2396
  }
@@ -2187,7 +2433,6 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2187
2433
  toneColors,
2188
2434
  valueFormatter,
2189
2435
  axisFormatter = valueFormatter,
2190
- locale = DEFAULT_LOCALE,
2191
2436
  type = "default",
2192
2437
  layout = "horizontal",
2193
2438
  curveType = "linear",
@@ -2224,7 +2469,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2224
2469
  chartOptions,
2225
2470
  reserveActions = false,
2226
2471
  } = cfg
2227
- const provisionalLabel = cfg.provisionalLabel ?? DEFAULT_PROVISIONAL_LABEL
2472
+ const locale = resolveLocale(cfg.locale)
2473
+ const text = chartText(locale)
2474
+ const provisionalLabel = cfg.provisionalLabel ?? text.provisional
2228
2475
 
2229
2476
  const stacked = kind !== "line" && type !== "default"
2230
2477
  const percent = kind !== "line" && type === "percent"
@@ -2373,9 +2620,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2373
2620
  // `barOptions.targetKey` (a target per bar) takes over from the single `target` line.
2374
2621
  const perBarTarget = kind === "bar" && Boolean(targetKey)
2375
2622
  const hasTarget = !perBarTarget && target !== undefined && Number.isFinite(target)
2376
- const targetText = hasTarget ? (targetLabel ?? `目標 ${axisFormatter(target)}`) : ""
2623
+ const targetText = hasTarget ? (targetLabel ?? `${text.target} ${axisFormatter(target)}`) : ""
2377
2624
  const highlightText = (value: number) =>
2378
- shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${STACK_TOTAL_LABEL} ` : ""}${axisFormatter(value)}`
2625
+ shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${text.total} ` : ""}${axisFormatter(value)}`
2379
2626
  /** Whether `p`'s label sits within the target's value band and under the target label (at the right end). */
2380
2627
  const nearTargetLabel = (p: HighlightPoint, side: "under" | "over") => {
2381
2628
  if (!hasTarget || !plotWidth || kind === "bar" || labels.length < 2) return false
@@ -2401,7 +2648,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2401
2648
  // One series (or a stack, against its total) can be compared to a target; grouped series and 100% stacks cannot.
2402
2649
  const barTargets: (number | null)[] | null =
2403
2650
  perBarTarget && targetKey && !percent && (stacked || categories.length === 1) ? barTargetValues(data, targetKey) : null
2404
- const barTargetName = targetLabel ?? BAR_TARGET_LABEL
2651
+ const barTargetName = targetLabel ?? text.target
2405
2652
  const barTargetColor = labelColor ?? guideColor
2406
2653
 
2407
2654
  const markPointFor = (c: number, color: string | undefined) =>
@@ -2615,7 +2862,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2615
2862
  }
2616
2863
 
2617
2864
  /* -- forecast: dashed continuation (+ optional band) of one series */
2618
- const forecastLabel = forecast?.label ?? DEFAULT_FORECAST_LABEL
2865
+ const forecastLabel = forecast?.label ?? text.forecast
2619
2866
  const forecastBandAlpha = isDarkSurface(labelBackground) ? FORECAST_BAND_ALPHA_DARK : FORECAST_BAND_ALPHA_LIGHT
2620
2867
  const forecastValues: (number | null)[] = []
2621
2868
  if (forecast) {
@@ -3091,7 +3338,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
3091
3338
  })
3092
3339
  }
3093
3340
  if (stackTotals && i <= lastActual && rows.length > 1) {
3094
- rows.push({ name: STACK_TOTAL_LABEL, color: "transparent", value: stackTotals[i] ?? null })
3341
+ rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
3095
3342
  }
3096
3343
  const provisional = provStart !== -1 && i >= provStart && i <= lastActual
3097
3344
  if (provisional) {
@@ -3107,19 +3354,19 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
3107
3354
  ...noteFor(at),
3108
3355
  })
3109
3356
  })
3110
- if (stackTotals && rows.length > 1) rows.push({ name: STACK_TOTAL_LABEL, color: "transparent", value: stackTotals[i] ?? null })
3357
+ if (stackTotals && rows.length > 1) rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
3111
3358
  }
3112
3359
  const title = tooltipTitles[i] ?? labels[i] ?? ""
3113
3360
  const lines: string[] = []
3114
3361
  if (barTargets && i <= lastActual) {
3115
3362
  const total = stackTotals ? (stackTotals[i] ?? null) : (values[0]?.[i] ?? null)
3116
- const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter)
3363
+ const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter, locale)
3117
3364
  if (note) lines.push(note)
3118
3365
  }
3119
3366
  for (const note of notes) {
3120
3367
  if (note.to === undefined ? note.from === i : note.from <= i && i <= note.to) lines.push(note.label)
3121
3368
  }
3122
- return tooltipHtml(provisional ? `${title}(${provisionalLabel})` : title, rows, valueFormatter, lines)
3369
+ return tooltipHtml(provisional ? text.provisionalTitle(title, provisionalLabel) : title, rows, valueFormatter, lines, locale)
3123
3370
  },
3124
3371
  },
3125
3372
  xAxis: horizontalBars ? valueAxis : categoryAxis,
@@ -3211,8 +3458,6 @@ interface DonutOptionSlice {
3211
3458
  /** How many of the largest slices get an outside label (「自然検索 41%」) by default. */
3212
3459
  const DONUT_OUTSIDE_LABELS = 2
3213
3460
 
3214
- const DEFAULT_TOTAL_LABEL = "合計"
3215
-
3216
3461
  const sliceValue = (value: number) => (Number.isFinite(value) && value > 0 ? value : 0)
3217
3462
 
3218
3463
  /** A slice's share of `total` as text: whole percent, `<1%` for a sliver, `0%` for nothing. */
@@ -3245,15 +3490,16 @@ function donutLegendItems(slices: readonly DonutOptionSlice[]): DonutLegendItem[
3245
3490
  }
3246
3491
 
3247
3492
  /**
3248
- * The default center label: what is totalled (「合計」), the formatted total
3493
+ * The default center label: what is totalled (「合計」 / "Total" in `locale`, default the page's), the formatted total
3249
3494
  * (`amount`, the big line), the unit (a small line under it) and both as one
3250
3495
  * string (`value`, 「9.2万 セッション」).
3251
3496
  */
3252
3497
  function donutCenterText(
3253
3498
  total: number,
3254
3499
  valueFormatter: (value: number) => string,
3255
- { totalLabel = DEFAULT_TOTAL_LABEL, unit }: { totalLabel?: string; unit?: string } = {},
3500
+ { totalLabel, unit, locale }: { totalLabel?: string; unit?: string; locale?: string } = {},
3256
3501
  ): { label: string; value: string; amount: string; unit?: string } {
3502
+ totalLabel ??= chartText(locale).total
3257
3503
  const amount = valueFormatter(total)
3258
3504
  return { label: totalLabel, value: unit ? `${amount} ${unit}` : amount, amount, ...(unit ? { unit } : {}) }
3259
3505
  }
@@ -3481,8 +3727,10 @@ function hasLabel(labels: Readonly<Record<string, string>> | undefined, key: str
3481
3727
 
3482
3728
  /* ------------------------------------------------------------ provisional */
3483
3729
 
3484
- /** The default label for points that are not final yet. */
3485
- const DEFAULT_PROVISIONAL_LABEL = "速報"
3730
+ /** The default label for points that are not final yet, in `locale`: "Preliminary" / 「速報」. Default the page's locale. */
3731
+ function defaultProvisionalLabel(locale?: string): string {
3732
+ return chartText(locale).provisional
3733
+ }
3486
3734
 
3487
3735
  /**
3488
3736
  * Position of the first provisional point: the first entry of `indexValues`
@@ -3549,6 +3797,11 @@ export interface DonutChartProps<T extends Record<string, unknown> = Record<stri
3549
3797
  loading?: boolean
3550
3798
  }
3551
3799
 
3800
+ const TEXT = {
3801
+ ja: { item: "項目", value: "値", chart: (shares: readonly string[]) => `構成比のドーナツグラフ(${shares.join("、")})` },
3802
+ en: { item: "Item", value: "Value", chart: (shares: readonly string[]) => `Donut chart of shares (${shares.join(", ")})` },
3803
+ }
3804
+
3552
3805
  /** The element's width, kept current (`null` until measured). */
3553
3806
  function useElementWidth(): [(el: HTMLDivElement | null) => void, number | null] {
3554
3807
  const [node, setNode] = useState<HTMLDivElement | null>(null)
@@ -3604,12 +3857,15 @@ function DonutChartImpl<T extends Record<string, unknown>>(
3604
3857
  const center = resolveDonutCenter(donutOptions?.centerLabel, { showLabel: donutOptions?.showLabel ?? showLabel, label })
3605
3858
  const maxSlices = donutOptions?.maxSlices
3606
3859
  const showLegend = donutOptions?.showLegend ?? true
3607
- const valueFormatter = useMemo(() => resolveValueFormatter(valueFormatterProp, format), [valueFormatterProp, format])
3860
+ const locale = useLocale()
3861
+ const t = TEXT[uiLanguage(locale)]
3862
+ const valueFormatter = useMemo(() => resolveValueFormatter(valueFormatterProp, format, locale), [valueFormatterProp, format, locale])
3608
3863
 
3609
3864
  const { ref: frameRef, tokens } = useChartSurface(ref)
3610
3865
  const slices = useMemo(
3611
- () => donutSlices(data, { index: labelKey, value: valueKey, colors, maxSlices, otherColor: CHART_OTHER_COLOR }),
3612
- [data, labelKey, valueKey, colors, maxSlices],
3866
+ () =>
3867
+ donutSlices(data, { index: labelKey, value: valueKey, colors, maxSlices, otherColor: CHART_OTHER_COLOR, otherLabel: otherSeriesLabel(locale) }),
3868
+ [data, labelKey, valueKey, colors, maxSlices, locale],
3613
3869
  )
3614
3870
  const palette = useMemo(() => resolveChartColors(slices.map((s) => chartFillName(s.color)), tokens), [slices, tokens])
3615
3871
  const rows = useMemo(() => slices.map((s) => s.row), [slices])
@@ -3659,15 +3915,14 @@ function DonutChartImpl<T extends Record<string, unknown>>(
3659
3915
  let centerLabel: ReactNode = null
3660
3916
  if (center.kind === "custom") centerLabel = center.node
3661
3917
  else if (center.kind === "total") {
3662
- const text = donutCenterText(total, valueFormatter, { totalLabel: donutOptions?.totalLabel, unit: donutOptions?.unit })
3918
+ const text = donutCenterText(total, valueFormatter, { totalLabel: donutOptions?.totalLabel, unit: donutOptions?.unit, locale })
3663
3919
  centerLabel = caption(text.label, text.amount, text.unit)
3664
3920
  } else if (center.kind === "top") {
3665
3921
  const top = donutTopText(slices, labels)
3666
3922
  centerLabel = top && caption(top.label, top.amount)
3667
3923
  }
3668
3924
  // Built from the slices (names and shares), not the column keys.
3669
- const a11yLabel =
3670
- ariaLabel ?? `構成比のドーナツグラフ(${legend.map((item) => `${labelOf(item.name, labels)} ${item.share}`).join("、")})`
3925
+ const a11yLabel = ariaLabel ?? t.chart(legend.map((item) => `${labelOf(item.name, labels)} ${item.share}`))
3671
3926
  const dataTable = useMemo(
3672
3927
  () =>
3673
3928
  chartDataTable(
@@ -3676,14 +3931,14 @@ function DonutChartImpl<T extends Record<string, unknown>>(
3676
3931
  "name",
3677
3932
  ["value"],
3678
3933
  valueFormatter,
3679
- { name: labels?.[labelKey] ?? "項目", value: labels?.[valueKey] ?? "値" },
3934
+ { name: labels?.[labelKey] ?? t.item, value: labels?.[valueKey] ?? t.value },
3680
3935
  ),
3681
- [rows, labelKey, valueKey, valueFormatter, labels],
3936
+ [rows, labelKey, valueKey, valueFormatter, labels, t],
3682
3937
  )
3683
3938
 
3684
3939
  const resolvedSummary = useMemo(
3685
- () => (summary === "auto" ? buildShareSummary(slices, title) : summary),
3686
- [summary, slices, title],
3940
+ () => (summary === "auto" ? buildShareSummary(slices, title, locale) : summary),
3941
+ [summary, slices, title, locale],
3687
3942
  )
3688
3943
  return (
3689
3944
  <ChartFrame