@squadbase/vantage 0.6.2-dev1.48b34e1 → 0.6.2-dev1.56da87f

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 +3 -2
  64. package/templates/agents/api.md +7 -0
  65. package/templates/agents/page.md +36 -0
  66. package/dist/chunk-2N72FKLB.js.map +0 -1
  67. package/dist/chunk-57GHR5LN.js.map +0 -1
  68. package/dist/chunk-6OTTGAPO.js.map +0 -1
  69. package/dist/chunk-JXFCSF3K.js.map +0 -1
  70. package/dist/chunk-PDOBHMUQ.js.map +0 -1
  71. package/dist/chunk-QOXXOFGL.js.map +0 -1
@@ -20,11 +20,143 @@ import { forwardRef, useId, type HTMLAttributes, type ReactNode } from "react"
20
20
 
21
21
  import { cn } from "@squadbase/vantage/ui"
22
22
  import type { ChartEmptyState } from "@squadbase/vantage/components"
23
+ import { useSyncExternalStore } from "react"
23
24
  import { createContext, useCallback, useEffect, useRef, useState, type ForwardedRef, type RefObject } from "react"
24
25
  import type { ChartTone } from "@squadbase/vantage/components"
25
- import { DEFAULT_LOCALE, EMPTY_VALUE, formatValue, toValueFormatter, type ValueFormat } from "@squadbase/vantage/components"
26
+ import { EMPTY_VALUE, formatValue, toValueFormatter, type ValueFormat } from "@squadbase/vantage/components"
26
27
  import type { ChartColor, ChartColors, ChartSeriesColor, ValueFormatter } from "@squadbase/vantage/components"
27
28
 
