@squadbase/vantage 0.6.2-dev1.54bde93 → 0.6.2-dev1.5c79ef5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{build-A23M4GCH.js → build-U7GO26HR.js} +3 -3
- package/dist/{build-A23M4GCH.js.map → build-U7GO26HR.js.map} +1 -1
- package/dist/{check-FBHT6W6J.js → check-MRVAIHG5.js} +3 -3
- package/dist/{check-FBHT6W6J.js.map → check-MRVAIHG5.js.map} +1 -1
- package/dist/{chunk-57GHR5LN.js → chunk-3RJEOP3J.js} +29 -18
- package/dist/chunk-3RJEOP3J.js.map +1 -0
- package/dist/{chunk-QOXXOFGL.js → chunk-77CISBMG.js} +994 -464
- package/dist/chunk-77CISBMG.js.map +1 -0
- package/dist/{chunk-JXFCSF3K.js → chunk-EG4PX726.js} +12 -7
- package/dist/chunk-EG4PX726.js.map +1 -0
- package/dist/{chunk-2N72FKLB.js → chunk-NZ5QIIG4.js} +33 -11
- package/dist/chunk-NZ5QIIG4.js.map +1 -0
- package/dist/{chunk-5C2SEDPH.js → chunk-PJ4SPXHS.js} +4 -4
- package/dist/{chunk-5C2SEDPH.js.map → chunk-PJ4SPXHS.js.map} +1 -1
- package/dist/{chunk-7S52M2SL.js → chunk-QNOX7WLZ.js} +3 -3
- package/dist/{chunk-7S52M2SL.js.map → chunk-QNOX7WLZ.js.map} +1 -1
- package/dist/{chunk-PDOBHMUQ.js → chunk-ROHNXP7J.js} +72 -3
- package/dist/chunk-ROHNXP7J.js.map +1 -0
- package/dist/{chunk-6OTTGAPO.js → chunk-TRMHNMMZ.js} +24 -7
- package/dist/chunk-TRMHNMMZ.js.map +1 -0
- package/dist/cli.js +4 -4
- package/dist/components/index.d.ts +49 -29
- package/dist/components/index.js +5 -5
- package/dist/{delta-text-CvwBGJ95.d.ts → delta-text-CbrOaW83.d.ts} +4 -2
- package/dist/{dev-H4I2JL63.js → dev-O42JFQ64.js} +3 -3
- package/dist/{dev-H4I2JL63.js.map → dev-O42JFQ64.js.map} +1 -1
- package/dist/doc/index.d.ts +14 -14
- package/dist/doc/index.js +198 -84
- package/dist/doc/index.js.map +1 -1
- package/dist/{doctor-B2O3GJDU.js → doctor-YD3XAAOT.js} +3 -3
- package/dist/{doctor-B2O3GJDU.js.map → doctor-YD3XAAOT.js.map} +1 -1
- package/dist/{format-U0cQl069.d.ts → format-DihQkL1N.d.ts} +17 -11
- package/dist/slide/index.d.ts +2 -2
- package/dist/slide/index.js +95 -44
- package/dist/slide/index.js.map +1 -1
- package/dist/table/index.js +6 -6
- package/dist/ui/index.d.ts +1 -1
- package/dist/ui/index.js +5 -5
- package/docs/en/agent-skills.md +1 -1
- package/docs/ja/agent-skills.md +1 -1
- package/package.json +2 -2
- package/registry/ui/app-shell.tsx +138 -1
- package/registry/ui/area-chart.tsx +344 -85
- package/registry/ui/attention-list.tsx +177 -15
- package/registry/ui/bar-chart.tsx +341 -85
- package/registry/ui/bar-list.tsx +332 -77
- package/registry/ui/box-plot-chart.tsx +405 -97
- package/registry/ui/category-bar.tsx +248 -40
- package/registry/ui/chart-frame.tsx +257 -46
- package/registry/ui/data-table.tsx +229 -37
- package/registry/ui/date-range-picker.tsx +190 -36
- package/registry/ui/delta.tsx +171 -12
- package/registry/ui/donut-chart.tsx +339 -84
- package/registry/ui/echart.tsx +142 -4
- package/registry/ui/filter-bar.tsx +179 -20
- package/registry/ui/funnel-steps.tsx +354 -80
- package/registry/ui/heatmap.tsx +293 -52
- package/registry/ui/histogram-chart.tsx +395 -97
- package/registry/ui/kpi-card.tsx +245 -44
- package/registry/ui/legend.tsx +330 -76
- package/registry/ui/line-chart.tsx +344 -85
- package/registry/ui/multi-select.tsx +159 -9
- package/registry/ui/page-shell.tsx +141 -3
- package/registry/ui/paired-chart.tsx +349 -90
- package/registry/ui/progress-circle.tsx +205 -39
- package/registry/ui/sample-data.tsx +153 -9
- package/registry/ui/scatter-chart.tsx +391 -100
- package/registry/ui/searchable-select.tsx +145 -7
- package/registry/ui/states.tsx +170 -13
- package/registry/ui/status-badge.tsx +193 -28
- package/registry/ui/tracker.tsx +205 -39
- package/registry/ui/trend-indicator.tsx +149 -6
- package/registry/ui/updated-at.tsx +170 -16
- package/registry/ui/waterfall-chart.tsx +415 -102
- package/skills/vantage-design/SKILL.md +25 -27
- package/skills/vantage-design/references/extract.md +10 -13
- package/skills/vantage-design/references/format.md +12 -173
- package/skills/vantage-design/references/presets.md +0 -10
- package/templates/AGENTS.md +29 -15
- package/templates/agents/page.md +1 -1
- package/templates/agents/slide.md +7 -1
- package/dist/chunk-2N72FKLB.js.map +0 -1
- package/dist/chunk-57GHR5LN.js.map +0 -1
- package/dist/chunk-6OTTGAPO.js.map +0 -1
- package/dist/chunk-JXFCSF3K.js.map +0 -1
- package/dist/chunk-PDOBHMUQ.js.map +0 -1
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
{ char: "万", size: 1e4 },
|
|
547
|
-
] as const
|
|
675
|
+
interface CompactUnit {
|
|
676
|
+
token: string
|
|
677
|
+
size: number
|
|
678
|
+
}
|
|
548
679
|
|
|
549
|
-
const
|
|
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
|
|
554
|
-
* Only the
|
|
555
|
-
* any other format —
|
|
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
|
-
|
|
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
|
|
565
|
-
|
|
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 =
|
|
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.
|
|
575
|
-
return text.replace(
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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> は
|
|
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
|
|
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 ?
|
|
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}
|
|
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}: ` : ""}
|
|
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
|
|
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"
|
|
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 ??
|
|
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>
|