@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
|
@@ -32,7 +32,8 @@ import { EChart } from "@squadbase/vantage/components"
|
|
|
32
32
|
import type { Polarity } from "@squadbase/vantage/components"
|
|
33
33
|
import { EMPTY_VALUE, MINUS_SIGN } from "@squadbase/vantage/components"
|
|
34
34
|
import type { ChartCommonProps, NumericKeys, ValueFormatter } from "@squadbase/vantage/components"
|
|
35
|
-
import {
|
|
35
|
+
import { useSyncExternalStore } from "react"
|
|
36
|
+
import { createContext, useCallback, useEffect, useRef, useState, type RefObject } from "react"
|
|
36
37
|
import type { ChartTone } from "@squadbase/vantage/components"
|
|
37
38
|
import { type ReactNode } from "react"
|
|
38
39
|
import { computeComparison, defaultChangeDisplay, formatChange, toneForValue, type ComparisonKind, type PeriodGrain } from "@squadbase/vantage/components"
|
|
@@ -41,6 +42,137 @@ import type { ChartThreshold, ChartAnnotation, ChartEventPayload, ChartForecast,
|
|
|
41
42
|
import { formatValue, toValueFormatter } from "@squadbase/vantage/components"
|
|
42
43
|
import type { ChartColor, ChartColors, ChartSeriesColor } from "@squadbase/vantage/components"
|
|
43
44
|
|
|
45
|
+
/* ---------- inlined from @squadbase/squadkit registry/lib/locale.ts (internal to @squadbase/vantage) ---------- */
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The display language of the parts and the defaults that follow it.
|
|
49
|
+
*
|
|
50
|
+
* The language comes from `<html lang>`: a tag starting with `ja` reads Japanese, anything else English.
|
|
51
|
+
* Without a usable `lang` (missing, invalid, or no `document` — SSR, Node) the locale is `FALLBACK_LOCALE`
|
|
52
|
+
* (`en-US`). A `locale` passed explicitly (a prop, an argument) always wins.
|
|
53
|
+
*
|
|
54
|
+
* Parts read the locale through `useLocale` (`hooks/use-locale`) and pass it down; the pure helpers here
|
|
55
|
+
* and in `format` / `comparison` / `chart-*` take it as an argument and fall back to `documentLocale()`.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/** The language of the built-in wording: Japanese or English. */
|
|
59
|
+
type UiLanguage = "ja" | "en"
|
|
60
|
+
|
|
61
|
+
/** The locale used when `<html lang>` gives none (and on the server). */
|
|
62
|
+
const FALLBACK_LOCALE = "en-US"
|
|
63
|
+
|
|
64
|
+
/** `"ja"` when `locale` starts with `ja` (`ja`, `ja-JP`), else `"en"`. A missing locale is `"en"`. */
|
|
65
|
+
function uiLanguage(locale: string | null | undefined): UiLanguage {
|
|
66
|
+
return typeof locale === "string" && locale.trim().toLowerCase().startsWith("ja") ? "ja" : "en"
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A BCP 47 tag in its canonical form (`en-gb` → `en-GB`), or `FALLBACK_LOCALE` when it is empty or invalid. */
|
|
70
|
+
function normalizeLocale(tag: string | null | undefined): string {
|
|
71
|
+
const value = tag?.trim()
|
|
72
|
+
if (!value) return FALLBACK_LOCALE
|
|
73
|
+
try {
|
|
74
|
+
return Intl.getCanonicalLocales(value)[0] ?? FALLBACK_LOCALE
|
|
75
|
+
} catch {
|
|
76
|
+
return FALLBACK_LOCALE
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The locale of the page: `document.documentElement.lang`, canonicalized. `FALLBACK_LOCALE` when it is empty or
|
|
82
|
+
* invalid, or there is no `document`. Parts read it through `useLocale`, never while rendering.
|
|
83
|
+
*/
|
|
84
|
+
function documentLocale(): string {
|
|
85
|
+
if (typeof document === "undefined") return FALLBACK_LOCALE
|
|
86
|
+
return normalizeLocale(document.documentElement?.getAttribute("lang"))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `locale` when given, else the page's locale (`documentLocale()`). */
|
|
90
|
+
function resolveLocale(locale?: string | null): string {
|
|
91
|
+
return locale || documentLocale()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Picks the wording table of the locale's language. */
|
|
95
|
+
function pickLocale<T>(table: { ja: T; en: T }, locale?: string | null): T {
|
|
96
|
+
return table[uiLanguage(resolveLocale(locale))]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The currency `format="currency"` uses without a `currency`: JPY for Japanese, USD otherwise. */
|
|
100
|
+
function defaultCurrency(locale?: string | null): string {
|
|
101
|
+
return uiLanguage(resolveLocale(locale)) === "ja" ? "JPY" : "USD"
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The month a fiscal year starts in (1–12) when none is given: April for Japanese, January otherwise. */
|
|
105
|
+
function defaultFiscalYearStartMonth(locale?: string | null): number {
|
|
106
|
+
return uiLanguage(resolveLocale(locale)) === "ja" ? 4 : 1
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A day of the week, 0 = Sunday … 6 = Saturday (the `Date#getDay` / date-fns numbering). */
|
|
110
|
+
type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6
|
|
111
|
+
|
|
112
|
+
/** Locales whose week starts on Sunday when the runtime has no week data. */
|
|
113
|
+
const SUNDAY_FIRST = new Set(["en", "en-US", "en-CA"])
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The first day of the week: Monday for Japanese; otherwise the locale's convention from `Intl.Locale`
|
|
117
|
+
* (`en-US` Sunday, `en-GB` Monday). Without week data in the runtime: Sunday for `en` / `en-US` / `en-CA`,
|
|
118
|
+
* Monday for the rest.
|
|
119
|
+
*/
|
|
120
|
+
function weekStartsOn(locale?: string | null): WeekDay {
|
|
121
|
+
const tag = normalizeLocale(resolveLocale(locale))
|
|
122
|
+
if (uiLanguage(tag) === "ja") return 1
|
|
123
|
+
try {
|
|
124
|
+
const info = new Intl.Locale(tag) as Intl.Locale & {
|
|
125
|
+
getWeekInfo?: () => { firstDay: number }
|
|
126
|
+
weekInfo?: { firstDay: number }
|
|
127
|
+
}
|
|
128
|
+
const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay
|
|
129
|
+
if (typeof firstDay === "number" && firstDay >= 1 && firstDay <= 7) return (firstDay % 7) as WeekDay
|
|
130
|
+
} catch {
|
|
131
|
+
// Fall through to the table below.
|
|
132
|
+
}
|
|
133
|
+
return SUNDAY_FIRST.has(tag) ? 0 : 1
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* ---------- inlined from @squadbase/squadkit registry/hooks/use-locale.ts (internal to @squadbase/vantage) ---------- */
|
|
137
|
+
|
|
138
|
+
const listeners = new Set<() => void>()
|
|
139
|
+
let observer: MutationObserver | null = null
|
|
140
|
+
|
|
141
|
+
/** One observer on `<html lang>` for every subscribed part. */
|
|
142
|
+
function subscribe(onChange: () => void): () => void {
|
|
143
|
+
if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {}
|
|
144
|
+
listeners.add(onChange)
|
|
145
|
+
if (!observer) {
|
|
146
|
+
observer = new MutationObserver(() => {
|
|
147
|
+
for (const listener of [...listeners]) listener()
|
|
148
|
+
})
|
|
149
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] })
|
|
150
|
+
}
|
|
151
|
+
return () => {
|
|
152
|
+
listeners.delete(onChange)
|
|
153
|
+
if (listeners.size === 0 && observer) {
|
|
154
|
+
observer.disconnect()
|
|
155
|
+
observer = null
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const serverLocale = () => FALLBACK_LOCALE
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The page's locale (`<html lang>`), following changes made while the page runs. On the server and during
|
|
164
|
+
* hydration it is `FALLBACK_LOCALE` (`en-US`), so the markup matches; it switches right after.
|
|
165
|
+
*/
|
|
166
|
+
function useDocumentLocale(): string {
|
|
167
|
+
return useSyncExternalStore(subscribe, documentLocale, serverLocale)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The locale a part renders in: its `locale` prop when given, else the page's (`useDocumentLocale`). */
|
|
171
|
+
function useLocale(locale?: string | null): string {
|
|
172
|
+
const page = useDocumentLocale()
|
|
173
|
+
return locale || page
|
|
174
|
+
}
|
|
175
|
+
|
|
44
176
|
/* ---------- inlined from @squadbase/squadkit registry/lib/chart-theme.ts (internal to @squadbase/vantage) ---------- */
|
|
45
177
|
|
|
46
178
|
/**
|
|
@@ -75,10 +207,23 @@ interface ChartTokens {
|
|
|
75
207
|
border: string | null
|
|
76
208
|
/** Tooltip surface. */
|
|
77
209
|
popover: string | null
|
|
210
|
+
/** Semantic and surface dependencies used by typed chart options. Optional for hand-built tokens. */
|
|
211
|
+
colors?: Record<string, string>
|
|
212
|
+
/** Actual inherited painting surface (may change without a token changing). */
|
|
213
|
+
surface?: string | null
|
|
78
214
|
/** Tooltip text. */
|
|
79
215
|
popoverForeground: string | null
|
|
80
216
|
}
|
|
81
217
|
|
|
218
|
+
// Keep scope out of the public token shape. A resolved snapshot remembers where arbitrary
|
|
219
|
+
// var(--token) expressions must be read, without retaining unmounted charts globally.
|
|
220
|
+
const tokenScopes = new WeakMap<ChartTokens, Element>()
|
|
221
|
+
const OPTION_COLOR_TOKENS = [
|
|
222
|
+
"--background", "--card", "--muted", "--primary", "--destructive",
|
|
223
|
+
...["positive", "negative", "warning", "neutral", "info"].flatMap((tone) =>
|
|
224
|
+
["", "-fill", "-subtle"].map((suffix) => `--${tone}${suffix}`)),
|
|
225
|
+
]
|
|
226
|
+
|
|
82
227
|
let normalizeContext: CanvasRenderingContext2D | null | undefined
|
|
83
228
|
|
|
84
229
|
/** The forms `fillStyle` serializes legacy colours to, and zrender can parse. */
|
|
@@ -134,8 +279,10 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
|
|
|
134
279
|
const color = read(token)
|
|
135
280
|
if (color) series.push(color)
|
|
136
281
|
}
|
|
137
|
-
|
|
282
|
+
const tokens: ChartTokens = {
|
|
138
283
|
series,
|
|
284
|
+
colors: Object.fromEntries(OPTION_COLOR_TOKENS.map((name) => [name, styles.getPropertyValue(name).trim()])),
|
|
285
|
+
surface: readChartSurface(target),
|
|
139
286
|
...(styles.fontFamily ? { fontFamily: styles.fontFamily } : {}),
|
|
140
287
|
foreground: read("--foreground"),
|
|
141
288
|
mutedForeground: read("--muted-foreground"),
|
|
@@ -143,6 +290,8 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
|
|
|
143
290
|
popover: read("--popover"),
|
|
144
291
|
popoverForeground: read("--popover-foreground"),
|
|
145
292
|
}
|
|
293
|
+
tokenScopes.set(tokens, target)
|
|
294
|
+
return tokens
|
|
146
295
|
}
|
|
147
296
|
|
|
148
297
|
/** Structural equality, so a theme toggle that changes nothing doesn't re-init. */
|
|
@@ -150,6 +299,10 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
|
|
|
150
299
|
if (a === b) return true
|
|
151
300
|
if (!a || !b) return false
|
|
152
301
|
return (
|
|
302
|
+
tokenScopes.get(a) === tokenScopes.get(b) &&
|
|
303
|
+
a.surface === b.surface &&
|
|
304
|
+
Object.keys(a.colors ?? {}).length === Object.keys(b.colors ?? {}).length &&
|
|
305
|
+
Object.entries(a.colors ?? {}).every(([name, value]) => b.colors?.[name] === value) &&
|
|
153
306
|
a.series.length === b.series.length &&
|
|
154
307
|
a.series.every((color, i) => color === b.series[i]) &&
|
|
155
308
|
a.fontFamily === b.fontFamily &&
|
|
@@ -175,7 +328,7 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
|
|
|
175
328
|
* attributes alone leaves the chart painted in the previous palette until a
|
|
176
329
|
* reload — the tokens in the DOM are new, the canvas is not.
|
|
177
330
|
*/
|
|
178
|
-
function watchChartTokens(onChange: () => void): () => void {
|
|
331
|
+
function watchChartTokens(onChange: () => void, scope?: Element | null): () => void {
|
|
179
332
|
if (typeof document === "undefined") return () => {}
|
|
180
333
|
// Style edits arrive as bursts of mutations; coalesce them into one read, and
|
|
181
334
|
// take it after the next frame so a swapped stylesheet has been applied.
|
|
@@ -190,8 +343,11 @@ function watchChartTokens(onChange: () => void): () => void {
|
|
|
190
343
|
|
|
191
344
|
const themeObserver = new MutationObserver(schedule)
|
|
192
345
|
const attributeFilter = ["class", "style", "data-theme"]
|
|
193
|
-
|
|
194
|
-
|
|
346
|
+
// Only the chart and its ancestors can change its inherited theme. Never observe
|
|
347
|
+
// the document subtree: canvas/tooltip/rendering mutations would trigger every chart.
|
|
348
|
+
for (let node: Element | null = scope ?? document.body ?? document.documentElement; node; node = node.parentElement) {
|
|
349
|
+
themeObserver.observe(node, { attributes: true, attributeFilter })
|
|
350
|
+
}
|
|
195
351
|
|
|
196
352
|
const styleObserver = new MutationObserver(schedule)
|
|
197
353
|
if (document.head) {
|
|
@@ -279,22 +435,23 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
|
|
|
279
435
|
* components that build an ECharts option from token colours themselves (the
|
|
280
436
|
* typed charts), so their palette follows light/dark like `EChart`'s theme.
|
|
281
437
|
*/
|
|
282
|
-
function useChartTokens(): ChartTokens | null {
|
|
438
|
+
function useChartTokens(scope?: Element | null | RefObject<Element | null>): ChartTokens | null {
|
|
283
439
|
const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
|
|
284
440
|
// Compared here, not inside a `setTokens` updater: an updater that reads the DOM is
|
|
285
441
|
// replayed by React on every rebase and returns a fresh object each time, so the state
|
|
286
442
|
// never settles ("Maximum update depth exceeded" once the tokens change after mount).
|
|
287
443
|
const tokensRef = useRef(tokens)
|
|
288
444
|
useEffect(() => {
|
|
445
|
+
const target = scope && "current" in scope ? scope.current : scope
|
|
289
446
|
const sync = () => {
|
|
290
|
-
const next = readChartTokens()
|
|
447
|
+
const next = readChartTokens(target)
|
|
291
448
|
if (sameChartTokens(tokensRef.current, next)) return
|
|
292
449
|
tokensRef.current = next
|
|
293
450
|
setTokens(next)
|
|
294
451
|
}
|
|
295
452
|
sync()
|
|
296
|
-
return watchChartTokens(sync)
|
|
297
|
-
}, [])
|
|
453
|
+
return watchChartTokens(sync, target)
|
|
454
|
+
}, [scope])
|
|
298
455
|
return tokens
|
|
299
456
|
}
|
|
300
457
|
|
|
@@ -306,7 +463,7 @@ const EMBEDDED_VAR_RE = /var\(\s*(--[\w-]+)\s*\)/g
|
|
|
306
463
|
/**
|
|
307
464
|
* Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
|
|
308
465
|
* the resolved series tokens; `"positive"` / `"warning"` / `"negative"` / `"neutral"` (and the area
|
|
309
|
-
* variants `"positive-fill"`, …) are read
|
|
466
|
+
* variants `"positive-fill"`, …) are read in the snapshot's inherited scope (`--positive`, …); `var(--token)` uses the same scope; any
|
|
310
467
|
* other CSS colour is normalized like the tokens are. A colour that can't be
|
|
311
468
|
* resolved comes back as `""`, which ECharts treats as "use the palette".
|
|
312
469
|
*/
|
|
@@ -317,12 +474,13 @@ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | nul
|
|
|
317
474
|
// Meaning colours are the theme tokens of the same name: same path as `var(--positive)`.
|
|
318
475
|
if (TONE_NAME_RE.test(color.trim())) {
|
|
319
476
|
if (typeof document === "undefined") return ""
|
|
320
|
-
|
|
477
|
+
const target = (tokens && tokenScopes.get(tokens)) ?? document.documentElement
|
|
478
|
+
return toCanvasColor(getComputedStyle(target).getPropertyValue(`--${color.trim()}`)) ?? ""
|
|
321
479
|
}
|
|
322
480
|
if (typeof document === "undefined") return color
|
|
323
481
|
let value = color.trim()
|
|
324
482
|
const cssVar = CSS_VAR_RE.exec(value)
|
|
325
|
-
const styles = getComputedStyle(document.documentElement)
|
|
483
|
+
const styles = getComputedStyle((tokens && tokenScopes.get(tokens)) ?? document.documentElement)
|
|
326
484
|
if (cssVar) {
|
|
327
485
|
value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
|
|
328
486
|
} else if (value.includes("var(")) {
|
|
@@ -368,7 +526,7 @@ function readChartSurface(el: Element | null): string | null {
|
|
|
368
526
|
if (color) return color
|
|
369
527
|
}
|
|
370
528
|
}
|
|
371
|
-
return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue("--background"))
|
|
529
|
+
return toCanvasColor(getComputedStyle(el ?? document.documentElement).getPropertyValue("--background"))
|
|
372
530
|
}
|
|
373
531
|
|
|
374
532
|
/** Widths are bucketed so a resize only rebuilds the option when the label budget can change. */
|
|
@@ -382,9 +540,10 @@ const WIDTH_BUCKET = 40
|
|
|
382
540
|
*/
|
|
383
541
|
function useChartSurface<E extends Element>(
|
|
384
542
|
forwarded: ForwardedRef<E>,
|
|
385
|
-
tokens
|
|
386
|
-
): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined } {
|
|
543
|
+
tokens?: ChartTokens | null,
|
|
544
|
+
): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined; tokens: ChartTokens | null } {
|
|
387
545
|
const [node, setNode] = useState<E | null>(null)
|
|
546
|
+
const scopedTokens = useChartTokens(node)
|
|
388
547
|
const [surface, setSurface] = useState<string | undefined>(undefined)
|
|
389
548
|
const [width, setWidth] = useState<number | undefined>(undefined)
|
|
390
549
|
const ref = useCallback(
|
|
@@ -398,7 +557,7 @@ function useChartSurface<E extends Element>(
|
|
|
398
557
|
useEffect(() => {
|
|
399
558
|
if (!node) return
|
|
400
559
|
setSurface(readChartSurface(node) ?? undefined)
|
|
401
|
-
}, [node, tokens])
|
|
560
|
+
}, [node, tokens, scopedTokens])
|
|
402
561
|
useEffect(() => {
|
|
403
562
|
if (!node || typeof ResizeObserver === "undefined") return
|
|
404
563
|
const update = (w: number) => setWidth(Math.floor(w / WIDTH_BUCKET) * WIDTH_BUCKET)
|
|
@@ -407,11 +566,37 @@ function useChartSurface<E extends Element>(
|
|
|
407
566
|
observer.observe(node)
|
|
408
567
|
return () => observer.disconnect()
|
|
409
568
|
}, [node])
|
|
410
|
-
return { ref, surface, width }
|
|
569
|
+
return { ref, surface, width, tokens: scopedTokens }
|
|
411
570
|
}
|
|
412
571
|
|
|
413
572
|
/* ---------- inlined from @squadbase/squadkit registry/lib/chart-options.ts (internal to @squadbase/vantage) ---------- */
|
|
414
573
|
|
|
574
|
+
/** The built-in words of the option builders, by language. */
|
|
575
|
+
const CHART_TEXT = {
|
|
576
|
+
ja: {
|
|
577
|
+
target: "目標",
|
|
578
|
+
forecast: "予測",
|
|
579
|
+
total: "合計",
|
|
580
|
+
provisional: "速報",
|
|
581
|
+
noComparison: "比較なし",
|
|
582
|
+
share: (share: string) => `(${share})`,
|
|
583
|
+
targetRate: (rate: string) => `(達成率 ${rate})`,
|
|
584
|
+
provisionalTitle: (title: string, label: string) => `${title}(${label})`,
|
|
585
|
+
},
|
|
586
|
+
en: {
|
|
587
|
+
target: "Target",
|
|
588
|
+
forecast: "Forecast",
|
|
589
|
+
total: "Total",
|
|
590
|
+
provisional: "Preliminary",
|
|
591
|
+
noComparison: "No comparison",
|
|
592
|
+
share: (share: string) => ` (${share})`,
|
|
593
|
+
targetRate: (rate: string) => ` (${rate} achieved)`,
|
|
594
|
+
provisionalTitle: (title: string, label: string) => `${title} (${label})`,
|
|
595
|
+
},
|
|
596
|
+
} satisfies Record<UiLanguage, unknown>
|
|
597
|
+
|
|
598
|
+
const chartText = (locale?: string) => CHART_TEXT[uiLanguage(resolveLocale(locale))]
|
|
599
|
+
|
|
415
600
|
type CartesianChartKind = "area" | "bar" | "line"
|
|
416
601
|
type CartesianStackType = "default" | "stacked" | "percent"
|
|
417
602
|
type CartesianCurveType = "linear" | "smooth" | "step"
|
|
@@ -596,7 +781,7 @@ interface CartesianChartConfig {
|
|
|
596
781
|
* all ticks read in one unit (¥0.5億 / ¥1億). Default: `axisFormatter` for every tick.
|
|
597
782
|
*/
|
|
598
783
|
axisTicks?: (top: number) => (value: number) => string
|
|
599
|
-
/** Locale of the time-axis labels. Default
|
|
784
|
+
/** Locale of the time-axis labels and the built-in words (total, target, forecast). Default the page's (`<html lang>`). */
|
|
600
785
|
locale?: string
|
|
601
786
|
type?: CartesianStackType
|
|
602
787
|
/** Bar only. `"vertical"` lays the categories down the y axis (horizontal bars). */
|
|
@@ -834,11 +1019,12 @@ function timeAxisLabelStep(values: readonly unknown[], maxLabels: number = TIME_
|
|
|
834
1019
|
*/
|
|
835
1020
|
function timeAxisLabels(
|
|
836
1021
|
values: readonly unknown[],
|
|
837
|
-
|
|
1022
|
+
localeArg?: string,
|
|
838
1023
|
shown: (i: number) => boolean = () => true,
|
|
839
1024
|
): string[] | null {
|
|
840
1025
|
const parsed = parseTimeIndex(values)
|
|
841
1026
|
if (!parsed) return null
|
|
1027
|
+
const locale = resolveLocale(localeArg)
|
|
842
1028
|
const { granularity, times } = parsed
|
|
843
1029
|
let prevYear: number | null = null
|
|
844
1030
|
return times.map((t, i) => {
|
|
@@ -861,9 +1047,10 @@ function timeAxisLabels(
|
|
|
861
1047
|
}
|
|
862
1048
|
|
|
863
1049
|
/** The tooltip titles for time-like x values (`2025年9月`, `2025年9月1日`), or `null` when they are not dates. */
|
|
864
|
-
function timeTooltipLabels(values: readonly unknown[],
|
|
1050
|
+
function timeTooltipLabels(values: readonly unknown[], localeArg?: string): string[] | null {
|
|
865
1051
|
const parsed = parseTimeIndex(values)
|
|
866
1052
|
if (!parsed) return null
|
|
1053
|
+
const locale = resolveLocale(localeArg)
|
|
867
1054
|
const options: Intl.DateTimeFormatOptions =
|
|
868
1055
|
parsed.granularity === "year"
|
|
869
1056
|
? { year: "numeric" }
|
|
@@ -1003,7 +1190,9 @@ function tooltipHtml(
|
|
|
1003
1190
|
rows: readonly TooltipRow[],
|
|
1004
1191
|
valueFormatter: (value: number) => string,
|
|
1005
1192
|
notes: readonly string[] = [],
|
|
1193
|
+
locale?: string,
|
|
1006
1194
|
): string {
|
|
1195
|
+
const t = chartText(locale)
|
|
1007
1196
|
const lines = rows.map(
|
|
1008
1197
|
(row) =>
|
|
1009
1198
|
`<div style="display:flex;align-items:center;gap:8px;line-height:20px">` +
|
|
@@ -1012,7 +1201,7 @@ function tooltipHtml(
|
|
|
1012
1201
|
`<span style="font-weight:600;font-variant-numeric:tabular-nums;margin-left:12px">${
|
|
1013
1202
|
row.value === null ? EMPTY_VALUE : escapeHtml(valueFormatter(row.value))
|
|
1014
1203
|
}${
|
|
1015
|
-
row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px"
|
|
1204
|
+
row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px">${escapeHtml(t.share(formatShare(row.share)))}</span>`
|
|
1016
1205
|
}</span></div>` +
|
|
1017
1206
|
(row.note === undefined
|
|
1018
1207
|
? ""
|
|
@@ -1026,8 +1215,10 @@ function tooltipHtml(
|
|
|
1026
1215
|
|
|
1027
1216
|
/* ------------------------------------------------- per-bar targets / notes */
|
|
1028
1217
|
|
|
1029
|
-
/** Default name of the per-bar target marker (legend, tooltip). */
|
|
1030
|
-
|
|
1218
|
+
/** Default name of the per-bar target marker (legend, tooltip) in `locale`: "Target" / 「目標」. Default the page's locale. */
|
|
1219
|
+
function barTargetLabel(locale?: string): string {
|
|
1220
|
+
return chartText(locale).target
|
|
1221
|
+
}
|
|
1031
1222
|
|
|
1032
1223
|
/** How far (px) the target marker reaches past each side of its bar. */
|
|
1033
1224
|
const TARGET_MARKER_OVERHANG = 4
|
|
@@ -1078,16 +1269,17 @@ function achievementRate(value: number | null | undefined, target: number | null
|
|
|
1078
1269
|
return (value / target) * 100
|
|
1079
1270
|
}
|
|
1080
1271
|
|
|
1081
|
-
/** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3
|
|
1272
|
+
/** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3%)」 / "Target $12M (85.3% achieved)". `null` without a target. */
|
|
1082
1273
|
function barTargetNote(
|
|
1083
1274
|
label: string,
|
|
1084
1275
|
value: number | null,
|
|
1085
1276
|
target: number | null,
|
|
1086
1277
|
valueFormatter: (value: number) => string,
|
|
1278
|
+
locale?: string,
|
|
1087
1279
|
): string | null {
|
|
1088
1280
|
if (target === null) return null
|
|
1089
1281
|
const rate = achievementRate(value, target)
|
|
1090
|
-
return `${label} ${valueFormatter(target)}${rate === null ? "" :
|
|
1282
|
+
return `${label} ${valueFormatter(target)}${rate === null ? "" : chartText(locale).targetRate(formatShare(rate))}`
|
|
1091
1283
|
}
|
|
1092
1284
|
|
|
1093
1285
|
/** Half the width of a bar in category-axis units, from `barCategoryGap` (`"20%"`; a px gap or none → ECharts' 20%). */
|
|
@@ -1179,11 +1371,12 @@ const TARGET_LEGEND_SERIES_ID = "__target-legend"
|
|
|
1179
1371
|
/** A flat tick (8×8 legend box, aspect kept): the per-bar target's legend icon. */
|
|
1180
1372
|
const TARGET_LEGEND_ICON = "path://M0,0 L100,0 L100,24 L0,24 Z"
|
|
1181
1373
|
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1374
|
+
/**
|
|
1375
|
+
* 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.
|
|
1376
|
+
* Without a `label`, "Forecast" / 「予測」 in `locale` (default the page's).
|
|
1377
|
+
*/
|
|
1378
|
+
function forecastBoundaryText(label?: string, locale?: string): string {
|
|
1379
|
+
return `${label ?? chartText(locale).forecast} →`
|
|
1187
1380
|
}
|
|
1188
1381
|
|
|
1189
1382
|
/** A comparison series: a thin, muted, solid line with no markers (60% of `--muted-foreground`). */
|
|
@@ -1223,8 +1416,10 @@ interface AxisExtent {
|
|
|
1223
1416
|
max: number
|
|
1224
1417
|
}
|
|
1225
1418
|
|
|
1226
|
-
/** The label of a stack's total (highlight and tooltip). */
|
|
1227
|
-
|
|
1419
|
+
/** The label of a stack's total (highlight and tooltip) in `locale`: "Total" / 「合計」. Default the page's locale. */
|
|
1420
|
+
function stackTotalLabel(locale?: string): string {
|
|
1421
|
+
return chartText(locale).total
|
|
1422
|
+
}
|
|
1228
1423
|
|
|
1229
1424
|
/** Per point, the sum of the stacked series; `null` where every series is missing. */
|
|
1230
1425
|
function stackTotalsOf(values: readonly (readonly (number | null)[])[]): (number | null)[] {
|
|
@@ -1346,8 +1541,8 @@ function indexGrain(values: readonly unknown[]): PeriodGrain | null {
|
|
|
1346
1541
|
|
|
1347
1542
|
/**
|
|
1348
1543
|
* The tooltip line under the main series' row when a comparison series is drawn: the arrow, the signed change and the
|
|
1349
|
-
* comparison's name, as `Delta` writes it ("↑ +12.4% 前年同月比"; a minus is U+2212).
|
|
1350
|
-
* either value is missing or the baseline is 0.
|
|
1544
|
+
* comparison's name, as `Delta` writes it ("↑ +12.4% vs. same month last year" / "↑ +12.4% 前年同月比"; a minus is U+2212).
|
|
1545
|
+
* "No comparison vs. same month last year" / "比較なし 前年同月比" when either value is missing or the baseline is 0.
|
|
1351
1546
|
*/
|
|
1352
1547
|
function comparisonNote(
|
|
1353
1548
|
current: number | null,
|
|
@@ -1355,16 +1550,18 @@ function comparisonNote(
|
|
|
1355
1550
|
comparison: Pick<CartesianComparisonConfig, "vs" | "label">,
|
|
1356
1551
|
grain?: PeriodGrain,
|
|
1357
1552
|
format?: ValueFormat,
|
|
1358
|
-
|
|
1553
|
+
localeArg?: string,
|
|
1359
1554
|
): string {
|
|
1555
|
+
const locale = resolveLocale(localeArg)
|
|
1360
1556
|
const result = computeComparison(
|
|
1361
1557
|
current,
|
|
1362
1558
|
comparison.vs === "custom"
|
|
1363
1559
|
? { previous, vs: "custom", label: comparison.label ?? "", grain }
|
|
1364
1560
|
: { previous, vs: comparison.vs, label: comparison.label, grain },
|
|
1561
|
+
{ locale },
|
|
1365
1562
|
)
|
|
1366
1563
|
const text = formatChange(result, defaultChangeDisplay(format), locale, format)
|
|
1367
|
-
if (text === null) return
|
|
1564
|
+
if (text === null) return `${chartText(locale).noComparison} ${result.label}`
|
|
1368
1565
|
const arrow = result.direction === "up" ? "↑" : result.direction === "down" ? "↓" : "→"
|
|
1369
1566
|
return `${arrow} ${text.replace(/^-/, "−")} ${result.label}`
|
|
1370
1567
|
}
|
|
@@ -1407,7 +1604,6 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1407
1604
|
toneColors,
|
|
1408
1605
|
valueFormatter,
|
|
1409
1606
|
axisFormatter = valueFormatter,
|
|
1410
|
-
locale = DEFAULT_LOCALE,
|
|
1411
1607
|
type = "default",
|
|
1412
1608
|
layout = "horizontal",
|
|
1413
1609
|
curveType = "linear",
|
|
@@ -1444,7 +1640,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1444
1640
|
chartOptions,
|
|
1445
1641
|
reserveActions = false,
|
|
1446
1642
|
} = cfg
|
|
1447
|
-
const
|
|
1643
|
+
const locale = resolveLocale(cfg.locale)
|
|
1644
|
+
const text = chartText(locale)
|
|
1645
|
+
const provisionalLabel = cfg.provisionalLabel ?? text.provisional
|
|
1448
1646
|
|
|
1449
1647
|
const stacked = kind !== "line" && type !== "default"
|
|
1450
1648
|
const percent = kind !== "line" && type === "percent"
|
|
@@ -1593,9 +1791,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1593
1791
|
// `barOptions.targetKey` (a target per bar) takes over from the single `target` line.
|
|
1594
1792
|
const perBarTarget = kind === "bar" && Boolean(targetKey)
|
|
1595
1793
|
const hasTarget = !perBarTarget && target !== undefined && Number.isFinite(target)
|
|
1596
|
-
const targetText = hasTarget ? (targetLabel ??
|
|
1794
|
+
const targetText = hasTarget ? (targetLabel ?? `${text.target} ${axisFormatter(target)}`) : ""
|
|
1597
1795
|
const highlightText = (value: number) =>
|
|
1598
|
-
shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${
|
|
1796
|
+
shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${text.total} ` : ""}${axisFormatter(value)}`
|
|
1599
1797
|
/** Whether `p`'s label sits within the target's value band and under the target label (at the right end). */
|
|
1600
1798
|
const nearTargetLabel = (p: HighlightPoint, side: "under" | "over") => {
|
|
1601
1799
|
if (!hasTarget || !plotWidth || kind === "bar" || labels.length < 2) return false
|
|
@@ -1621,7 +1819,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1621
1819
|
// One series (or a stack, against its total) can be compared to a target; grouped series and 100% stacks cannot.
|
|
1622
1820
|
const barTargets: (number | null)[] | null =
|
|
1623
1821
|
perBarTarget && targetKey && !percent && (stacked || categories.length === 1) ? barTargetValues(data, targetKey) : null
|
|
1624
|
-
const barTargetName = targetLabel ??
|
|
1822
|
+
const barTargetName = targetLabel ?? text.target
|
|
1625
1823
|
const barTargetColor = labelColor ?? guideColor
|
|
1626
1824
|
|
|
1627
1825
|
const markPointFor = (c: number, color: string | undefined) =>
|
|
@@ -1835,7 +2033,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1835
2033
|
}
|
|
1836
2034
|
|
|
1837
2035
|
/* -- forecast: dashed continuation (+ optional band) of one series */
|
|
1838
|
-
const forecastLabel = forecast?.label ??
|
|
2036
|
+
const forecastLabel = forecast?.label ?? text.forecast
|
|
1839
2037
|
const forecastBandAlpha = isDarkSurface(labelBackground) ? FORECAST_BAND_ALPHA_DARK : FORECAST_BAND_ALPHA_LIGHT
|
|
1840
2038
|
const forecastValues: (number | null)[] = []
|
|
1841
2039
|
if (forecast) {
|
|
@@ -2311,7 +2509,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
2311
2509
|
})
|
|
2312
2510
|
}
|
|
2313
2511
|
if (stackTotals && i <= lastActual && rows.length > 1) {
|
|
2314
|
-
rows.push({ name:
|
|
2512
|
+
rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
|
|
2315
2513
|
}
|
|
2316
2514
|
const provisional = provStart !== -1 && i >= provStart && i <= lastActual
|
|
2317
2515
|
if (provisional) {
|
|
@@ -2327,19 +2525,19 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
2327
2525
|
...noteFor(at),
|
|
2328
2526
|
})
|
|
2329
2527
|
})
|
|
2330
|
-
if (stackTotals && rows.length > 1) rows.push({ name:
|
|
2528
|
+
if (stackTotals && rows.length > 1) rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
|
|
2331
2529
|
}
|
|
2332
2530
|
const title = tooltipTitles[i] ?? labels[i] ?? ""
|
|
2333
2531
|
const lines: string[] = []
|
|
2334
2532
|
if (barTargets && i <= lastActual) {
|
|
2335
2533
|
const total = stackTotals ? (stackTotals[i] ?? null) : (values[0]?.[i] ?? null)
|
|
2336
|
-
const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter)
|
|
2534
|
+
const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter, locale)
|
|
2337
2535
|
if (note) lines.push(note)
|
|
2338
2536
|
}
|
|
2339
2537
|
for (const note of notes) {
|
|
2340
2538
|
if (note.to === undefined ? note.from === i : note.from <= i && i <= note.to) lines.push(note.label)
|
|
2341
2539
|
}
|
|
2342
|
-
return tooltipHtml(provisional ?
|
|
2540
|
+
return tooltipHtml(provisional ? text.provisionalTitle(title, provisionalLabel) : title, rows, valueFormatter, lines, locale)
|
|
2343
2541
|
},
|
|
2344
2542
|
},
|
|
2345
2543
|
xAxis: horizontalBars ? valueAxis : categoryAxis,
|
|
@@ -2431,8 +2629,6 @@ interface DonutOptionSlice {
|
|
|
2431
2629
|
/** How many of the largest slices get an outside label (「自然検索 41%」) by default. */
|
|
2432
2630
|
const DONUT_OUTSIDE_LABELS = 2
|
|
2433
2631
|
|
|
2434
|
-
const DEFAULT_TOTAL_LABEL = "合計"
|
|
2435
|
-
|
|
2436
2632
|
const sliceValue = (value: number) => (Number.isFinite(value) && value > 0 ? value : 0)
|
|
2437
2633
|
|
|
2438
2634
|
/** A slice's share of `total` as text: whole percent, `<1%` for a sliver, `0%` for nothing. */
|
|
@@ -2465,15 +2661,16 @@ function donutLegendItems(slices: readonly DonutOptionSlice[]): DonutLegendItem[
|
|
|
2465
2661
|
}
|
|
2466
2662
|
|
|
2467
2663
|
/**
|
|
2468
|
-
* The default center label: what is totalled (「合計」), the formatted total
|
|
2664
|
+
* The default center label: what is totalled (「合計」 / "Total" in `locale`, default the page's), the formatted total
|
|
2469
2665
|
* (`amount`, the big line), the unit (a small line under it) and both as one
|
|
2470
2666
|
* string (`value`, 「9.2万 セッション」).
|
|
2471
2667
|
*/
|
|
2472
2668
|
function donutCenterText(
|
|
2473
2669
|
total: number,
|
|
2474
2670
|
valueFormatter: (value: number) => string,
|
|
2475
|
-
{ totalLabel
|
|
2671
|
+
{ totalLabel, unit, locale }: { totalLabel?: string; unit?: string; locale?: string } = {},
|
|
2476
2672
|
): { label: string; value: string; amount: string; unit?: string } {
|
|
2673
|
+
totalLabel ??= chartText(locale).total
|
|
2477
2674
|
const amount = valueFormatter(total)
|
|
2478
2675
|
return { label: totalLabel, value: unit ? `${amount} ${unit}` : amount, amount, ...(unit ? { unit } : {}) }
|
|
2479
2676
|
}
|
|
@@ -2701,8 +2898,10 @@ function hasLabel(labels: Readonly<Record<string, string>> | undefined, key: str
|
|
|
2701
2898
|
|
|
2702
2899
|
/* ------------------------------------------------------------ provisional */
|
|
2703
2900
|
|
|
2704
|
-
/** The default label for points that are not final yet. */
|
|
2705
|
-
|
|
2901
|
+
/** The default label for points that are not final yet, in `locale`: "Preliminary" / 「速報」. Default the page's locale. */
|
|
2902
|
+
function defaultProvisionalLabel(locale?: string): string {
|
|
2903
|
+
return chartText(locale).provisional
|
|
2904
|
+
}
|
|
2706
2905
|
|
|
2707
2906
|
/**
|
|
2708
2907
|
* Position of the first provisional point: the first entry of `indexValues`
|
|
@@ -2840,9 +3039,10 @@ function resolveValueFormatter(
|
|
|
2840
3039
|
function resolveAxisFormatter(
|
|
2841
3040
|
valueFormatter: ValueFormatter | undefined,
|
|
2842
3041
|
format: ValueFormat | undefined,
|
|
2843
|
-
|
|
3042
|
+
localeArg?: string,
|
|
2844
3043
|
): ValueFormatter {
|
|
2845
3044
|
if (valueFormatter) return valueFormatter
|
|
3045
|
+
const locale = resolveLocale(localeArg)
|
|
2846
3046
|
const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
|
|
2847
3047
|
if (spec.type !== "compact" && spec.type !== "number" && spec.type !== "currency") return toValueFormatter(format, locale)
|
|
2848
3048
|
const currency = "currency" in spec ? spec.currency : undefined
|
|
@@ -2851,40 +3051,75 @@ function resolveAxisFormatter(
|
|
|
2851
3051
|
return (value) => formatValue(value, short, locale)
|
|
2852
3052
|
}
|
|
2853
3053
|
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
{ char: "万", size: 1e4 },
|
|
2859
|
-
] as const
|
|
3054
|
+
interface CompactUnit {
|
|
3055
|
+
token: string
|
|
3056
|
+
size: number
|
|
3057
|
+
}
|
|
2860
3058
|
|
|
2861
|
-
const
|
|
3059
|
+
const compactUnitCache = new Map<string, CompactUnit[] | null>()
|
|
3060
|
+
|
|
3061
|
+
/**
|
|
3062
|
+
* The compact-notation unit words of a locale and their sizes, largest first: `兆` / `億` / `万` in ja,
|
|
3063
|
+
* `T` / `B` / `M` / `K` in en. `null` when the locale's compact form puts anything between the number and its
|
|
3064
|
+
* word (`1,5 Mio.`), where a token cannot be swapped in place.
|
|
3065
|
+
*/
|
|
3066
|
+
function compactUnits(locale: string): CompactUnit[] | null {
|
|
3067
|
+
const cached = compactUnitCache.get(locale)
|
|
3068
|
+
if (cached !== undefined) return cached
|
|
3069
|
+
let units: CompactUnit[] | null = []
|
|
3070
|
+
try {
|
|
3071
|
+
const compact = new Intl.NumberFormat(locale, { notation: "compact" })
|
|
3072
|
+
const seen = new Set<string>()
|
|
3073
|
+
for (let exp = 3; exp <= 15; exp++) {
|
|
3074
|
+
const parts = compact.formatToParts(10 ** exp)
|
|
3075
|
+
const at = parts.findIndex((p) => p.type === "compact")
|
|
3076
|
+
if (at === -1) continue
|
|
3077
|
+
if (parts[at - 1]?.type !== "integer" || at !== parts.length - 1) {
|
|
3078
|
+
units = null
|
|
3079
|
+
break
|
|
3080
|
+
}
|
|
3081
|
+
const token = parts[at]!.value
|
|
3082
|
+
if (seen.has(token)) continue
|
|
3083
|
+
seen.add(token)
|
|
3084
|
+
units.push({ token, size: 10 ** exp })
|
|
3085
|
+
}
|
|
3086
|
+
} catch {
|
|
3087
|
+
units = null
|
|
3088
|
+
}
|
|
3089
|
+
const sorted = units && units.length > 0 ? units.sort((a, b) => b.size - a.size) : null
|
|
3090
|
+
compactUnitCache.set(locale, sorted)
|
|
3091
|
+
return sorted
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
2862
3095
|
|
|
2863
3096
|
/**
|
|
2864
3097
|
* The value-axis tick formatter for an axis whose largest tick is `top`: every tick reads in the unit of
|
|
2865
|
-
* that largest tick, so
|
|
2866
|
-
* Only the
|
|
2867
|
-
* any other format —
|
|
3098
|
+
* that largest tick, so units never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`;
|
|
3099
|
+
* `$0.5M` / `$1M` / `$1.5M`, not `$500K` / `$1M`). Only the short forms (`compact` / `number` / `currency`,
|
|
3100
|
+
* no `valueFormatter`) have units to align; any other format — an axis whose largest tick is under the smallest
|
|
3101
|
+
* unit (1万 / 1K), or a locale whose compact form cannot be aligned in place — keeps `resolveAxisFormatter`'s output as it is.
|
|
2868
3102
|
*/
|
|
2869
3103
|
function resolveAxisTickFormatter(
|
|
2870
3104
|
valueFormatter: ValueFormatter | undefined,
|
|
2871
3105
|
format: ValueFormat | undefined,
|
|
2872
|
-
|
|
3106
|
+
localeArg?: string,
|
|
2873
3107
|
): (top: number) => ValueFormatter {
|
|
3108
|
+
const locale = resolveLocale(localeArg)
|
|
2874
3109
|
const base = resolveAxisFormatter(valueFormatter, format, locale)
|
|
2875
3110
|
const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
|
|
2876
|
-
const
|
|
2877
|
-
|
|
2878
|
-
if (!aligns) return () => base
|
|
3111
|
+
const units = !valueFormatter && (spec.type === "compact" || spec.type === "number" || spec.type === "currency") ? compactUnits(locale) : null
|
|
3112
|
+
if (!units) return () => base
|
|
2879
3113
|
const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 3 })
|
|
3114
|
+
const numberToken = new RegExp(`\\d[\\d,]*(?:\\.\\d+)?(?:${units.map((u) => escapeRegExp(u.token)).join("|")})?`)
|
|
2880
3115
|
return (top) => {
|
|
2881
|
-
const unit =
|
|
3116
|
+
const unit = units.find((u) => Math.abs(top) >= u.size)
|
|
2882
3117
|
if (!unit) return base
|
|
2883
3118
|
return (value) => {
|
|
2884
3119
|
const text = base(value)
|
|
2885
3120
|
if (value === 0 || !Number.isFinite(value)) return text
|
|
2886
|
-
const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.
|
|
2887
|
-
return text.replace(
|
|
3121
|
+
const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.token}`
|
|
3122
|
+
return text.replace(numberToken, () => scaled)
|
|
2888
3123
|
}
|
|
2889
3124
|
}
|
|
2890
3125
|
}
|
|
@@ -2913,8 +3148,20 @@ function clamp(value: number, min: number, max: number): number {
|
|
|
2913
3148
|
/** How many categories a chart colours individually; the rest fold into「その他」. */
|
|
2914
3149
|
const MAX_CHART_SERIES = 5
|
|
2915
3150
|
|
|
2916
|
-
/** The series name the folded categories get. */
|
|
2917
|
-
const
|
|
3151
|
+
/** The series name the folded categories get, by language. */
|
|
3152
|
+
const OTHER_SERIES_LABELS: Record<UiLanguage, string> = { ja: "その他", en: "Other" }
|
|
3153
|
+
|
|
3154
|
+
/** The series name the folded categories get in `locale` ("Other" / "その他"). Default the page's locale (`<html lang>`). */
|
|
3155
|
+
function otherSeriesLabel(locale?: string): string {
|
|
3156
|
+
return OTHER_SERIES_LABELS[uiLanguage(resolveLocale(locale))]
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
const OTHER_NAMES = new Set(Object.values(OTHER_SERIES_LABELS))
|
|
3160
|
+
|
|
3161
|
+
/** Whether a category / row is the "other" bucket by name (「その他」 or "Other", whatever the page language). */
|
|
3162
|
+
function isOtherName(name: string | null | undefined): boolean {
|
|
3163
|
+
return typeof name === "string" && OTHER_NAMES.has(name)
|
|
3164
|
+
}
|
|
2918
3165
|
|
|
2919
3166
|
interface AggregatedItems<Item extends { name: string; value: number }> {
|
|
2920
3167
|
/** The `n` largest items, in their given order. All items when there are `n` or fewer. */
|
|
@@ -2934,7 +3181,7 @@ interface AggregatedItems<Item extends { name: string; value: number }> {
|
|
|
2934
3181
|
function aggregateTopN<Item extends { name: string; value: number }>(
|
|
2935
3182
|
items: readonly Item[],
|
|
2936
3183
|
n: number = MAX_CHART_SERIES,
|
|
2937
|
-
otherLabel: string =
|
|
3184
|
+
otherLabel: string = otherSeriesLabel(),
|
|
2938
3185
|
): AggregatedItems<Item> {
|
|
2939
3186
|
if (items.length <= n) return { top: [...items], rest: [], other: null }
|
|
2940
3187
|
const ranked = items
|
|
@@ -2963,7 +3210,7 @@ function aggregateSeries(
|
|
|
2963
3210
|
data: readonly Record<string, unknown>[],
|
|
2964
3211
|
categories: readonly string[],
|
|
2965
3212
|
n: number = MAX_CHART_SERIES,
|
|
2966
|
-
otherLabel: string =
|
|
3213
|
+
otherLabel: string = otherSeriesLabel(),
|
|
2967
3214
|
): AggregatedSeries {
|
|
2968
3215
|
const totals = categories.map((name) => ({
|
|
2969
3216
|
name,
|
|
@@ -2995,12 +3242,14 @@ function aggregateSeriesKeeping(
|
|
|
2995
3242
|
categories: readonly string[],
|
|
2996
3243
|
keep: string | undefined,
|
|
2997
3244
|
n: number = MAX_CHART_SERIES,
|
|
3245
|
+
otherLabel: string = otherSeriesLabel(),
|
|
2998
3246
|
): AggregatedSeries {
|
|
2999
|
-
if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n)
|
|
3247
|
+
if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n, otherLabel)
|
|
3000
3248
|
const aggregated = aggregateSeries(
|
|
3001
3249
|
data,
|
|
3002
3250
|
categories.filter((c) => c !== keep),
|
|
3003
3251
|
n,
|
|
3252
|
+
otherLabel,
|
|
3004
3253
|
)
|
|
3005
3254
|
return { ...aggregated, categories: [...aggregated.categories, keep] }
|
|
3006
3255
|
}
|
|
@@ -3036,7 +3285,7 @@ function seriesColorsWithComparison(
|
|
|
3036
3285
|
* Colour per drawn series: the record's token for that category, else the
|
|
3037
3286
|
* array's token at the category's position in `allCategories` (cycled), else
|
|
3038
3287
|
* the token palette by drawn position. `otherCategory` — and a category the
|
|
3039
|
-
* data itself names
|
|
3288
|
+
* data itself names「その他」/ "Other" (`OTHER_SERIES_LABELS`) — always gets `otherColor`.
|
|
3040
3289
|
*/
|
|
3041
3290
|
function seriesColorsFor(
|
|
3042
3291
|
categories: readonly string[],
|
|
@@ -3048,7 +3297,7 @@ function seriesColorsFor(
|
|
|
3048
3297
|
const list = Array.isArray(colors) ? (colors as ChartSeriesColor[]) : undefined
|
|
3049
3298
|
const record = !Array.isArray(colors) && colors ? (colors as Partial<Record<string, ChartSeriesColor>>) : undefined
|
|
3050
3299
|
return categories.map((category, i) => {
|
|
3051
|
-
if ((otherCategory !== null && category === otherCategory) || category
|
|
3300
|
+
if ((otherCategory !== null && category === otherCategory) || isOtherName(category)) return otherColor
|
|
3052
3301
|
const fromRecord = record?.[category]
|
|
3053
3302
|
if (fromRecord) return fromRecord
|
|
3054
3303
|
if (list && list.length > 0) {
|
|
@@ -3095,7 +3344,7 @@ interface DonutSlicesConfig {
|
|
|
3095
3344
|
*/
|
|
3096
3345
|
function donutSlices(
|
|
3097
3346
|
data: readonly Record<string, unknown>[],
|
|
3098
|
-
{ index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel =
|
|
3347
|
+
{ index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = otherSeriesLabel() }: DonutSlicesConfig,
|
|
3099
3348
|
): DonutSlice[] {
|
|
3100
3349
|
const items = data
|
|
3101
3350
|
.map((row, i) => {
|
|
@@ -3108,7 +3357,8 @@ function donutSlices(
|
|
|
3108
3357
|
const slices: DonutSlice[] = top.map((item, rank) => ({
|
|
3109
3358
|
name: item.name,
|
|
3110
3359
|
value: item.value,
|
|
3111
|
-
|
|
3360
|
+
// A row the data itself names「その他」/ "Other" is the same remainder: grey, like the folded slice.
|
|
3361
|
+
color: isOtherName(item.name) ? otherColor : palette[rank % palette.length]!,
|
|
3112
3362
|
isOther: false,
|
|
3113
3363
|
row: item.row,
|
|
3114
3364
|
}))
|
|
@@ -3181,7 +3431,7 @@ interface BarListRowsConfig extends BarListOptions {
|
|
|
3181
3431
|
*/
|
|
3182
3432
|
function barListRows<Row extends object>(
|
|
3183
3433
|
data: readonly Row[],
|
|
3184
|
-
{ index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel =
|
|
3434
|
+
{ index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = otherSeriesLabel(), exclude }: BarListRowsConfig = {},
|
|
3185
3435
|
): BarListRow<Row>[] {
|
|
3186
3436
|
const items = data.map((row): BarListRow<Row> => {
|
|
3187
3437
|
const cells = row as Record<string, unknown>
|
|
@@ -3227,19 +3477,21 @@ const BAR_LIST_OTHER_TINT = 30
|
|
|
3227
3477
|
|
|
3228
3478
|
/**
|
|
3229
3479
|
* The fill of each BarList row: all real rows in the same tint of `color`
|
|
3230
|
-
* (`BAR_LIST_TINT`), the folded「その他」row in a tint of `otherColor`
|
|
3480
|
+
* (`BAR_LIST_TINT`), the folded「その他」row (and a row the data names「その他」/ "Other") in a tint of `otherColor`
|
|
3231
3481
|
* (`BAR_LIST_OTHER_TINT`) — the palest fill, a remainder never outweighs a
|
|
3232
3482
|
* ranked row. A legacy per-row `color` (or a threshold tone) replaces `color`
|
|
3233
3483
|
* for that row. A tint, not the solid token, so the label printed over the bar
|
|
3234
3484
|
* keeps its contrast.
|
|
3235
3485
|
*/
|
|
3236
3486
|
function barListFills(
|
|
3237
|
-
rows: readonly Pick<BarListRow, "isOther" | "color">[],
|
|
3487
|
+
rows: readonly (Pick<BarListRow, "isOther" | "color"> & { name?: string })[],
|
|
3238
3488
|
color: ChartColor,
|
|
3239
3489
|
otherColor: string,
|
|
3240
3490
|
): string[] {
|
|
3241
3491
|
return rows.map((row) =>
|
|
3242
|
-
row.isOther
|
|
3492
|
+
row.isOther || isOtherName(row.name)
|
|
3493
|
+
? tintCss(otherColor, BAR_LIST_OTHER_TINT)
|
|
3494
|
+
: tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
|
|
3243
3495
|
)
|
|
3244
3496
|
}
|
|
3245
3497
|
|
|
@@ -3455,11 +3707,30 @@ interface ChartSummaryInput {
|
|
|
3455
3707
|
format?: ValueFormat
|
|
3456
3708
|
/** Wins over `format`. */
|
|
3457
3709
|
valueFormatter?: ValueFormatter
|
|
3710
|
+
/** Language of the sentence. Default the page's locale (`<html lang>`). */
|
|
3711
|
+
locale?: string
|
|
3712
|
+
}
|
|
3713
|
+
|
|
3714
|
+
const SUMMARY_TEXT = {
|
|
3715
|
+
ja: {
|
|
3716
|
+
up: "、前の点より増加",
|
|
3717
|
+
down: "、前の点より減少",
|
|
3718
|
+
flat: "、前の点と横ばい",
|
|
3719
|
+
latest: (label: string, value: string) => `最新 ${label} は ${value}`,
|
|
3720
|
+
top: (name: string, share: string) => `最大は ${name}(${share})`,
|
|
3721
|
+
},
|
|
3722
|
+
en: {
|
|
3723
|
+
up: ", up from the previous point",
|
|
3724
|
+
down: ", down from the previous point",
|
|
3725
|
+
flat: ", flat from the previous point",
|
|
3726
|
+
latest: (label: string, value: string) => `Latest ${label}: ${value}`,
|
|
3727
|
+
top: (name: string, share: string) => `Largest: ${name} (${share})`,
|
|
3728
|
+
},
|
|
3458
3729
|
}
|
|
3459
3730
|
|
|
3460
3731
|
/**
|
|
3461
3732
|
* One-sentence summary built from the rows, for `summary="auto"`:
|
|
3462
|
-
* 「<title>: 最新 <index> は
|
|
3733
|
+
* 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」("<title>: Latest <index>: <value>, up from the previous point"). The direction is
|
|
3463
3734
|
* computed from the last two non-empty points of the first series — never
|
|
3464
3735
|
* written by hand, so it cannot contradict the data. `undefined` when there is
|
|
3465
3736
|
* no usable point.
|
|
@@ -3475,26 +3746,29 @@ function buildChartSummary(input: ChartSummaryInput): string | undefined {
|
|
|
3475
3746
|
}
|
|
3476
3747
|
const latest = points[points.length - 1]
|
|
3477
3748
|
if (!latest) return undefined
|
|
3478
|
-
const
|
|
3749
|
+
const locale = resolveLocale(input.locale)
|
|
3750
|
+
const t = SUMMARY_TEXT[uiLanguage(locale)]
|
|
3751
|
+
const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact", locale)
|
|
3479
3752
|
const prev = points[points.length - 2]
|
|
3480
|
-
const direction = !prev ? "" : latest.value > prev.value ?
|
|
3753
|
+
const direction = !prev ? "" : latest.value > prev.value ? t.up : latest.value < prev.value ? t.down : t.flat
|
|
3481
3754
|
const head = input.title ? `${input.title}: ` : ""
|
|
3482
|
-
return `${head}
|
|
3755
|
+
return `${head}${t.latest(latest.label, text(latest.value))}${direction}`
|
|
3483
3756
|
}
|
|
3484
3757
|
|
|
3485
3758
|
/**
|
|
3486
|
-
* `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」 from the slices
|
|
3487
|
-
* (largest first). `undefined` when there is nothing to describe.
|
|
3759
|
+
* `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」("<title>: Largest: <name> (<share>)") from the slices
|
|
3760
|
+
* (largest first). `undefined` when there is nothing to describe. `locale` picks the language; default the page's.
|
|
3488
3761
|
*/
|
|
3489
3762
|
function buildShareSummary(
|
|
3490
3763
|
slices: readonly { name: string; value: number }[],
|
|
3491
3764
|
title?: string,
|
|
3765
|
+
locale?: string,
|
|
3492
3766
|
): string | undefined {
|
|
3493
3767
|
const total = slices.reduce((sum, s) => sum + s.value, 0)
|
|
3494
3768
|
const top = slices[0]
|
|
3495
3769
|
if (!top || total <= 0) return undefined
|
|
3496
3770
|
const share = `${Math.round((top.value / total) * 1000) / 10}%`
|
|
3497
|
-
return `${title ? `${title}: ` : ""}
|
|
3771
|
+
return `${title ? `${title}: ` : ""}${SUMMARY_TEXT[uiLanguage(resolveLocale(locale))].top(top.name, share)}`
|
|
3498
3772
|
}
|
|
3499
3773
|
|
|
3500
3774
|
/* ---------- end of inlined code ---------- */
|
|
@@ -3561,6 +3835,47 @@ export interface WaterfallChartProps<T extends Record<string, unknown> = Record<
|
|
|
3561
3835
|
|
|
3562
3836
|
/* ------------------------------------------------------------------ model */
|
|
3563
3837
|
|
|
3838
|
+
const TEXT = {
|
|
3839
|
+
ja: {
|
|
3840
|
+
net: "純増",
|
|
3841
|
+
total: "合計",
|
|
3842
|
+
change: "増減",
|
|
3843
|
+
cumulative: "累計",
|
|
3844
|
+
cumulativeChange: "累計増減",
|
|
3845
|
+
item: "項目",
|
|
3846
|
+
start: "開始",
|
|
3847
|
+
end: "終了",
|
|
3848
|
+
fromTo: (from: string, to: string, net: string) => `${from} → ${to}(${net})`,
|
|
3849
|
+
from: (from: string, net: string) => `${from} から ${net}`,
|
|
3850
|
+
to: (to: string, net: string) => `${to}(${net})`,
|
|
3851
|
+
netOnly: (net: string) => `増減の合計 ${net}`,
|
|
3852
|
+
increase: (name: string, value: string) => `最大の増加は ${name}(${value})`,
|
|
3853
|
+
decrease: (name: string, value: string) => `最大の減少は ${name}(${value})`,
|
|
3854
|
+
sentenceJoin: "。",
|
|
3855
|
+
chart: (label: string) => `${label}のウォーターフォールチャート`,
|
|
3856
|
+
},
|
|
3857
|
+
en: {
|
|
3858
|
+
net: "Net change",
|
|
3859
|
+
total: "Total",
|
|
3860
|
+
change: "Change",
|
|
3861
|
+
cumulative: "Cumulative",
|
|
3862
|
+
cumulativeChange: "Cumulative change",
|
|
3863
|
+
item: "Item",
|
|
3864
|
+
start: "Start",
|
|
3865
|
+
end: "End",
|
|
3866
|
+
fromTo: (from: string, to: string, net: string) => `${from} → ${to} (${net})`,
|
|
3867
|
+
from: (from: string, net: string) => `${net} from ${from}`,
|
|
3868
|
+
to: (to: string, net: string) => `${to} (${net})`,
|
|
3869
|
+
netOnly: (net: string) => `Net change ${net}`,
|
|
3870
|
+
increase: (name: string, value: string) => `Largest increase: ${name} (${value})`,
|
|
3871
|
+
decrease: (name: string, value: string) => `Largest decrease: ${name} (${value})`,
|
|
3872
|
+
sentenceJoin: ". ",
|
|
3873
|
+
chart: (label: string) => `Waterfall chart of ${label}`,
|
|
3874
|
+
},
|
|
3875
|
+
}
|
|
3876
|
+
|
|
3877
|
+
const textOf = (locale?: string) => TEXT[uiLanguage(resolveLocale(locale))]
|
|
3878
|
+
|
|
3564
3879
|
export type WaterfallKind = "total" | "increase" | "decrease" | "flat" | "gap"
|
|
3565
3880
|
|
|
3566
3881
|
export interface WaterfallStep {
|
|
@@ -3582,7 +3897,7 @@ export interface WaterfallStep {
|
|
|
3582
3897
|
*/
|
|
3583
3898
|
export function waterfallSteps(
|
|
3584
3899
|
data: readonly Record<string, unknown>[],
|
|
3585
|
-
config: { index: string; value: string; totals?: readonly string[]; mode?: WaterfallMode },
|
|
3900
|
+
config: { index: string; value: string; totals?: readonly string[]; mode?: WaterfallMode; locale?: string },
|
|
3586
3901
|
): WaterfallStep[] {
|
|
3587
3902
|
const totals = new Set(config.totals ?? [])
|
|
3588
3903
|
const delta = config.mode === "delta"
|
|
@@ -3603,13 +3918,15 @@ export function waterfallSteps(
|
|
|
3603
3918
|
})
|
|
3604
3919
|
// `delta`: the net change closes the plot as a total bar from 0 (series colour 1), so the moves add up to something.
|
|
3605
3920
|
if (delta && steps.some((s) => s.kind !== "gap")) {
|
|
3606
|
-
steps.push({ name:
|
|
3921
|
+
steps.push({ name: deltaTotalName(config.locale), kind: "total", value: running, start: 0, end: running, row: {} })
|
|
3607
3922
|
}
|
|
3608
3923
|
return steps
|
|
3609
3924
|
}
|
|
3610
3925
|
|
|
3611
|
-
/** The name of the net-change bar that `mode="delta"` appends. */
|
|
3612
|
-
export
|
|
3926
|
+
/** The name of the net-change bar that `mode="delta"` appends, in `locale` ("Net change" / 「純増」). Default the page's locale. */
|
|
3927
|
+
export function deltaTotalName(locale?: string): string {
|
|
3928
|
+
return textOf(locale).net
|
|
3929
|
+
}
|
|
3613
3930
|
|
|
3614
3931
|
/** What `mode="delta"` leaves out of the bars: the opening / closing totals and the net change of the moves. */
|
|
3615
3932
|
export interface WaterfallDeltaEnds {
|
|
@@ -3647,16 +3964,22 @@ export function waterfallDeltaEnds(
|
|
|
3647
3964
|
}
|
|
3648
3965
|
|
|
3649
3966
|
/** The note above a delta plot: `前期 ¥2.2億 → 当期 ¥2.2億(+¥130万)`; without the totals' values, the net change alone. */
|
|
3650
|
-
export function waterfallDeltaNote(
|
|
3967
|
+
export function waterfallDeltaNote(
|
|
3968
|
+
ends: WaterfallDeltaEnds,
|
|
3969
|
+
formatter: ValueFormatter,
|
|
3970
|
+
labels?: Record<string, string>,
|
|
3971
|
+
locale?: string,
|
|
3972
|
+
): string {
|
|
3973
|
+
const t = textOf(locale)
|
|
3651
3974
|
const net = signedText(ends.net, formatter)
|
|
3652
3975
|
const { opening, closing } = ends
|
|
3653
3976
|
const side = (e: { name: string; value: number | null } | null) => (e && e.value !== null ? `${labelOf(e.name, labels)} ${formatter(e.value)}` : null)
|
|
3654
3977
|
const from = side(opening)
|
|
3655
3978
|
const to = side(closing)
|
|
3656
|
-
if (from && to) return
|
|
3657
|
-
if (from) return
|
|
3658
|
-
if (to) return
|
|
3659
|
-
return
|
|
3979
|
+
if (from && to) return t.fromTo(from, to, net)
|
|
3980
|
+
if (from) return t.from(from, net)
|
|
3981
|
+
if (to) return t.to(to, net)
|
|
3982
|
+
return t.netOnly(net)
|
|
3660
3983
|
}
|
|
3661
3984
|
|
|
3662
3985
|
export interface WaterfallStack {
|
|
@@ -3740,7 +4063,9 @@ export function buildWaterfallSummary(
|
|
|
3740
4063
|
title?: string,
|
|
3741
4064
|
labels?: Record<string, string>,
|
|
3742
4065
|
delta?: WaterfallDeltaEnds,
|
|
4066
|
+
locale?: string,
|
|
3743
4067
|
): string | undefined {
|
|
4068
|
+
const t = textOf(locale)
|
|
3744
4069
|
const real = steps.filter((s) => s.kind !== "gap")
|
|
3745
4070
|
const first = real[0]
|
|
3746
4071
|
const last = real[real.length - 1]
|
|
@@ -3750,7 +4075,7 @@ export function buildWaterfallSummary(
|
|
|
3750
4075
|
// A first step starts at 0, so its end is its value; a total's end is the total. `delta`: the note (totals and net change) instead.
|
|
3751
4076
|
const parts = [
|
|
3752
4077
|
delta
|
|
3753
|
-
? waterfallDeltaNote(delta, formatter, labels)
|
|
4078
|
+
? waterfallDeltaNote(delta, formatter, labels, locale)
|
|
3754
4079
|
: real.length === 1
|
|
3755
4080
|
? `${name(first)} ${formatter(first.end)}`
|
|
3756
4081
|
: `${name(first)} ${formatter(first.end)} → ${name(last)} ${formatter(last.end)}`,
|
|
@@ -3761,9 +4086,9 @@ export function buildWaterfallSummary(
|
|
|
3761
4086
|
if (s.kind === "increase" && (!up || s.value! > up.value!)) up = s
|
|
3762
4087
|
if (s.kind === "decrease" && (!down || s.value! < down.value!)) down = s
|
|
3763
4088
|
}
|
|
3764
|
-
if (up) parts.push(
|
|
3765
|
-
if (down) parts.push(
|
|
3766
|
-
return `${head}${parts.join(
|
|
4089
|
+
if (up) parts.push(t.increase(name(up), signedText(up.value!, formatter)))
|
|
4090
|
+
if (down) parts.push(t.decrease(name(down), signedText(down.value!, formatter)))
|
|
4091
|
+
return `${head}${parts.join(t.sentenceJoin)}`
|
|
3767
4092
|
}
|
|
3768
4093
|
|
|
3769
4094
|
/** The screen-reader table: one row per step with its change and the running total. */
|
|
@@ -3773,19 +4098,21 @@ export function waterfallDataTable(
|
|
|
3773
4098
|
formatter: ValueFormatter,
|
|
3774
4099
|
labels?: Record<string, string>,
|
|
3775
4100
|
delta?: WaterfallDeltaEnds,
|
|
4101
|
+
locale?: string,
|
|
3776
4102
|
): ChartDataTable {
|
|
4103
|
+
const t = textOf(locale)
|
|
3777
4104
|
const label = (key: string) => labelOf(key, labels)
|
|
3778
4105
|
const rows = steps.map((s) => [
|
|
3779
4106
|
label(s.name),
|
|
3780
|
-
s.kind === "total" ? (delta ?
|
|
4107
|
+
s.kind === "total" ? (delta ? t.net : t.total) : s.value === null ? EMPTY_VALUE : signedText(s.value, formatter),
|
|
3781
4108
|
s.kind === "gap" ? EMPTY_VALUE : delta ? signedText(s.end, formatter) : formatter(s.end),
|
|
3782
4109
|
])
|
|
3783
4110
|
// `delta`: the totals are not bars, so the opening / closing values are rows of their own (they are not in `steps`).
|
|
3784
4111
|
const end = (e: WaterfallDeltaEnds["opening"], role: string) =>
|
|
3785
4112
|
e && e.value !== null ? [[label(e.name), role, formatter(e.value)]] : []
|
|
3786
4113
|
return {
|
|
3787
|
-
columns: [labels?.[names.index] ??
|
|
3788
|
-
rows: delta ? [...end(delta.opening,
|
|
4114
|
+
columns: [labels?.[names.index] ?? t.item, t.change, delta ? t.cumulativeChange : t.cumulative],
|
|
4115
|
+
rows: delta ? [...end(delta.opening, t.start), ...rows, ...end(delta.closing, t.end)] : rows,
|
|
3789
4116
|
}
|
|
3790
4117
|
}
|
|
3791
4118
|
|
|
@@ -3820,6 +4147,8 @@ export interface WaterfallOptionConfig {
|
|
|
3820
4147
|
noteColor?: string
|
|
3821
4148
|
/** Colour of the 1px line joining the bars' tops (the border token). `undefined` draws no connector. */
|
|
3822
4149
|
connectorColor?: string
|
|
4150
|
+
/** Language of the tooltip. Default the page's locale (`<html lang>`). */
|
|
4151
|
+
locale?: string
|
|
3823
4152
|
}
|
|
3824
4153
|
|
|
3825
4154
|
/** The bar fills 45% of its category band. ECharts reads `barCategoryGap` as a share of the band (not of the bar), so the gap is the other 55%; a gap over 100% leaves the bar at its 1px minimum. */
|
|
@@ -3960,6 +4289,7 @@ const ROW_STYLE = "display:flex;align-items:center;gap:8px;line-height:20px"
|
|
|
3960
4289
|
/** The ECharts option: a transparent base plus the part above and below 0, coloured per step. */
|
|
3961
4290
|
export function buildWaterfallOption(config: WaterfallOptionConfig): EChartsOption {
|
|
3962
4291
|
const { steps, colors, polarity, labels, valueFormatter, showValues = true, showTooltip = true } = config
|
|
4292
|
+
const t = textOf(config.locale)
|
|
3963
4293
|
const delta = config.mode === "delta"
|
|
3964
4294
|
const axisFormatter = config.axisFormatter ?? valueFormatter
|
|
3965
4295
|
const noteWidth = config.plotWidth ? Math.max(120, config.plotWidth - 16) : undefined
|
|
@@ -4041,9 +4371,13 @@ export function buildWaterfallOption(config: WaterfallOptionConfig): EChartsOpti
|
|
|
4041
4371
|
`<span style="font-weight:500;font-variant-numeric:tabular-nums;margin-left:12px">${escapeHtml(text)}</span></div>`
|
|
4042
4372
|
const rows =
|
|
4043
4373
|
step.kind === "total"
|
|
4044
|
-
? line(delta ?
|
|
4045
|
-
: line(
|
|
4046
|
-
(step.kind === "gap"
|
|
4374
|
+
? line(delta ? t.net : t.total, delta ? signedText(step.end, valueFormatter) : valueFormatter(step.end))
|
|
4375
|
+
: line(t.change, waterfallStepText(step, valueFormatter)) +
|
|
4376
|
+
(step.kind === "gap"
|
|
4377
|
+
? ""
|
|
4378
|
+
: delta
|
|
4379
|
+
? line(t.cumulativeChange, signedText(step.end, valueFormatter))
|
|
4380
|
+
: line(t.cumulative, valueFormatter(step.end)))
|
|
4047
4381
|
return `<div style="font-weight:500;margin-bottom:4px">${escapeHtml(labelOf(step.name, labels))}</div>${rows}`
|
|
4048
4382
|
},
|
|
4049
4383
|
},
|
|
@@ -4141,14 +4475,17 @@ function WaterfallChartImpl<T extends Record<string, unknown>>(
|
|
|
4141
4475
|
ref: ForwardedRef<HTMLDivElement>,
|
|
4142
4476
|
) {
|
|
4143
4477
|
const valueKey: string = categories[0] ?? ""
|
|
4144
|
-
const tokens =
|
|
4145
|
-
const { ref: frameRef, width: plotWidth } = useChartSurface(ref, tokens)
|
|
4478
|
+
const { ref: frameRef, width: plotWidth, tokens } = useChartSurface(ref)
|
|
4146
4479
|
// Value labels and the tooltip read in full (as `format` says); ticks read short.
|
|
4147
|
-
const
|
|
4148
|
-
const
|
|
4149
|
-
const
|
|
4480
|
+
const locale = useLocale()
|
|
4481
|
+
const valueFormatter = useMemo(() => resolveValueFormatter(valueFormatterProp, format, locale), [valueFormatterProp, format, locale])
|
|
4482
|
+
const axisFormatter = useMemo(() => resolveAxisFormatter(valueFormatterProp, format, locale), [valueFormatterProp, format, locale])
|
|
4483
|
+
const steps = useMemo(
|
|
4484
|
+
() => waterfallSteps(data, { index, value: valueKey, totals, mode, locale }),
|
|
4485
|
+
[data, index, valueKey, totals, mode, locale],
|
|
4486
|
+
)
|
|
4150
4487
|
const ends = useMemo(() => (mode === "delta" ? waterfallDeltaEnds(data, { index, value: valueKey, totals }) : undefined), [data, index, valueKey, totals, mode])
|
|
4151
|
-
const deltaNote = useMemo(() => (ends ? waterfallDeltaNote(ends, valueFormatter, labels) : undefined), [ends, valueFormatter, labels])
|
|
4488
|
+
const deltaNote = useMemo(() => (ends ? waterfallDeltaNote(ends, valueFormatter, labels, locale) : undefined), [ends, valueFormatter, labels, locale])
|
|
4152
4489
|
const colors = useMemo(() => {
|
|
4153
4490
|
const fills = resolveFillColors(tokens)
|
|
4154
4491
|
const [total = ""] = resolveChartColors(["chart-1"], tokens)
|
|
@@ -4181,18 +4518,20 @@ function WaterfallChartImpl<T extends Record<string, unknown>>(
|
|
|
4181
4518
|
minValue,
|
|
4182
4519
|
maxValue,
|
|
4183
4520
|
yAxisWidth,
|
|
4521
|
+
locale,
|
|
4184
4522
|
}),
|
|
4185
|
-
[steps, colors, polarity, labels, valueFormatter, axisFormatter, showValues, showTooltip, tokens, mode, deltaNote, plotWidth, chartOptions, minValue, maxValue, yAxisWidth],
|
|
4523
|
+
[steps, colors, polarity, labels, valueFormatter, axisFormatter, showValues, showTooltip, tokens, mode, deltaNote, plotWidth, chartOptions, minValue, maxValue, yAxisWidth, locale],
|
|
4186
4524
|
)
|
|
4187
4525
|
const dataTable = useMemo(
|
|
4188
|
-
() => waterfallDataTable(steps, { index, value: valueKey }, valueFormatter, labels, ends),
|
|
4189
|
-
[steps, index, valueKey, valueFormatter, labels, ends],
|
|
4526
|
+
() => waterfallDataTable(steps, { index, value: valueKey }, valueFormatter, labels, ends, locale),
|
|
4527
|
+
[steps, index, valueKey, valueFormatter, labels, ends, locale],
|
|
4190
4528
|
)
|
|
4191
4529
|
const resolvedSummary = useMemo(
|
|
4192
|
-
() => (summary === "auto" ? buildWaterfallSummary(steps, valueFormatter, title, labels, ends) : summary),
|
|
4193
|
-
[summary, steps, valueFormatter, title, labels, ends],
|
|
4530
|
+
() => (summary === "auto" ? buildWaterfallSummary(steps, valueFormatter, title, labels, ends, locale) : summary),
|
|
4531
|
+
[summary, steps, valueFormatter, title, labels, ends, locale],
|
|
4194
4532
|
)
|
|
4195
|
-
const
|
|
4533
|
+
const t = textOf(locale)
|
|
4534
|
+
const label = ariaLabel ?? t.chart(labelOf(valueKey, labels) || t.change.toLowerCase())
|
|
4196
4535
|
return (
|
|
4197
4536
|
<ChartFrame
|
|
4198
4537
|
{...chartRootProps(rest)}
|