@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
@@ -11,9 +11,100 @@ import { forwardRef, useEffect, useState, type ComponentProps } from "react"
11
11
 
12
12
  import { cn } from "@squadbase/vantage/ui"
13
13
  import type { ChartColor } from "@squadbase/vantage/components"
14
- import { DEFAULT_LOCALE, EMPTY_VALUE, formatValue, toValueFormatter, type ValueFormat } from "@squadbase/vantage/components"
14
+ import { EMPTY_VALUE, formatValue, toValueFormatter, type ValueFormat } from "@squadbase/vantage/components"
15
15
  import type { ChartColors, ChartSeriesColor, ChartTone, ValueFormatter } from "@squadbase/vantage/components"
16
16
 
17
+ /* ---------- inlined from @squadbase/squadkit registry/lib/locale.ts (internal to @squadbase/vantage) ---------- */
18
+
19
+ /**
20
+ * The display language of the parts and the defaults that follow it.
21
+ *
22
+ * The language comes from `<html lang>`: a tag starting with `ja` reads Japanese, anything else English.
23
+ * Without a usable `lang` (missing, invalid, or no `document` — SSR, Node) the locale is `FALLBACK_LOCALE`
24
+ * (`en-US`). A `locale` passed explicitly (a prop, an argument) always wins.
25
+ *
26
+ * Parts read the locale through `useLocale` (`hooks/use-locale`) and pass it down; the pure helpers here
27
+ * and in `format` / `comparison` / `chart-*` take it as an argument and fall back to `documentLocale()`.
28
+ */
29
+
30
+ /** The language of the built-in wording: Japanese or English. */
31
+ type UiLanguage = "ja" | "en"
32
+
33
+ /** The locale used when `<html lang>` gives none (and on the server). */
34
+ const FALLBACK_LOCALE = "en-US"
35
+
36
+ /** `"ja"` when `locale` starts with `ja` (`ja`, `ja-JP`), else `"en"`. A missing locale is `"en"`. */
37
+ function uiLanguage(locale: string | null | undefined): UiLanguage {
38
+ return typeof locale === "string" && locale.trim().toLowerCase().startsWith("ja") ? "ja" : "en"
39
+ }
40
+
41
+ /** A BCP 47 tag in its canonical form (`en-gb` → `en-GB`), or `FALLBACK_LOCALE` when it is empty or invalid. */
42
+ function normalizeLocale(tag: string | null | undefined): string {
43
+ const value = tag?.trim()
44
+ if (!value) return FALLBACK_LOCALE
45
+ try {
46
+ return Intl.getCanonicalLocales(value)[0] ?? FALLBACK_LOCALE
47
+ } catch {
48
+ return FALLBACK_LOCALE
49
+ }
50
+ }
51
+
52
+ /**
53
+ * The locale of the page: `document.documentElement.lang`, canonicalized. `FALLBACK_LOCALE` when it is empty or
54
+ * invalid, or there is no `document`. Parts read it through `useLocale`, never while rendering.
55
+ */
56
+ function documentLocale(): string {
57
+ if (typeof document === "undefined") return FALLBACK_LOCALE
58
+ return normalizeLocale(document.documentElement?.getAttribute("lang"))
59
+ }
60
+
61
+ /** `locale` when given, else the page's locale (`documentLocale()`). */
62
+ function resolveLocale(locale?: string | null): string {
63
+ return locale || documentLocale()
64
+ }
65
+
66
+ /** Picks the wording table of the locale's language. */
67
+ function pickLocale<T>(table: { ja: T; en: T }, locale?: string | null): T {
68
+ return table[uiLanguage(resolveLocale(locale))]
69
+ }
70
+
71
+ /** The currency `format="currency"` uses without a `currency`: JPY for Japanese, USD otherwise. */
72
+ function defaultCurrency(locale?: string | null): string {
73
+ return uiLanguage(resolveLocale(locale)) === "ja" ? "JPY" : "USD"
74
+ }
75
+
76
+ /** The month a fiscal year starts in (1–12) when none is given: April for Japanese, January otherwise. */
77
+ function defaultFiscalYearStartMonth(locale?: string | null): number {
78
+ return uiLanguage(resolveLocale(locale)) === "ja" ? 4 : 1
79
+ }
80
+
81
+ /** A day of the week, 0 = Sunday … 6 = Saturday (the `Date#getDay` / date-fns numbering). */
82
+ type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6
83
+
84
+ /** Locales whose week starts on Sunday when the runtime has no week data. */
85
+ const SUNDAY_FIRST = new Set(["en", "en-US", "en-CA"])
86
+
87
+ /**
88
+ * The first day of the week: Monday for Japanese; otherwise the locale's convention from `Intl.Locale`
89
+ * (`en-US` Sunday, `en-GB` Monday). Without week data in the runtime: Sunday for `en` / `en-US` / `en-CA`,
90
+ * Monday for the rest.
91
+ */
92
+ function weekStartsOn(locale?: string | null): WeekDay {
93
+ const tag = normalizeLocale(resolveLocale(locale))
94
+ if (uiLanguage(tag) === "ja") return 1
95
+ try {
96
+ const info = new Intl.Locale(tag) as Intl.Locale & {
97
+ getWeekInfo?: () => { firstDay: number }
98
+ weekInfo?: { firstDay: number }
99
+ }
100
+ const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay
101
+ if (typeof firstDay === "number" && firstDay >= 1 && firstDay <= 7) return (firstDay % 7) as WeekDay
102
+ } catch {
103
+ // Fall through to the table below.
104
+ }
105
+ return SUNDAY_FIRST.has(tag) ? 0 : 1
106
+ }
107
+
17
108
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-utils.ts (internal to @squadbase/vantage) ---------- */
18
109
 
