@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
@@ -27,11 +27,143 @@ import { ChartFrame } from "@squadbase/vantage/components"
27
27
  import { EChart } from "@squadbase/vantage/components"
28
28
  import { EMPTY_VALUE, type ValueFormat } from "@squadbase/vantage/components"
29
29
  import type { ChartActionsOptions, ChartEmptyState, ChartRootAttributes, ChartSeriesColor, ValueFormatter } from "@squadbase/vantage/components"
30
+ import { useSyncExternalStore } from "react"
30
31
  import { createContext, useCallback, useEffect, useRef, useState, type RefObject } from "react"
31
32
  import type { ChartTone } from "@squadbase/vantage/components"
32
- import { DEFAULT_LOCALE, formatValue, toValueFormatter } from "@squadbase/vantage/components"
33
+ import { formatValue, toValueFormatter } from "@squadbase/vantage/components"
33
34
  import type { ChartColor, ChartColors } from "@squadbase/vantage/components"
34
35
 
36
+ /* ---------- inlined from @squadbase/squadkit registry/lib/locale.ts (internal to @squadbase/vantage) ---------- */
37
+
38
+ /**
39
+ * The display language of the parts and the defaults that follow it.
40
+ *
41
+ * The language comes from `<html lang>`: a tag starting with `ja` reads Japanese, anything else English.
42
+ * Without a usable `lang` (missing, invalid, or no `document` — SSR, Node) the locale is `FALLBACK_LOCALE`
43
+ * (`en-US`). A `locale` passed explicitly (a prop, an argument) always wins.
44
+ *
45
+ * Parts read the locale through `useLocale` (`hooks/use-locale`) and pass it down; the pure helpers here
46
+ * and in `format` / `comparison` / `chart-*` take it as an argument and fall back to `documentLocale()`.
47
+ */
48
+
49
+ /** The language of the built-in wording: Japanese or English. */
50
+ type UiLanguage = "ja" | "en"
51
+
52
+ /** The locale used when `<html lang>` gives none (and on the server). */
53
+ const FALLBACK_LOCALE = "en-US"
54
+
55
+ /** `"ja"` when `locale` starts with `ja` (`ja`, `ja-JP`), else `"en"`. A missing locale is `"en"`. */
56
+ function uiLanguage(locale: string | null | undefined): UiLanguage {
57
+ return typeof locale === "string" && locale.trim().toLowerCase().startsWith("ja") ? "ja" : "en"
58
+ }
59
+
60
+ /** A BCP 47 tag in its canonical form (`en-gb` → `en-GB`), or `FALLBACK_LOCALE` when it is empty or invalid. */
61
+ function normalizeLocale(tag: string | null | undefined): string {
62
+ const value = tag?.trim()
63
+ if (!value) return FALLBACK_LOCALE
64
+ try {
65
+ return Intl.getCanonicalLocales(value)[0] ?? FALLBACK_LOCALE
66
+ } catch {
67
+ return FALLBACK_LOCALE
68
+ }
69
+ }
70
+
71
+ /**
72
+ * The locale of the page: `document.documentElement.lang`, canonicalized. `FALLBACK_LOCALE` when it is empty or
73
+ * invalid, or there is no `document`. Parts read it through `useLocale`, never while rendering.
74
+ */
75
+ function documentLocale(): string {
76
+ if (typeof document === "undefined") return FALLBACK_LOCALE
77
+ return normalizeLocale(document.documentElement?.getAttribute("lang"))
78
+ }
79
+
80
+ /** `locale` when given, else the page's locale (`documentLocale()`). */
81
+ function resolveLocale(locale?: string | null): string {
82
+ return locale || documentLocale()
83
+ }
84
+
85
+ /** Picks the wording table of the locale's language. */
86
+ function pickLocale<T>(table: { ja: T; en: T }, locale?: string | null): T {
87
+ return table[uiLanguage(resolveLocale(locale))]
88
+ }
89
+
90
+ /** The currency `format="currency"` uses without a `currency`: JPY for Japanese, USD otherwise. */
91
+ function defaultCurrency(locale?: string | null): string {
92
+ return uiLanguage(resolveLocale(locale)) === "ja" ? "JPY" : "USD"
93
+ }
94
+
95
+ /** The month a fiscal year starts in (1–12) when none is given: April for Japanese, January otherwise. */
96
+ function defaultFiscalYearStartMonth(locale?: string | null): number {
97
+ return uiLanguage(resolveLocale(locale)) === "ja" ? 4 : 1
98
+ }
99
+
100
+ /** A day of the week, 0 = Sunday … 6 = Saturday (the `Date#getDay` / date-fns numbering). */
101
+ type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6
102
+
103
+ /** Locales whose week starts on Sunday when the runtime has no week data. */
104
+ const SUNDAY_FIRST = new Set(["en", "en-US", "en-CA"])
105
+
106
+ /**
107
+ * The first day of the week: Monday for Japanese; otherwise the locale's convention from `Intl.Locale`
108
+ * (`en-US` Sunday, `en-GB` Monday). Without week data in the runtime: Sunday for `en` / `en-US` / `en-CA`,
109
+ * Monday for the rest.
110
+ */
111
+ function weekStartsOn(locale?: string | null): WeekDay {
112
+ const tag = normalizeLocale(resolveLocale(locale))
113
+ if (uiLanguage(tag) === "ja") return 1
114
+ try {
115
+ const info = new Intl.Locale(tag) as Intl.Locale & {
116
+ getWeekInfo?: () => { firstDay: number }
117
+ weekInfo?: { firstDay: number }
118
+ }
119
+ const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay
120
+ if (typeof firstDay === "number" && firstDay >= 1 && firstDay <= 7) return (firstDay % 7) as WeekDay
121
+ } catch {
122
+ // Fall through to the table below.
123
+ }
124
+ return SUNDAY_FIRST.has(tag) ? 0 : 1
125
+ }
126
+
127
+ /* ---------- inlined from @squadbase/squadkit registry/hooks/use-locale.ts (internal to @squadbase/vantage) ---------- */
128
+
129
+ const listeners = new Set<() => void>()
130
+ let observer: MutationObserver | null = null
131
+
132
+ /** One observer on `<html lang>` for every subscribed part. */
133
+ function subscribe(onChange: () => void): () => void {
134
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {}
135
+ listeners.add(onChange)
136
+ if (!observer) {
137
+ observer = new MutationObserver(() => {
138
+ for (const listener of [...listeners]) listener()
139
+ })
140
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] })
141
+ }
142
+ return () => {
143
+ listeners.delete(onChange)
144
+ if (listeners.size === 0 && observer) {
145
+ observer.disconnect()
146
+ observer = null
147
+ }
148
+ }
149
+ }
150
+
151
+ const serverLocale = () => FALLBACK_LOCALE
152
+
153
+ /**
154
+ * The page's locale (`<html lang>`), following changes made while the page runs. On the server and during
155
+ * hydration it is `FALLBACK_LOCALE` (`en-US`), so the markup matches; it switches right after.
156
+ */
157
+ function useDocumentLocale(): string {
158
+ return useSyncExternalStore(subscribe, documentLocale, serverLocale)
159
+ }
160
+
161
+ /** The locale a part renders in: its `locale` prop when given, else the page's (`useDocumentLocale`). */
162
+ function useLocale(locale?: string | null): string {
163
+ const page = useDocumentLocale()
164
+ return locale || page
165
+ }
166
+
35
167
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-theme.ts (internal to @squadbase/vantage) ---------- */
36
168
 