29
+ /* ---------- inlined from @squadbase/squadkit registry/lib/locale.ts (internal to @squadbase/vantage) ---------- */
30
+
31
+ /**
32
+ * The display language of the parts and the defaults that follow it.
33
+ *
34
+ * The language comes from `<html lang>`: a tag starting with `ja` reads Japanese, anything else English.
35
+ * Without a usable `lang` (missing, invalid, or no `document` — SSR, Node) the locale is `FALLBACK_LOCALE`
36
+ * (`en-US`). A `locale` passed explicitly (a prop, an argument) always wins.
37
+ *
38
+ * Parts read the locale through `useLocale` (`hooks/use-locale`) and pass it down; the pure helpers here
39
+ * and in `format` / `comparison` / `chart-*` take it as an argument and fall back to `documentLocale()`.
40
+ */
41
+
42
+ /** The language of the built-in wording: Japanese or English. */
43
+ type UiLanguage = "ja" | "en"
44
+
45
+ /** The locale used when `<html lang>` gives none (and on the server). */
46
+ const FALLBACK_LOCALE = "en-US"
47
+
48
+ /** `"ja"` when `locale` starts with `ja` (`ja`, `ja-JP`), else `"en"`. A missing locale is `"en"`. */
49
+ function uiLanguage(locale: string | null | undefined): UiLanguage {
50
+ return typeof locale === "string" && locale.trim().toLowerCase().startsWith("ja") ? "ja" : "en"
51
+ }
52
+
53
+ /** A BCP 47 tag in its canonical form (`en-gb` → `en-GB`), or `FALLBACK_LOCALE` when it is empty or invalid. */
54
+ function normalizeLocale(tag: string | null | undefined): string {
55
+ const value = tag?.trim()
56
+ if (!value) return FALLBACK_LOCALE
57
+ try {
58
+ return Intl.getCanonicalLocales(value)[0] ?? FALLBACK_LOCALE
59
+ } catch {
60
+ return FALLBACK_LOCALE
61
+ }
62
+ }
63
+
64
+ /**
65
+ * The locale of the page: `document.documentElement.lang`, canonicalized. `FALLBACK_LOCALE` when it is empty or
66
+ * invalid, or there is no `document`. Parts read it through `useLocale`, never while rendering.
67
+ */
68
+ function documentLocale(): string {
69
+ if (typeof document === "undefined") return FALLBACK_LOCALE
70
+ return normalizeLocale(document.documentElement?.getAttribute("lang"))
71
+ }
72
+
73
+ /** `locale` when given, else the page's locale (`documentLocale()`). */
74
+ function resolveLocale(locale?: string | null): string {
75
+ return locale || documentLocale()
76
+ }
77
+
78
+ /** Picks the wording table of the locale's language. */
79
+ function pickLocale<T>(table: { ja: T; en: T }, locale?: string | null): T {
80
+ return table[uiLanguage(resolveLocale(locale))]
81
+ }
82
+
83
+ /** The currency `format="currency"` uses without a `currency`: JPY for Japanese, USD otherwise. */
84
+ function defaultCurrency(locale?: string | null): string {
85
+ return uiLanguage(resolveLocale(locale)) === "ja" ? "JPY" : "USD"
86
+ }
87
+
88
+ /** The month a fiscal year starts in (1–12) when none is given: April for Japanese, January otherwise. */
89
+ function defaultFiscalYearStartMonth(locale?: string | null): number {
90
+ return uiLanguage(resolveLocale(locale)) === "ja" ? 4 : 1
91
+ }
92
+
93
+ /** A day of the week, 0 = Sunday … 6 = Saturday (the `Date#getDay` / date-fns numbering). */
94
+ type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6
95
+
96
+ /** Locales whose week starts on Sunday when the runtime has no week data. */
97
+ const SUNDAY_FIRST = new Set(["en", "en-US", "en-CA"])
98
+
99
+ /**
100
+ * The first day of the week: Monday for Japanese; otherwise the locale's convention from `Intl.Locale`
101
+ * (`en-US` Sunday, `en-GB` Monday). Without week data in the runtime: Sunday for `en` / `en-US` / `en-CA`,
102
+ * Monday for the rest.
103
+ */
104
+ function weekStartsOn(locale?: string | null): WeekDay {
105
+ const tag = normalizeLocale(resolveLocale(locale))
106
+ if (uiLanguage(tag) === "ja") return 1
107
+ try {
108
+ const info = new Intl.Locale(tag) as Intl.Locale & {
109
+ getWeekInfo?: () => { firstDay: number }
110
+ weekInfo?: { firstDay: number }
111
+ }
112
+ const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay
113
+ if (typeof firstDay === "number" && firstDay >= 1 && firstDay <= 7) return (firstDay % 7) as WeekDay
114
+ } catch {
115
+ // Fall through to the table below.
116
+ }
117
+ return SUNDAY_FIRST.has(tag) ? 0 : 1
118
+ }
119
+
120
+ /* ---------- inlined from @squadbase/squadkit registry/hooks/use-locale.ts (internal to @squadbase/vantage) ---------- */
121
+
122
+ const listeners = new Set<() => void>()
123
+ let observer: MutationObserver | null = null
124
+
125
+ /** One observer on `<html lang>` for every subscribed part. */
126
+ function subscribe(onChange: () => void): () => void {
127
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {}
128
+ listeners.add(onChange)
129
+ if (!observer) {
130
+ observer = new MutationObserver(() => {
131
+ for (const listener of [...listeners]) listener()
132
+ })
133
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] })
134
+ }
135
+ return () => {
136
+ listeners.delete(onChange)
137
+ if (listeners.size === 0 && observer) {
138
+ observer.disconnect()
139
+ observer = null
140
+ }
141
+ }
142
+ }
143
+
144
+ const serverLocale = () => FALLBACK_LOCALE
145
+
146
+ /**
147
+ * The page's locale (`<html lang>`), following changes made while the page runs. On the server and during
148
+ * hydration it is `FALLBACK_LOCALE` (`en-US`), so the markup matches; it switches right after.
149
+ */
150
+ function useDocumentLocale(): string {
151
+ return useSyncExternalStore(subscribe, documentLocale, serverLocale)
152
+ }
153
+
154
+ /** The locale a part renders in: its `locale` prop when given, else the page's (`useDocumentLocale`). */
155
+ function useLocale(locale?: string | null): string {
156
+ const page = useDocumentLocale()
157
+ return locale || page
158
+ }
159
+
28
160
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-theme.ts (internal to @squadbase/vantage) ---------- */
29
161
 