19
110
  const ROOT_PROP_KEYS = new Set(["id", "style", "role", "tabIndex", "lang", "hidden"])
@@ -121,9 +212,10 @@ function resolveValueFormatter(
121
212
  function resolveAxisFormatter(
122
213
  valueFormatter: ValueFormatter | undefined,
123
214
  format: ValueFormat | undefined,
124
- locale: string = DEFAULT_LOCALE,
215
+ localeArg?: string,
125
216
  ): ValueFormatter {
126
217
  if (valueFormatter) return valueFormatter
218
+ const locale = resolveLocale(localeArg)
127
219
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
128
220
  if (spec.type !== "compact" && spec.type !== "number" && spec.type !== "currency") return toValueFormatter(format, locale)
129
221
  const currency = "currency" in spec ? spec.currency : undefined
@@ -132,40 +224,75 @@ function resolveAxisFormatter(
132
224
  return (value) => formatValue(value, short, locale)
133
225
  }
134
226
 
135
- /** The unit words of a ja number (`1.2万` / `3.4億` / `1兆`) and their sizes, largest first. */
136
- const JA_UNITS = [
137
- { char: "兆", size: 1e12 },
138
- { char: "億", size: 1e8 },
139
- { char: "万", size: 1e4 },
140
- ] as const
227
+ interface CompactUnit {
228
+ token: string
229
+ size: number
230
+ }
141
231
 
142
- const JA_NUMBER_TOKEN_RE = /\d[\d,]*(?:\.\d+)?(?:兆|億|万)?/
232
+ const compactUnitCache = new Map<string, CompactUnit[] | null>()
233
+
234
+ /**
235
+ * The compact-notation unit words of a locale and their sizes, largest first: `兆` / `億` / `万` in ja,
236
+ * `T` / `B` / `M` / `K` in en. `null` when the locale's compact form puts anything between the number and its
237
+ * word (`1,5 Mio.`), where a token cannot be swapped in place.
238
+ */
239
+ function compactUnits(locale: string): CompactUnit[] | null {
240
+ const cached = compactUnitCache.get(locale)
241
+ if (cached !== undefined) return cached
242
+ let units: CompactUnit[] | null = []
243
+ try {
244
+ const compact = new Intl.NumberFormat(locale, { notation: "compact" })
245
+ const seen = new Set<string>()
246
+ for (let exp = 3; exp <= 15; exp++) {
247
+ const parts = compact.formatToParts(10 ** exp)
248
+ const at = parts.findIndex((p) => p.type === "compact")
249
+ if (at === -1) continue
250
+ if (parts[at - 1]?.type !== "integer" || at !== parts.length - 1) {
251
+ units = null
252
+ break
253
+ }
254
+ const token = parts[at]!.value
255
+ if (seen.has(token)) continue
256
+ seen.add(token)
257
+ units.push({ token, size: 10 ** exp })
258
+ }
259
+ } catch {
260
+ units = null
261
+ }
262
+ const sorted = units && units.length > 0 ? units.sort((a, b) => b.size - a.size) : null
263
+ compactUnitCache.set(locale, sorted)
264
+ return sorted
265
+ }
266
+
267
+ const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
143
268
 
144
269
  /**
145
270
  * The value-axis tick formatter for an axis whose largest tick is `top`: every tick reads in the unit of
146
- * that largest tick, so 万 and 億 never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`).
147
- * Only the ja short forms (`compact` / `number` / `currency`, no `valueFormatter`) have units to align;
148
- * any other format — and an axis whose largest tick is under 1万 — keeps `resolveAxisFormatter`'s output as it is.
271
+ * that largest tick, so units never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`;
272
+ * `$0.5M` / `$1M` / `$1.5M`, not `$500K` / `$1M`). Only the short forms (`compact` / `number` / `currency`,
273
+ * no `valueFormatter`) have units to align; any other format — an axis whose largest tick is under the smallest
274
+ * unit (1万 / 1K), or a locale whose compact form cannot be aligned in place — keeps `resolveAxisFormatter`'s output as it is.
149
275
  */
150
276
  function resolveAxisTickFormatter(
151
277
  valueFormatter: ValueFormatter | undefined,
152
278
  format: ValueFormat | undefined,
153
- locale: string = DEFAULT_LOCALE,
279
+ localeArg?: string,
154
280
  ): (top: number) => ValueFormatter {
281
+ const locale = resolveLocale(localeArg)
155
282
  const base = resolveAxisFormatter(valueFormatter, format, locale)
156
283
  const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
157
- const aligns =
158
- !valueFormatter && locale.toLowerCase().startsWith("ja") && (spec.type === "compact" || spec.type === "number" || spec.type === "currency")
159
- if (!aligns) return () => base
284
+ const units = !valueFormatter && (spec.type === "compact" || spec.type === "number" || spec.type === "currency") ? compactUnits(locale) : null
285
+ if (!units) return () => base
160
286
  const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 3 })
287
+ const numberToken = new RegExp(`\\d[\\d,]*(?:\\.\\d+)?(?:${units.map((u) => escapeRegExp(u.token)).join("|")})?`)
161
288
  return (top) => {
162
- const unit = JA_UNITS.find((u) => Math.abs(top) >= u.size)
289
+ const unit = units.find((u) => Math.abs(top) >= u.size)
163
290
  if (!unit) return base
164
291
  return (value) => {
165
292
  const text = base(value)
166
293
  if (value === 0 || !Number.isFinite(value)) return text
167
- const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.char}`
168
- return text.replace(JA_NUMBER_TOKEN_RE, () => scaled)
294
+ const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.token}`
295
+ return text.replace(numberToken, () => scaled)
169
296
  }
170
297
  }