37
169
  /**
@@ -535,9 +667,10 @@ function resolveValueFormatter(
535
667
  function resolveAxisFormatter(
536
668
  valueFormatter: ValueFormatter | undefined,
537
669
  format: ValueFormat | undefined,
538
- locale: string = DEFAULT_LOCALE,
670
+ localeArg?: string,
539
671
  ): ValueFormatter {
540
672
  if (valueFormatter) return valueFormatter
673
+ const locale = resolveLocale(localeArg)
541
674
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
542
675
  if (spec.type !== "compact" && spec.type !== "number" && spec.type !== "currency") return toValueFormatter(format, locale)
543
676
  const currency = "currency" in spec ? spec.currency : undefined
@@ -546,40 +679,75 @@ function resolveAxisFormatter(
546
679
  return (value) => formatValue(value, short, locale)
547
680
  }
548
681
 
549
- /** The unit words of a ja number (`1.2万` / `3.4億` / `1兆`) and their sizes, largest first. */
550
- const JA_UNITS = [
551
- { char: "兆", size: 1e12 },
552
- { char: "億", size: 1e8 },
553
- { char: "万", size: 1e4 },
554
- ] as const
682
+ interface CompactUnit {
683
+ token: string
684
+ size: number
685
+ }
686
+
687
+ const compactUnitCache = new Map<string, CompactUnit[] | null>()
688
+
689
+ /**
690
+ * The compact-notation unit words of a locale and their sizes, largest first: `兆` / `億` / `万` in ja,
691
+ * `T` / `B` / `M` / `K` in en. `null` when the locale's compact form puts anything between the number and its
692
+ * word (`1,5 Mio.`), where a token cannot be swapped in place.
693
+ */
694
+ function compactUnits(locale: string): CompactUnit[] | null {
695
+ const cached = compactUnitCache.get(locale)
696
+ if (cached !== undefined) return cached
697
+ let units: CompactUnit[] | null = []
698
+ try {
699
+ const compact = new Intl.NumberFormat(locale, { notation: "compact" })
700
+ const seen = new Set<string>()
701
+ for (let exp = 3; exp <= 15; exp++) {
702
+ const parts = compact.formatToParts(10 ** exp)
703
+ const at = parts.findIndex((p) => p.type === "compact")
704
+ if (at === -1) continue
705
+ if (parts[at - 1]?.type !== "integer" || at !== parts.length - 1) {
706
+ units = null
707
+ break
708
+ }
709
+ const token = parts[at]!.value
710
+ if (seen.has(token)) continue
711
+ seen.add(token)
712
+ units.push({ token, size: 10 ** exp })
713
+ }
714
+ } catch {
715
+ units = null
716
+ }
717
+ const sorted = units && units.length > 0 ? units.sort((a, b) => b.size - a.size) : null
718
+ compactUnitCache.set(locale, sorted)
719
+ return sorted
720
+ }
555
721
 