30
162
  /**
@@ -528,9 +660,10 @@ function resolveValueFormatter(
528
660
  function resolveAxisFormatter(
529
661
  valueFormatter: ValueFormatter | undefined,
530
662
  format: ValueFormat | undefined,
531
- locale: string = DEFAULT_LOCALE,
663
+ localeArg?: string,
532
664
  ): ValueFormatter {
533
665
  if (valueFormatter) return valueFormatter
666
+ const locale = resolveLocale(localeArg)
534
667
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
535
668
  if (spec.type !== "compact" && spec.type !== "number" && spec.type !== "currency") return toValueFormatter(format, locale)
536
669
  const currency = "currency" in spec ? spec.currency : undefined
@@ -539,40 +672,75 @@ function resolveAxisFormatter(
539
672
  return (value) => formatValue(value, short, locale)
540
673
  }
541
674
 
542
- /** The unit words of a ja number (`1.2万` / `3.4億` / `1兆`) and their sizes, largest first. */
543
- const JA_UNITS = [
544
- { char: "兆", size: 1e12 },
545
- { char: "億", size: 1e8 },
546
- { char: "万", size: 1e4 },
547
- ] as const
675
+ interface CompactUnit {
676
+ token: string
677
+ size: number
678
+ }
548
679
 
549
- const JA_NUMBER_TOKEN_RE = /\d[\d,]*(?:\.\d+)?(?:兆|億|万)?/
680
+ const compactUnitCache = new Map<string, CompactUnit[] | null>()
681
+
682
+ /**
683
+ * The compact-notation unit words of a locale and their sizes, largest first: `兆` / `億` / `万` in ja,
684
+ * `T` / `B` / `M` / `K` in en. `null` when the locale's compact form puts anything between the number and its
685
+ * word (`1,5 Mio.`), where a token cannot be swapped in place.
686
+ */
687
+ function compactUnits(locale: string): CompactUnit[] | null {
688
+ const cached = compactUnitCache.get(locale)
689
+ if (cached !== undefined) return cached
690
+ let units: CompactUnit[] | null = []
691
+ try {
692
+ const compact = new Intl.NumberFormat(locale, { notation: "compact" })
693
+ const seen = new Set<string>()
694
+ for (let exp = 3; exp <= 15; exp++) {
695
+ const parts = compact.formatToParts(10 ** exp)
696
+ const at = parts.findIndex((p) => p.type === "compact")
697
+ if (at === -1) continue
698
+ if (parts[at - 1]?.type !== "integer" || at !== parts.length - 1) {
699
+ units = null
700
+ break
701
+ }
702
+ const token = parts[at]!.value
703
+ if (seen.has(token)) continue
704
+ seen.add(token)
705
+ units.push({ token, size: 10 ** exp })
706
+ }
707
+ } catch {
708
+ units = null
709
+ }
710
+ const sorted = units && units.length > 0 ? units.sort((a, b) => b.size - a.size) : null
711
+ compactUnitCache.set(locale, sorted)
712
+ return sorted
713
+ }
714
+
715
+ const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
550
716
 
551
717
  /**
552
718
  * The value-axis tick formatter for an axis whose largest tick is `top`: every tick reads in the unit of
553
- * that largest tick, so 万 and 億 never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`).
554
- * Only the ja short forms (`compact` / `number` / `currency`, no `valueFormatter`) have units to align;
555
- * any other format — and an axis whose largest tick is under 1万 — keeps `resolveAxisFormatter`'s output as it is.
719
+ * that largest tick, so units never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`;
720
+ * `$0.5M` / `$1M` / `$1.5M`, not `$500K` / `$1M`). Only the short forms (`compact` / `number` / `currency`,
721
+ * no `valueFormatter`) have units to align; any other format — an axis whose largest tick is under the smallest
722
+ * unit (1万 / 1K), or a locale whose compact form cannot be aligned in place — keeps `resolveAxisFormatter`'s output as it is.
556
723
  */
557
724
  function resolveAxisTickFormatter(
558
725
  valueFormatter: ValueFormatter | undefined,
559
726
  format: ValueFormat | undefined,
560
- locale: string = DEFAULT_LOCALE,
727
+ localeArg?: string,
561
728
  ): (top: number) => ValueFormatter {
729
+ const locale = resolveLocale(localeArg)
562
730
  const base = resolveAxisFormatter(valueFormatter, format, locale)
563
731
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
564
- const aligns =
565
- !valueFormatter && locale.toLowerCase().startsWith("ja") && (spec.type === "compact" || spec.type === "number" || spec.type === "currency")
566
- if (!aligns) return () => base
732
+ const units = !valueFormatter && (spec.type === "compact" || spec.type === "number" || spec.type === "currency") ? compactUnits(locale) : null
733
+ if (!units) return () => base
567
734
  const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 3 })
