@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
|
@@ -29,7 +29,8 @@ import { ChartFrame } from "@squadbase/vantage/components"
|
|
|
29
29
|
import { EChart } from "@squadbase/vantage/components"
|
|
30
30
|
import { EMPTY_VALUE, type ValueFormat } from "@squadbase/vantage/components"
|
|
31
31
|
import type { ChartCommonProps, ChartEmptyState, ChartSeriesColor, NumericKeys, ValueFormatter } from "@squadbase/vantage/components"
|
|
32
|
-
import {
|
|
32
|
+
import { useSyncExternalStore } from "react"
|
|
33
|
+
import { createContext, useCallback, useEffect, useRef, useState, type RefObject } from "react"
|
|
33
34
|
import type { ChartTone } from "@squadbase/vantage/components"
|
|
34
35
|
import { type ReactNode } from "react"
|
|
35
36
|
import { computeComparison, defaultChangeDisplay, formatChange, toneForValue, type ComparisonKind, type PeriodGrain } from "@squadbase/vantage/components"
|
|
@@ -38,6 +39,137 @@ import type { ChartThreshold, ChartAnnotation, ChartEventPayload, ChartForecast,
|
|
|
38
39
|
import { formatValue, toValueFormatter } from "@squadbase/vantage/components"
|
|
39
40
|
import type { ChartColor, ChartColors } from "@squadbase/vantage/components"
|
|
40
41
|
|
|
42
|
+
/* ---------- inlined from @squadbase/squadkit registry/lib/locale.ts (internal to @squadbase/vantage) ---------- */
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The display language of the parts and the defaults that follow it.
|
|
46
|
+
*
|
|
47
|
+
* The language comes from `<html lang>`: a tag starting with `ja` reads Japanese, anything else English.
|
|
48
|
+
* Without a usable `lang` (missing, invalid, or no `document` — SSR, Node) the locale is `FALLBACK_LOCALE`
|
|
49
|
+
* (`en-US`). A `locale` passed explicitly (a prop, an argument) always wins.
|
|
50
|
+
*
|
|
51
|
+
* Parts read the locale through `useLocale` (`hooks/use-locale`) and pass it down; the pure helpers here
|
|
52
|
+
* and in `format` / `comparison` / `chart-*` take it as an argument and fall back to `documentLocale()`.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/** The language of the built-in wording: Japanese or English. */
|
|
56
|
+
type UiLanguage = "ja" | "en"
|
|
57
|
+
|
|
58
|
+
/** The locale used when `<html lang>` gives none (and on the server). */
|
|
59
|
+
const FALLBACK_LOCALE = "en-US"
|
|
60
|
+
|
|
61
|
+
/** `"ja"` when `locale` starts with `ja` (`ja`, `ja-JP`), else `"en"`. A missing locale is `"en"`. */
|
|
62
|
+
function uiLanguage(locale: string | null | undefined): UiLanguage {
|
|
63
|
+
return typeof locale === "string" && locale.trim().toLowerCase().startsWith("ja") ? "ja" : "en"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A BCP 47 tag in its canonical form (`en-gb` → `en-GB`), or `FALLBACK_LOCALE` when it is empty or invalid. */
|
|
67
|
+
function normalizeLocale(tag: string | null | undefined): string {
|
|
68
|
+
const value = tag?.trim()
|
|
69
|
+
if (!value) return FALLBACK_LOCALE
|
|
70
|
+
try {
|
|
71
|
+
return Intl.getCanonicalLocales(value)[0] ?? FALLBACK_LOCALE
|
|
72
|
+
} catch {
|
|
73
|
+
return FALLBACK_LOCALE
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The locale of the page: `document.documentElement.lang`, canonicalized. `FALLBACK_LOCALE` when it is empty or
|
|
79
|
+
* invalid, or there is no `document`. Parts read it through `useLocale`, never while rendering.
|
|
80
|
+
*/
|
|
81
|
+
function documentLocale(): string {
|
|
82
|
+
if (typeof document === "undefined") return FALLBACK_LOCALE
|
|
83
|
+
return normalizeLocale(document.documentElement?.getAttribute("lang"))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** `locale` when given, else the page's locale (`documentLocale()`). */
|
|
87
|
+
function resolveLocale(locale?: string | null): string {
|
|
88
|
+
return locale || documentLocale()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Picks the wording table of the locale's language. */
|
|
92
|
+
function pickLocale<T>(table: { ja: T; en: T }, locale?: string | null): T {
|
|
93
|
+
return table[uiLanguage(resolveLocale(locale))]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The currency `format="currency"` uses without a `currency`: JPY for Japanese, USD otherwise. */
|
|
97
|
+
function defaultCurrency(locale?: string | null): string {
|
|
98
|
+
return uiLanguage(resolveLocale(locale)) === "ja" ? "JPY" : "USD"
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The month a fiscal year starts in (1–12) when none is given: April for Japanese, January otherwise. */
|
|
102
|
+
function defaultFiscalYearStartMonth(locale?: string | null): number {
|
|
103
|
+
return uiLanguage(resolveLocale(locale)) === "ja" ? 4 : 1
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A day of the week, 0 = Sunday … 6 = Saturday (the `Date#getDay` / date-fns numbering). */
|
|
107
|
+
type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6
|
|
108
|
+
|
|
109
|
+
/** Locales whose week starts on Sunday when the runtime has no week data. */
|
|
110
|
+
const SUNDAY_FIRST = new Set(["en", "en-US", "en-CA"])
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The first day of the week: Monday for Japanese; otherwise the locale's convention from `Intl.Locale`
|
|
114
|
+
* (`en-US` Sunday, `en-GB` Monday). Without week data in the runtime: Sunday for `en` / `en-US` / `en-CA`,
|
|
115
|
+
* Monday for the rest.
|
|
116
|
+
*/
|
|
117
|
+
function weekStartsOn(locale?: string | null): WeekDay {
|
|
118
|
+
const tag = normalizeLocale(resolveLocale(locale))
|
|
119
|
+
if (uiLanguage(tag) === "ja") return 1
|
|
120
|
+
try {
|
|
121
|
+
const info = new Intl.Locale(tag) as Intl.Locale & {
|
|
122
|
+
getWeekInfo?: () => { firstDay: number }
|
|
123
|
+
weekInfo?: { firstDay: number }
|
|
124
|
+
}
|
|
125
|
+
const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay
|
|
126
|
+
if (typeof firstDay === "number" && firstDay >= 1 && firstDay <= 7) return (firstDay % 7) as WeekDay
|
|
127
|
+
} catch {
|
|
128
|
+
// Fall through to the table below.
|
|
129
|
+
}
|
|
130
|
+
return SUNDAY_FIRST.has(tag) ? 0 : 1
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* ---------- inlined from @squadbase/squadkit registry/hooks/use-locale.ts (internal to @squadbase/vantage) ---------- */
|
|
134
|
+
|
|
135
|
+
const listeners = new Set<() => void>()
|
|
136
|
+
let observer: MutationObserver | null = null
|
|
137
|
+
|
|
138
|
+
/** One observer on `<html lang>` for every subscribed part. */
|
|
139
|
+
function subscribe(onChange: () => void): () => void {
|
|
140
|
+
if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {}
|
|
141
|
+
listeners.add(onChange)
|
|
142
|
+
if (!observer) {
|
|
143
|
+
observer = new MutationObserver(() => {
|
|
144
|
+
for (const listener of [...listeners]) listener()
|
|
145
|
+
})
|
|
146
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] })
|
|
147
|
+
}
|
|
148
|
+
return () => {
|
|
149
|
+
listeners.delete(onChange)
|
|
150
|
+
if (listeners.size === 0 && observer) {
|
|
151
|
+
observer.disconnect()
|
|
152
|
+
observer = null
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const serverLocale = () => FALLBACK_LOCALE
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The page's locale (`<html lang>`), following changes made while the page runs. On the server and during
|
|
161
|
+
* hydration it is `FALLBACK_LOCALE` (`en-US`), so the markup matches; it switches right after.
|
|
162
|
+
*/
|
|
163
|
+
function useDocumentLocale(): string {
|
|
164
|
+
return useSyncExternalStore(subscribe, documentLocale, serverLocale)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The locale a part renders in: its `locale` prop when given, else the page's (`useDocumentLocale`). */
|
|
168
|
+
function useLocale(locale?: string | null): string {
|
|
169
|
+
const page = useDocumentLocale()
|
|
170
|
+
return locale || page
|
|
171
|
+
}
|
|
172
|
+
|
|
41
173
|
/* ---------- inlined from @squadbase/squadkit registry/lib/chart-theme.ts (internal to @squadbase/vantage) ---------- */
|
|
42
174
|
|
|
43
175
|
/**
|
|
@@ -72,10 +204,23 @@ interface ChartTokens {
|
|
|
72
204
|
border: string | null
|
|
73
205
|
/** Tooltip surface. */
|
|
74
206
|
popover: string | null
|
|
207
|
+
/** Semantic and surface dependencies used by typed chart options. Optional for hand-built tokens. */
|
|
208
|
+
colors?: Record<string, string>
|
|
209
|
+
/** Actual inherited painting surface (may change without a token changing). */
|
|
210
|
+
surface?: string | null
|
|
75
211
|
/** Tooltip text. */
|
|
76
212
|
popoverForeground: string | null
|
|
77
213
|
}
|
|
78
214
|
|
|
215
|
+
// Keep scope out of the public token shape. A resolved snapshot remembers where arbitrary
|
|
216
|
+
// var(--token) expressions must be read, without retaining unmounted charts globally.
|
|
217
|
+
const tokenScopes = new WeakMap<ChartTokens, Element>()
|
|
218
|
+
const OPTION_COLOR_TOKENS = [
|
|
219
|
+
"--background", "--card", "--muted", "--primary", "--destructive",
|
|
220
|
+
...["positive", "negative", "warning", "neutral", "info"].flatMap((tone) =>
|
|
221
|
+
["", "-fill", "-subtle"].map((suffix) => `--${tone}${suffix}`)),
|
|
222
|
+
]
|
|
223
|
+
|
|
79
224
|
let normalizeContext: CanvasRenderingContext2D | null | undefined
|
|
80
225
|
|
|
81
226
|
/** The forms `fillStyle` serializes legacy colours to, and zrender can parse. */
|
|
@@ -131,8 +276,10 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
|
|
|
131
276
|
const color = read(token)
|
|
132
277
|
if (color) series.push(color)
|
|
133
278
|
}
|
|
134
|
-
|
|
279
|
+
const tokens: ChartTokens = {
|
|
135
280
|
series,
|
|
281
|
+
colors: Object.fromEntries(OPTION_COLOR_TOKENS.map((name) => [name, styles.getPropertyValue(name).trim()])),
|
|
282
|
+
surface: readChartSurface(target),
|
|
136
283
|
...(styles.fontFamily ? { fontFamily: styles.fontFamily } : {}),
|
|
137
284
|
foreground: read("--foreground"),
|
|
138
285
|
mutedForeground: read("--muted-foreground"),
|
|
@@ -140,6 +287,8 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
|
|
|
140
287
|
popover: read("--popover"),
|
|
141
288
|
popoverForeground: read("--popover-foreground"),
|
|
142
289
|
}
|
|
290
|
+
tokenScopes.set(tokens, target)
|
|
291
|
+
return tokens
|
|
143
292
|
}
|
|
144
293
|
|
|
145
294
|
/** Structural equality, so a theme toggle that changes nothing doesn't re-init. */
|
|
@@ -147,6 +296,10 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
|
|
|
147
296
|
if (a === b) return true
|
|
148
297
|
if (!a || !b) return false
|
|
149
298
|
return (
|
|
299
|
+
tokenScopes.get(a) === tokenScopes.get(b) &&
|
|
300
|
+
a.surface === b.surface &&
|
|
301
|
+
Object.keys(a.colors ?? {}).length === Object.keys(b.colors ?? {}).length &&
|
|
302
|
+
Object.entries(a.colors ?? {}).every(([name, value]) => b.colors?.[name] === value) &&
|
|
150
303
|
a.series.length === b.series.length &&
|
|
151
304
|
a.series.every((color, i) => color === b.series[i]) &&
|
|
152
305
|
a.fontFamily === b.fontFamily &&
|
|
@@ -172,7 +325,7 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
|
|
|
172
325
|
* attributes alone leaves the chart painted in the previous palette until a
|
|
173
326
|
* reload — the tokens in the DOM are new, the canvas is not.
|
|
174
327
|
*/
|
|
175
|
-
function watchChartTokens(onChange: () => void): () => void {
|
|
328
|
+
function watchChartTokens(onChange: () => void, scope?: Element | null): () => void {
|
|
176
329
|
if (typeof document === "undefined") return () => {}
|
|
177
330
|
// Style edits arrive as bursts of mutations; coalesce them into one read, and
|
|
178
331
|
// take it after the next frame so a swapped stylesheet has been applied.
|
|
@@ -187,8 +340,11 @@ function watchChartTokens(onChange: () => void): () => void {
|
|
|
187
340
|
|
|
188
341
|
const themeObserver = new MutationObserver(schedule)
|
|
189
342
|
const attributeFilter = ["class", "style", "data-theme"]
|
|
190
|
-
|
|
191
|
-
|
|
343
|
+
// Only the chart and its ancestors can change its inherited theme. Never observe
|
|
344
|
+
// the document subtree: canvas/tooltip/rendering mutations would trigger every chart.
|
|
345
|
+
for (let node: Element | null = scope ?? document.body ?? document.documentElement; node; node = node.parentElement) {
|
|
346
|
+
themeObserver.observe(node, { attributes: true, attributeFilter })
|
|
347
|
+
}
|
|
192
348
|
|
|
193
349
|
const styleObserver = new MutationObserver(schedule)
|
|
194
350
|
if (document.head) {
|
|
@@ -276,22 +432,23 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
|
|
|
276
432
|
* components that build an ECharts option from token colours themselves (the
|
|
277
433
|
* typed charts), so their palette follows light/dark like `EChart`'s theme.
|
|
278
434
|
*/
|
|
279
|
-
function useChartTokens(): ChartTokens | null {
|
|
435
|
+
function useChartTokens(scope?: Element | null | RefObject<Element | null>): ChartTokens | null {
|
|
280
436
|
const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
|
|
281
437
|
// Compared here, not inside a `setTokens` updater: an updater that reads the DOM is
|
|
282
438
|
// replayed by React on every rebase and returns a fresh object each time, so the state
|
|
283
439
|
// never settles ("Maximum update depth exceeded" once the tokens change after mount).
|
|
284
440
|
const tokensRef = useRef(tokens)
|
|
285
441
|
useEffect(() => {
|
|
442
|
+
const target = scope && "current" in scope ? scope.current : scope
|
|
286
443
|
const sync = () => {
|
|
287
|
-
const next = readChartTokens()
|
|
444
|
+
const next = readChartTokens(target)
|
|
288
445
|
if (sameChartTokens(tokensRef.current, next)) return
|
|
289
446
|
tokensRef.current = next
|
|
290
447
|
setTokens(next)
|
|
291
448
|
}
|
|
292
449
|
sync()
|
|
293
|
-
return watchChartTokens(sync)
|
|
294
|
-
}, [])
|
|
450
|
+
return watchChartTokens(sync, target)
|
|
451
|
+
}, [scope])
|
|
295
452
|
return tokens
|
|
296
453
|
}
|
|
297
454
|
|
|
@@ -303,7 +460,7 @@ const EMBEDDED_VAR_RE = /var\(\s*(--[\w-]+)\s*\)/g
|
|
|
303
460
|
/**
|
|
304
461
|
* Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
|
|
305
462
|
* the resolved series tokens; `"positive"` / `"warning"` / `"negative"` / `"neutral"` (and the area
|
|
306
|
-
* variants `"positive-fill"`, …) are read
|
|
463
|
+
* variants `"positive-fill"`, …) are read in the snapshot's inherited scope (`--positive`, …); `var(--token)` uses the same scope; any
|
|
307
464
|
* other CSS colour is normalized like the tokens are. A colour that can't be
|
|
308
465
|
* resolved comes back as `""`, which ECharts treats as "use the palette".
|
|
309
466
|
*/
|
|
@@ -314,12 +471,13 @@ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | nul
|
|
|
314
471
|
// Meaning colours are the theme tokens of the same name: same path as `var(--positive)`.
|
|
315
472
|
if (TONE_NAME_RE.test(color.trim())) {
|
|
316
473
|
if (typeof document === "undefined") return ""
|
|
317
|
-
|
|
474
|
+
const target = (tokens && tokenScopes.get(tokens)) ?? document.documentElement
|
|
475
|
+
return toCanvasColor(getComputedStyle(target).getPropertyValue(`--${color.trim()}`)) ?? ""
|
|
318
476
|
}
|
|
319
477
|
if (typeof document === "undefined") return color
|
|
320
478
|
let value = color.trim()
|
|
321
479
|
const cssVar = CSS_VAR_RE.exec(value)
|
|
322
|
-
const styles = getComputedStyle(document.documentElement)
|
|
480
|
+
const styles = getComputedStyle((tokens && tokenScopes.get(tokens)) ?? document.documentElement)
|
|
323
481
|
if (cssVar) {
|
|
324
482
|
value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
|
|
325
483
|
} else if (value.includes("var(")) {
|
|
@@ -365,7 +523,7 @@ function readChartSurface(el: Element | null): string | null {
|
|
|
365
523
|
if (color) return color
|
|
366
524
|
}
|
|
367
525
|
}
|
|
368
|
-
return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue("--background"))
|
|
526
|
+
return toCanvasColor(getComputedStyle(el ?? document.documentElement).getPropertyValue("--background"))
|
|
369
527
|
}
|
|
370
528
|
|
|
371
529
|
/** Widths are bucketed so a resize only rebuilds the option when the label budget can change. */
|
|
@@ -379,9 +537,10 @@ const WIDTH_BUCKET = 40
|
|
|
379
537
|
*/
|
|
380
538
|
function useChartSurface<E extends Element>(
|
|
381
539
|
forwarded: ForwardedRef<E>,
|
|
382
|
-
tokens
|
|
383
|
-
): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined } {
|
|
540
|
+
tokens?: ChartTokens | null,
|
|
541
|
+
): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined; tokens: ChartTokens | null } {
|
|
384
542
|
const [node, setNode] = useState<E | null>(null)
|
|
543
|
+
const scopedTokens = useChartTokens(node)
|
|
385
544
|
const [surface, setSurface] = useState<string | undefined>(undefined)
|
|
386
545
|
const [width, setWidth] = useState<number | undefined>(undefined)
|
|
387
546
|
const ref = useCallback(
|
|
@@ -395,7 +554,7 @@ function useChartSurface<E extends Element>(
|
|
|
395
554
|
useEffect(() => {
|
|
396
555
|
if (!node) return
|
|
397
556
|
setSurface(readChartSurface(node) ?? undefined)
|
|
398
|
-
}, [node, tokens])
|
|
557
|
+
}, [node, tokens, scopedTokens])
|
|
399
558
|
useEffect(() => {
|
|
400
559
|
if (!node || typeof ResizeObserver === "undefined") return
|
|
401
560
|
const update = (w: number) => setWidth(Math.floor(w / WIDTH_BUCKET) * WIDTH_BUCKET)
|
|
@@ -404,11 +563,37 @@ function useChartSurface<E extends Element>(
|
|
|
404
563
|
observer.observe(node)
|
|
405
564
|
return () => observer.disconnect()
|
|
406
565
|
}, [node])
|
|
407
|
-
return { ref, surface, width }
|
|
566
|
+
return { ref, surface, width, tokens: scopedTokens }
|
|
408
567
|
}
|
|
409
568
|
|
|
410
569
|
/* ---------- inlined from @squadbase/squadkit registry/lib/chart-options.ts (internal to @squadbase/vantage) ---------- */
|
|
411
570
|
|
|
571
|
+
/** The built-in words of the option builders, by language. */
|
|
572
|
+
const CHART_TEXT = {
|
|
573
|
+
ja: {
|
|
574
|
+
target: "目標",
|
|
575
|
+
forecast: "予測",
|
|
576
|
+
total: "合計",
|
|
577
|
+
provisional: "速報",
|
|
578
|
+
noComparison: "比較なし",
|
|
579
|
+
share: (share: string) => `(${share})`,
|
|
580
|
+
targetRate: (rate: string) => `(達成率 ${rate})`,
|
|
581
|
+
provisionalTitle: (title: string, label: string) => `${title}(${label})`,
|
|
582
|
+
},
|
|
583
|
+
en: {
|
|
584
|
+
target: "Target",
|
|
585
|
+
forecast: "Forecast",
|
|
586
|
+
total: "Total",
|
|
587
|
+
provisional: "Preliminary",
|
|
588
|
+
noComparison: "No comparison",
|
|
589
|
+
share: (share: string) => ` (${share})`,
|
|
590
|
+
targetRate: (rate: string) => ` (${rate} achieved)`,
|
|
591
|
+
provisionalTitle: (title: string, label: string) => `${title} (${label})`,
|
|
592
|
+
},
|
|
593
|
+
} satisfies Record<UiLanguage, unknown>
|
|
594
|
+
|
|
595
|
+
const chartText = (locale?: string) => CHART_TEXT[uiLanguage(resolveLocale(locale))]
|
|
596
|
+
|
|
412
597
|
type CartesianChartKind = "area" | "bar" | "line"
|
|
413
598
|
type CartesianStackType = "default" | "stacked" | "percent"
|
|
414
599
|
type CartesianCurveType = "linear" | "smooth" | "step"
|
|
@@ -593,7 +778,7 @@ interface CartesianChartConfig {
|
|
|
593
778
|
* all ticks read in one unit (¥0.5億 / ¥1億). Default: `axisFormatter` for every tick.
|
|
594
779
|
*/
|
|
595
780
|
axisTicks?: (top: number) => (value: number) => string
|
|
596
|
-
/** Locale of the time-axis labels. Default
|
|
781
|
+
/** Locale of the time-axis labels and the built-in words (total, target, forecast). Default the page's (`<html lang>`). */
|
|
597
782
|
locale?: string
|
|
598
783
|
type?: CartesianStackType
|
|
599
784
|
/** Bar only. `"vertical"` lays the categories down the y axis (horizontal bars). */
|
|
@@ -831,11 +1016,12 @@ function timeAxisLabelStep(values: readonly unknown[], maxLabels: number = TIME_
|
|
|
831
1016
|
*/
|
|
832
1017
|
function timeAxisLabels(
|
|
833
1018
|
values: readonly unknown[],
|
|
834
|
-
|
|
1019
|
+
localeArg?: string,
|
|
835
1020
|
shown: (i: number) => boolean = () => true,
|
|
836
1021
|
): string[] | null {
|
|
837
1022
|
const parsed = parseTimeIndex(values)
|
|
838
1023
|
if (!parsed) return null
|
|
1024
|
+
const locale = resolveLocale(localeArg)
|
|
839
1025
|
const { granularity, times } = parsed
|
|
840
1026
|
let prevYear: number | null = null
|
|
841
1027
|
return times.map((t, i) => {
|
|
@@ -858,9 +1044,10 @@ function timeAxisLabels(
|
|
|
858
1044
|
}
|
|
859
1045
|
|
|
860
1046
|
/** The tooltip titles for time-like x values (`2025年9月`, `2025年9月1日`), or `null` when they are not dates. */
|
|
861
|
-
function timeTooltipLabels(values: readonly unknown[],
|
|
1047
|
+
function timeTooltipLabels(values: readonly unknown[], localeArg?: string): string[] | null {
|
|
862
1048
|
const parsed = parseTimeIndex(values)
|
|
863
1049
|
if (!parsed) return null
|
|
1050
|
+
const locale = resolveLocale(localeArg)
|
|
864
1051
|
const options: Intl.DateTimeFormatOptions =
|
|
865
1052
|
parsed.granularity === "year"
|
|
866
1053
|
? { year: "numeric" }
|
|
@@ -1000,7 +1187,9 @@ function tooltipHtml(
|
|
|
1000
1187
|
rows: readonly TooltipRow[],
|
|
1001
1188
|
valueFormatter: (value: number) => string,
|
|
1002
1189
|
notes: readonly string[] = [],
|
|
1190
|
+
locale?: string,
|
|
1003
1191
|
): string {
|
|
1192
|
+
const t = chartText(locale)
|
|
1004
1193
|
const lines = rows.map(
|
|
1005
1194
|
(row) =>
|
|
1006
1195
|
`<div style="display:flex;align-items:center;gap:8px;line-height:20px">` +
|
|
@@ -1009,7 +1198,7 @@ function tooltipHtml(
|
|
|
1009
1198
|
`<span style="font-weight:600;font-variant-numeric:tabular-nums;margin-left:12px">${
|
|
1010
1199
|
row.value === null ? EMPTY_VALUE : escapeHtml(valueFormatter(row.value))
|
|
1011
1200
|
}${
|
|
1012
|
-
row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px"
|
|
1201
|
+
row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px">${escapeHtml(t.share(formatShare(row.share)))}</span>`
|
|
1013
1202
|
}</span></div>` +
|
|
1014
1203
|
(row.note === undefined
|
|
1015
1204
|
? ""
|
|
@@ -1023,8 +1212,10 @@ function tooltipHtml(
|
|
|
1023
1212
|
|
|
1024
1213
|
/* ------------------------------------------------- per-bar targets / notes */
|
|
1025
1214
|
|
|
1026
|
-
/** Default name of the per-bar target marker (legend, tooltip). */
|
|
1027
|
-
|
|
1215
|
+
/** Default name of the per-bar target marker (legend, tooltip) in `locale`: "Target" / 「目標」. Default the page's locale. */
|
|
1216
|
+
function barTargetLabel(locale?: string): string {
|
|
1217
|
+
return chartText(locale).target
|
|
1218
|
+
}
|
|
1028
1219
|
|
|
1029
1220
|
/** How far (px) the target marker reaches past each side of its bar. */
|
|
1030
1221
|
const TARGET_MARKER_OVERHANG = 4
|
|
@@ -1075,16 +1266,17 @@ function achievementRate(value: number | null | undefined, target: number | null
|
|
|
1075
1266
|
return (value / target) * 100
|
|
1076
1267
|
}
|
|
1077
1268
|
|
|
1078
|
-
/** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3
|
|
1269
|
+
/** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3%)」 / "Target $12M (85.3% achieved)". `null` without a target. */
|
|
1079
1270
|
function barTargetNote(
|
|
1080
1271
|
label: string,
|
|
1081
1272
|
value: number | null,
|
|
1082
1273
|
target: number | null,
|
|
1083
1274
|
valueFormatter: (value: number) => string,
|
|
1275
|
+
locale?: string,
|
|
1084
1276
|
): string | null {
|
|
1085
1277
|
if (target === null) return null
|
|
1086
1278
|
const rate = achievementRate(value, target)
|
|
1087
|
-
return `${label} ${valueFormatter(target)}${rate === null ? "" :
|
|
1279
|
+
return `${label} ${valueFormatter(target)}${rate === null ? "" : chartText(locale).targetRate(formatShare(rate))}`
|
|
1088
1280
|
}
|
|
1089
1281
|
|
|
1090
1282
|
/** Half the width of a bar in category-axis units, from `barCategoryGap` (`"20%"`; a px gap or none → ECharts' 20%). */
|
|
@@ -1176,11 +1368,12 @@ const TARGET_LEGEND_SERIES_ID = "__target-legend"
|
|
|
1176
1368
|
/** A flat tick (8×8 legend box, aspect kept): the per-bar target's legend icon. */
|
|
1177
1369
|
const TARGET_LEGEND_ICON = "path://M0,0 L100,0 L100,24 L0,24 Z"
|
|
1178
1370
|
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1371
|
+
/**
|
|
1372
|
+
* 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.
|
|
1373
|
+
* Without a `label`, "Forecast" / 「予測」 in `locale` (default the page's).
|
|
1374
|
+
*/
|
|
1375
|
+
function forecastBoundaryText(label?: string, locale?: string): string {
|
|
1376
|
+
return `${label ?? chartText(locale).forecast} →`
|
|
1184
1377
|
}
|
|
1185
1378
|
|
|
1186
1379
|
/** A comparison series: a thin, muted, solid line with no markers (60% of `--muted-foreground`). */
|
|
@@ -1220,8 +1413,10 @@ interface AxisExtent {
|
|
|
1220
1413
|
max: number
|
|
1221
1414
|
}
|
|
1222
1415
|
|
|
1223
|
-
/** The label of a stack's total (highlight and tooltip). */
|
|
1224
|
-
|
|
1416
|
+
/** The label of a stack's total (highlight and tooltip) in `locale`: "Total" / 「合計」. Default the page's locale. */
|
|
1417
|
+
function stackTotalLabel(locale?: string): string {
|
|
1418
|
+
return chartText(locale).total
|
|
1419
|
+
}
|
|
1225
1420
|
|
|
1226
1421
|
/** Per point, the sum of the stacked series; `null` where every series is missing. */
|
|
1227
1422
|
function stackTotalsOf(values: readonly (readonly (number | null)[])[]): (number | null)[] {
|
|
@@ -1343,8 +1538,8 @@ function indexGrain(values: readonly unknown[]): PeriodGrain | null {
|
|
|
1343
1538
|
|
|
1344
1539
|
/**
|
|
1345
1540
|
* The tooltip line under the main series' row when a comparison series is drawn: the arrow, the signed change and the
|
|
1346
|
-
* comparison's name, as `Delta` writes it ("↑ +12.4% 前年同月比"; a minus is U+2212).
|
|
1347
|
-
* either value is missing or the baseline is 0.
|
|
1541
|
+
* comparison's name, as `Delta` writes it ("↑ +12.4% vs. same month last year" / "↑ +12.4% 前年同月比"; a minus is U+2212).
|
|
1542
|
+
* "No comparison vs. same month last year" / "比較なし 前年同月比" when either value is missing or the baseline is 0.
|
|
1348
1543
|
*/
|
|
1349
1544
|
function comparisonNote(
|
|
1350
1545
|
current: number | null,
|
|
@@ -1352,16 +1547,18 @@ function comparisonNote(
|
|
|
1352
1547
|
comparison: Pick<CartesianComparisonConfig, "vs" | "label">,
|
|
1353
1548
|
grain?: PeriodGrain,
|
|
1354
1549
|
format?: ValueFormat,
|
|
1355
|
-
|
|
1550
|
+
localeArg?: string,
|
|
1356
1551
|
): string {
|
|
1552
|
+
const locale = resolveLocale(localeArg)
|
|
1357
1553
|
const result = computeComparison(
|
|
1358
1554
|
current,
|
|
1359
1555
|
comparison.vs === "custom"
|
|
1360
1556
|
? { previous, vs: "custom", label: comparison.label ?? "", grain }
|
|
1361
1557
|
: { previous, vs: comparison.vs, label: comparison.label, grain },
|
|
1558
|
+
{ locale },
|
|
1362
1559
|
)
|
|
1363
1560
|
const text = formatChange(result, defaultChangeDisplay(format), locale, format)
|
|
1364
|
-
if (text === null) return
|
|
1561
|
+
if (text === null) return `${chartText(locale).noComparison} ${result.label}`
|
|
1365
1562
|
const arrow = result.direction === "up" ? "↑" : result.direction === "down" ? "↓" : "→"
|
|
1366
1563
|
return `${arrow} ${text.replace(/^-/, "−")} ${result.label}`
|
|
1367
1564
|
}
|
|
@@ -1404,7 +1601,6 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1404
1601
|
toneColors,
|
|
1405
1602
|
valueFormatter,
|
|
1406
1603
|
axisFormatter = valueFormatter,
|
|
1407
|
-
locale = DEFAULT_LOCALE,
|
|
1408
1604
|
type = "default",
|
|
1409
1605
|
layout = "horizontal",
|
|
1410
1606
|
curveType = "linear",
|
|
@@ -1441,7 +1637,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1441
1637
|
chartOptions,
|
|
1442
1638
|
reserveActions = false,
|
|
1443
1639
|
} = cfg
|
|
1444
|
-
const
|
|
1640
|
+
const locale = resolveLocale(cfg.locale)
|
|
1641
|
+
const text = chartText(locale)
|
|
1642
|
+
const provisionalLabel = cfg.provisionalLabel ?? text.provisional
|
|
1445
1643
|
|
|
1446
1644
|
const stacked = kind !== "line" && type !== "default"
|
|
1447
1645
|
const percent = kind !== "line" && type === "percent"
|
|
@@ -1590,9 +1788,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1590
1788
|
// `barOptions.targetKey` (a target per bar) takes over from the single `target` line.
|
|
1591
1789
|
const perBarTarget = kind === "bar" && Boolean(targetKey)
|
|
1592
1790
|
const hasTarget = !perBarTarget && target !== undefined && Number.isFinite(target)
|
|
1593
|
-
const targetText = hasTarget ? (targetLabel ??
|
|
1791
|
+
const targetText = hasTarget ? (targetLabel ?? `${text.target} ${axisFormatter(target)}`) : ""
|
|
1594
1792
|
const highlightText = (value: number) =>
|
|
1595
|
-
shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${
|
|
1793
|
+
shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${text.total} ` : ""}${axisFormatter(value)}`
|
|
1596
1794
|
/** Whether `p`'s label sits within the target's value band and under the target label (at the right end). */
|
|
1597
1795
|
const nearTargetLabel = (p: HighlightPoint, side: "under" | "over") => {
|
|
1598
1796
|
if (!hasTarget || !plotWidth || kind === "bar" || labels.length < 2) return false
|
|
@@ -1618,7 +1816,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1618
1816
|
// One series (or a stack, against its total) can be compared to a target; grouped series and 100% stacks cannot.
|
|
1619
1817
|
const barTargets: (number | null)[] | null =
|
|
1620
1818
|
perBarTarget && targetKey && !percent && (stacked || categories.length === 1) ? barTargetValues(data, targetKey) : null
|
|
1621
|
-
const barTargetName = targetLabel ??
|
|
1819
|
+
const barTargetName = targetLabel ?? text.target
|
|
1622
1820
|
const barTargetColor = labelColor ?? guideColor
|
|
1623
1821
|
|
|
1624
1822
|
const markPointFor = (c: number, color: string | undefined) =>
|
|
@@ -1832,7 +2030,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1832
2030
|
}
|
|
1833
2031
|
|
|
1834
2032
|
/* -- forecast: dashed continuation (+ optional band) of one series */
|
|
1835
|
-
const forecastLabel = forecast?.label ??
|
|
2033
|
+
const forecastLabel = forecast?.label ?? text.forecast
|
|
1836
2034
|
const forecastBandAlpha = isDarkSurface(labelBackground) ? FORECAST_BAND_ALPHA_DARK : FORECAST_BAND_ALPHA_LIGHT
|
|
1837
2035
|
const forecastValues: (number | null)[] = []
|
|
1838
2036
|
if (forecast) {
|
|
@@ -2308,7 +2506,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
2308
2506
|
})
|
|
2309
2507
|
}
|
|
2310
2508
|
if (stackTotals && i <= lastActual && rows.length > 1) {
|
|
2311
|
-
rows.push({ name:
|
|
2509
|
+
rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
|
|
2312
2510
|
}
|
|
2313
2511
|
const provisional = provStart !== -1 && i >= provStart && i <= lastActual
|
|
2314
2512
|
if (provisional) {
|
|
@@ -2324,19 +2522,19 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
2324
2522
|
...noteFor(at),
|
|
2325
2523
|
})
|
|
2326
2524
|
})
|
|
2327
|
-
if (stackTotals && rows.length > 1) rows.push({ name:
|
|
2525
|
+
if (stackTotals && rows.length > 1) rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
|
|
2328
2526
|
}
|
|
2329
2527
|
const title = tooltipTitles[i] ?? labels[i] ?? ""
|
|
2330
2528
|
const lines: string[] = []
|
|
2331
2529
|
if (barTargets && i <= lastActual) {
|
|
2332
2530
|
const total = stackTotals ? (stackTotals[i] ?? null) : (values[0]?.[i] ?? null)
|
|
2333
|
-
const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter)
|
|
2531
|
+
const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter, locale)
|
|
2334
2532
|
if (note) lines.push(note)
|
|
2335
2533
|
}
|
|
2336
2534
|
for (const note of notes) {
|
|
2337
2535
|
if (note.to === undefined ? note.from === i : note.from <= i && i <= note.to) lines.push(note.label)
|
|
2338
2536
|
}
|
|
2339
|
-
return tooltipHtml(provisional ?
|
|
2537
|
+
return tooltipHtml(provisional ? text.provisionalTitle(title, provisionalLabel) : title, rows, valueFormatter, lines, locale)
|
|
2340
2538
|
},
|
|
2341
2539
|
},
|
|
2342
2540
|
xAxis: horizontalBars ? valueAxis : categoryAxis,
|
|
@@ -2428,8 +2626,6 @@ interface DonutOptionSlice {
|
|
|
2428
2626
|
/** How many of the largest slices get an outside label (「自然検索 41%」) by default. */
|
|
2429
2627
|
const DONUT_OUTSIDE_LABELS = 2
|
|
2430
2628
|
|
|
2431
|
-
const DEFAULT_TOTAL_LABEL = "合計"
|
|
2432
|
-
|
|
2433
2629
|
const sliceValue = (value: number) => (Number.isFinite(value) && value > 0 ? value : 0)
|
|
2434
2630
|
|
|
2435
2631
|
/** A slice's share of `total` as text: whole percent, `<1%` for a sliver, `0%` for nothing. */
|
|
@@ -2462,15 +2658,16 @@ function donutLegendItems(slices: readonly DonutOptionSlice[]): DonutLegendItem[
|
|
|
2462
2658
|
}
|
|
2463
2659
|
|
|
2464
2660
|
/**
|
|
2465
|
-
* The default center label: what is totalled (「合計」), the formatted total
|
|
2661
|
+
* The default center label: what is totalled (「合計」 / "Total" in `locale`, default the page's), the formatted total
|
|
2466
2662
|
* (`amount`, the big line), the unit (a small line under it) and both as one
|
|
2467
2663
|
* string (`value`, 「9.2万 セッション」).
|
|
2468
2664
|
*/
|
|
2469
2665
|
function donutCenterText(
|
|
2470
2666
|
total: number,
|
|
2471
2667
|
valueFormatter: (value: number) => string,
|
|
2472
|
-
{ totalLabel
|
|
2668
|
+
{ totalLabel, unit, locale }: { totalLabel?: string; unit?: string; locale?: string } = {},
|
|
2473
2669
|
): { label: string; value: string; amount: string; unit?: string } {
|
|
2670
|
+
totalLabel ??= chartText(locale).total
|
|
2474
2671
|
const amount = valueFormatter(total)
|
|
2475
2672
|
return { label: totalLabel, value: unit ? `${amount} ${unit}` : amount, amount, ...(unit ? { unit } : {}) }
|
|
2476
2673
|
}
|
|
@@ -2698,8 +2895,10 @@ function hasLabel(labels: Readonly<Record<string, string>> | undefined, key: str
|
|
|
2698
2895
|
|
|
2699
2896
|
/* ------------------------------------------------------------ provisional */
|
|
2700
2897
|
|
|
2701
|
-
/** The default label for points that are not final yet. */
|
|
2702
|
-
|
|
2898
|
+
/** The default label for points that are not final yet, in `locale`: "Preliminary" / 「速報」. Default the page's locale. */
|
|
2899
|
+
function defaultProvisionalLabel(locale?: string): string {
|
|
2900
|
+
return chartText(locale).provisional
|
|
2901
|
+
}
|
|
2703
2902
|
|
|
2704
2903
|
/**
|
|
2705
2904
|
* Position of the first provisional point: the first entry of `indexValues`
|
|
@@ -2837,9 +3036,10 @@ function resolveValueFormatter(
|
|
|
2837
3036
|
function resolveAxisFormatter(
|
|
2838
3037
|
valueFormatter: ValueFormatter | undefined,
|
|
2839
3038
|
format: ValueFormat | undefined,
|
|
2840
|
-
|
|
3039
|
+
localeArg?: string,
|
|
2841
3040
|
): ValueFormatter {
|
|
2842
3041
|
if (valueFormatter) return valueFormatter
|
|
3042
|
+
const locale = resolveLocale(localeArg)
|
|
2843
3043
|
const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
|
|
2844
3044
|
if (spec.type !== "compact" && spec.type !== "number" && spec.type !== "currency") return toValueFormatter(format, locale)
|
|
2845
3045
|
const currency = "currency" in spec ? spec.currency : undefined
|
|
@@ -2848,40 +3048,75 @@ function resolveAxisFormatter(
|
|
|
2848
3048
|
return (value) => formatValue(value, short, locale)
|
|
2849
3049
|
}
|
|
2850
3050
|
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
]
|
|
3051
|
+
interface CompactUnit {
|
|
3052
|
+
token: string
|
|
3053
|
+
size: number
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
const compactUnitCache = new Map<string, CompactUnit[] | null>()
|
|
3057
|
+
|
|
3058
|
+
/**
|
|
3059
|
+
* The compact-notation unit words of a locale and their sizes, largest first: `兆` / `億` / `万` in ja,
|
|
3060
|
+
* `T` / `B` / `M` / `K` in en. `null` when the locale's compact form puts anything between the number and its
|
|
3061
|
+
* word (`1,5 Mio.`), where a token cannot be swapped in place.
|
|
3062
|
+
*/
|
|
3063
|
+
function compactUnits(locale: string): CompactUnit[] | null {
|
|
3064
|
+
const cached = compactUnitCache.get(locale)
|
|
3065
|
+
if (cached !== undefined) return cached
|
|
3066
|
+
let units: CompactUnit[] | null = []
|
|
3067
|
+
try {
|
|
3068
|
+
const compact = new Intl.NumberFormat(locale, { notation: "compact" })
|
|
3069
|
+
const seen = new Set<string>()
|
|
3070
|
+
for (let exp = 3; exp <= 15; exp++) {
|
|
3071
|
+
const parts = compact.formatToParts(10 ** exp)
|
|
3072
|
+
const at = parts.findIndex((p) => p.type === "compact")
|
|
3073
|
+
if (at === -1) continue
|
|
3074
|
+
if (parts[at - 1]?.type !== "integer" || at !== parts.length - 1) {
|
|
3075
|
+
units = null
|
|
3076
|
+
break
|
|
3077
|
+
}
|
|
3078
|
+
const token = parts[at]!.value
|
|
3079
|
+
if (seen.has(token)) continue
|
|
3080
|
+
seen.add(token)
|
|
3081
|
+
units.push({ token, size: 10 ** exp })
|
|
3082
|
+
}
|
|
3083
|
+
} catch {
|
|
3084
|
+
units = null
|
|
3085
|
+
}
|
|
3086
|
+
const sorted = units && units.length > 0 ? units.sort((a, b) => b.size - a.size) : null
|
|
3087
|
+
compactUnitCache.set(locale, sorted)
|
|
3088
|
+
return sorted
|
|
3089
|
+
}
|
|
2857
3090
|
|
|
2858
|
-
const
|
|
3091
|
+
const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
2859
3092
|
|
|
2860
3093
|
/**
|
|
2861
3094
|
* The value-axis tick formatter for an axis whose largest tick is `top`: every tick reads in the unit of
|
|
2862
|
-
* that largest tick, so
|
|
2863
|
-
* Only the
|
|
2864
|
-
* any other format —
|
|
3095
|
+
* that largest tick, so units never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`;
|
|
3096
|
+
* `$0.5M` / `$1M` / `$1.5M`, not `$500K` / `$1M`). Only the short forms (`compact` / `number` / `currency`,
|
|
3097
|
+
* no `valueFormatter`) have units to align; any other format — an axis whose largest tick is under the smallest
|
|
3098
|
+
* unit (1万 / 1K), or a locale whose compact form cannot be aligned in place — keeps `resolveAxisFormatter`'s output as it is.
|
|
2865
3099
|
*/
|
|
2866
3100
|
function resolveAxisTickFormatter(
|
|
2867
3101
|
valueFormatter: ValueFormatter | undefined,
|
|
2868
3102
|
format: ValueFormat | undefined,
|
|
2869
|
-
|
|
3103
|
+
localeArg?: string,
|
|
2870
3104
|
): (top: number) => ValueFormatter {
|
|
3105
|
+
const locale = resolveLocale(localeArg)
|
|
2871
3106
|
const base = resolveAxisFormatter(valueFormatter, format, locale)
|
|
2872
3107
|
const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
|
|
2873
|
-
const
|
|
2874
|
-
|
|
2875
|
-
if (!aligns) return () => base
|
|
3108
|
+
const units = !valueFormatter && (spec.type === "compact" || spec.type === "number" || spec.type === "currency") ? compactUnits(locale) : null
|
|
3109
|
+
if (!units) return () => base
|
|
2876
3110
|
const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 3 })
|
|
3111
|
+
const numberToken = new RegExp(`\\d[\\d,]*(?:\\.\\d+)?(?:${units.map((u) => escapeRegExp(u.token)).join("|")})?`)
|
|
2877
3112
|
return (top) => {
|
|
2878
|
-
const unit =
|
|
3113
|
+
const unit = units.find((u) => Math.abs(top) >= u.size)
|
|
2879
3114
|
if (!unit) return base
|
|
2880
3115
|
return (value) => {
|
|
2881
3116
|
const text = base(value)
|
|
2882
3117
|
if (value === 0 || !Number.isFinite(value)) return text
|
|
2883
|
-
const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.
|
|
2884
|
-
return text.replace(
|
|
3118
|
+
const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.token}`
|
|
3119
|
+
return text.replace(numberToken, () => scaled)
|
|
2885
3120
|
}
|
|
2886
3121
|
}
|
|
2887
3122
|
}
|
|
@@ -2910,8 +3145,20 @@ function clamp(value: number, min: number, max: number): number {
|
|
|
2910
3145
|
/** How many categories a chart colours individually; the rest fold into「その他」. */
|
|
2911
3146
|
const MAX_CHART_SERIES = 5
|
|
2912
3147
|
|
|
2913
|
-
/** The series name the folded categories get. */
|
|
2914
|
-
const
|
|
3148
|
+
/** The series name the folded categories get, by language. */
|
|
3149
|
+
const OTHER_SERIES_LABELS: Record<UiLanguage, string> = { ja: "その他", en: "Other" }
|
|
3150
|
+
|
|
3151
|
+
/** The series name the folded categories get in `locale` ("Other" / "その他"). Default the page's locale (`<html lang>`). */
|
|
3152
|
+
function otherSeriesLabel(locale?: string): string {
|
|
3153
|
+
return OTHER_SERIES_LABELS[uiLanguage(resolveLocale(locale))]
|
|
3154
|
+
}
|
|
3155
|
+
|
|
3156
|
+
const OTHER_NAMES = new Set(Object.values(OTHER_SERIES_LABELS))
|
|
3157
|
+
|
|
3158
|
+
/** Whether a category / row is the "other" bucket by name (「その他」 or "Other", whatever the page language). */
|
|
3159
|
+
function isOtherName(name: string | null | undefined): boolean {
|
|
3160
|
+
return typeof name === "string" && OTHER_NAMES.has(name)
|
|
3161
|
+
}
|
|
2915
3162
|
|
|
2916
3163
|
interface AggregatedItems<Item extends { name: string; value: number }> {
|
|
2917
3164
|
/** The `n` largest items, in their given order. All items when there are `n` or fewer. */
|
|
@@ -2931,7 +3178,7 @@ interface AggregatedItems<Item extends { name: string; value: number }> {
|
|
|
2931
3178
|
function aggregateTopN<Item extends { name: string; value: number }>(
|
|
2932
3179
|
items: readonly Item[],
|
|
2933
3180
|
n: number = MAX_CHART_SERIES,
|
|
2934
|
-
otherLabel: string =
|
|
3181
|
+
otherLabel: string = otherSeriesLabel(),
|
|
2935
3182
|
): AggregatedItems<Item> {
|
|
2936
3183
|
if (items.length <= n) return { top: [...items], rest: [], other: null }
|
|
2937
3184
|
const ranked = items
|
|
@@ -2960,7 +3207,7 @@ function aggregateSeries(
|
|
|
2960
3207
|
data: readonly Record<string, unknown>[],
|
|
2961
3208
|
categories: readonly string[],
|
|
2962
3209
|
n: number = MAX_CHART_SERIES,
|
|
2963
|
-
otherLabel: string =
|
|
3210
|
+
otherLabel: string = otherSeriesLabel(),
|
|
2964
3211
|
): AggregatedSeries {
|
|
2965
3212
|
const totals = categories.map((name) => ({
|
|
2966
3213
|
name,
|
|
@@ -2992,12 +3239,14 @@ function aggregateSeriesKeeping(
|
|
|
2992
3239
|
categories: readonly string[],
|
|
2993
3240
|
keep: string | undefined,
|
|
2994
3241
|
n: number = MAX_CHART_SERIES,
|
|
3242
|
+
otherLabel: string = otherSeriesLabel(),
|
|
2995
3243
|
): AggregatedSeries {
|
|
2996
|
-
if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n)
|
|
3244
|
+
if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n, otherLabel)
|
|
2997
3245
|
const aggregated = aggregateSeries(
|
|
2998
3246
|
data,
|
|
2999
3247
|
categories.filter((c) => c !== keep),
|
|
3000
3248
|
n,
|
|
3249
|
+
otherLabel,
|
|
3001
3250
|
)
|
|
3002
3251
|
return { ...aggregated, categories: [...aggregated.categories, keep] }
|
|
3003
3252
|
}
|
|
@@ -3033,7 +3282,7 @@ function seriesColorsWithComparison(
|
|
|
3033
3282
|
* Colour per drawn series: the record's token for that category, else the
|
|
3034
3283
|
* array's token at the category's position in `allCategories` (cycled), else
|
|
3035
3284
|
* the token palette by drawn position. `otherCategory` — and a category the
|
|
3036
|
-
* data itself names
|
|
3285
|
+
* data itself names「その他」/ "Other" (`OTHER_SERIES_LABELS`) — always gets `otherColor`.
|
|
3037
3286
|
*/
|
|
3038
3287
|
function seriesColorsFor(
|
|
3039
3288
|
categories: readonly string[],
|
|
@@ -3045,7 +3294,7 @@ function seriesColorsFor(
|
|
|
3045
3294
|
const list = Array.isArray(colors) ? (colors as ChartSeriesColor[]) : undefined
|
|
3046
3295
|
const record = !Array.isArray(colors) && colors ? (colors as Partial<Record<string, ChartSeriesColor>>) : undefined
|
|
3047
3296
|
return categories.map((category, i) => {
|
|
3048
|
-
if ((otherCategory !== null && category === otherCategory) || category
|
|
3297
|
+
if ((otherCategory !== null && category === otherCategory) || isOtherName(category)) return otherColor
|
|
3049
3298
|
const fromRecord = record?.[category]
|
|
3050
3299
|
if (fromRecord) return fromRecord
|
|
3051
3300
|
if (list && list.length > 0) {
|
|
@@ -3092,7 +3341,7 @@ interface DonutSlicesConfig {
|
|
|
3092
3341
|
*/
|
|
3093
3342
|
function donutSlices(
|
|
3094
3343
|
data: readonly Record<string, unknown>[],
|
|
3095
|
-
{ index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel =
|
|
3344
|
+
{ index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = otherSeriesLabel() }: DonutSlicesConfig,
|
|
3096
3345
|
): DonutSlice[] {
|
|
3097
3346
|
const items = data
|
|
3098
3347
|
.map((row, i) => {
|
|
@@ -3105,7 +3354,8 @@ function donutSlices(
|
|
|
3105
3354
|
const slices: DonutSlice[] = top.map((item, rank) => ({
|
|
3106
3355
|
name: item.name,
|
|
3107
3356
|
value: item.value,
|
|
3108
|
-
|
|
3357
|
+
// A row the data itself names「その他」/ "Other" is the same remainder: grey, like the folded slice.
|
|
3358
|
+
color: isOtherName(item.name) ? otherColor : palette[rank % palette.length]!,
|
|
3109
3359
|
isOther: false,
|
|
3110
3360
|
row: item.row,
|
|
3111
3361
|
}))
|
|
@@ -3178,7 +3428,7 @@ interface BarListRowsConfig extends BarListOptions {
|
|
|
3178
3428
|
*/
|
|
3179
3429
|
function barListRows<Row extends object>(
|
|
3180
3430
|
data: readonly Row[],
|
|
3181
|
-
{ index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel =
|
|
3431
|
+
{ index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = otherSeriesLabel(), exclude }: BarListRowsConfig = {},
|
|
3182
3432
|
): BarListRow<Row>[] {
|
|
3183
3433
|
const items = data.map((row): BarListRow<Row> => {
|
|
3184
3434
|
const cells = row as Record<string, unknown>
|
|
@@ -3224,19 +3474,21 @@ const BAR_LIST_OTHER_TINT = 30
|
|
|
3224
3474
|
|
|
3225
3475
|
/**
|
|
3226
3476
|
* The fill of each BarList row: all real rows in the same tint of `color`
|
|
3227
|
-
* (`BAR_LIST_TINT`), the folded「その他」row in a tint of `otherColor`
|
|
3477
|
+
* (`BAR_LIST_TINT`), the folded「その他」row (and a row the data names「その他」/ "Other") in a tint of `otherColor`
|
|
3228
3478
|
* (`BAR_LIST_OTHER_TINT`) — the palest fill, a remainder never outweighs a
|
|
3229
3479
|
* ranked row. A legacy per-row `color` (or a threshold tone) replaces `color`
|
|
3230
3480
|
* for that row. A tint, not the solid token, so the label printed over the bar
|
|
3231
3481
|
* keeps its contrast.
|
|
3232
3482
|
*/
|
|
3233
3483
|
function barListFills(
|
|
3234
|
-
rows: readonly Pick<BarListRow, "isOther" | "color">[],
|
|
3484
|
+
rows: readonly (Pick<BarListRow, "isOther" | "color"> & { name?: string })[],
|
|
3235
3485
|
color: ChartColor,
|
|
3236
3486
|
otherColor: string,
|
|
3237
3487
|
): string[] {
|
|
3238
3488
|
return rows.map((row) =>
|
|
3239
|
-
row.isOther
|
|
3489
|
+
row.isOther || isOtherName(row.name)
|
|
3490
|
+
? tintCss(otherColor, BAR_LIST_OTHER_TINT)
|
|
3491
|
+
: tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
|
|
3240
3492
|
)
|
|
3241
3493
|
}
|
|
3242
3494
|
|
|
@@ -3452,11 +3704,30 @@ interface ChartSummaryInput {
|
|
|
3452
3704
|
format?: ValueFormat
|
|
3453
3705
|
/** Wins over `format`. */
|
|
3454
3706
|
valueFormatter?: ValueFormatter
|
|
3707
|
+
/** Language of the sentence. Default the page's locale (`<html lang>`). */
|
|
3708
|
+
locale?: string
|
|
3709
|
+
}
|
|
3710
|
+
|
|
3711
|
+
const SUMMARY_TEXT = {
|
|
3712
|
+
ja: {
|
|
3713
|
+
up: "、前の点より増加",
|
|
3714
|
+
down: "、前の点より減少",
|
|
3715
|
+
flat: "、前の点と横ばい",
|
|
3716
|
+
latest: (label: string, value: string) => `最新 ${label} は ${value}`,
|
|
3717
|
+
top: (name: string, share: string) => `最大は ${name}(${share})`,
|
|
3718
|
+
},
|
|
3719
|
+
en: {
|
|
3720
|
+
up: ", up from the previous point",
|
|
3721
|
+
down: ", down from the previous point",
|
|
3722
|
+
flat: ", flat from the previous point",
|
|
3723
|
+
latest: (label: string, value: string) => `Latest ${label}: ${value}`,
|
|
3724
|
+
top: (name: string, share: string) => `Largest: ${name} (${share})`,
|
|
3725
|
+
},
|
|
3455
3726
|
}
|
|
3456
3727
|
|
|
3457
3728
|
/**
|
|
3458
3729
|
* One-sentence summary built from the rows, for `summary="auto"`:
|
|
3459
|
-
* 「<title>: 最新 <index> は
|
|
3730
|
+
* 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」("<title>: Latest <index>: <value>, up from the previous point"). The direction is
|
|
3460
3731
|
* computed from the last two non-empty points of the first series — never
|
|
3461
3732
|
* written by hand, so it cannot contradict the data. `undefined` when there is
|
|
3462
3733
|
* no usable point.
|
|
@@ -3472,26 +3743,29 @@ function buildChartSummary(input: ChartSummaryInput): string | undefined {
|
|
|
3472
3743
|
}
|
|
3473
3744
|
const latest = points[points.length - 1]
|
|
3474
3745
|
if (!latest) return undefined
|
|
3475
|
-
const
|
|
3746
|
+
const locale = resolveLocale(input.locale)
|
|
3747
|
+
const t = SUMMARY_TEXT[uiLanguage(locale)]
|
|
3748
|
+
const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact", locale)
|
|
3476
3749
|
const prev = points[points.length - 2]
|
|
3477
|
-
const direction = !prev ? "" : latest.value > prev.value ?
|
|
3750
|
+
const direction = !prev ? "" : latest.value > prev.value ? t.up : latest.value < prev.value ? t.down : t.flat
|
|
3478
3751
|
const head = input.title ? `${input.title}: ` : ""
|
|
3479
|
-
return `${head}
|
|
3752
|
+
return `${head}${t.latest(latest.label, text(latest.value))}${direction}`
|
|
3480
3753
|
}
|
|
3481
3754
|
|
|
3482
3755
|
/**
|
|
3483
|
-
* `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」 from the slices
|
|
3484
|
-
* (largest first). `undefined` when there is nothing to describe.
|
|
3756
|
+
* `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」("<title>: Largest: <name> (<share>)") from the slices
|
|
3757
|
+
* (largest first). `undefined` when there is nothing to describe. `locale` picks the language; default the page's.
|
|
3485
3758
|
*/
|
|
3486
3759
|
function buildShareSummary(
|
|
3487
3760
|
slices: readonly { name: string; value: number }[],
|
|
3488
3761
|
title?: string,
|
|
3762
|
+
locale?: string,
|
|
3489
3763
|
): string | undefined {
|
|
3490
3764
|
const total = slices.reduce((sum, s) => sum + s.value, 0)
|
|
3491
3765
|
const top = slices[0]
|
|
3492
3766
|
if (!top || total <= 0) return undefined
|
|
3493
3767
|
const share = `${Math.round((top.value / total) * 1000) / 10}%`
|
|
3494
|
-
return `${title ? `${title}: ` : ""}
|
|
3768
|
+
return `${title ? `${title}: ` : ""}${SUMMARY_TEXT[uiLanguage(resolveLocale(locale))].top(top.name, share)}`
|
|
3495
3769
|
}
|
|
3496
3770
|
|
|
3497
3771
|
/* ---------- end of inlined code ---------- */
|
|
@@ -3629,12 +3903,49 @@ export interface ScatterSeries {
|
|
|
3629
3903
|
points: ScatterPoint[]
|
|
3630
3904
|
}
|
|
3631
3905
|
|
|
3906
|
+
const TEXT = {
|
|
3907
|
+
ja: {
|
|
3908
|
+
who: (name: string, value: string) => `${name}(${value})`,
|
|
3909
|
+
summary: (count: number, y: string, who: string) => `${count}点。${y}が最大なのは ${who}`,
|
|
3910
|
+
name: "名前",
|
|
3911
|
+
series: "系列",
|
|
3912
|
+
x: "横軸",
|
|
3913
|
+
y: "縦軸",
|
|
3914
|
+
size: "大きさ",
|
|
3915
|
+
pair: (y: string, x: string) => `${y}×${x}`,
|
|
3916
|
+
first: (pair: string, count: number) => `${pair}(先頭 ${count} 点)`,
|
|
3917
|
+
guide: (label: string, axis: string, value: string) => `${label}(${axis} ${value})`,
|
|
3918
|
+
caption: (base: string, guides: readonly string[]) => `${base}。基準線: ${guides.join("、")}`,
|
|
3919
|
+
chart: (y: string, x: string) => `${y}×${x}の散布図`,
|
|
3920
|
+
},
|
|
3921
|
+
en: {
|
|
3922
|
+
who: (name: string, value: string) => `${name} (${value})`,
|
|
3923
|
+
summary: (count: number, y: string, who: string) => `${count === 1 ? "1 point" : `${count} points`}. Highest ${y}: ${who}`,
|
|
3924
|
+
name: "Name",
|
|
3925
|
+
series: "Series",
|
|
3926
|
+
x: "X",
|
|
3927
|
+
y: "Y",
|
|
3928
|
+
size: "Size",
|
|
3929
|
+
pair: (y: string, x: string) => `${y} × ${x}`,
|
|
3930
|
+
first: (pair: string, count: number) => `${pair} (first ${count} points)`,
|
|
3931
|
+
guide: (label: string, axis: string, value: string) => `${label} (${axis} ${value})`,
|
|
3932
|
+
caption: (base: string, guides: readonly string[]) => `${base}. Reference lines: ${guides.join(", ")}`,
|
|
3933
|
+
chart: (y: string, x: string) => `Scatter plot of ${y} × ${x}`,
|
|
3934
|
+
},
|
|
3935
|
+
}
|
|
3936
|
+
|
|
3937
|
+
const textOf = (locale?: string) => TEXT[uiLanguage(resolveLocale(locale))]
|
|
3938
|
+
|
|
3632
3939
|
/**
|
|
3633
3940
|
* Points → series. Series keep their order of first appearance (that order is the
|
|
3634
3941
|
* colour order). More than `max` of them: the `max` with the most points are kept
|
|
3635
3942
|
* and the rest fold into one trailing「その他」series.
|
|
3636
3943
|
*/
|
|
3637
|
-
export function scatterSeries(
|
|
3944
|
+
export function scatterSeries(
|
|
3945
|
+
points: readonly ScatterPoint[],
|
|
3946
|
+
max: number = MAX_CHART_SERIES,
|
|
3947
|
+
otherLabel: string = otherSeriesLabel(),
|
|
3948
|
+
): ScatterSeries[] {
|
|
3638
3949
|
const order: string[] = []
|
|
3639
3950
|
const counts = new Map<string, number>()
|
|
3640
3951
|
for (const p of points) {
|
|
@@ -3649,7 +3960,7 @@ export function scatterSeries(points: readonly ScatterPoint[], max: number = MAX
|
|
|
3649
3960
|
const result: ScatterSeries[] = order
|
|
3650
3961
|
.filter((name) => kept.has(name))
|
|
3651
3962
|
.map((name) => ({ name, isOther: false, points: points.filter((p) => p.series === name) }))
|
|
3652
|
-
result.push({ name:
|
|
3963
|
+
result.push({ name: otherLabel, isOther: true, points: points.filter((p) => !kept.has(p.series)) })
|
|
3653
3964
|
return result
|
|
3654
3965
|
}
|
|
3655
3966
|
|
|
@@ -3676,20 +3987,22 @@ function pointTitle(point: ScatterPoint, labels?: Record<string, string>): strin
|
|
|
3676
3987
|
return labelOf(point.name || point.series, labels)
|
|
3677
3988
|
}
|
|
3678
3989
|
|
|
3679
|
-
/** `summary="auto"`: the point count and the highest y. `undefined` with no points. */
|
|
3990
|
+
/** `summary="auto"`: the point count and the highest y. `undefined` with no points. `locale` picks the language; default the page's. */
|
|
3680
3991
|
export function buildScatterSummary(
|
|
3681
3992
|
points: readonly ScatterPoint[],
|
|
3682
3993
|
yName: string,
|
|
3683
3994
|
yFormatter: ValueFormatter,
|
|
3684
3995
|
title?: string,
|
|
3685
3996
|
labels?: Record<string, string>,
|
|
3997
|
+
locale?: string,
|
|
3686
3998
|
): string | undefined {
|
|
3687
3999
|
if (points.length === 0) return undefined
|
|
4000
|
+
const t = textOf(locale)
|
|
3688
4001
|
let best = points[0]!
|
|
3689
4002
|
for (const p of points) if (p.y > best.y) best = p
|
|
3690
4003
|
const head = title ? `${title}: ` : ""
|
|
3691
|
-
const who = best.name ?
|
|
3692
|
-
return `${head}${points.length
|
|
4004
|
+
const who = best.name ? t.who(labelOf(best.name, labels), yFormatter(best.y)) : yFormatter(best.y)
|
|
4005
|
+
return `${head}${t.summary(points.length, labelOf(yName, labels), who)}`
|
|
3693
4006
|
}
|
|
3694
4007
|
|
|
3695
4008
|
/** Cap of the screen-reader table (a scatter can hold thousands of dots). */
|
|
@@ -3707,17 +4020,20 @@ export function scatterDataTable(
|
|
|
3707
4020
|
formatters: { x: ValueFormatter; y: ValueFormatter; size: ValueFormatter }
|
|
3708
4021
|
labels?: Record<string, string>
|
|
3709
4022
|
referenceLines?: readonly ScatterReferenceLine[]
|
|
4023
|
+
/** Language of the headers and caption. Default the page's locale (`<html lang>`). */
|
|
4024
|
+
locale?: string
|
|
3710
4025
|
},
|
|
3711
4026
|
): ChartDataTable {
|
|
4027
|
+
const t = textOf(config.locale)
|
|
3712
4028
|
const label = (key: string) => labelOf(key, config.labels)
|
|
3713
|
-
// Column headers: the caller's `labels`, else a plain
|
|
4029
|
+
// Column headers: the caller's `labels`, else a plain name (not the data key).
|
|
3714
4030
|
const column = (key: string, fallback: string) => config.labels?.[key] ?? fallback
|
|
3715
4031
|
const columns = [
|
|
3716
|
-
...(config.index ? [column(config.index,
|
|
3717
|
-
...(config.series ? [column(config.series,
|
|
3718
|
-
column(config.x,
|
|
3719
|
-
column(config.y,
|
|
3720
|
-
...(config.size ? [column(config.size,
|
|
4032
|
+
...(config.index ? [column(config.index, t.name)] : []),
|
|
4033
|
+
...(config.series ? [column(config.series, t.series)] : []),
|
|
4034
|
+
column(config.x, t.x),
|
|
4035
|
+
column(config.y, t.y),
|
|
4036
|
+
...(config.size ? [column(config.size, t.size)] : []),
|
|
3721
4037
|
]
|
|
3722
4038
|
const shown = points.slice(0, SCATTER_TABLE_MAX_ROWS)
|
|
3723
4039
|
const rows = shown.map((p, i) => {
|
|
@@ -3731,15 +4047,16 @@ export function scatterDataTable(
|
|
|
3731
4047
|
// The first column is the row header; without an `index` column, number the dots.
|
|
3732
4048
|
return config.index ? cells : [String(i + 1), ...cells]
|
|
3733
4049
|
})
|
|
3734
|
-
const
|
|
4050
|
+
const pair = t.pair(label(config.y), label(config.x))
|
|
4051
|
+
const base = points.length > shown.length ? t.first(pair, shown.length) : pair
|
|
3735
4052
|
// The guide lines carry information the dots do not: read them out with the table.
|
|
3736
4053
|
const guides = (config.referenceLines ?? []).map((line) => {
|
|
3737
4054
|
const key = line.axis === "x" ? config.x : config.y
|
|
3738
4055
|
const text = config.formatters[line.axis](line.value)
|
|
3739
|
-
return line.label ?
|
|
4056
|
+
return line.label ? t.guide(line.label, label(key), text) : `${label(key)} ${text}`
|
|
3740
4057
|
})
|
|
3741
4058
|
return {
|
|
3742
|
-
caption: guides.length > 0 ?
|
|
4059
|
+
caption: guides.length > 0 ? t.caption(base, guides) : base,
|
|
3743
4060
|
columns: config.index ? columns : ["#", ...columns],
|
|
3744
4061
|
rows,
|
|
3745
4062
|
}
|
|
@@ -4090,29 +4407,29 @@ function ScatterChartImpl<T extends Record<string, unknown>>(
|
|
|
4090
4407
|
}: ScatterChartProps<T>,
|
|
4091
4408
|
ref: ForwardedRef<HTMLDivElement>,
|
|
4092
4409
|
) {
|
|
4093
|
-
const tokens =
|
|
4094
|
-
const
|
|
4410
|
+
const { ref: frameRef, width: plotWidth, tokens } = useChartSurface(ref)
|
|
4411
|
+
const locale = useLocale()
|
|
4095
4412
|
// Tooltips and the table read in full; axis ticks read short.
|
|
4096
4413
|
const formatters = useMemo(
|
|
4097
4414
|
() => ({
|
|
4098
|
-
x: resolveValueFormatter(valueFormatter, xFormat ?? format),
|
|
4099
|
-
y: resolveValueFormatter(valueFormatter, yFormat ?? format),
|
|
4100
|
-
size: resolveValueFormatter(valueFormatter, sizeFormat ?? format),
|
|
4415
|
+
x: resolveValueFormatter(valueFormatter, xFormat ?? format, locale),
|
|
4416
|
+
y: resolveValueFormatter(valueFormatter, yFormat ?? format, locale),
|
|
4417
|
+
size: resolveValueFormatter(valueFormatter, sizeFormat ?? format, locale),
|
|
4101
4418
|
}),
|
|
4102
|
-
[valueFormatter, xFormat, yFormat, sizeFormat, format],
|
|
4419
|
+
[valueFormatter, xFormat, yFormat, sizeFormat, format, locale],
|
|
4103
4420
|
)
|
|
4104
4421
|
const axisFormatters = useMemo(
|
|
4105
4422
|
() => ({
|
|
4106
|
-
x: resolveAxisFormatter(valueFormatter, xFormat ?? format),
|
|
4107
|
-
y: resolveAxisFormatter(valueFormatter, yFormat ?? format),
|
|
4423
|
+
x: resolveAxisFormatter(valueFormatter, xFormat ?? format, locale),
|
|
4424
|
+
y: resolveAxisFormatter(valueFormatter, yFormat ?? format, locale),
|
|
4108
4425
|
}),
|
|
4109
|
-
[valueFormatter, xFormat, yFormat, format],
|
|
4426
|
+
[valueFormatter, xFormat, yFormat, format, locale],
|
|
4110
4427
|
)
|
|
4111
4428
|
const points = useMemo(
|
|
4112
4429
|
() => scatterPoints(data, { x, y, size, index, series: seriesKey, labelKey }),
|
|
4113
4430
|
[data, x, y, size, index, seriesKey, labelKey],
|
|
4114
4431
|
)
|
|
4115
|
-
const series = useMemo(() => scatterSeries(points), [points])
|
|
4432
|
+
const series = useMemo(() => scatterSeries(points, undefined, otherSeriesLabel(locale)), [points, locale])
|
|
4116
4433
|
const palette = useMemo(
|
|
4117
4434
|
() => resolveChartColors(scatterSeriesColors(series, colors).map(chartFillName), tokens),
|
|
4118
4435
|
[series, colors, tokens],
|
|
@@ -4149,14 +4466,14 @@ function ScatterChartImpl<T extends Record<string, unknown>>(
|
|
|
4149
4466
|
[series, palette, x, y, size, seriesKey, seriesLabel, highlightList, referenceLines, labelKey, tokens, labels, formatters, axisFormatters, fitToData, showLegend, showTooltip, actions, plotWidth, chartOptions, minValue, maxValue, xMinValue, xMaxValue],
|
|
4150
4467
|
)
|
|
4151
4468
|
const dataTable = useMemo(
|
|
4152
|
-
() => scatterDataTable(points, { x, y, size, index: index ?? labelKey, series: seriesKey, formatters, labels, referenceLines }),
|
|
4153
|
-
[points, x, y, size, index, labelKey, seriesKey, formatters, labels, referenceLines],
|
|
4469
|
+
() => scatterDataTable(points, { x, y, size, index: index ?? labelKey, series: seriesKey, formatters, labels, referenceLines, locale }),
|
|
4470
|
+
[points, x, y, size, index, labelKey, seriesKey, formatters, labels, referenceLines, locale],
|
|
4154
4471
|
)
|
|
4155
4472
|
const resolvedSummary = useMemo(
|
|
4156
|
-
() => (summary === "auto" ? buildScatterSummary(points, y, formatters.y, title, labels) : summary),
|
|
4157
|
-
[summary, points, y, formatters, title, labels],
|
|
4473
|
+
() => (summary === "auto" ? buildScatterSummary(points, y, formatters.y, title, labels, locale) : summary),
|
|
4474
|
+
[summary, points, y, formatters, title, labels, locale],
|
|
4158
4475
|
)
|
|
4159
|
-
const label = ariaLabel ??
|
|
4476
|
+
const label = ariaLabel ?? textOf(locale).chart(labelOf(y, labels), labelOf(x, labels))
|
|
4160
4477
|
return (
|
|
4161
4478
|
<ChartFrame
|
|
4162
4479
|
{...chartRootProps(rest)}
|