556
- const JA_NUMBER_TOKEN_RE = /\d[\d,]*(?:\.\d+)?(?:兆|億|万)?/
722
+ const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
557
723
 
558
724
  /**
559
725
  * The value-axis tick formatter for an axis whose largest tick is `top`: every tick reads in the unit of
560
- * that largest tick, so 万 and 億 never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`).
561
- * Only the ja short forms (`compact` / `number` / `currency`, no `valueFormatter`) have units to align;
562
- * any other format — and an axis whose largest tick is under 1万 — keeps `resolveAxisFormatter`'s output as it is.
726
+ * that largest tick, so units never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`;
727
+ * `$0.5M` / `$1M` / `$1.5M`, not `$500K` / `$1M`). Only the short forms (`compact` / `number` / `currency`,
728
+ * no `valueFormatter`) have units to align; any other format — an axis whose largest tick is under the smallest
729
+ * unit (1万 / 1K), or a locale whose compact form cannot be aligned in place — keeps `resolveAxisFormatter`'s output as it is.
563
730
  */
564
731
  function resolveAxisTickFormatter(
565
732
  valueFormatter: ValueFormatter | undefined,
566
733
  format: ValueFormat | undefined,
567
- locale: string = DEFAULT_LOCALE,
734
+ localeArg?: string,
568
735
  ): (top: number) => ValueFormatter {
736
+ const locale = resolveLocale(localeArg)
569
737
  const base = resolveAxisFormatter(valueFormatter, format, locale)
570
738
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
571
- const aligns =
572
- !valueFormatter && locale.toLowerCase().startsWith("ja") && (spec.type === "compact" || spec.type === "number" || spec.type === "currency")
573
- if (!aligns) return () => base
739
+ const units = !valueFormatter && (spec.type === "compact" || spec.type === "number" || spec.type === "currency") ? compactUnits(locale) : null
740
+ if (!units) return () => base
574
741
  const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 3 })