735
+ const numberToken = new RegExp(`\\d[\\d,]*(?:\\.\\d+)?(?:${units.map((u) => escapeRegExp(u.token)).join("|")})?`)
568
736
  return (top) => {
569
- const unit = JA_UNITS.find((u) => Math.abs(top) >= u.size)
737
+ const unit = units.find((u) => Math.abs(top) >= u.size)
570
738
  if (!unit) return base
571
739
  return (value) => {
572
740
  const text = base(value)
573
741
  if (value === 0 || !Number.isFinite(value)) return text
574
- const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.char}`
575
- return text.replace(JA_NUMBER_TOKEN_RE, () => scaled)
742
+ const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.token}`
743
+ return text.replace(numberToken, () => scaled)
576
744
  }
577
745
  }
578
746
  }
@@ -601,8 +769,20 @@ function clamp(value: number, min: number, max: number): number {
601
769
  /** How many categories a chart colours individually; the rest fold into「その他」. */
602
770
  const MAX_CHART_SERIES = 5
603
771
 
604
- /** The series name the folded categories get. */
605
- const OTHER_SERIES_LABEL = "その他"
772
+ /** The series name the folded categories get, by language. */
773
+ const OTHER_SERIES_LABELS: Record<UiLanguage, string> = { ja: "その他", en: "Other" }
774
+
775
+ /** The series name the folded categories get in `locale` ("Other" / "その他"). Default the page's locale (`<html lang>`). */
776
+ function otherSeriesLabel(locale?: string): string {
777
+ return OTHER_SERIES_LABELS[uiLanguage(resolveLocale(locale))]
778
+ }
779
+
780
+ const OTHER_NAMES = new Set(Object.values(OTHER_SERIES_LABELS))
781
+
782
+ /** Whether a category / row is the "other" bucket by name (「その他」 or "Other", whatever the page language). */
783
+ function isOtherName(name: string | null | undefined): boolean {
784
+ return typeof name === "string" && OTHER_NAMES.has(name)
785
+ }
606
786
 
607
787
  interface AggregatedItems<Item extends { name: string; value: number }> {
608
788
  /** The `n` largest items, in their given order. All items when there are `n` or fewer. */
@@ -622,7 +802,7 @@ interface AggregatedItems<Item extends { name: string; value: number }> {
622
802
  function aggregateTopN<Item extends { name: string; value: number }>(
623
803
  items: readonly Item[],
624
804
  n: number = MAX_CHART_SERIES,
625
- otherLabel: string = OTHER_SERIES_LABEL,
805
+ otherLabel: string = otherSeriesLabel(),
626
806
  ): AggregatedItems<Item> {
627
807
  if (items.length <= n) return { top: [...items], rest: [], other: null }
628
808
  const ranked = items
@@ -651,7 +831,7 @@ function aggregateSeries(
651
831
  data: readonly Record<string, unknown>[],
652
832
  categories: readonly string[],
653
833
  n: number = MAX_CHART_SERIES,
654
- otherLabel: string = OTHER_SERIES_LABEL,
834
+ otherLabel: string = otherSeriesLabel(),
655
835
  ): AggregatedSeries {
656
836
  const totals = categories.map((name) => ({
657
837
  name,
@@ -683,12 +863,14 @@ function aggregateSeriesKeeping(
683
863
  categories: readonly string[],
684
864
  keep: string | undefined,
685
865
  n: number = MAX_CHART_SERIES,
866
+ otherLabel: string = otherSeriesLabel(),
686
867
  ): AggregatedSeries {
687
- if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n)
868
+ if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n, otherLabel)
688
869
  const aggregated = aggregateSeries(
689
870
  data,
690
871
  categories.filter((c) => c !== keep),
691
872
  n,
873
+ otherLabel,
692
874
  )
693
875
  return { ...aggregated, categories: [...aggregated.categories, keep] }
694
876
  }
@@ -724,7 +906,7 @@ function seriesColorsWithComparison(
724
906
  * Colour per drawn series: the record's token for that category, else the
725
907
  * array's token at the category's position in `allCategories` (cycled), else
726
908
  * the token palette by drawn position. `otherCategory` — and a category the
727
- * data itself names「その他」(`OTHER_SERIES_LABEL`) — always gets `otherColor`.
909
+ * data itself names「その他」/ "Other" (`OTHER_SERIES_LABELS`) — always gets `otherColor`.
728
910
  */
729
911
  function seriesColorsFor(
730
912
  categories: readonly string[],
@@ -736,7 +918,7 @@ function seriesColorsFor(
736
918
  const list = Array.isArray(colors) ? (colors as ChartSeriesColor[]) : undefined
737
919
  const record = !Array.isArray(colors) && colors ? (colors as Partial<Record<string, ChartSeriesColor>>) : undefined
738
920
  return categories.map((category, i) => {
739
- if ((otherCategory !== null && category === otherCategory) || category === OTHER_SERIES_LABEL) return otherColor
921
+ if ((otherCategory !== null && category === otherCategory) || isOtherName(category)) return otherColor
740
922
  const fromRecord = record?.[category]
741
923
  if (fromRecord) return fromRecord
742
924
  if (list && list.length > 0) {
@@ -783,7 +965,7 @@ interface DonutSlicesConfig {
783
965
  */
784
966
  function donutSlices(
785
967
  data: readonly Record<string, unknown>[],
786
- { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = OTHER_SERIES_LABEL }: DonutSlicesConfig,
968
+ { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = otherSeriesLabel() }: DonutSlicesConfig,
787
969
  ): DonutSlice[] {
788
970
  const items = data
789
971
  .map((row, i) => {
@@ -796,7 +978,8 @@ function donutSlices(
796
978
  const slices: DonutSlice[] = top.map((item, rank) => ({
797
979
  name: item.name,
798
980
  value: item.value,
799
- color: palette[rank % palette.length]!,
981
+ // A row the data itself names「その他」/ "Other" is the same remainder: grey, like the folded slice.
982
+ color: isOtherName(item.name) ? otherColor : palette[rank % palette.length]!,
800
983
  isOther: false,
801
984
  row: item.row,
802
985
  }))
@@ -869,7 +1052,7 @@ interface BarListRowsConfig extends BarListOptions {
869
1052
  */
870
1053
  function barListRows<Row extends object>(
871
1054
  data: readonly Row[],
872
- { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = OTHER_SERIES_LABEL, exclude }: BarListRowsConfig = {},
1055
+ { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = otherSeriesLabel(), exclude }: BarListRowsConfig = {},
873
1056
  ): BarListRow<Row>[] {
874
1057
  const items = data.map((row): BarListRow<Row> => {
875
1058
  const cells = row as Record<string, unknown>
@@ -915,19 +1098,21 @@ const BAR_LIST_OTHER_TINT = 30
915
1098
 
916
1099
  /**
917
1100
  * The fill of each BarList row: all real rows in the same tint of `color`
918
- * (`BAR_LIST_TINT`), the folded「その他」row in a tint of `otherColor`
1101
+ * (`BAR_LIST_TINT`), the folded「その他」row (and a row the data names「その他」/ "Other") in a tint of `otherColor`
919
1102
  * (`BAR_LIST_OTHER_TINT`) — the palest fill, a remainder never outweighs a
920
1103
  * ranked row. A legacy per-row `color` (or a threshold tone) replaces `color`
921
1104
  * for that row. A tint, not the solid token, so the label printed over the bar
922
1105
  * keeps its contrast.
923
1106
  */
924
1107
  function barListFills(
925
- rows: readonly Pick<BarListRow, "isOther" | "color">[],
1108
+ rows: readonly (Pick<BarListRow, "isOther" | "color"> & { name?: string })[],
926
1109
  color: ChartColor,
927
1110
  otherColor: string,
928
1111
  ): string[] {
929
1112
  return rows.map((row) =>
930
- row.isOther ? tintCss(otherColor, BAR_LIST_OTHER_TINT) : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
1113
+ row.isOther || isOtherName(row.name)
1114
+ ? tintCss(otherColor, BAR_LIST_OTHER_TINT)
1115
+ : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
931
1116
  )
932
1117
  }
933
1118
 
@@ -1143,11 +1328,30 @@ interface ChartSummaryInput {
1143
1328
  format?: ValueFormat
1144
1329
  /** Wins over `format`. */
1145
1330
  valueFormatter?: ValueFormatter
1331
+ /** Language of the sentence. Default the page's locale (`<html lang>`). */
1332
+ locale?: string
1333
+ }
1334
+
1335
+ const SUMMARY_TEXT = {
1336
+ ja: {
1337
+ up: "、前の点より増加",
1338
+ down: "、前の点より減少",
1339
+ flat: "、前の点と横ばい",
1340
+ latest: (label: string, value: string) => `最新 ${label} は ${value}`,
1341
+ top: (name: string, share: string) => `最大は ${name}(${share})`,
1342
+ },
1343
+ en: {
1344
+ up: ", up from the previous point",
1345
+ down: ", down from the previous point",
1346
+ flat: ", flat from the previous point",
1347
+ latest: (label: string, value: string) => `Latest ${label}: ${value}`,
1348
+ top: (name: string, share: string) => `Largest: ${name} (${share})`,
1349
+ },
1146
1350
  }
1147
1351
 
1148
1352
  /**
1149
1353
  * One-sentence summary built from the rows, for `summary="auto"`:
1150
- * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」. The direction is
1354
+ * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」("<title>: Latest <index>: <value>, up from the previous point"). The direction is
1151
1355
  * computed from the last two non-empty points of the first series — never
1152
1356
  * written by hand, so it cannot contradict the data. `undefined` when there is
1153
1357
  * no usable point.
@@ -1163,26 +1367,29 @@ function buildChartSummary(input: ChartSummaryInput): string | undefined {
1163
1367
  }
1164
1368
  const latest = points[points.length - 1]
1165
1369
  if (!latest) return undefined
1166
- const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact")
1370
+ const locale = resolveLocale(input.locale)
1371
+ const t = SUMMARY_TEXT[uiLanguage(locale)]
1372
+ const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact", locale)
1167
1373
  const prev = points[points.length - 2]
1168
- const direction = !prev ? "" : latest.value > prev.value ? "、前の点より増加" : latest.value < prev.value ? "、前の点より減少" : "、前の点と横ばい"
1374
+ const direction = !prev ? "" : latest.value > prev.value ? t.up : latest.value < prev.value ? t.down : t.flat
1169
1375
  const head = input.title ? `${input.title}: ` : ""
1170
- return `${head}最新 ${latest.label} は ${text(latest.value)}${direction}`
1376
+ return `${head}${t.latest(latest.label, text(latest.value))}${direction}`
1171
1377
  }
1172
1378
 
1173
1379
  /**
1174
- * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」 from the slices
1175
- * (largest first). `undefined` when there is nothing to describe.
1380
+ * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」("<title>: Largest: <name> (<share>)") from the slices
1381
+ * (largest first). `undefined` when there is nothing to describe. `locale` picks the language; default the page's.
1176
1382
  */
1177
1383
  function buildShareSummary(
1178
1384
  slices: readonly { name: string; value: number }[],
1179
1385
  title?: string,
1386
+ locale?: string,
1180
1387
  ): string | undefined {
1181
1388
  const total = slices.reduce((sum, s) => sum + s.value, 0)
1182
1389
  const top = slices[0]
1183
1390
  if (!top || total <= 0) return undefined
1184
1391
  const share = `${Math.round((top.value / total) * 1000) / 10}%`
1185
- return `${title ? `${title}: ` : ""}最大は ${top.name}(${share})`
1392
+ return `${title ? `${title}: ` : ""}${SUMMARY_TEXT[uiLanguage(resolveLocale(locale))].top(top.name, share)}`
1186
1393
  }
1187
1394
 
1188
1395
  /* ---------- end of inlined code ---------- */
@@ -1193,7 +1400,10 @@ export type ChartSkeletonVariant = "line" | "area" | "bar" | "donut" | "list"
1193
1400
  /** The plot height of every typed chart unless `height` says otherwise (px). */
1194
1401
  export const DEFAULT_CHART_HEIGHT = 288
1195
1402
 
1196
- const DEFAULT_EMPTY_TITLE = "表示できるデータがありません"
1403
+ const TEXT = {
1404
+ ja: { emptyTitle: "表示できるデータがありません", loading: "読み込み中" },
1405
+ en: { emptyTitle: "No data to display", loading: "Loading" },
1406
+ }
1197
1407
 
1198
1408
  export interface ChartFrameProps extends Omit<HTMLAttributes<HTMLElement>, "title" | "children"> {
1199
1409
  /** グラフの見出し。プロット領域の `aria-labelledby` になる。 */
@@ -1245,6 +1455,7 @@ export const ChartFrame = forwardRef<HTMLDivElement, ChartFrameProps>(function C
1245
1455
  ref,
1246
1456
  ) {
1247
1457
  const id = useId()
1458
+ const t = TEXT[uiLanguage(useLocale())]
1248
1459
  const titleId = title ? `${id}-title` : undefined
1249
1460
  const summaryId = summary ? `${id}-summary` : undefined
1250
1461
  const state = isLoading ? "loading" : isEmpty ? "empty" : "ready"
@@ -1278,9 +1489,9 @@ export const ChartFrame = forwardRef<HTMLDivElement, ChartFrameProps>(function C
1278
1489
  style={{ height: typeof height === "number" ? `${height}px` : height }}
1279
1490
  >
1280
1491
  {state === "loading" ? (
1281
- <ChartSkeleton variant={variant} />
1492
+ <ChartSkeleton variant={variant} loadingText={t.loading} />
1282
1493
  ) : state === "empty" ? (
1283
- <ChartEmpty {...emptyState} />
1494
+ <ChartEmpty {...emptyState} defaultTitle={t.emptyTitle} />
1284
1495
  ) : (
1285
1496
  <ChartFrameContext.Provider value>{children}</ChartFrameContext.Provider>
1286
1497
  )}
@@ -1299,7 +1510,7 @@ const BAR_HEIGHTS = [58, 74, 46, 88, 64, 80, 52]
1299
1510
  const LIST_WIDTHS = [92, 76, 64, 48, 36]
1300
1511
  const LINE_POINTS = "0,70 16,58 33,62 50,40 66,46 83,28 100,34"
1301
1512
 
1302
- function ChartSkeleton({ variant }: { variant: ChartSkeletonVariant }) {
1513
+ function ChartSkeleton({ variant, loadingText }: { variant: ChartSkeletonVariant; loadingText: string }) {
1303
1514
  const pulse = "animate-pulse motion-reduce:animate-none"
1304
1515
  return (
1305
1516
  <div data-slot="chart-skeleton" data-variant={variant} className="absolute inset-0 flex flex-col gap-3">
@@ -1333,7 +1544,7 @@ function ChartSkeleton({ variant }: { variant: ChartSkeletonVariant }) {
1333
1544
  </div>
1334
1545
  )}
1335
1546
  </div>
1336
- <span className="sr-only">読み込み中</span>
1547
+ <span className="sr-only">{loadingText}</span>
1337
1548
  </div>
1338
1549
  )
1339
1550
  }
@@ -1379,13 +1590,13 @@ function ChartDataTableView({ table, fallbackCaption }: { table: ChartDataTable;
1379
1590
 
1380
1591
  /* ------------------------------------------------------------------- empty */
1381
1592
 
1382
- function ChartEmpty({ title, description, action }: ChartEmptyState) {
1593
+ function ChartEmpty({ title, description, action, defaultTitle }: ChartEmptyState & { defaultTitle: string }) {
1383
1594
  return (
1384
1595
  <div
1385
1596
  data-slot="chart-empty"
1386
1597
  className="absolute inset-0 flex flex-col items-center justify-center gap-1 rounded-md border border-dashed border-border p-6 text-center"
1387
1598
  >
1388
- <p className="text-sm font-medium text-balance">{title ?? DEFAULT_EMPTY_TITLE}</p>
1599
+ <p className="text-sm font-medium text-balance">{title ?? defaultTitle}</p>
1389
1600
  {description && <p className="max-w-prose text-sm text-balance text-muted-foreground">{description}</p>}
1390
1601
  {action && <div className="mt-3">{action}</div>}
1391
1602
  </div>