171
298
  }
@@ -194,8 +321,20 @@ function clamp(value: number, min: number, max: number): number {
194
321
  /** How many categories a chart colours individually; the rest fold into「その他」. */
195
322
  const MAX_CHART_SERIES = 5
196
323
 
197
- /** The series name the folded categories get. */
198
- const OTHER_SERIES_LABEL = "その他"
324
+ /** The series name the folded categories get, by language. */
325
+ const OTHER_SERIES_LABELS: Record<UiLanguage, string> = { ja: "その他", en: "Other" }
326
+
327
+ /** The series name the folded categories get in `locale` ("Other" / "その他"). Default the page's locale (`<html lang>`). */
328
+ function otherSeriesLabel(locale?: string): string {
329
+ return OTHER_SERIES_LABELS[uiLanguage(resolveLocale(locale))]
330
+ }
331
+
332
+ const OTHER_NAMES = new Set(Object.values(OTHER_SERIES_LABELS))
333
+
334
+ /** Whether a category / row is the "other" bucket by name (「その他」 or "Other", whatever the page language). */
335
+ function isOtherName(name: string | null | undefined): boolean {
336
+ return typeof name === "string" && OTHER_NAMES.has(name)
337
+ }
199
338
 
200
339
  interface AggregatedItems<Item extends { name: string; value: number }> {
201
340
  /** The `n` largest items, in their given order. All items when there are `n` or fewer. */
@@ -215,7 +354,7 @@ interface AggregatedItems<Item extends { name: string; value: number }> {
215
354
  function aggregateTopN<Item extends { name: string; value: number }>(
216
355
  items: readonly Item[],
217
356
  n: number = MAX_CHART_SERIES,
218
- otherLabel: string = OTHER_SERIES_LABEL,
357
+ otherLabel: string = otherSeriesLabel(),
219
358
  ): AggregatedItems<Item> {
220
359
  if (items.length <= n) return { top: [...items], rest: [], other: null }
221
360
  const ranked = items
@@ -244,7 +383,7 @@ function aggregateSeries(
244
383
  data: readonly Record<string, unknown>[],
245
384
  categories: readonly string[],
246
385
  n: number = MAX_CHART_SERIES,
247
- otherLabel: string = OTHER_SERIES_LABEL,
386
+ otherLabel: string = otherSeriesLabel(),
248
387
  ): AggregatedSeries {
249
388
  const totals = categories.map((name) => ({
250
389
  name,
@@ -276,12 +415,14 @@ function aggregateSeriesKeeping(
276
415
  categories: readonly string[],
277
416
  keep: string | undefined,
278
417
  n: number = MAX_CHART_SERIES,
418
+ otherLabel: string = otherSeriesLabel(),
279
419
  ): AggregatedSeries {
280
- if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n)
420
+ if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n, otherLabel)
281
421
  const aggregated = aggregateSeries(
282
422
  data,
283
423
  categories.filter((c) => c !== keep),
284
424
  n,
425
+ otherLabel,
285
426
  )
286
427
  return { ...aggregated, categories: [...aggregated.categories, keep] }
287
428
  }
@@ -317,7 +458,7 @@ function seriesColorsWithComparison(
317
458
  * Colour per drawn series: the record's token for that category, else the
318
459
  * array's token at the category's position in `allCategories` (cycled), else
319
460
  * the token palette by drawn position. `otherCategory` — and a category the
320
- * data itself names「その他」(`OTHER_SERIES_LABEL`) — always gets `otherColor`.
461
+ * data itself names「その他」/ "Other" (`OTHER_SERIES_LABELS`) — always gets `otherColor`.
321
462
  */
322
463
  function seriesColorsFor(
323
464
  categories: readonly string[],
@@ -329,7 +470,7 @@ function seriesColorsFor(
329
470
  const list = Array.isArray(colors) ? (colors as ChartSeriesColor[]) : undefined
330
471
  const record = !Array.isArray(colors) && colors ? (colors as Partial<Record<string, ChartSeriesColor>>) : undefined
331
472
  return categories.map((category, i) => {
332
- if ((otherCategory !== null && category === otherCategory) || category === OTHER_SERIES_LABEL) return otherColor
473
+ if ((otherCategory !== null && category === otherCategory) || isOtherName(category)) return otherColor
333
474
  const fromRecord = record?.[category]
334
475
  if (fromRecord) return fromRecord
335
476
  if (list && list.length > 0) {
@@ -376,7 +517,7 @@ interface DonutSlicesConfig {
376
517
  */
377
518
  function donutSlices(
378
519
  data: readonly Record<string, unknown>[],
379
- { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = OTHER_SERIES_LABEL }: DonutSlicesConfig,
520
+ { index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = otherSeriesLabel() }: DonutSlicesConfig,
380
521
  ): DonutSlice[] {
381
522
  const items = data
382
523
  .map((row, i) => {
@@ -389,7 +530,8 @@ function donutSlices(
389
530
  const slices: DonutSlice[] = top.map((item, rank) => ({
390
531
  name: item.name,
391
532
  value: item.value,
392
- color: palette[rank % palette.length]!,
533
+ // A row the data itself names「その他」/ "Other" is the same remainder: grey, like the folded slice.
534
+ color: isOtherName(item.name) ? otherColor : palette[rank % palette.length]!,
393
535
  isOther: false,
394
536
  row: item.row,
395
537
  }))
@@ -462,7 +604,7 @@ interface BarListRowsConfig extends BarListOptions {
462
604
  */
463
605
  function barListRows<Row extends object>(
464
606
  data: readonly Row[],
465
- { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = OTHER_SERIES_LABEL, exclude }: BarListRowsConfig = {},
607
+ { index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = otherSeriesLabel(), exclude }: BarListRowsConfig = {},
466
608
  ): BarListRow<Row>[] {
467
609
  const items = data.map((row): BarListRow<Row> => {
468
610
  const cells = row as Record<string, unknown>
@@ -508,19 +650,21 @@ const BAR_LIST_OTHER_TINT = 30
508
650
 
509
651
  /**
510
652
  * The fill of each BarList row: all real rows in the same tint of `color`
511
- * (`BAR_LIST_TINT`), the folded「その他」row in a tint of `otherColor`
653
+ * (`BAR_LIST_TINT`), the folded「その他」row (and a row the data names「その他」/ "Other") in a tint of `otherColor`
512
654
  * (`BAR_LIST_OTHER_TINT`) — the palest fill, a remainder never outweighs a
513
655
  * ranked row. A legacy per-row `color` (or a threshold tone) replaces `color`
514
656
  * for that row. A tint, not the solid token, so the label printed over the bar
515
657
  * keeps its contrast.
516
658
  */
517
659
  function barListFills(
518
- rows: readonly Pick<BarListRow, "isOther" | "color">[],
660
+ rows: readonly (Pick<BarListRow, "isOther" | "color"> & { name?: string })[],
519
661
  color: ChartColor,
520
662
  otherColor: string,
521
663
  ): string[] {
522
664
  return rows.map((row) =>
523
- row.isOther ? tintCss(otherColor, BAR_LIST_OTHER_TINT) : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
665
+ row.isOther || isOtherName(row.name)
666
+ ? tintCss(otherColor, BAR_LIST_OTHER_TINT)
667
+ : tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
524
668
  )
525
669
  }
526
670
 
@@ -736,11 +880,30 @@ interface ChartSummaryInput {
736
880
  format?: ValueFormat
737
881
  /** Wins over `format`. */
738
882
  valueFormatter?: ValueFormatter
883
+ /** Language of the sentence. Default the page's locale (`<html lang>`). */
884
+ locale?: string
885
+ }
886
+
887
+ const SUMMARY_TEXT = {
888
+ ja: {
889
+ up: "、前の点より増加",
890
+ down: "、前の点より減少",
891
+ flat: "、前の点と横ばい",
892
+ latest: (label: string, value: string) => `最新 ${label} は ${value}`,
893
+ top: (name: string, share: string) => `最大は ${name}(${share})`,
894
+ },
895
+ en: {
896
+ up: ", up from the previous point",
897
+ down: ", down from the previous point",
898
+ flat: ", flat from the previous point",
899
+ latest: (label: string, value: string) => `Latest ${label}: ${value}`,
900
+ top: (name: string, share: string) => `Largest: ${name} (${share})`,
901
+ },
739
902
  }
740
903
 
741
904
  /**
742
905
  * One-sentence summary built from the rows, for `summary="auto"`:
743
- * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」. The direction is
906
+ * 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」("<title>: Latest <index>: <value>, up from the previous point"). The direction is
744
907
  * computed from the last two non-empty points of the first series — never
745
908
  * written by hand, so it cannot contradict the data. `undefined` when there is
746
909
  * no usable point.
@@ -756,26 +919,29 @@ function buildChartSummary(input: ChartSummaryInput): string | undefined {
756
919
  }
757
920
  const latest = points[points.length - 1]
758
921
  if (!latest) return undefined
759
- const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact")
922
+ const locale = resolveLocale(input.locale)
923
+ const t = SUMMARY_TEXT[uiLanguage(locale)]
924
+ const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact", locale)
760
925
  const prev = points[points.length - 2]
761
- const direction = !prev ? "" : latest.value > prev.value ? "、前の点より増加" : latest.value < prev.value ? "、前の点より減少" : "、前の点と横ばい"
926
+ const direction = !prev ? "" : latest.value > prev.value ? t.up : latest.value < prev.value ? t.down : t.flat
762
927
  const head = input.title ? `${input.title}: ` : ""
763
- return `${head}最新 ${latest.label} は ${text(latest.value)}${direction}`
928
+ return `${head}${t.latest(latest.label, text(latest.value))}${direction}`
764
929
  }
765
930
 
766
931
  /**
767
- * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」 from the slices
768
- * (largest first). `undefined` when there is nothing to describe.
932
+ * `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」("<title>: Largest: <name> (<share>)") from the slices
933
+ * (largest first). `undefined` when there is nothing to describe. `locale` picks the language; default the page's.
769
934
  */
770
935
  function buildShareSummary(
771
936
  slices: readonly { name: string; value: number }[],
772
937
  title?: string,
938
+ locale?: string,
773
939
  ): string | undefined {
774
940
  const total = slices.reduce((sum, s) => sum + s.value, 0)
775
941
  const top = slices[0]
776
942
  if (!top || total <= 0) return undefined
777
943
  const share = `${Math.round((top.value / total) * 1000) / 10}%`
778
- return `${title ? `${title}: ` : ""}最大は ${top.name}(${share})`
944
+ return `${title ? `${title}: ` : ""}${SUMMARY_TEXT[uiLanguage(resolveLocale(locale))].top(top.name, share)}`
779
945
  }
780
946
 
781
947
  /* ---------- end of inlined code ---------- */
@@ -28,9 +28,151 @@ import { createContext, useContext, useMemo, type HTMLAttributes, type ReactNode
28
28
 
29
29
  import { cn } from "@squadbase/vantage/ui"
30
30
  import { Badge } from "@squadbase/vantage/ui"
31
+ import { useSyncExternalStore } from "react"
31
32
 
32
- export const DEFAULT_SAMPLE_LABEL = "見本の値"
33
- const SAMPLE_TITLE = "実データにまだつながっていない見本の値です"
33
+ /* ---------- inlined from @squadbase/squadkit registry/lib/locale.ts (internal to @squadbase/vantage) ---------- */
34
+
35
+ /**
36
+ * The display language of the parts and the defaults that follow it.
37
+ *
38
+ * The language comes from `<html lang>`: a tag starting with `ja` reads Japanese, anything else English.
39
+ * Without a usable `lang` (missing, invalid, or no `document` — SSR, Node) the locale is `FALLBACK_LOCALE`
40
+ * (`en-US`). A `locale` passed explicitly (a prop, an argument) always wins.
41
+ *
42
+ * Parts read the locale through `useLocale` (`hooks/use-locale`) and pass it down; the pure helpers here
43
+ * and in `format` / `comparison` / `chart-*` take it as an argument and fall back to `documentLocale()`.
44
+ */
45
+
46
+ /** The language of the built-in wording: Japanese or English. */
47
+ type UiLanguage = "ja" | "en"
48
+
49
+ /** The locale used when `<html lang>` gives none (and on the server). */
50
+ const FALLBACK_LOCALE = "en-US"
51
+
52
+ /** `"ja"` when `locale` starts with `ja` (`ja`, `ja-JP`), else `"en"`. A missing locale is `"en"`. */
53
+ function uiLanguage(locale: string | null | undefined): UiLanguage {
54
+ return typeof locale === "string" && locale.trim().toLowerCase().startsWith("ja") ? "ja" : "en"
55
+ }
56
+
57
+ /** A BCP 47 tag in its canonical form (`en-gb` → `en-GB`), or `FALLBACK_LOCALE` when it is empty or invalid. */
58
+ function normalizeLocale(tag: string | null | undefined): string {
59
+ const value = tag?.trim()
60
+ if (!value) return FALLBACK_LOCALE
61
+ try {
62
+ return Intl.getCanonicalLocales(value)[0] ?? FALLBACK_LOCALE
63
+ } catch {
64
+ return FALLBACK_LOCALE
65
+ }
66
+ }
67
+
68
+ /**
69
+ * The locale of the page: `document.documentElement.lang`, canonicalized. `FALLBACK_LOCALE` when it is empty or
70
+ * invalid, or there is no `document`. Parts read it through `useLocale`, never while rendering.
71
+ */
72
+ function documentLocale(): string {
73
+ if (typeof document === "undefined") return FALLBACK_LOCALE
74
+ return normalizeLocale(document.documentElement?.getAttribute("lang"))
75
+ }
76
+
77
+ /** `locale` when given, else the page's locale (`documentLocale()`). */
78
+ function resolveLocale(locale?: string | null): string {
79
+ return locale || documentLocale()
80
+ }
81
+
82
+ /** Picks the wording table of the locale's language. */
83
+ function pickLocale<T>(table: { ja: T; en: T }, locale?: string | null): T {
84
+ return table[uiLanguage(resolveLocale(locale))]
85
+ }
86
+
87
+ /** The currency `format="currency"` uses without a `currency`: JPY for Japanese, USD otherwise. */
88
+ function defaultCurrency(locale?: string | null): string {
89
+ return uiLanguage(resolveLocale(locale)) === "ja" ? "JPY" : "USD"
90
+ }
91
+
92
+ /** The month a fiscal year starts in (1–12) when none is given: April for Japanese, January otherwise. */
93
+ function defaultFiscalYearStartMonth(locale?: string | null): number {
94
+ return uiLanguage(resolveLocale(locale)) === "ja" ? 4 : 1
95
+ }
96
+
97
+ /** A day of the week, 0 = Sunday … 6 = Saturday (the `Date#getDay` / date-fns numbering). */
98
+ type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6
99
+
100
+ /** Locales whose week starts on Sunday when the runtime has no week data. */
101
+ const SUNDAY_FIRST = new Set(["en", "en-US", "en-CA"])
102
+
103
+ /**
104
+ * The first day of the week: Monday for Japanese; otherwise the locale's convention from `Intl.Locale`
105
+ * (`en-US` Sunday, `en-GB` Monday). Without week data in the runtime: Sunday for `en` / `en-US` / `en-CA`,
106
+ * Monday for the rest.
107
+ */
108
+ function weekStartsOn(locale?: string | null): WeekDay {
109
+ const tag = normalizeLocale(resolveLocale(locale))
110
+ if (uiLanguage(tag) === "ja") return 1
111
+ try {
112
+ const info = new Intl.Locale(tag) as Intl.Locale & {
113
+ getWeekInfo?: () => { firstDay: number }
114
+ weekInfo?: { firstDay: number }
115
+ }
116
+ const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay
117
+ if (typeof firstDay === "number" && firstDay >= 1 && firstDay <= 7) return (firstDay % 7) as WeekDay
118
+ } catch {
119
+ // Fall through to the table below.
120
+ }
121
+ return SUNDAY_FIRST.has(tag) ? 0 : 1
122
+ }
123
+
124
+ /* ---------- inlined from @squadbase/squadkit registry/hooks/use-locale.ts (internal to @squadbase/vantage) ---------- */
125
+
126
+ const listeners = new Set<() => void>()
127
+ let observer: MutationObserver | null = null
128
+
129
+ /** One observer on `<html lang>` for every subscribed part. */
130
+ function subscribe(onChange: () => void): () => void {
131
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {}
132
+ listeners.add(onChange)
133
+ if (!observer) {
134
+ observer = new MutationObserver(() => {
135
+ for (const listener of [...listeners]) listener()
136
+ })
137
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] })
138
+ }
139
+ return () => {
140
+ listeners.delete(onChange)
141
+ if (listeners.size === 0 && observer) {
142
+ observer.disconnect()
143
+ observer = null
144
+ }
145
+ }
146
+ }
147
+
148
+ const serverLocale = () => FALLBACK_LOCALE
149
+
150
+ /**
151
+ * The page's locale (`<html lang>`), following changes made while the page runs. On the server and during
152
+ * hydration it is `FALLBACK_LOCALE` (`en-US`), so the markup matches; it switches right after.
153
+ */
154
+ function useDocumentLocale(): string {
155
+ return useSyncExternalStore(subscribe, documentLocale, serverLocale)
156
+ }
157
+
158
+ /** The locale a part renders in: its `locale` prop when given, else the page's (`useDocumentLocale`). */
159
+ function useLocale(locale?: string | null): string {
160
+ const page = useDocumentLocale()
161
+ return locale || page
162
+ }
163
+
164
+ /* ---------- end of inlined code ---------- */
165
+
166
+ const TEXT = {
167
+ ja: { label: "見本の値", title: "実データにまだつながっていない見本の値です" },
168
+ en: { label: "Sample data", title: "Sample values, not connected to real data yet" },
169
+ }
170
+
171
+ /**
172
+ * The mark's wording in English (the fallback language). Without a `label`, the mark follows the page's
173
+ * language (`<html lang>`): ja reads「見本の値」.
174
+ */
175
+ export const DEFAULT_SAMPLE_LABEL = TEXT.en.label
34
176
 
35
177
  interface SampleState {
36
178
  label: string
@@ -52,32 +194,34 @@ export function SampleClaim({ children }: { children?: ReactNode }) {
52
194
  }
53
195
 
54
196
  export interface SampleBadgeProps extends HTMLAttributes<HTMLSpanElement> {
55
- /** 印の文言。既定「見本の値」。 */
197
+ /** 印の文言。既定はページの言語で「見本の値」(英語は "Sample data")。 */
56
198
  label?: string
57
199
  }
58
200
 
59
- export function SampleBadge({ label = DEFAULT_SAMPLE_LABEL, className, ...props }: SampleBadgeProps) {
201
+ export function SampleBadge({ label, className, ...props }: SampleBadgeProps) {
202
+ const t = TEXT[uiLanguage(useLocale())]
60
203
  return (
61
204
  <Badge
62
205
  data-slot="sample-badge"
63
- title={SAMPLE_TITLE}
206
+ title={t.title}
64
207
  className={cn("border-warning/20 dark:border-warning/25 bg-warning-subtle text-warning align-middle", className)}
65
208
  {...props}
66
209
  >
67
- {label}
210
+ {label ?? t.label}
68
211
  </Badge>
69
212
  )
70
213
  }
71
214
 
72
215
  export interface SampleDataProps {
73
- /** 印の文言。既定「見本の値」。 */
216
+ /** 印の文言。既定はページの言語で「見本の値」(英語は "Sample data")。 */
74
217
  label?: string
75
218
  children?: ReactNode
76
219
  }
77
220
 
78
- export function SampleData({ label = DEFAULT_SAMPLE_LABEL, children }: SampleDataProps) {
221
+ export function SampleData({ label, children }: SampleDataProps) {
222
+ const shown = label ?? TEXT[uiLanguage(useLocale())].label
79
223
  // A stable value, so the cards inside do not re-render when only the parent does.
80
- const state = useMemo(() => ({ label }), [label])
224
+ const state = useMemo(() => ({ label: shown }), [shown])
81
225
  return (
82
226
  <div data-slot="sample-data" style={{ display: "contents" }}>
83
227
  <SampleContext.Provider value={state}>{children}</SampleContext.Provider>