742
+ const numberToken = new RegExp(`\\d[\\d,]*(?:\\.\\d+)?(?:${units.map((u) => escapeRegExp(u.token)).join("|")})?`)
575
743
  return (top) => {
576
- const unit = JA_UNITS.find((u) => Math.abs(top) >= u.size)
744
+ const unit = units.find((u) => Math.abs(top) >= u.size)
577
745
  if (!unit) return base
578
746
  return (value) => {
579
747
  const text = base(value)
580
748
  if (value === 0 || !Number.isFinite(value)) return text
581
- const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.char}`
582
- return text.replace(JA_NUMBER_TOKEN_RE, () => scaled)
749
+ const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.token}`
750
+ return text.replace(numberToken, () => scaled)
583
751
  }
584
752
  }
585
753
  }
@@ -608,8 +776,20 @@ function clamp(value: number, min: number, max: number): number {
608
776
  /** How many categories a chart colours individually; the rest fold into「その他」. */
609
777
  const MAX_CHART_SERIES = 5
610
778
 
611
- /** The series name the folded categories get. */
612
- const OTHER_SERIES_LABEL = "その他"
779
+ /** The series name the folded categories get, by language. */
780
+ const OTHER_SERIES_LABELS: Record<UiLanguage, string> = { ja: "その他", en: "Other" }
781
+
782
+ /** The series name the folded categories get in `locale` ("Other" / "その他"). Default the page's locale (`<html lang>`). */
783
+ function otherSeriesLabel(locale?: string): string {
784
+ return OTHER_SERIES_LABELS[uiLanguage(resolveLocale(locale))]
785
+ }
786
+
787
+ const OTHER_NAMES = new Set(Object.values(OTHER_SERIES_LABELS))
788
+
789
+ /** Whether a category / row is the "other" bucket by name (「その他」 or "Other", whatever the page language). */
790
+ function isOtherName(name: string | null | undefined): boolean {
791
+ return typeof name === "string" && OTHER_NAMES.has(name)
792
+ }
613
793
 
614
794
  interface AggregatedItems<Item extends { name: string; value: number }> {
615
795
  /** The `n` largest items, in their given order. All items when there are `n` or fewer. */
@@ -629,7 +809,7 @@ interface AggregatedItems<Item extends { name: string; value: number }> {
629
809
  function aggregateTopN<Item extends { name: string; value: number }>(
630
810
  items: readonly Item[],
631
811
  n: number = MAX_CHART_SERIES,
632
- otherLabel: string = OTHER_SERIES_LABEL,
812
+ otherLabel: string = otherSeriesLabel(),
633
813
  ): AggregatedItems<Item> {
634
814
  if (items.length <= n) return { top: [...items], rest: [], other: null }
635
815
  const ranked = items
@@ -658,7 +838,7 @@ function aggregateSeries(
658
838
  data: readonly Record<string, unknown>[],
659
839
  categories: readonly string[],
660
840
  n: number = MAX_CHART_SERIES,
661
- otherLabel: string = OTHER_SERIES_LABEL,
841
+ otherLabel: string = otherSeriesLabel(),
662
842
  ): AggregatedSeries {
663
843
  const totals = categories.map((name) => ({
664
844
  name,
@@ -690,12 +870,14 @@ function aggregateSeriesKeeping(
690
870
  categories: readonly string[],
691
871
  keep: string | undefined,
692
872
  n: number = MAX_CHART_SERIES,
873
+ otherLabel: string = otherSeriesLabel(),
693
874
  ): AggregatedSeries {
694
- if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n)
875
+ if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n, otherLabel)
695
876
  const aggregated = aggregateSeries(
696
877
  data,
697
878
  categories.filter((c) => c !== keep),
698
879
  n,
880
+ otherLabel,
699
881
  )
700
882
  return { ...aggregated, categories: [...aggregated.categories, keep] }
701
883
  }
@@ -731,7 +913,7 @@ function seriesColorsWithComparison(
731
913
  * Colour per drawn series: the record's token for that category, else the
732
914
  * array's token at the category's position in `allCategories` (cycled), else
733
915
  * the token palette by drawn position. `otherCategory` — and a category the
734
- * data itself names「その他」(`OTHER_SERIES_LABEL`) — always gets `otherColor`.
916
+ * data itself names「その他」/ "Other" (`OTHER_SERIES_LABELS`) — always gets `otherColor`.
735
917
  */
736
918
  function seriesColorsFor(
737
919
  categories: readonly string[],
@@ -743,7 +925,7 @@ function seriesColorsFor(
743
925
  const list = Array.isArray(colors) ? (colors as ChartSeriesColor[]) : undefined
744
926
  const record = !Array.isArray(colors) && colors ? (colors as Partial<Record<string, ChartSeriesColor>>) : undefined
745
927
  return categories.map((category, i) => {
746
- if ((otherCategory !== null && category === otherCategory) || category === OTHER_SERIES_LABEL) return otherColor
928
+ if ((otherCategory !== null && category === otherCategory) || isOtherName(category)) return otherColor
747
929
  const fromRecord = record?.[category]
748
930
  if (fromRecord) return fromRecord
749
931
  if (list && list.length > 0) {
@@ -790,7 +972,7 @@ interface DonutSlicesConfig {
790
972
  */
791
973
  function donutSlices(
792
974
  data: readonly Record<string, unknown>[],
793
- { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = OTHER_SERIES_LABEL }: DonutSlicesConfig,
975
+ { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = otherSeriesLabel() }: DonutSlicesConfig,
794
976
  ): DonutSlice[] {
795
977
  const items = data
796
978
  .map((row, i) => {
@@ -803,7 +985,8 @@ function donutSlices(
803
985
  const slices: DonutSlice[] = top.map((item, rank) => ({
804
986
  name: item.name,
805
987
  value: item.value,
806
- color: palette[rank % palette.length]!,
988
+ // A row the data itself names「その他」/ "Other" is the same remainder: grey, like the folded slice.
989
+ color: isOtherName(item.name) ? otherColor : palette[rank % palette.length]!,
807
990
  isOther: false,
808
991
  row: item.row,
809
992
  }))
@@ -876,7 +1059,7 @@ interface BarListRowsConfig extends BarListOptions {
876
1059
  */
877
1060
  function barListRows<Row extends object>(
878
1061
  data: readonly Row[],
879
- { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = OTHER_SERIES_LABEL, exclude }: BarListRowsConfig = {},
1062
+ { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = otherSeriesLabel(), exclude }: BarListRowsConfig = {},
880
1063
  ): BarListRow<Row>[] {
881
1064
  const items = data.map((row): BarListRow<Row> => {
882
1065
  const cells = row as Record<string, unknown>
@@ -922,19 +1105,21 @@ const BAR_LIST_OTHER_TINT = 30
922
1105
 
923
1106
  /**
924
1107
  * The fill of each BarList row: all real rows in the same tint of `color`
925
- * (`BAR_LIST_TINT`), the folded「その他」row in a tint of `otherColor`
1108
+ * (`BAR_LIST_TINT`), the folded「その他」row (and a row the data names「その他」/ "Other") in a tint of `otherColor`
926
1109
  * (`BAR_LIST_OTHER_TINT`) — the palest fill, a remainder never outweighs a
927
1110
  * ranked row. A legacy per-row `color` (or a threshold tone) replaces `color`
928
1111
  * for that row. A tint, not the solid token, so the label printed over the bar
929
1112
  * keeps its contrast.
930
1113
  */
931
1114
  function barListFills(
932
- rows: readonly Pick<BarListRow, "isOther" | "color">[],
1115
+ rows: readonly (Pick<BarListRow, "isOther" | "color"> & { name?: string })[],
933
1116
  color: ChartColor,
934
1117
  otherColor: string,
935
1118
  ): string[] {
936
1119
  return rows.map((row) =>
937
- row.isOther ? tintCss(otherColor, BAR_LIST_OTHER_TINT) : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
1120
+ row.isOther || isOtherName(row.name)
1121
+ ? tintCss(otherColor, BAR_LIST_OTHER_TINT)
1122
+ : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
938
1123
  )
939
1124
  }
940
1125
 
@@ -1150,11 +1335,30 @@ interface ChartSummaryInput {
1150
1335
  format?: ValueFormat
1151
1336
  /** Wins over `format`. */
1152
1337
  valueFormatter?: ValueFormatter
1338
+ /** Language of the sentence. Default the page's locale (`<html lang>`). */
1339
+ locale?: string
1340
+ }
1341
+
1342
+ const SUMMARY_TEXT = {
1343
+ ja: {
1344
+ up: "、前の点より増加",
1345
+ down: "、前の点より減少",
1346
+ flat: "、前の点と横ばい",
1347
+ latest: (label: string, value: string) => `最新 ${label} は ${value}`,
1348
+ top: (name: string, share: string) => `最大は ${name}(${share})`,
1349
+ },
1350
+ en: {
1351
+ up: ", up from the previous point",
1352
+ down: ", down from the previous point",
1353
+ flat: ", flat from the previous point",
1354
+ latest: (label: string, value: string) => `Latest ${label}: ${value}`,
1355
+ top: (name: string, share: string) => `Largest: ${name} (${share})`,
1356
+ },
1153
1357
  }
1154
1358
 
1155
1359
  /**
1156
1360
  * One-sentence summary built from the rows, for `summary="auto"`:
1157
- * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」. The direction is
1361
+ * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」("<title>: Latest <index>: <value>, up from the previous point"). The direction is
1158
1362
  * computed from the last two non-empty points of the first series — never
1159
1363
  * written by hand, so it cannot contradict the data. `undefined` when there is
1160
1364
  * no usable point.
@@ -1170,26 +1374,29 @@ function buildChartSummary(input: ChartSummaryInput): string | undefined {
1170
1374
  }
1171
1375
  const latest = points[points.length - 1]
1172
1376
  if (!latest) return undefined
1173
- const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact")
1377
+ const locale = resolveLocale(input.locale)
1378
+ const t = SUMMARY_TEXT[uiLanguage(locale)]
1379
+ const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact", locale)
1174
1380
  const prev = points[points.length - 2]
1175
- const direction = !prev ? "" : latest.value > prev.value ? "、前の点より増加" : latest.value < prev.value ? "、前の点より減少" : "、前の点と横ばい"
1381
+ const direction = !prev ? "" : latest.value > prev.value ? t.up : latest.value < prev.value ? t.down : t.flat
1176
1382
  const head = input.title ? `${input.title}: ` : ""
1177
- return `${head}最新 ${latest.label} は ${text(latest.value)}${direction}`
1383
+ return `${head}${t.latest(latest.label, text(latest.value))}${direction}`
1178
1384
  }
1179
1385
 
1180
1386
  /**
1181
- * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」 from the slices
1182
- * (largest first). `undefined` when there is nothing to describe.
1387
+ * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」("<title>: Largest: <name> (<share>)") from the slices
1388
+ * (largest first). `undefined` when there is nothing to describe. `locale` picks the language; default the page's.
1183
1389
  */
1184
1390
  function buildShareSummary(
1185
1391
  slices: readonly { name: string; value: number }[],
1186
1392
  title?: string,
1393
+ locale?: string,
1187
1394
  ): string | undefined {
1188
1395
  const total = slices.reduce((sum, s) => sum + s.value, 0)
1189
1396
  const top = slices[0]
1190
1397
  if (!top || total <= 0) return undefined
1191
1398
  const share = `${Math.round((top.value / total) * 1000) / 10}%`
1192
- return `${title ? `${title}: ` : ""}最大は ${top.name}(${share})`
1399
+ return `${title ? `${title}: ` : ""}${SUMMARY_TEXT[uiLanguage(resolveLocale(locale))].top(top.name, share)}`
1193
1400
  }
1194
1401
 
1195
1402
  /* ---------- end of inlined code ---------- */
@@ -1455,6 +1662,25 @@ export function heatmapCellFontSize(
1455
1662
  return (text * 10) / 12 + HEATMAP_CELL_PADDING <= cell ? 10 : null
1456
1663
  }
1457
1664
 
1665
+ const TEXT = {
1666
+ ja: {
1667
+ join: "・",
1668
+ flagged: "要注意",
1669
+ item: "項目",
1670
+ top: (y: string, x: string, value: string) => `最大は ${y} ${x} の ${value}`,
1671
+ chart: (y: string, x: string) => `${y}×${x}のヒートマップ`,
1672
+ },
1673
+ en: {
1674
+ join: ", ",
1675
+ flagged: "Needs attention",
1676
+ item: "Item",
1677
+ top: (y: string, x: string, value: string) => `Highest: ${y} ${x} (${value})`,
1678
+ chart: (y: string, x: string) => `Heatmap of ${y} × ${x}`,
1679
+ },
1680
+ }
1681
+
1682
+ const textOf = (locale?: string) => TEXT[uiLanguage(resolveLocale(locale))]
1683
+
1458
1684
  export interface HeatmapOptionConfig {
1459
1685
  model: HeatmapModel
1460
1686
  x: string
@@ -1479,6 +1705,8 @@ export interface HeatmapOptionConfig {
1479
1705
  /** The plot's width (px), when known: the numbers shrink (or go) when the cells are too narrow for them. */
1480
1706
  plotWidth?: number | null
1481
1707
  chartOptions?: EChartsOption
1708
+ /** Language of the tooltip. Default the page's locale (`<html lang>`). */
1709
+ locale?: string
1482
1710
  }
1483
1711
 
1484
1712
  /**
@@ -1490,6 +1718,7 @@ export interface HeatmapOptionConfig {
1490
1718
  */
1491
1719
  export function buildHeatmapOption(config: HeatmapOptionConfig): EChartsOption {
1492
1720
  const { model, labels, valueFormatter } = config
1721
+ const t = textOf(config.locale)
1493
1722
  const label = (key: string) => labels?.[key] ?? key
1494
1723
  const present = model.cells.filter((c) => c.value !== null)
1495
1724
  const missing = model.cells.filter((c) => c.value === null)
@@ -1507,9 +1736,9 @@ export function buildHeatmapOption(config: HeatmapOptionConfig): EChartsOption {
1507
1736
  const n = cell.row ? toChartNumber(cell.row[key]) : null
1508
1737
  return `${escapeHtml(label(key))} ${n === null ? EMPTY_VALUE : escapeHtml(valueFormatter(n))}`
1509
1738
  })
1510
- .join("・")
1511
- const flag = cell.flagged ? "・要注意" : ""
1512
- return `${head} ${main}${extras ? `・${extras}` : ""}${flag}`
1739
+ .join(t.join)
1740
+ const flag = cell.flagged ? `${t.join}${t.flagged}` : ""
1741
+ return `${head} ${main}${extras ? `${t.join}${extras}` : ""}${flag}`
1513
1742
  }
1514
1743
 
1515
1744
  const cellFont = config.plotWidth
@@ -1626,6 +1855,7 @@ export function heatmapDataTable(
1626
1855
  names: { x: string; y: string },
1627
1856
  formatter: ValueFormatter,
1628
1857
  labels?: Record<string, string>,
1858
+ locale?: string,
1629
1859
  ): ChartDataTable {
1630
1860
  const label = (key: string) => labels?.[key] ?? key
1631
1861
  const rows = model.ys.map((yk, yi) => [
@@ -1635,17 +1865,26 @@ export function heatmapDataTable(
1635
1865
  return v === null || v === undefined ? EMPTY_VALUE : formatter(v)
1636
1866
  }),
1637
1867
  ])
1638
- return { caption: `${label(names.y)}×${label(names.x)}`, columns: [labels?.[names.y] ?? "項目", ...model.xs.map(label)], rows }
1868
+ return { caption: `${label(names.y)}×${label(names.x)}`, columns: [labels?.[names.y] ?? textOf(locale).item, ...model.xs.map(label)], rows }
1639
1869
  }
1640
1870
 
1641
- /** `summary="auto"`: the strongest cell, e.g. 「最大は 月曜 9時台 の 12件」. `undefined` with no usable cell. */
1642
- export function buildHeatmapSummary(model: HeatmapModel, formatter: ValueFormatter, title?: string, labels?: Record<string, string>): string | undefined {
1871
+ /**
1872
+ * `summary="auto"`: the strongest cell, e.g. 「最大は 月曜 9時台 の 12件」 / "Highest: Mon 9am (12)". `undefined` with no usable cell.
1873
+ * `locale` picks the language; default the page's (`<html lang>`).
1874
+ */
1875
+ export function buildHeatmapSummary(
1876
+ model: HeatmapModel,
1877
+ formatter: ValueFormatter,
1878
+ title?: string,
1879
+ labels?: Record<string, string>,
1880
+ locale?: string,
1881
+ ): string | undefined {
1643
1882
  let best: HeatmapCell | undefined
1644
1883
  for (const cell of model.cells) if (cell.value !== null && (!best || cell.value > (best.value as number))) best = cell
1645
1884
  if (!best) return undefined
1646
1885
  const label = (key: string) => labels?.[key] ?? key
1647
1886
  const head = title ? `${title}: ` : ""
1648
- return `${head}最大は ${label(model.ys[best.yi]!)} ${label(model.xs[best.xi]!)} の ${formatter(best.value as number)}`
1887
+ return `${head}${textOf(locale).top(label(model.ys[best.yi]!), label(model.xs[best.xi]!), formatter(best.value as number))}`
1649
1888
  }
1650
1889
 
1651
1890
  /* -------------------------------------------------------------- component */
@@ -1693,7 +1932,8 @@ function HeatmapImpl<T extends Record<string, unknown>>(
1693
1932
  ref: ForwardedRef<HTMLDivElement>,
1694
1933
  ) {
1695
1934
  const { ref: frameRef, surface, width: plotWidth, tokens } = useChartSurface(ref)
1696
- const formatter = useMemo(() => resolveValueFormatter(valueFormatter, format ?? "number"), [valueFormatter, format])
1935
+ const locale = useLocale()
1936
+ const formatter = useMemo(() => resolveValueFormatter(valueFormatter, format ?? "number", locale), [valueFormatter, format, locale])
1697
1937
  const model = useMemo(
1698
1938
  () => buildHeatmapModel({ data, x, y, value, xLabels, yLabels, flag, scale, minValue, maxValue }),
1699
1939
  [data, x, y, value, xLabels, yLabels, flag, scale?.min, scale?.max, minValue, maxValue],
@@ -1719,15 +1959,16 @@ function HeatmapImpl<T extends Record<string, unknown>>(
1719
1959
  showTooltip,
1720
1960
  plotWidth,
1721
1961
  chartOptions,
1962
+ locale,
1722
1963
  }),
1723
- [model, x, y, value, colors, surface, tokens, labels, formatter, details, showValues, showTooltip, plotWidth, chartOptions],
1964
+ [model, x, y, value, colors, surface, tokens, labels, formatter, details, showValues, showTooltip, plotWidth, chartOptions, locale],
1724
1965
  )
1725
- const dataTable = useMemo(() => heatmapDataTable(model, { x, y }, formatter, labels), [model, x, y, formatter, labels])
1966
+ const dataTable = useMemo(() => heatmapDataTable(model, { x, y }, formatter, labels, locale), [model, x, y, formatter, labels, locale])
1726
1967
  const resolvedSummary = useMemo(
1727
- () => (summary === "auto" ? buildHeatmapSummary(model, formatter, title, labels) : summary),
1728
- [summary, model, formatter, title, labels],
1968
+ () => (summary === "auto" ? buildHeatmapSummary(model, formatter, title, labels, locale) : summary),
1969
+ [summary, model, formatter, title, labels, locale],
1729
1970
  )
1730
- const label = ariaLabel ?? `${labels?.[y] ?? y}×${labels?.[x] ?? x}のヒートマップ`
1971
+ const label = ariaLabel ?? textOf(locale).chart(labels?.[y] ?? y, labels?.[x] ?? x)
1731
1972
  const legend = showLegend ? (
1732
1973
  <HeatmapLegend model={model} formatter={formatter} tone={tone} />
1733
1974
  ) : null