@squadbase/vantage 0.6.2-dev1.5aae04e → 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-X5NVCQKT.js → build-U7GO26HR.js} +7 -7
- package/dist/{build-X5NVCQKT.js.map → build-U7GO26HR.js.map} +1 -1
- package/dist/{check-ZBIH422T.js → check-MRVAIHG5.js} +5 -5
- package/dist/{check-ZBIH422T.js.map → check-MRVAIHG5.js.map} +1 -1
- package/dist/chunk-2IAPIOPS.js +152 -0
- package/dist/chunk-2IAPIOPS.js.map +1 -0
- package/dist/chunk-3RJEOP3J.js +174 -0
- package/dist/chunk-3RJEOP3J.js.map +1 -0
- package/dist/{chunk-HKSVYMYJ.js → chunk-77CISBMG.js} +1049 -1387
- package/dist/chunk-77CISBMG.js.map +1 -0
- package/dist/{chunk-V3LF7BE7.js → chunk-ANZLVSEP.js} +4 -5
- package/dist/chunk-ANZLVSEP.js.map +1 -0
- package/dist/{chunk-XQUSGIDG.js → chunk-CKWPK4RO.js} +10 -3
- package/dist/chunk-CKWPK4RO.js.map +1 -0
- package/dist/chunk-EG4PX726.js +678 -0
- 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-7DZGJQ3P.js → chunk-PJ4SPXHS.js} +23 -6
- package/dist/chunk-PJ4SPXHS.js.map +1 -0
- package/dist/{chunk-XPQSTZUU.js → chunk-QNOX7WLZ.js} +5 -4
- package/dist/chunk-QNOX7WLZ.js.map +1 -0
- package/dist/{chunk-PDOBHMUQ.js → chunk-ROHNXP7J.js} +72 -3
- package/dist/chunk-ROHNXP7J.js.map +1 -0
- package/dist/{chunk-CEXAQQ5W.js → chunk-S5MDTHH7.js} +180 -18
- package/dist/chunk-S5MDTHH7.js.map +1 -0
- package/dist/{chunk-2ICNHUG6.js → chunk-SDJTTMLK.js} +64 -7
- package/dist/chunk-SDJTTMLK.js.map +1 -0
- package/dist/{chunk-WAMZWZJB.js → chunk-SHZM3EXB.js} +19 -8
- package/dist/chunk-SHZM3EXB.js.map +1 -0
- package/dist/chunk-TRMHNMMZ.js +61 -0
- package/dist/chunk-TRMHNMMZ.js.map +1 -0
- package/dist/{chunk-JKLLFVEU.js → chunk-UWSQUHX6.js} +4 -99
- package/dist/chunk-UWSQUHX6.js.map +1 -0
- package/dist/cli.js +6 -6
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +8 -2
- package/dist/client/index.js.map +1 -1
- package/dist/components/index.d.ts +57 -273
- package/dist/components/index.js +6 -3
- package/dist/delta-text-CbrOaW83.d.ts +17 -0
- package/dist/{dev-4XEDTELV.js → dev-O42JFQ64.js} +7 -7
- package/dist/{dev-4XEDTELV.js.map → dev-O42JFQ64.js.map} +1 -1
- package/dist/doc/index.d.ts +215 -2
- package/dist/doc/index.js +422 -4
- package/dist/doc/index.js.map +1 -1
- package/dist/{doctor-5E2VEZPK.js → doctor-YD3XAAOT.js} +5 -5
- package/dist/{doctor-5E2VEZPK.js.map → doctor-YD3XAAOT.js.map} +1 -1
- package/dist/format-DihQkL1N.d.ts +249 -0
- package/dist/query/index.js +2 -2
- package/dist/{routes-3SGAFDMC.js → routes-PVKNT777.js} +4 -4
- package/dist/{routes-3SGAFDMC.js.map → routes-PVKNT777.js.map} +1 -1
- package/dist/slide/index.d.ts +272 -1
- package/dist/slide/index.js +1023 -2
- package/dist/slide/index.js.map +1 -1
- package/dist/table/index.js +9 -6
- package/dist/table/index.js.map +1 -1
- package/dist/ui/index.d.ts +5 -2
- package/dist/ui/index.js +6 -6
- package/dist/ui/index.js.map +1 -1
- package/dist/{upgrade-JHXZBGQS.js → upgrade-6DT7ADRP.js} +4 -4
- package/dist/{upgrade-JHXZBGQS.js.map → upgrade-6DT7ADRP.js.map} +1 -1
- package/dist/vite/index.js +3 -3
- package/docs/en/agent-skills.md +1 -1
- package/docs/en/changelog.md +2 -0
- package/docs/en/components.md +13 -2
- package/docs/en/doc/delta-text.md +40 -0
- package/docs/en/doc/doc-action-items.md +49 -0
- package/docs/en/doc/doc-callout.md +38 -0
- package/docs/en/doc/doc-data-table.md +60 -0
- package/docs/en/doc/doc-executive-summary.md +64 -0
- package/docs/en/doc/doc-figure.md +56 -0
- package/docs/en/doc/doc-key-findings.md +61 -0
- package/docs/en/doc/doc-kpi-scorecard.md +80 -0
- package/docs/en/doc/doc-notes-and-sources.md +43 -0
- package/docs/en/doc/doc-report-header.md +46 -0
- package/docs/en/pages-and-metadata.md +15 -0
- package/docs/en/parts/area-chart.md +99 -0
- package/docs/en/parts/bar-chart.md +91 -0
- package/docs/en/parts/bar-list.md +50 -0
- package/docs/en/parts/category-bar.md +37 -0
- package/docs/en/parts/chart-frame.md +39 -0
- package/docs/en/parts/delta.md +4 -3
- package/docs/en/parts/donut-chart.md +72 -0
- package/docs/en/parts/funnel-steps.md +2 -1
- package/docs/en/parts/heatmap.md +53 -0
- package/docs/en/parts/kpi-card.md +2 -1
- package/docs/en/parts/legend.md +35 -0
- package/docs/en/parts/line-chart.md +94 -0
- package/docs/en/parts/progress-circle.md +33 -0
- package/docs/en/parts/sample-data.md +39 -0
- package/docs/en/parts/scatter-chart.md +56 -0
- package/docs/en/parts/tracker.md +36 -0
- package/docs/en/parts/updated-at.md +30 -0
- package/docs/en/parts/waterfall-chart.md +56 -0
- package/docs/en/slide/slide-chart-full.md +48 -0
- package/docs/en/slide/slide-chart-insight.md +52 -0
- package/docs/en/slide/slide-columns.md +46 -0
- package/docs/en/slide/slide-comparison.md +68 -0
- package/docs/en/slide/slide-cover.md +39 -0
- package/docs/en/slide/slide-exec-summary.md +47 -0
- package/docs/en/slide/slide-heading.md +38 -0
- package/docs/en/slide/slide-kpi-grid.md +55 -0
- package/docs/en/slide/slide-kpi-hero.md +58 -0
- package/docs/en/slide/slide-next-actions.md +49 -0
- package/docs/en/slide/slide-scorecard-table.md +54 -0
- package/docs/en/slide/slide-section.md +44 -0
- package/docs/en/slide/slide-source-line.md +34 -0
- package/docs/en/ui/alert-dialog.md +42 -0
- package/docs/en/ui/avatar.md +31 -0
- package/docs/en/ui/combobox.md +74 -0
- package/docs/en/ui/drawer.md +41 -0
- package/docs/en/ui/empty.md +1 -1
- package/docs/en/ui/error-state.md +4 -3
- package/docs/en/ui/field.md +42 -0
- package/docs/en/ui/hover-card.md +21 -0
- package/docs/en/ui/kbd.md +17 -0
- package/docs/en/ui/loading.md +1 -1
- package/docs/en/ui/pagination.md +36 -0
- package/docs/en/ui/radio-group.md +21 -0
- package/docs/en/ui/toast.md +44 -0
- package/docs/en/ui/toggle-group.md +4 -3
- package/docs/index.json +857 -77
- package/docs/ja/agent-skills.md +1 -1
- package/docs/ja/changelog.md +2 -0
- package/docs/ja/components.md +12 -1
- package/docs/ja/doc/delta-text.md +40 -0
- package/docs/ja/doc/doc-action-items.md +49 -0
- package/docs/ja/doc/doc-callout.md +38 -0
- package/docs/ja/doc/doc-data-table.md +60 -0
- package/docs/ja/doc/doc-executive-summary.md +64 -0
- package/docs/ja/doc/doc-figure.md +56 -0
- package/docs/ja/doc/doc-key-findings.md +61 -0
- package/docs/ja/doc/doc-kpi-scorecard.md +80 -0
- package/docs/ja/doc/doc-notes-and-sources.md +43 -0
- package/docs/ja/doc/doc-report-header.md +46 -0
- package/docs/ja/page-types/slide.md +1 -0
- package/docs/ja/pages-and-metadata.md +15 -0
- package/docs/ja/parts/area-chart.md +49 -25
- package/docs/ja/parts/bar-chart.md +50 -24
- package/docs/ja/parts/bar-list.md +31 -8
- package/docs/ja/parts/chart-frame.md +36 -0
- package/docs/ja/parts/delta.md +5 -4
- package/docs/ja/parts/donut-chart.md +45 -26
- package/docs/ja/parts/funnel-steps.md +27 -8
- package/docs/ja/parts/heatmap.md +45 -0
- package/docs/ja/parts/kpi-card.md +2 -1
- package/docs/ja/parts/legend.md +36 -0
- package/docs/ja/parts/line-chart.md +48 -22
- package/docs/ja/parts/sample-data.md +61 -0
- package/docs/ja/parts/scatter-chart.md +69 -0
- package/docs/ja/parts/updated-at.md +27 -0
- package/docs/ja/parts/waterfall-chart.md +61 -0
- package/docs/ja/slide/slide-chart-full.md +48 -0
- package/docs/ja/slide/slide-chart-insight.md +52 -0
- package/docs/ja/slide/slide-columns.md +46 -0
- package/docs/ja/slide/slide-comparison.md +68 -0
- package/docs/ja/slide/slide-cover.md +39 -0
- package/docs/ja/slide/slide-exec-summary.md +47 -0
- package/docs/ja/slide/slide-heading.md +38 -0
- package/docs/ja/slide/slide-kpi-grid.md +55 -0
- package/docs/ja/slide/slide-kpi-hero.md +58 -0
- package/docs/ja/slide/slide-next-actions.md +49 -0
- package/docs/ja/slide/slide-scorecard-table.md +54 -0
- package/docs/ja/slide/slide-section.md +44 -0
- package/docs/ja/slide/slide-source-line.md +34 -0
- package/docs/ja/ui/empty.md +1 -1
- package/docs/ja/ui/error-state.md +4 -3
- package/docs/ja/ui/loading.md +1 -1
- package/docs/ja/ui/toggle-group.md +4 -3
- package/package.json +2 -2
- package/registry/ui/app-shell.tsx +138 -1
- package/registry/ui/area-chart.tsx +389 -104
- package/registry/ui/attention-list.tsx +177 -15
- package/registry/ui/bar-chart.tsx +386 -104
- package/registry/ui/bar-list.tsx +376 -94
- package/registry/ui/box-plot-chart.tsx +450 -116
- package/registry/ui/category-bar.tsx +292 -57
- package/registry/ui/chart-frame.tsx +301 -63
- package/registry/ui/data-table.tsx +229 -37
- package/registry/ui/date-range-picker.tsx +190 -36
- package/registry/ui/delta.tsx +181 -19
- package/registry/ui/donut-chart.tsx +385 -103
- package/registry/ui/echart.tsx +313 -58
- package/registry/ui/filter-bar.tsx +179 -20
- package/registry/ui/funnel-steps.tsx +354 -80
- package/registry/ui/heatmap.tsx +338 -71
- package/registry/ui/histogram-chart.tsx +440 -116
- package/registry/ui/kpi-card.tsx +254 -48
- package/registry/ui/legend.tsx +374 -93
- package/registry/ui/line-chart.tsx +389 -104
- package/registry/ui/multi-select.tsx +159 -9
- package/registry/ui/page-shell.tsx +148 -10
- package/registry/ui/paired-chart.tsx +394 -109
- package/registry/ui/progress-circle.tsx +205 -39
- package/registry/ui/sample-data.tsx +154 -10
- package/registry/ui/scatter-chart.tsx +436 -119
- package/registry/ui/searchable-select.tsx +145 -7
- package/registry/ui/slider.tsx +1 -1
- package/registry/ui/states.tsx +170 -13
- package/registry/ui/status-badge.tsx +201 -36
- package/registry/ui/tooltip.tsx +6 -2
- package/registry/ui/tracker.tsx +214 -42
- package/registry/ui/trend-indicator.tsx +151 -8
- package/registry/ui/updated-at.tsx +170 -16
- package/registry/ui/waterfall-chart.tsx +460 -121
- package/skills/vantage-design/SKILL.md +54 -54
- package/skills/vantage-design/references/extract.md +26 -27
- package/skills/vantage-design/references/format.md +12 -172
- package/skills/vantage-design/references/presets.md +50 -110
- package/skills/vantage-pitfalls/SKILL.md +5 -0
- package/skills/vantage-slide/SKILL.md +54 -36
- package/templates/AGENTS.md +32 -16
- package/templates/agents/doc.md +48 -0
- package/templates/agents/page.md +23 -2
- package/templates/agents/slide.md +77 -3
- package/theme.css +23 -19
- package/dist/chunk-2ICNHUG6.js.map +0 -1
- package/dist/chunk-2N72FKLB.js.map +0 -1
- package/dist/chunk-7DZGJQ3P.js.map +0 -1
- package/dist/chunk-CEXAQQ5W.js.map +0 -1
- package/dist/chunk-HKSVYMYJ.js.map +0 -1
- package/dist/chunk-JKLLFVEU.js.map +0 -1
- package/dist/chunk-PDOBHMUQ.js.map +0 -1
- package/dist/chunk-V3LF7BE7.js.map +0 -1
- package/dist/chunk-WAMZWZJB.js.map +0 -1
- package/dist/chunk-XPQSTZUU.js.map +0 -1
- package/dist/chunk-XQUSGIDG.js.map +0 -1
|
@@ -20,7 +20,8 @@ import { ChartFrame } from "@squadbase/vantage/components"
|
|
|
20
20
|
import { EChart } from "@squadbase/vantage/components"
|
|
21
21
|
import { EMPTY_VALUE } from "@squadbase/vantage/components"
|
|
22
22
|
import type { ChartCommonProps, ValueFormatter } from "@squadbase/vantage/components"
|
|
23
|
-
import {
|
|
23
|
+
import { useSyncExternalStore } from "react"
|
|
24
|
+
import { createContext, useCallback, useEffect, useRef, useState, type RefObject } from "react"
|
|
24
25
|
import type { ChartTone } from "@squadbase/vantage/components"
|
|
25
26
|
import { type ReactNode } from "react"
|
|
26
27
|
import { computeComparison, defaultChangeDisplay, formatChange, toneForValue, type ComparisonKind, type PeriodGrain } from "@squadbase/vantage/components"
|
|
@@ -29,6 +30,137 @@ import type { ChartThreshold, ChartAnnotation, ChartEventPayload, ChartForecast,
|
|
|
29
30
|
import { formatValue, toValueFormatter } from "@squadbase/vantage/components"
|
|
30
31
|
import type { ChartColor, ChartColors, ChartSeriesColor } from "@squadbase/vantage/components"
|
|
31
32
|
|
|
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
|
+
|
|
32
164
|
/* ---------- inlined from @squadbase/squadkit registry/lib/chart-theme.ts (internal to @squadbase/vantage) ---------- */
|
|
33
165
|
|
|
34
166
|
/**
|
|
@@ -63,10 +195,23 @@ interface ChartTokens {
|
|
|
63
195
|
border: string | null
|
|
64
196
|
/** Tooltip surface. */
|
|
65
197
|
popover: string | null
|
|
198
|
+
/** Semantic and surface dependencies used by typed chart options. Optional for hand-built tokens. */
|
|
199
|
+
colors?: Record<string, string>
|
|
200
|
+
/** Actual inherited painting surface (may change without a token changing). */
|
|
201
|
+
surface?: string | null
|
|
66
202
|
/** Tooltip text. */
|
|
67
203
|
popoverForeground: string | null
|
|
68
204
|
}
|
|
69
205
|
|
|
206
|
+
// Keep scope out of the public token shape. A resolved snapshot remembers where arbitrary
|
|
207
|
+
// var(--token) expressions must be read, without retaining unmounted charts globally.
|
|
208
|
+
const tokenScopes = new WeakMap<ChartTokens, Element>()
|
|
209
|
+
const OPTION_COLOR_TOKENS = [
|
|
210
|
+
"--background", "--card", "--muted", "--primary", "--destructive",
|
|
211
|
+
...["positive", "negative", "warning", "neutral", "info"].flatMap((tone) =>
|
|
212
|
+
["", "-fill", "-subtle"].map((suffix) => `--${tone}${suffix}`)),
|
|
213
|
+
]
|
|
214
|
+
|
|
70
215
|
let normalizeContext: CanvasRenderingContext2D | null | undefined
|
|
71
216
|
|
|
72
217
|
/** The forms `fillStyle` serializes legacy colours to, and zrender can parse. */
|
|
@@ -122,8 +267,10 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
|
|
|
122
267
|
const color = read(token)
|
|
123
268
|
if (color) series.push(color)
|
|
124
269
|
}
|
|
125
|
-
|
|
270
|
+
const tokens: ChartTokens = {
|
|
126
271
|
series,
|
|
272
|
+
colors: Object.fromEntries(OPTION_COLOR_TOKENS.map((name) => [name, styles.getPropertyValue(name).trim()])),
|
|
273
|
+
surface: readChartSurface(target),
|
|
127
274
|
...(styles.fontFamily ? { fontFamily: styles.fontFamily } : {}),
|
|
128
275
|
foreground: read("--foreground"),
|
|
129
276
|
mutedForeground: read("--muted-foreground"),
|
|
@@ -131,6 +278,8 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
|
|
|
131
278
|
popover: read("--popover"),
|
|
132
279
|
popoverForeground: read("--popover-foreground"),
|
|
133
280
|
}
|
|
281
|
+
tokenScopes.set(tokens, target)
|
|
282
|
+
return tokens
|
|
134
283
|
}
|
|
135
284
|
|
|
136
285
|
/** Structural equality, so a theme toggle that changes nothing doesn't re-init. */
|
|
@@ -138,6 +287,10 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
|
|
|
138
287
|
if (a === b) return true
|
|
139
288
|
if (!a || !b) return false
|
|
140
289
|
return (
|
|
290
|
+
tokenScopes.get(a) === tokenScopes.get(b) &&
|
|
291
|
+
a.surface === b.surface &&
|
|
292
|
+
Object.keys(a.colors ?? {}).length === Object.keys(b.colors ?? {}).length &&
|
|
293
|
+
Object.entries(a.colors ?? {}).every(([name, value]) => b.colors?.[name] === value) &&
|
|
141
294
|
a.series.length === b.series.length &&
|
|
142
295
|
a.series.every((color, i) => color === b.series[i]) &&
|
|
143
296
|
a.fontFamily === b.fontFamily &&
|
|
@@ -163,7 +316,7 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
|
|
|
163
316
|
* attributes alone leaves the chart painted in the previous palette until a
|
|
164
317
|
* reload — the tokens in the DOM are new, the canvas is not.
|
|
165
318
|
*/
|
|
166
|
-
function watchChartTokens(onChange: () => void): () => void {
|
|
319
|
+
function watchChartTokens(onChange: () => void, scope?: Element | null): () => void {
|
|
167
320
|
if (typeof document === "undefined") return () => {}
|
|
168
321
|
// Style edits arrive as bursts of mutations; coalesce them into one read, and
|
|
169
322
|
// take it after the next frame so a swapped stylesheet has been applied.
|
|
@@ -178,8 +331,11 @@ function watchChartTokens(onChange: () => void): () => void {
|
|
|
178
331
|
|
|
179
332
|
const themeObserver = new MutationObserver(schedule)
|
|
180
333
|
const attributeFilter = ["class", "style", "data-theme"]
|
|
181
|
-
|
|
182
|
-
|
|
334
|
+
// Only the chart and its ancestors can change its inherited theme. Never observe
|
|
335
|
+
// the document subtree: canvas/tooltip/rendering mutations would trigger every chart.
|
|
336
|
+
for (let node: Element | null = scope ?? document.body ?? document.documentElement; node; node = node.parentElement) {
|
|
337
|
+
themeObserver.observe(node, { attributes: true, attributeFilter })
|
|
338
|
+
}
|
|
183
339
|
|
|
184
340
|
const styleObserver = new MutationObserver(schedule)
|
|
185
341
|
if (document.head) {
|
|
@@ -267,22 +423,23 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
|
|
|
267
423
|
* components that build an ECharts option from token colours themselves (the
|
|
268
424
|
* typed charts), so their palette follows light/dark like `EChart`'s theme.
|
|
269
425
|
*/
|
|
270
|
-
function useChartTokens(): ChartTokens | null {
|
|
426
|
+
function useChartTokens(scope?: Element | null | RefObject<Element | null>): ChartTokens | null {
|
|
271
427
|
const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
|
|
272
428
|
// Compared here, not inside a `setTokens` updater: an updater that reads the DOM is
|
|
273
429
|
// replayed by React on every rebase and returns a fresh object each time, so the state
|
|
274
430
|
// never settles ("Maximum update depth exceeded" once the tokens change after mount).
|
|
275
431
|
const tokensRef = useRef(tokens)
|
|
276
432
|
useEffect(() => {
|
|
433
|
+
const target = scope && "current" in scope ? scope.current : scope
|
|
277
434
|
const sync = () => {
|
|
278
|
-
const next = readChartTokens()
|
|
435
|
+
const next = readChartTokens(target)
|
|
279
436
|
if (sameChartTokens(tokensRef.current, next)) return
|
|
280
437
|
tokensRef.current = next
|
|
281
438
|
setTokens(next)
|
|
282
439
|
}
|
|
283
440
|
sync()
|
|
284
|
-
return watchChartTokens(sync)
|
|
285
|
-
}, [])
|
|
441
|
+
return watchChartTokens(sync, target)
|
|
442
|
+
}, [scope])
|
|
286
443
|
return tokens
|
|
287
444
|
}
|
|
288
445
|
|
|
@@ -294,7 +451,7 @@ const EMBEDDED_VAR_RE = /var\(\s*(--[\w-]+)\s*\)/g
|
|
|
294
451
|
/**
|
|
295
452
|
* Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
|
|
296
453
|
* the resolved series tokens; `"positive"` / `"warning"` / `"negative"` / `"neutral"` (and the area
|
|
297
|
-
* variants `"positive-fill"`, …) are read
|
|
454
|
+
* variants `"positive-fill"`, …) are read in the snapshot's inherited scope (`--positive`, …); `var(--token)` uses the same scope; any
|
|
298
455
|
* other CSS colour is normalized like the tokens are. A colour that can't be
|
|
299
456
|
* resolved comes back as `""`, which ECharts treats as "use the palette".
|
|
300
457
|
*/
|
|
@@ -305,12 +462,13 @@ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | nul
|
|
|
305
462
|
// Meaning colours are the theme tokens of the same name: same path as `var(--positive)`.
|
|
306
463
|
if (TONE_NAME_RE.test(color.trim())) {
|
|
307
464
|
if (typeof document === "undefined") return ""
|
|
308
|
-
|
|
465
|
+
const target = (tokens && tokenScopes.get(tokens)) ?? document.documentElement
|
|
466
|
+
return toCanvasColor(getComputedStyle(target).getPropertyValue(`--${color.trim()}`)) ?? ""
|
|
309
467
|
}
|
|
310
468
|
if (typeof document === "undefined") return color
|
|
311
469
|
let value = color.trim()
|
|
312
470
|
const cssVar = CSS_VAR_RE.exec(value)
|
|
313
|
-
const styles = getComputedStyle(document.documentElement)
|
|
471
|
+
const styles = getComputedStyle((tokens && tokenScopes.get(tokens)) ?? document.documentElement)
|
|
314
472
|
if (cssVar) {
|
|
315
473
|
value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
|
|
316
474
|
} else if (value.includes("var(")) {
|
|
@@ -356,7 +514,7 @@ function readChartSurface(el: Element | null): string | null {
|
|
|
356
514
|
if (color) return color
|
|
357
515
|
}
|
|
358
516
|
}
|
|
359
|
-
return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue("--background"))
|
|
517
|
+
return toCanvasColor(getComputedStyle(el ?? document.documentElement).getPropertyValue("--background"))
|
|
360
518
|
}
|
|
361
519
|
|
|
362
520
|
/** Widths are bucketed so a resize only rebuilds the option when the label budget can change. */
|
|
@@ -370,9 +528,10 @@ const WIDTH_BUCKET = 40
|
|
|
370
528
|
*/
|
|
371
529
|
function useChartSurface<E extends Element>(
|
|
372
530
|
forwarded: ForwardedRef<E>,
|
|
373
|
-
tokens
|
|
374
|
-
): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined } {
|
|
531
|
+
tokens?: ChartTokens | null,
|
|
532
|
+
): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined; tokens: ChartTokens | null } {
|
|
375
533
|
const [node, setNode] = useState<E | null>(null)
|
|
534
|
+
const scopedTokens = useChartTokens(node)
|
|
376
535
|
const [surface, setSurface] = useState<string | undefined>(undefined)
|
|
377
536
|
const [width, setWidth] = useState<number | undefined>(undefined)
|
|
378
537
|
const ref = useCallback(
|
|
@@ -386,7 +545,7 @@ function useChartSurface<E extends Element>(
|
|
|
386
545
|
useEffect(() => {
|
|
387
546
|
if (!node) return
|
|
388
547
|
setSurface(readChartSurface(node) ?? undefined)
|
|
389
|
-
}, [node, tokens])
|
|
548
|
+
}, [node, tokens, scopedTokens])
|
|
390
549
|
useEffect(() => {
|
|
391
550
|
if (!node || typeof ResizeObserver === "undefined") return
|
|
392
551
|
const update = (w: number) => setWidth(Math.floor(w / WIDTH_BUCKET) * WIDTH_BUCKET)
|
|
@@ -395,11 +554,37 @@ function useChartSurface<E extends Element>(
|
|
|
395
554
|
observer.observe(node)
|
|
396
555
|
return () => observer.disconnect()
|
|
397
556
|
}, [node])
|
|
398
|
-
return { ref, surface, width }
|
|
557
|
+
return { ref, surface, width, tokens: scopedTokens }
|
|
399
558
|
}
|
|
400
559
|
|
|
401
560
|
/* ---------- inlined from @squadbase/squadkit registry/lib/chart-options.ts (internal to @squadbase/vantage) ---------- */
|
|
402
561
|
|
|
562
|
+
/** The built-in words of the option builders, by language. */
|
|
563
|
+
const CHART_TEXT = {
|
|
564
|
+
ja: {
|
|
565
|
+
target: "目標",
|
|
566
|
+
forecast: "予測",
|
|
567
|
+
total: "合計",
|
|
568
|
+
provisional: "速報",
|
|
569
|
+
noComparison: "比較なし",
|
|
570
|
+
share: (share: string) => `(${share})`,
|
|
571
|
+
targetRate: (rate: string) => `(達成率 ${rate})`,
|
|
572
|
+
provisionalTitle: (title: string, label: string) => `${title}(${label})`,
|
|
573
|
+
},
|
|
574
|
+
en: {
|
|
575
|
+
target: "Target",
|
|
576
|
+
forecast: "Forecast",
|
|
577
|
+
total: "Total",
|
|
578
|
+
provisional: "Preliminary",
|
|
579
|
+
noComparison: "No comparison",
|
|
580
|
+
share: (share: string) => ` (${share})`,
|
|
581
|
+
targetRate: (rate: string) => ` (${rate} achieved)`,
|
|
582
|
+
provisionalTitle: (title: string, label: string) => `${title} (${label})`,
|
|
583
|
+
},
|
|
584
|
+
} satisfies Record<UiLanguage, unknown>
|
|
585
|
+
|
|
586
|
+
const chartText = (locale?: string) => CHART_TEXT[uiLanguage(resolveLocale(locale))]
|
|
587
|
+
|
|
403
588
|
type CartesianChartKind = "area" | "bar" | "line"
|
|
404
589
|
type CartesianStackType = "default" | "stacked" | "percent"
|
|
405
590
|
type CartesianCurveType = "linear" | "smooth" | "step"
|
|
@@ -584,7 +769,7 @@ interface CartesianChartConfig {
|
|
|
584
769
|
* all ticks read in one unit (¥0.5億 / ¥1億). Default: `axisFormatter` for every tick.
|
|
585
770
|
*/
|
|
586
771
|
axisTicks?: (top: number) => (value: number) => string
|
|
587
|
-
/** Locale of the time-axis labels. Default
|
|
772
|
+
/** Locale of the time-axis labels and the built-in words (total, target, forecast). Default the page's (`<html lang>`). */
|
|
588
773
|
locale?: string
|
|
589
774
|
type?: CartesianStackType
|
|
590
775
|
/** Bar only. `"vertical"` lays the categories down the y axis (horizontal bars). */
|
|
@@ -822,11 +1007,12 @@ function timeAxisLabelStep(values: readonly unknown[], maxLabels: number = TIME_
|
|
|
822
1007
|
*/
|
|
823
1008
|
function timeAxisLabels(
|
|
824
1009
|
values: readonly unknown[],
|
|
825
|
-
|
|
1010
|
+
localeArg?: string,
|
|
826
1011
|
shown: (i: number) => boolean = () => true,
|
|
827
1012
|
): string[] | null {
|
|
828
1013
|
const parsed = parseTimeIndex(values)
|
|
829
1014
|
if (!parsed) return null
|
|
1015
|
+
const locale = resolveLocale(localeArg)
|
|
830
1016
|
const { granularity, times } = parsed
|
|
831
1017
|
let prevYear: number | null = null
|
|
832
1018
|
return times.map((t, i) => {
|
|
@@ -849,9 +1035,10 @@ function timeAxisLabels(
|
|
|
849
1035
|
}
|
|
850
1036
|
|
|
851
1037
|
/** The tooltip titles for time-like x values (`2025年9月`, `2025年9月1日`), or `null` when they are not dates. */
|
|
852
|
-
function timeTooltipLabels(values: readonly unknown[],
|
|
1038
|
+
function timeTooltipLabels(values: readonly unknown[], localeArg?: string): string[] | null {
|
|
853
1039
|
const parsed = parseTimeIndex(values)
|
|
854
1040
|
if (!parsed) return null
|
|
1041
|
+
const locale = resolveLocale(localeArg)
|
|
855
1042
|
const options: Intl.DateTimeFormatOptions =
|
|
856
1043
|
parsed.granularity === "year"
|
|
857
1044
|
? { year: "numeric" }
|
|
@@ -991,7 +1178,9 @@ function tooltipHtml(
|
|
|
991
1178
|
rows: readonly TooltipRow[],
|
|
992
1179
|
valueFormatter: (value: number) => string,
|
|
993
1180
|
notes: readonly string[] = [],
|
|
1181
|
+
locale?: string,
|
|
994
1182
|
): string {
|
|
1183
|
+
const t = chartText(locale)
|
|
995
1184
|
const lines = rows.map(
|
|
996
1185
|
(row) =>
|
|
997
1186
|
`<div style="display:flex;align-items:center;gap:8px;line-height:20px">` +
|
|
@@ -1000,7 +1189,7 @@ function tooltipHtml(
|
|
|
1000
1189
|
`<span style="font-weight:600;font-variant-numeric:tabular-nums;margin-left:12px">${
|
|
1001
1190
|
row.value === null ? EMPTY_VALUE : escapeHtml(valueFormatter(row.value))
|
|
1002
1191
|
}${
|
|
1003
|
-
row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px"
|
|
1192
|
+
row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px">${escapeHtml(t.share(formatShare(row.share)))}</span>`
|
|
1004
1193
|
}</span></div>` +
|
|
1005
1194
|
(row.note === undefined
|
|
1006
1195
|
? ""
|
|
@@ -1014,8 +1203,10 @@ function tooltipHtml(
|
|
|
1014
1203
|
|
|
1015
1204
|
/* ------------------------------------------------- per-bar targets / notes */
|
|
1016
1205
|
|
|
1017
|
-
/** Default name of the per-bar target marker (legend, tooltip). */
|
|
1018
|
-
|
|
1206
|
+
/** Default name of the per-bar target marker (legend, tooltip) in `locale`: "Target" / 「目標」. Default the page's locale. */
|
|
1207
|
+
function barTargetLabel(locale?: string): string {
|
|
1208
|
+
return chartText(locale).target
|
|
1209
|
+
}
|
|
1019
1210
|
|
|
1020
1211
|
/** How far (px) the target marker reaches past each side of its bar. */
|
|
1021
1212
|
const TARGET_MARKER_OVERHANG = 4
|
|
@@ -1066,16 +1257,17 @@ function achievementRate(value: number | null | undefined, target: number | null
|
|
|
1066
1257
|
return (value / target) * 100
|
|
1067
1258
|
}
|
|
1068
1259
|
|
|
1069
|
-
/** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3
|
|
1260
|
+
/** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3%)」 / "Target $12M (85.3% achieved)". `null` without a target. */
|
|
1070
1261
|
function barTargetNote(
|
|
1071
1262
|
label: string,
|
|
1072
1263
|
value: number | null,
|
|
1073
1264
|
target: number | null,
|
|
1074
1265
|
valueFormatter: (value: number) => string,
|
|
1266
|
+
locale?: string,
|
|
1075
1267
|
): string | null {
|
|
1076
1268
|
if (target === null) return null
|
|
1077
1269
|
const rate = achievementRate(value, target)
|
|
1078
|
-
return `${label} ${valueFormatter(target)}${rate === null ? "" :
|
|
1270
|
+
return `${label} ${valueFormatter(target)}${rate === null ? "" : chartText(locale).targetRate(formatShare(rate))}`
|
|
1079
1271
|
}
|
|
1080
1272
|
|
|
1081
1273
|
/** Half the width of a bar in category-axis units, from `barCategoryGap` (`"20%"`; a px gap or none → ECharts' 20%). */
|
|
@@ -1167,11 +1359,12 @@ const TARGET_LEGEND_SERIES_ID = "__target-legend"
|
|
|
1167
1359
|
/** A flat tick (8×8 legend box, aspect kept): the per-bar target's legend icon. */
|
|
1168
1360
|
const TARGET_LEGEND_ICON = "path://M0,0 L100,0 L100,24 L0,24 Z"
|
|
1169
1361
|
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1362
|
+
/**
|
|
1363
|
+
* The label at the top of a forecast's boundary line: the forecast's name (as in the legend) and an arrow toward the forecast side.
|
|
1364
|
+
* Without a `label`, "Forecast" / 「予測」 in `locale` (default the page's).
|
|
1365
|
+
*/
|
|
1366
|
+
function forecastBoundaryText(label?: string, locale?: string): string {
|
|
1367
|
+
return `${label ?? chartText(locale).forecast} →`
|
|
1175
1368
|
}
|
|
1176
1369
|
|
|
1177
1370
|
/** A comparison series: a thin, muted, solid line with no markers (60% of `--muted-foreground`). */
|
|
@@ -1211,8 +1404,10 @@ interface AxisExtent {
|
|
|
1211
1404
|
max: number
|
|
1212
1405
|
}
|
|
1213
1406
|
|
|
1214
|
-
/** The label of a stack's total (highlight and tooltip). */
|
|
1215
|
-
|
|
1407
|
+
/** The label of a stack's total (highlight and tooltip) in `locale`: "Total" / 「合計」. Default the page's locale. */
|
|
1408
|
+
function stackTotalLabel(locale?: string): string {
|
|
1409
|
+
return chartText(locale).total
|
|
1410
|
+
}
|
|
1216
1411
|
|
|
1217
1412
|
/** Per point, the sum of the stacked series; `null` where every series is missing. */
|
|
1218
1413
|
function stackTotalsOf(values: readonly (readonly (number | null)[])[]): (number | null)[] {
|
|
@@ -1334,8 +1529,8 @@ function indexGrain(values: readonly unknown[]): PeriodGrain | null {
|
|
|
1334
1529
|
|
|
1335
1530
|
/**
|
|
1336
1531
|
* The tooltip line under the main series' row when a comparison series is drawn: the arrow, the signed change and the
|
|
1337
|
-
* comparison's name, as `Delta` writes it ("↑ +12.4% 前年同月比"; a minus is U+2212).
|
|
1338
|
-
* either value is missing or the baseline is 0.
|
|
1532
|
+
* comparison's name, as `Delta` writes it ("↑ +12.4% vs. same month last year" / "↑ +12.4% 前年同月比"; a minus is U+2212).
|
|
1533
|
+
* "No comparison vs. same month last year" / "比較なし 前年同月比" when either value is missing or the baseline is 0.
|
|
1339
1534
|
*/
|
|
1340
1535
|
function comparisonNote(
|
|
1341
1536
|
current: number | null,
|
|
@@ -1343,16 +1538,18 @@ function comparisonNote(
|
|
|
1343
1538
|
comparison: Pick<CartesianComparisonConfig, "vs" | "label">,
|
|
1344
1539
|
grain?: PeriodGrain,
|
|
1345
1540
|
format?: ValueFormat,
|
|
1346
|
-
|
|
1541
|
+
localeArg?: string,
|
|
1347
1542
|
): string {
|
|
1543
|
+
const locale = resolveLocale(localeArg)
|
|
1348
1544
|
const result = computeComparison(
|
|
1349
1545
|
current,
|
|
1350
1546
|
comparison.vs === "custom"
|
|
1351
1547
|
? { previous, vs: "custom", label: comparison.label ?? "", grain }
|
|
1352
1548
|
: { previous, vs: comparison.vs, label: comparison.label, grain },
|
|
1549
|
+
{ locale },
|
|
1353
1550
|
)
|
|
1354
1551
|
const text = formatChange(result, defaultChangeDisplay(format), locale, format)
|
|
1355
|
-
if (text === null) return
|
|
1552
|
+
if (text === null) return `${chartText(locale).noComparison} ${result.label}`
|
|
1356
1553
|
const arrow = result.direction === "up" ? "↑" : result.direction === "down" ? "↓" : "→"
|
|
1357
1554
|
return `${arrow} ${text.replace(/^-/, "−")} ${result.label}`
|
|
1358
1555
|
}
|
|
@@ -1395,7 +1592,6 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1395
1592
|
toneColors,
|
|
1396
1593
|
valueFormatter,
|
|
1397
1594
|
axisFormatter = valueFormatter,
|
|
1398
|
-
locale = DEFAULT_LOCALE,
|
|
1399
1595
|
type = "default",
|
|
1400
1596
|
layout = "horizontal",
|
|
1401
1597
|
curveType = "linear",
|
|
@@ -1432,7 +1628,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1432
1628
|
chartOptions,
|
|
1433
1629
|
reserveActions = false,
|
|
1434
1630
|
} = cfg
|
|
1435
|
-
const
|
|
1631
|
+
const locale = resolveLocale(cfg.locale)
|
|
1632
|
+
const text = chartText(locale)
|
|
1633
|
+
const provisionalLabel = cfg.provisionalLabel ?? text.provisional
|
|
1436
1634
|
|
|
1437
1635
|
const stacked = kind !== "line" && type !== "default"
|
|
1438
1636
|
const percent = kind !== "line" && type === "percent"
|
|
@@ -1581,9 +1779,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1581
1779
|
// `barOptions.targetKey` (a target per bar) takes over from the single `target` line.
|
|
1582
1780
|
const perBarTarget = kind === "bar" && Boolean(targetKey)
|
|
1583
1781
|
const hasTarget = !perBarTarget && target !== undefined && Number.isFinite(target)
|
|
1584
|
-
const targetText = hasTarget ? (targetLabel ??
|
|
1782
|
+
const targetText = hasTarget ? (targetLabel ?? `${text.target} ${axisFormatter(target)}`) : ""
|
|
1585
1783
|
const highlightText = (value: number) =>
|
|
1586
|
-
shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${
|
|
1784
|
+
shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${text.total} ` : ""}${axisFormatter(value)}`
|
|
1587
1785
|
/** Whether `p`'s label sits within the target's value band and under the target label (at the right end). */
|
|
1588
1786
|
const nearTargetLabel = (p: HighlightPoint, side: "under" | "over") => {
|
|
1589
1787
|
if (!hasTarget || !plotWidth || kind === "bar" || labels.length < 2) return false
|
|
@@ -1609,7 +1807,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1609
1807
|
// One series (or a stack, against its total) can be compared to a target; grouped series and 100% stacks cannot.
|
|
1610
1808
|
const barTargets: (number | null)[] | null =
|
|
1611
1809
|
perBarTarget && targetKey && !percent && (stacked || categories.length === 1) ? barTargetValues(data, targetKey) : null
|
|
1612
|
-
const barTargetName = targetLabel ??
|
|
1810
|
+
const barTargetName = targetLabel ?? text.target
|
|
1613
1811
|
const barTargetColor = labelColor ?? guideColor
|
|
1614
1812
|
|
|
1615
1813
|
const markPointFor = (c: number, color: string | undefined) =>
|
|
@@ -1823,7 +2021,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1823
2021
|
}
|
|
1824
2022
|
|
|
1825
2023
|
/* -- forecast: dashed continuation (+ optional band) of one series */
|
|
1826
|
-
const forecastLabel = forecast?.label ??
|
|
2024
|
+
const forecastLabel = forecast?.label ?? text.forecast
|
|
1827
2025
|
const forecastBandAlpha = isDarkSurface(labelBackground) ? FORECAST_BAND_ALPHA_DARK : FORECAST_BAND_ALPHA_LIGHT
|
|
1828
2026
|
const forecastValues: (number | null)[] = []
|
|
1829
2027
|
if (forecast) {
|
|
@@ -2299,7 +2497,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
2299
2497
|
})
|
|
2300
2498
|
}
|
|
2301
2499
|
if (stackTotals && i <= lastActual && rows.length > 1) {
|
|
2302
|
-
rows.push({ name:
|
|
2500
|
+
rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
|
|
2303
2501
|
}
|
|
2304
2502
|
const provisional = provStart !== -1 && i >= provStart && i <= lastActual
|
|
2305
2503
|
if (provisional) {
|
|
@@ -2315,19 +2513,19 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
2315
2513
|
...noteFor(at),
|
|
2316
2514
|
})
|
|
2317
2515
|
})
|
|
2318
|
-
if (stackTotals && rows.length > 1) rows.push({ name:
|
|
2516
|
+
if (stackTotals && rows.length > 1) rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
|
|
2319
2517
|
}
|
|
2320
2518
|
const title = tooltipTitles[i] ?? labels[i] ?? ""
|
|
2321
2519
|
const lines: string[] = []
|
|
2322
2520
|
if (barTargets && i <= lastActual) {
|
|
2323
2521
|
const total = stackTotals ? (stackTotals[i] ?? null) : (values[0]?.[i] ?? null)
|
|
2324
|
-
const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter)
|
|
2522
|
+
const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter, locale)
|
|
2325
2523
|
if (note) lines.push(note)
|
|
2326
2524
|
}
|
|
2327
2525
|
for (const note of notes) {
|
|
2328
2526
|
if (note.to === undefined ? note.from === i : note.from <= i && i <= note.to) lines.push(note.label)
|
|
2329
2527
|
}
|
|
2330
|
-
return tooltipHtml(provisional ?
|
|
2528
|
+
return tooltipHtml(provisional ? text.provisionalTitle(title, provisionalLabel) : title, rows, valueFormatter, lines, locale)
|
|
2331
2529
|
},
|
|
2332
2530
|
},
|
|
2333
2531
|
xAxis: horizontalBars ? valueAxis : categoryAxis,
|
|
@@ -2419,8 +2617,6 @@ interface DonutOptionSlice {
|
|
|
2419
2617
|
/** How many of the largest slices get an outside label (「自然検索 41%」) by default. */
|
|
2420
2618
|
const DONUT_OUTSIDE_LABELS = 2
|
|
2421
2619
|
|
|
2422
|
-
const DEFAULT_TOTAL_LABEL = "合計"
|
|
2423
|
-
|
|
2424
2620
|
const sliceValue = (value: number) => (Number.isFinite(value) && value > 0 ? value : 0)
|
|
2425
2621
|
|
|
2426
2622
|
/** A slice's share of `total` as text: whole percent, `<1%` for a sliver, `0%` for nothing. */
|
|
@@ -2453,15 +2649,16 @@ function donutLegendItems(slices: readonly DonutOptionSlice[]): DonutLegendItem[
|
|
|
2453
2649
|
}
|
|
2454
2650
|
|
|
2455
2651
|
/**
|
|
2456
|
-
* The default center label: what is totalled (「合計」), the formatted total
|
|
2652
|
+
* The default center label: what is totalled (「合計」 / "Total" in `locale`, default the page's), the formatted total
|
|
2457
2653
|
* (`amount`, the big line), the unit (a small line under it) and both as one
|
|
2458
2654
|
* string (`value`, 「9.2万 セッション」).
|
|
2459
2655
|
*/
|
|
2460
2656
|
function donutCenterText(
|
|
2461
2657
|
total: number,
|
|
2462
2658
|
valueFormatter: (value: number) => string,
|
|
2463
|
-
{ totalLabel
|
|
2659
|
+
{ totalLabel, unit, locale }: { totalLabel?: string; unit?: string; locale?: string } = {},
|
|
2464
2660
|
): { label: string; value: string; amount: string; unit?: string } {
|
|
2661
|
+
totalLabel ??= chartText(locale).total
|
|
2465
2662
|
const amount = valueFormatter(total)
|
|
2466
2663
|
return { label: totalLabel, value: unit ? `${amount} ${unit}` : amount, amount, ...(unit ? { unit } : {}) }
|
|
2467
2664
|
}
|
|
@@ -2689,8 +2886,10 @@ function hasLabel(labels: Readonly<Record<string, string>> | undefined, key: str
|
|
|
2689
2886
|
|
|
2690
2887
|
/* ------------------------------------------------------------ provisional */
|
|
2691
2888
|
|
|
2692
|
-
/** The default label for points that are not final yet. */
|
|
2693
|
-
|
|
2889
|
+
/** The default label for points that are not final yet, in `locale`: "Preliminary" / 「速報」. Default the page's locale. */
|
|
2890
|
+
function defaultProvisionalLabel(locale?: string): string {
|
|
2891
|
+
return chartText(locale).provisional
|
|
2892
|
+
}
|
|
2694
2893
|
|
|
2695
2894
|
/**
|
|
2696
2895
|
* Position of the first provisional point: the first entry of `indexValues`
|
|
@@ -2828,9 +3027,10 @@ function resolveValueFormatter(
|
|
|
2828
3027
|
function resolveAxisFormatter(
|
|
2829
3028
|
valueFormatter: ValueFormatter | undefined,
|
|
2830
3029
|
format: ValueFormat | undefined,
|
|
2831
|
-
|
|
3030
|
+
localeArg?: string,
|
|
2832
3031
|
): ValueFormatter {
|
|
2833
3032
|
if (valueFormatter) return valueFormatter
|
|
3033
|
+
const locale = resolveLocale(localeArg)
|
|
2834
3034
|
const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
|
|
2835
3035
|
if (spec.type !== "compact" && spec.type !== "number" && spec.type !== "currency") return toValueFormatter(format, locale)
|
|
2836
3036
|
const currency = "currency" in spec ? spec.currency : undefined
|
|
@@ -2839,40 +3039,75 @@ function resolveAxisFormatter(
|
|
|
2839
3039
|
return (value) => formatValue(value, short, locale)
|
|
2840
3040
|
}
|
|
2841
3041
|
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
]
|
|
3042
|
+
interface CompactUnit {
|
|
3043
|
+
token: string
|
|
3044
|
+
size: number
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
const compactUnitCache = new Map<string, CompactUnit[] | null>()
|
|
3048
|
+
|
|
3049
|
+
/**
|
|
3050
|
+
* The compact-notation unit words of a locale and their sizes, largest first: `兆` / `億` / `万` in ja,
|
|
3051
|
+
* `T` / `B` / `M` / `K` in en. `null` when the locale's compact form puts anything between the number and its
|
|
3052
|
+
* word (`1,5 Mio.`), where a token cannot be swapped in place.
|
|
3053
|
+
*/
|
|
3054
|
+
function compactUnits(locale: string): CompactUnit[] | null {
|
|
3055
|
+
const cached = compactUnitCache.get(locale)
|
|
3056
|
+
if (cached !== undefined) return cached
|
|
3057
|
+
let units: CompactUnit[] | null = []
|
|
3058
|
+
try {
|
|
3059
|
+
const compact = new Intl.NumberFormat(locale, { notation: "compact" })
|
|
3060
|
+
const seen = new Set<string>()
|
|
3061
|
+
for (let exp = 3; exp <= 15; exp++) {
|
|
3062
|
+
const parts = compact.formatToParts(10 ** exp)
|
|
3063
|
+
const at = parts.findIndex((p) => p.type === "compact")
|
|
3064
|
+
if (at === -1) continue
|
|
3065
|
+
if (parts[at - 1]?.type !== "integer" || at !== parts.length - 1) {
|
|
3066
|
+
units = null
|
|
3067
|
+
break
|
|
3068
|
+
}
|
|
3069
|
+
const token = parts[at]!.value
|
|
3070
|
+
if (seen.has(token)) continue
|
|
3071
|
+
seen.add(token)
|
|
3072
|
+
units.push({ token, size: 10 ** exp })
|
|
3073
|
+
}
|
|
3074
|
+
} catch {
|
|
3075
|
+
units = null
|
|
3076
|
+
}
|
|
3077
|
+
const sorted = units && units.length > 0 ? units.sort((a, b) => b.size - a.size) : null
|
|
3078
|
+
compactUnitCache.set(locale, sorted)
|
|
3079
|
+
return sorted
|
|
3080
|
+
}
|
|
2848
3081
|
|
|
2849
|
-
const
|
|
3082
|
+
const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
2850
3083
|
|
|
2851
3084
|
/**
|
|
2852
3085
|
* The value-axis tick formatter for an axis whose largest tick is `top`: every tick reads in the unit of
|
|
2853
|
-
* that largest tick, so
|
|
2854
|
-
* Only the
|
|
2855
|
-
* any other format —
|
|
3086
|
+
* that largest tick, so units never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`;
|
|
3087
|
+
* `$0.5M` / `$1M` / `$1.5M`, not `$500K` / `$1M`). Only the short forms (`compact` / `number` / `currency`,
|
|
3088
|
+
* no `valueFormatter`) have units to align; any other format — an axis whose largest tick is under the smallest
|
|
3089
|
+
* unit (1万 / 1K), or a locale whose compact form cannot be aligned in place — keeps `resolveAxisFormatter`'s output as it is.
|
|
2856
3090
|
*/
|
|
2857
3091
|
function resolveAxisTickFormatter(
|
|
2858
3092
|
valueFormatter: ValueFormatter | undefined,
|
|
2859
3093
|
format: ValueFormat | undefined,
|
|
2860
|
-
|
|
3094
|
+
localeArg?: string,
|
|
2861
3095
|
): (top: number) => ValueFormatter {
|
|
3096
|
+
const locale = resolveLocale(localeArg)
|
|
2862
3097
|
const base = resolveAxisFormatter(valueFormatter, format, locale)
|
|
2863
3098
|
const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
|
|
2864
|
-
const
|
|
2865
|
-
|
|
2866
|
-
if (!aligns) return () => base
|
|
3099
|
+
const units = !valueFormatter && (spec.type === "compact" || spec.type === "number" || spec.type === "currency") ? compactUnits(locale) : null
|
|
3100
|
+
if (!units) return () => base
|
|
2867
3101
|
const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 3 })
|
|
3102
|
+
const numberToken = new RegExp(`\\d[\\d,]*(?:\\.\\d+)?(?:${units.map((u) => escapeRegExp(u.token)).join("|")})?`)
|
|
2868
3103
|
return (top) => {
|
|
2869
|
-
const unit =
|
|
3104
|
+
const unit = units.find((u) => Math.abs(top) >= u.size)
|
|
2870
3105
|
if (!unit) return base
|
|
2871
3106
|
return (value) => {
|
|
2872
3107
|
const text = base(value)
|
|
2873
3108
|
if (value === 0 || !Number.isFinite(value)) return text
|
|
2874
|
-
const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.
|
|
2875
|
-
return text.replace(
|
|
3109
|
+
const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.token}`
|
|
3110
|
+
return text.replace(numberToken, () => scaled)
|
|
2876
3111
|
}
|
|
2877
3112
|
}
|
|
2878
3113
|
}
|
|
@@ -2901,8 +3136,20 @@ function clamp(value: number, min: number, max: number): number {
|
|
|
2901
3136
|
/** How many categories a chart colours individually; the rest fold into「その他」. */
|
|
2902
3137
|
const MAX_CHART_SERIES = 5
|
|
2903
3138
|
|
|
2904
|
-
/** The series name the folded categories get. */
|
|
2905
|
-
const
|
|
3139
|
+
/** The series name the folded categories get, by language. */
|
|
3140
|
+
const OTHER_SERIES_LABELS: Record<UiLanguage, string> = { ja: "その他", en: "Other" }
|
|
3141
|
+
|
|
3142
|
+
/** The series name the folded categories get in `locale` ("Other" / "その他"). Default the page's locale (`<html lang>`). */
|
|
3143
|
+
function otherSeriesLabel(locale?: string): string {
|
|
3144
|
+
return OTHER_SERIES_LABELS[uiLanguage(resolveLocale(locale))]
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
const OTHER_NAMES = new Set(Object.values(OTHER_SERIES_LABELS))
|
|
3148
|
+
|
|
3149
|
+
/** Whether a category / row is the "other" bucket by name (「その他」 or "Other", whatever the page language). */
|
|
3150
|
+
function isOtherName(name: string | null | undefined): boolean {
|
|
3151
|
+
return typeof name === "string" && OTHER_NAMES.has(name)
|
|
3152
|
+
}
|
|
2906
3153
|
|
|
2907
3154
|
interface AggregatedItems<Item extends { name: string; value: number }> {
|
|
2908
3155
|
/** The `n` largest items, in their given order. All items when there are `n` or fewer. */
|
|
@@ -2922,7 +3169,7 @@ interface AggregatedItems<Item extends { name: string; value: number }> {
|
|
|
2922
3169
|
function aggregateTopN<Item extends { name: string; value: number }>(
|
|
2923
3170
|
items: readonly Item[],
|
|
2924
3171
|
n: number = MAX_CHART_SERIES,
|
|
2925
|
-
otherLabel: string =
|
|
3172
|
+
otherLabel: string = otherSeriesLabel(),
|
|
2926
3173
|
): AggregatedItems<Item> {
|
|
2927
3174
|
if (items.length <= n) return { top: [...items], rest: [], other: null }
|
|
2928
3175
|
const ranked = items
|
|
@@ -2951,7 +3198,7 @@ function aggregateSeries(
|
|
|
2951
3198
|
data: readonly Record<string, unknown>[],
|
|
2952
3199
|
categories: readonly string[],
|
|
2953
3200
|
n: number = MAX_CHART_SERIES,
|
|
2954
|
-
otherLabel: string =
|
|
3201
|
+
otherLabel: string = otherSeriesLabel(),
|
|
2955
3202
|
): AggregatedSeries {
|
|
2956
3203
|
const totals = categories.map((name) => ({
|
|
2957
3204
|
name,
|
|
@@ -2983,12 +3230,14 @@ function aggregateSeriesKeeping(
|
|
|
2983
3230
|
categories: readonly string[],
|
|
2984
3231
|
keep: string | undefined,
|
|
2985
3232
|
n: number = MAX_CHART_SERIES,
|
|
3233
|
+
otherLabel: string = otherSeriesLabel(),
|
|
2986
3234
|
): AggregatedSeries {
|
|
2987
|
-
if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n)
|
|
3235
|
+
if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n, otherLabel)
|
|
2988
3236
|
const aggregated = aggregateSeries(
|
|
2989
3237
|
data,
|
|
2990
3238
|
categories.filter((c) => c !== keep),
|
|
2991
3239
|
n,
|
|
3240
|
+
otherLabel,
|
|
2992
3241
|
)
|
|
2993
3242
|
return { ...aggregated, categories: [...aggregated.categories, keep] }
|
|
2994
3243
|
}
|
|
@@ -3024,7 +3273,7 @@ function seriesColorsWithComparison(
|
|
|
3024
3273
|
* Colour per drawn series: the record's token for that category, else the
|
|
3025
3274
|
* array's token at the category's position in `allCategories` (cycled), else
|
|
3026
3275
|
* the token palette by drawn position. `otherCategory` — and a category the
|
|
3027
|
-
* data itself names
|
|
3276
|
+
* data itself names「その他」/ "Other" (`OTHER_SERIES_LABELS`) — always gets `otherColor`.
|
|
3028
3277
|
*/
|
|
3029
3278
|
function seriesColorsFor(
|
|
3030
3279
|
categories: readonly string[],
|
|
@@ -3036,7 +3285,7 @@ function seriesColorsFor(
|
|
|
3036
3285
|
const list = Array.isArray(colors) ? (colors as ChartSeriesColor[]) : undefined
|
|
3037
3286
|
const record = !Array.isArray(colors) && colors ? (colors as Partial<Record<string, ChartSeriesColor>>) : undefined
|
|
3038
3287
|
return categories.map((category, i) => {
|
|
3039
|
-
if ((otherCategory !== null && category === otherCategory) || category
|
|
3288
|
+
if ((otherCategory !== null && category === otherCategory) || isOtherName(category)) return otherColor
|
|
3040
3289
|
const fromRecord = record?.[category]
|
|
3041
3290
|
if (fromRecord) return fromRecord
|
|
3042
3291
|
if (list && list.length > 0) {
|
|
@@ -3083,7 +3332,7 @@ interface DonutSlicesConfig {
|
|
|
3083
3332
|
*/
|
|
3084
3333
|
function donutSlices(
|
|
3085
3334
|
data: readonly Record<string, unknown>[],
|
|
3086
|
-
{ index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel =
|
|
3335
|
+
{ index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = otherSeriesLabel() }: DonutSlicesConfig,
|
|
3087
3336
|
): DonutSlice[] {
|
|
3088
3337
|
const items = data
|
|
3089
3338
|
.map((row, i) => {
|
|
@@ -3096,7 +3345,8 @@ function donutSlices(
|
|
|
3096
3345
|
const slices: DonutSlice[] = top.map((item, rank) => ({
|
|
3097
3346
|
name: item.name,
|
|
3098
3347
|
value: item.value,
|
|
3099
|
-
|
|
3348
|
+
// A row the data itself names「その他」/ "Other" is the same remainder: grey, like the folded slice.
|
|
3349
|
+
color: isOtherName(item.name) ? otherColor : palette[rank % palette.length]!,
|
|
3100
3350
|
isOther: false,
|
|
3101
3351
|
row: item.row,
|
|
3102
3352
|
}))
|
|
@@ -3169,7 +3419,7 @@ interface BarListRowsConfig extends BarListOptions {
|
|
|
3169
3419
|
*/
|
|
3170
3420
|
function barListRows<Row extends object>(
|
|
3171
3421
|
data: readonly Row[],
|
|
3172
|
-
{ index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel =
|
|
3422
|
+
{ index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = otherSeriesLabel(), exclude }: BarListRowsConfig = {},
|
|
3173
3423
|
): BarListRow<Row>[] {
|
|
3174
3424
|
const items = data.map((row): BarListRow<Row> => {
|
|
3175
3425
|
const cells = row as Record<string, unknown>
|
|
@@ -3215,19 +3465,21 @@ const BAR_LIST_OTHER_TINT = 30
|
|
|
3215
3465
|
|
|
3216
3466
|
/**
|
|
3217
3467
|
* The fill of each BarList row: all real rows in the same tint of `color`
|
|
3218
|
-
* (`BAR_LIST_TINT`), the folded「その他」row in a tint of `otherColor`
|
|
3468
|
+
* (`BAR_LIST_TINT`), the folded「その他」row (and a row the data names「その他」/ "Other") in a tint of `otherColor`
|
|
3219
3469
|
* (`BAR_LIST_OTHER_TINT`) — the palest fill, a remainder never outweighs a
|
|
3220
3470
|
* ranked row. A legacy per-row `color` (or a threshold tone) replaces `color`
|
|
3221
3471
|
* for that row. A tint, not the solid token, so the label printed over the bar
|
|
3222
3472
|
* keeps its contrast.
|
|
3223
3473
|
*/
|
|
3224
3474
|
function barListFills(
|
|
3225
|
-
rows: readonly Pick<BarListRow, "isOther" | "color">[],
|
|
3475
|
+
rows: readonly (Pick<BarListRow, "isOther" | "color"> & { name?: string })[],
|
|
3226
3476
|
color: ChartColor,
|
|
3227
3477
|
otherColor: string,
|
|
3228
3478
|
): string[] {
|
|
3229
3479
|
return rows.map((row) =>
|
|
3230
|
-
row.isOther
|
|
3480
|
+
row.isOther || isOtherName(row.name)
|
|
3481
|
+
? tintCss(otherColor, BAR_LIST_OTHER_TINT)
|
|
3482
|
+
: tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
|
|
3231
3483
|
)
|
|
3232
3484
|
}
|
|
3233
3485
|
|
|
@@ -3443,11 +3695,30 @@ interface ChartSummaryInput {
|
|
|
3443
3695
|
format?: ValueFormat
|
|
3444
3696
|
/** Wins over `format`. */
|
|
3445
3697
|
valueFormatter?: ValueFormatter
|
|
3698
|
+
/** Language of the sentence. Default the page's locale (`<html lang>`). */
|
|
3699
|
+
locale?: string
|
|
3700
|
+
}
|
|
3701
|
+
|
|
3702
|
+
const SUMMARY_TEXT = {
|
|
3703
|
+
ja: {
|
|
3704
|
+
up: "、前の点より増加",
|
|
3705
|
+
down: "、前の点より減少",
|
|
3706
|
+
flat: "、前の点と横ばい",
|
|
3707
|
+
latest: (label: string, value: string) => `最新 ${label} は ${value}`,
|
|
3708
|
+
top: (name: string, share: string) => `最大は ${name}(${share})`,
|
|
3709
|
+
},
|
|
3710
|
+
en: {
|
|
3711
|
+
up: ", up from the previous point",
|
|
3712
|
+
down: ", down from the previous point",
|
|
3713
|
+
flat: ", flat from the previous point",
|
|
3714
|
+
latest: (label: string, value: string) => `Latest ${label}: ${value}`,
|
|
3715
|
+
top: (name: string, share: string) => `Largest: ${name} (${share})`,
|
|
3716
|
+
},
|
|
3446
3717
|
}
|
|
3447
3718
|
|
|
3448
3719
|
/**
|
|
3449
3720
|
* One-sentence summary built from the rows, for `summary="auto"`:
|
|
3450
|
-
* 「<title>: 最新 <index> は
|
|
3721
|
+
* 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」("<title>: Latest <index>: <value>, up from the previous point"). The direction is
|
|
3451
3722
|
* computed from the last two non-empty points of the first series — never
|
|
3452
3723
|
* written by hand, so it cannot contradict the data. `undefined` when there is
|
|
3453
3724
|
* no usable point.
|
|
@@ -3463,26 +3734,29 @@ function buildChartSummary(input: ChartSummaryInput): string | undefined {
|
|
|
3463
3734
|
}
|
|
3464
3735
|
const latest = points[points.length - 1]
|
|
3465
3736
|
if (!latest) return undefined
|
|
3466
|
-
const
|
|
3737
|
+
const locale = resolveLocale(input.locale)
|
|
3738
|
+
const t = SUMMARY_TEXT[uiLanguage(locale)]
|
|
3739
|
+
const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact", locale)
|
|
3467
3740
|
const prev = points[points.length - 2]
|
|
3468
|
-
const direction = !prev ? "" : latest.value > prev.value ?
|
|
3741
|
+
const direction = !prev ? "" : latest.value > prev.value ? t.up : latest.value < prev.value ? t.down : t.flat
|
|
3469
3742
|
const head = input.title ? `${input.title}: ` : ""
|
|
3470
|
-
return `${head}
|
|
3743
|
+
return `${head}${t.latest(latest.label, text(latest.value))}${direction}`
|
|
3471
3744
|
}
|
|
3472
3745
|
|
|
3473
3746
|
/**
|
|
3474
|
-
* `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」 from the slices
|
|
3475
|
-
* (largest first). `undefined` when there is nothing to describe.
|
|
3747
|
+
* `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」("<title>: Largest: <name> (<share>)") from the slices
|
|
3748
|
+
* (largest first). `undefined` when there is nothing to describe. `locale` picks the language; default the page's.
|
|
3476
3749
|
*/
|
|
3477
3750
|
function buildShareSummary(
|
|
3478
3751
|
slices: readonly { name: string; value: number }[],
|
|
3479
3752
|
title?: string,
|
|
3753
|
+
locale?: string,
|
|
3480
3754
|
): string | undefined {
|
|
3481
3755
|
const total = slices.reduce((sum, s) => sum + s.value, 0)
|
|
3482
3756
|
const top = slices[0]
|
|
3483
3757
|
if (!top || total <= 0) return undefined
|
|
3484
3758
|
const share = `${Math.round((top.value / total) * 1000) / 10}%`
|
|
3485
|
-
return `${title ? `${title}: ` : ""}
|
|
3759
|
+
return `${title ? `${title}: ` : ""}${SUMMARY_TEXT[uiLanguage(resolveLocale(locale))].top(top.name, share)}`
|
|
3486
3760
|
}
|
|
3487
3761
|
|
|
3488
3762
|
/* ---------- inlined from @squadbase/squadkit registry/lib/distribution.ts (internal to @squadbase/vantage) ---------- */
|
|
@@ -3664,6 +3938,51 @@ export interface BoxPlotChartProps extends Omit<ChartCommonProps, "data" | "inde
|
|
|
3664
3938
|
|
|
3665
3939
|
/* ------------------------------------------------------------------ model */
|
|
3666
3940
|
|
|
3941
|
+
const TEXT = {
|
|
3942
|
+
ja: {
|
|
3943
|
+
value: "値",
|
|
3944
|
+
valueInSentence: "値",
|
|
3945
|
+
medianOf: (name: string, label: string, median: string) => `${name}の${label}の中央値は ${median}`,
|
|
3946
|
+
medianRange: (label: string, top: string, topMedian: string, low: string, lowMedian: string) =>
|
|
3947
|
+
`${label}の中央値が最も高いのは ${top}(${topMedian})、最も低いのは ${low}(${lowMedian})`,
|
|
3948
|
+
outliers: (n: number) => `外れ値は ${n} 件`,
|
|
3949
|
+
sentenceJoin: "。",
|
|
3950
|
+
columns: ["群", "件数", "最小(ひげ)", "第1四分位", "中央値", "第3四分位", "最大(ひげ)", "外れ値"],
|
|
3951
|
+
listJoin: "、",
|
|
3952
|
+
none: "なし",
|
|
3953
|
+
tooltipTitle: (name: string, label: string, count: number) => `${name}(${label}・${count}件)`,
|
|
3954
|
+
max: "最大(ひげ)",
|
|
3955
|
+
q3: "第3四分位",
|
|
3956
|
+
median: "中央値",
|
|
3957
|
+
q1: "第1四分位",
|
|
3958
|
+
min: "最小(ひげ)",
|
|
3959
|
+
outlierRow: "外れ値",
|
|
3960
|
+
chart: (label: string) => `${label}の箱ひげ図`,
|
|
3961
|
+
},
|
|
3962
|
+
en: {
|
|
3963
|
+
value: "Value",
|
|
3964
|
+
valueInSentence: "value",
|
|
3965
|
+
medianOf: (name: string, label: string, median: string) => `Median ${label} for ${name}: ${median}`,
|
|
3966
|
+
medianRange: (label: string, top: string, topMedian: string, low: string, lowMedian: string) =>
|
|
3967
|
+
`Highest median ${label}: ${top} (${topMedian}); lowest: ${low} (${lowMedian})`,
|
|
3968
|
+
outliers: (n: number) => (n === 1 ? "1 outlier" : `${n} outliers`),
|
|
3969
|
+
sentenceJoin: ". ",
|
|
3970
|
+
columns: ["Group", "Count", "Min (whisker)", "Q1", "Median", "Q3", "Max (whisker)", "Outliers"],
|
|
3971
|
+
listJoin: ", ",
|
|
3972
|
+
none: "None",
|
|
3973
|
+
tooltipTitle: (name: string, label: string, count: number) => `${name} (${label}, n=${count})`,
|
|
3974
|
+
max: "Max (whisker)",
|
|
3975
|
+
q3: "Q3",
|
|
3976
|
+
median: "Median",
|
|
3977
|
+
q1: "Q1",
|
|
3978
|
+
min: "Min (whisker)",
|
|
3979
|
+
outlierRow: "Outliers",
|
|
3980
|
+
chart: (label: string) => `Box plot of ${label}`,
|
|
3981
|
+
},
|
|
3982
|
+
}
|
|
3983
|
+
|
|
3984
|
+
const textOf = (locale?: string) => TEXT[uiLanguage(resolveLocale(locale))]
|
|
3985
|
+
|
|
3667
3986
|
export interface BoxPlotEntry {
|
|
3668
3987
|
name: string
|
|
3669
3988
|
stats: BoxStats | null
|
|
@@ -3674,27 +3993,38 @@ export function boxPlotEntries(groups: readonly BoxPlotGroup[], whisker?: number
|
|
|
3674
3993
|
return groups.map((g) => ({ name: g.name, stats: boxStats(g.values, whisker) }))
|
|
3675
3994
|
}
|
|
3676
3995
|
|
|
3677
|
-
/**
|
|
3678
|
-
|
|
3996
|
+
/**
|
|
3997
|
+
* `summary="auto"`: the groups with the highest / lowest median and the outlier count. `undefined` with no usable group.
|
|
3998
|
+
* `locale` picks the language; default the page's (`<html lang>`).
|
|
3999
|
+
*/
|
|
4000
|
+
export function buildBoxPlotSummary(
|
|
4001
|
+
entries: readonly BoxPlotEntry[],
|
|
4002
|
+
formatter: ValueFormatter,
|
|
4003
|
+
valueLabel?: string,
|
|
4004
|
+
locale?: string,
|
|
4005
|
+
): string | undefined {
|
|
4006
|
+
const t = textOf(locale)
|
|
4007
|
+
const label = valueLabel ?? t.valueInSentence
|
|
3679
4008
|
const real = entries.filter((e): e is BoxPlotEntry & { stats: BoxStats } => e.stats !== null)
|
|
3680
4009
|
if (real.length === 0) return undefined
|
|
3681
4010
|
const top = real.reduce((a, b) => (b.stats.median > a.stats.median ? b : a))
|
|
3682
4011
|
const low = real.reduce((a, b) => (b.stats.median < a.stats.median ? b : a))
|
|
3683
4012
|
const parts = [
|
|
3684
4013
|
real.length === 1
|
|
3685
|
-
?
|
|
3686
|
-
:
|
|
4014
|
+
? t.medianOf(top.name, label, formatter(top.stats.median))
|
|
4015
|
+
: t.medianRange(label, top.name, formatter(top.stats.median), low.name, formatter(low.stats.median)),
|
|
3687
4016
|
]
|
|
3688
4017
|
const outliers = real.reduce((sum, e) => sum + e.stats.outliers.length, 0)
|
|
3689
|
-
if (outliers > 0) parts.push(
|
|
3690
|
-
return parts.join(
|
|
4018
|
+
if (outliers > 0) parts.push(t.outliers(outliers))
|
|
4019
|
+
return parts.join(t.sentenceJoin)
|
|
3691
4020
|
}
|
|
3692
4021
|
|
|
3693
4022
|
/** The screen-reader table: one row per group with n, the quartiles, the whisker ends and the outliers. */
|
|
3694
|
-
export function boxPlotDataTable(entries: readonly BoxPlotEntry[], formatter: ValueFormatter): ChartDataTable {
|
|
4023
|
+
export function boxPlotDataTable(entries: readonly BoxPlotEntry[], formatter: ValueFormatter, locale?: string): ChartDataTable {
|
|
4024
|
+
const t = textOf(locale)
|
|
3695
4025
|
const f = (n: number | undefined) => (n === undefined ? EMPTY_VALUE : formatter(n))
|
|
3696
4026
|
return {
|
|
3697
|
-
columns: [
|
|
4027
|
+
columns: [...t.columns],
|
|
3698
4028
|
rows: entries.map(({ name, stats: s }) => [
|
|
3699
4029
|
name,
|
|
3700
4030
|
s ? `${s.count}` : EMPTY_VALUE,
|
|
@@ -3703,7 +4033,7 @@ export function boxPlotDataTable(entries: readonly BoxPlotEntry[], formatter: Va
|
|
|
3703
4033
|
f(s?.median),
|
|
3704
4034
|
f(s?.q3),
|
|
3705
4035
|
f(s?.whiskerHigh),
|
|
3706
|
-
s ? (s.outliers.length > 0 ? s.outliers.map(formatter).join(
|
|
4036
|
+
s ? (s.outliers.length > 0 ? s.outliers.map(formatter).join(t.listJoin) : t.none) : EMPTY_VALUE,
|
|
3707
4037
|
]),
|
|
3708
4038
|
}
|
|
3709
4039
|
}
|
|
@@ -3720,6 +4050,8 @@ export interface BoxPlotOptionConfig {
|
|
|
3720
4050
|
showTooltip?: boolean
|
|
3721
4051
|
labelColor?: string
|
|
3722
4052
|
chartOptions?: EChartsOption
|
|
4053
|
+
/** Language of the tooltip. Default the page's locale (`<html lang>`). */
|
|
4054
|
+
locale?: string
|
|
3723
4055
|
}
|
|
3724
4056
|
|
|
3725
4057
|
const ROW_STYLE = "display:flex;align-items:center;gap:8px;line-height:20px"
|
|
@@ -3765,7 +4097,8 @@ export function boxPlotRenderer(color: string, outliers: readonly (readonly numb
|
|
|
3765
4097
|
|
|
3766
4098
|
/** The ECharts option: one `custom` series, a box per group. Groups with no value draw nothing. */
|
|
3767
4099
|
export function buildBoxPlotOption(config: BoxPlotOptionConfig): EChartsOption {
|
|
3768
|
-
const
|
|
4100
|
+
const t = textOf(config.locale)
|
|
4101
|
+
const { entries, valueFormatter, showTooltip = true, valueLabel = t.value } = config
|
|
3769
4102
|
const axisFormatter = config.axisFormatter ?? valueFormatter
|
|
3770
4103
|
const color = config.color || "#888"
|
|
3771
4104
|
// [index, whisker low, Q1, median, Q3, whisker high, min, max]: the last two stretch the value axis over the outliers.
|
|
@@ -3786,13 +4119,13 @@ export function buildBoxPlotOption(config: BoxPlotOptionConfig): EChartsOption {
|
|
|
3786
4119
|
`<div style="${ROW_STYLE}"><span style="flex:1;opacity:.8">${escapeHtml(name)}</span>` +
|
|
3787
4120
|
`<span style="font-weight:500;font-variant-numeric:tabular-nums;margin-left:12px">${escapeHtml(text)}</span></div>`
|
|
3788
4121
|
return (
|
|
3789
|
-
`<div style="font-weight:500;margin-bottom:4px">${escapeHtml(entry.name
|
|
3790
|
-
line(
|
|
3791
|
-
line(
|
|
3792
|
-
line(
|
|
3793
|
-
line(
|
|
3794
|
-
line(
|
|
3795
|
-
(s.outliers.length > 0 ? line(
|
|
4122
|
+
`<div style="font-weight:500;margin-bottom:4px">${escapeHtml(t.tooltipTitle(entry.name, valueLabel, s.count))}</div>` +
|
|
4123
|
+
line(t.max, valueFormatter(s.whiskerHigh)) +
|
|
4124
|
+
line(t.q3, valueFormatter(s.q3)) +
|
|
4125
|
+
line(t.median, valueFormatter(s.median)) +
|
|
4126
|
+
line(t.q1, valueFormatter(s.q1)) +
|
|
4127
|
+
line(t.min, valueFormatter(s.whiskerLow)) +
|
|
4128
|
+
(s.outliers.length > 0 ? line(t.outlierRow, s.outliers.map(valueFormatter).join(t.listJoin)) : "")
|
|
3796
4129
|
)
|
|
3797
4130
|
},
|
|
3798
4131
|
},
|
|
@@ -3843,21 +4176,22 @@ export const BoxPlotChart = forwardRef(function BoxPlotChart(
|
|
|
3843
4176
|
}: BoxPlotChartProps,
|
|
3844
4177
|
ref: ForwardedRef<HTMLDivElement>,
|
|
3845
4178
|
) {
|
|
3846
|
-
const tokens =
|
|
3847
|
-
const
|
|
3848
|
-
const
|
|
4179
|
+
const { ref: frameRef, tokens } = useChartSurface(ref)
|
|
4180
|
+
const locale = useLocale()
|
|
4181
|
+
const t = TEXT[uiLanguage(locale)]
|
|
4182
|
+
const valueFormatter = useMemo(() => resolveValueFormatter(valueFormatterProp, format, locale), [valueFormatterProp, format, locale])
|
|
3849
4183
|
const entries = useMemo(() => boxPlotEntries(groups, whisker), [groups, whisker])
|
|
3850
4184
|
const [color = ""] = useMemo(() => resolveChartColors(["chart-1"], tokens), [tokens])
|
|
3851
4185
|
const option = useMemo(
|
|
3852
|
-
() => buildBoxPlotOption({ entries, color, valueFormatter, valueLabel, showTooltip, labelColor: tokens?.foreground ?? undefined, chartOptions }),
|
|
3853
|
-
[entries, color, valueFormatter, valueLabel, showTooltip, tokens, chartOptions],
|
|
4186
|
+
() => buildBoxPlotOption({ entries, color, valueFormatter, valueLabel, showTooltip, labelColor: tokens?.foreground ?? undefined, chartOptions, locale }),
|
|
4187
|
+
[entries, color, valueFormatter, valueLabel, showTooltip, tokens, chartOptions, locale],
|
|
3854
4188
|
)
|
|
3855
|
-
const dataTable = useMemo(() => boxPlotDataTable(entries, valueFormatter), [entries, valueFormatter])
|
|
4189
|
+
const dataTable = useMemo(() => boxPlotDataTable(entries, valueFormatter, locale), [entries, valueFormatter, locale])
|
|
3856
4190
|
const resolvedSummary = useMemo(
|
|
3857
|
-
() => (summary === "auto" ? buildBoxPlotSummary(entries, valueFormatter, valueLabel) : summary),
|
|
3858
|
-
[summary, entries, valueFormatter, valueLabel],
|
|
4191
|
+
() => (summary === "auto" ? buildBoxPlotSummary(entries, valueFormatter, valueLabel, locale) : summary),
|
|
4192
|
+
[summary, entries, valueFormatter, valueLabel, locale],
|
|
3859
4193
|
)
|
|
3860
|
-
const label = ariaLabel ??
|
|
4194
|
+
const label = ariaLabel ?? t.chart(valueLabel ?? t.valueInSentence)
|
|
3861
4195
|
return (
|
|
3862
4196
|
<ChartFrame
|
|
3863
4197
|
{...chartRootProps(rest)}
|