@squadbase/vantage 0.6.2-dev1.54bde93 → 0.6.2-dev1.5c79ef5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{build-A23M4GCH.js → build-U7GO26HR.js} +3 -3
- package/dist/{build-A23M4GCH.js.map → build-U7GO26HR.js.map} +1 -1
- package/dist/{check-FBHT6W6J.js → check-MRVAIHG5.js} +3 -3
- package/dist/{check-FBHT6W6J.js.map → check-MRVAIHG5.js.map} +1 -1
- package/dist/{chunk-57GHR5LN.js → chunk-3RJEOP3J.js} +29 -18
- package/dist/chunk-3RJEOP3J.js.map +1 -0
- package/dist/{chunk-QOXXOFGL.js → chunk-77CISBMG.js} +994 -464
- package/dist/chunk-77CISBMG.js.map +1 -0
- package/dist/{chunk-JXFCSF3K.js → chunk-EG4PX726.js} +12 -7
- package/dist/chunk-EG4PX726.js.map +1 -0
- package/dist/{chunk-2N72FKLB.js → chunk-NZ5QIIG4.js} +33 -11
- package/dist/chunk-NZ5QIIG4.js.map +1 -0
- package/dist/{chunk-5C2SEDPH.js → chunk-PJ4SPXHS.js} +4 -4
- package/dist/{chunk-5C2SEDPH.js.map → chunk-PJ4SPXHS.js.map} +1 -1
- package/dist/{chunk-7S52M2SL.js → chunk-QNOX7WLZ.js} +3 -3
- package/dist/{chunk-7S52M2SL.js.map → chunk-QNOX7WLZ.js.map} +1 -1
- package/dist/{chunk-PDOBHMUQ.js → chunk-ROHNXP7J.js} +72 -3
- package/dist/chunk-ROHNXP7J.js.map +1 -0
- package/dist/{chunk-6OTTGAPO.js → chunk-TRMHNMMZ.js} +24 -7
- package/dist/chunk-TRMHNMMZ.js.map +1 -0
- package/dist/cli.js +4 -4
- package/dist/components/index.d.ts +49 -29
- package/dist/components/index.js +5 -5
- package/dist/{delta-text-CvwBGJ95.d.ts → delta-text-CbrOaW83.d.ts} +4 -2
- package/dist/{dev-H4I2JL63.js → dev-O42JFQ64.js} +3 -3
- package/dist/{dev-H4I2JL63.js.map → dev-O42JFQ64.js.map} +1 -1
- package/dist/doc/index.d.ts +14 -14
- package/dist/doc/index.js +198 -84
- package/dist/doc/index.js.map +1 -1
- package/dist/{doctor-B2O3GJDU.js → doctor-YD3XAAOT.js} +3 -3
- package/dist/{doctor-B2O3GJDU.js.map → doctor-YD3XAAOT.js.map} +1 -1
- package/dist/{format-U0cQl069.d.ts → format-DihQkL1N.d.ts} +17 -11
- package/dist/slide/index.d.ts +2 -2
- package/dist/slide/index.js +95 -44
- package/dist/slide/index.js.map +1 -1
- package/dist/table/index.js +6 -6
- package/dist/ui/index.d.ts +1 -1
- package/dist/ui/index.js +5 -5
- package/docs/en/agent-skills.md +1 -1
- package/docs/ja/agent-skills.md +1 -1
- package/package.json +2 -2
- package/registry/ui/app-shell.tsx +138 -1
- package/registry/ui/area-chart.tsx +344 -85
- package/registry/ui/attention-list.tsx +177 -15
- package/registry/ui/bar-chart.tsx +341 -85
- package/registry/ui/bar-list.tsx +332 -77
- package/registry/ui/box-plot-chart.tsx +405 -97
- package/registry/ui/category-bar.tsx +248 -40
- package/registry/ui/chart-frame.tsx +257 -46
- package/registry/ui/data-table.tsx +229 -37
- package/registry/ui/date-range-picker.tsx +190 -36
- package/registry/ui/delta.tsx +171 -12
- package/registry/ui/donut-chart.tsx +339 -84
- package/registry/ui/echart.tsx +142 -4
- package/registry/ui/filter-bar.tsx +179 -20
- package/registry/ui/funnel-steps.tsx +354 -80
- package/registry/ui/heatmap.tsx +293 -52
- package/registry/ui/histogram-chart.tsx +395 -97
- package/registry/ui/kpi-card.tsx +245 -44
- package/registry/ui/legend.tsx +330 -76
- package/registry/ui/line-chart.tsx +344 -85
- package/registry/ui/multi-select.tsx +159 -9
- package/registry/ui/page-shell.tsx +141 -3
- package/registry/ui/paired-chart.tsx +349 -90
- package/registry/ui/progress-circle.tsx +205 -39
- package/registry/ui/sample-data.tsx +153 -9
- package/registry/ui/scatter-chart.tsx +391 -100
- package/registry/ui/searchable-select.tsx +145 -7
- package/registry/ui/states.tsx +170 -13
- package/registry/ui/status-badge.tsx +193 -28
- package/registry/ui/tracker.tsx +205 -39
- package/registry/ui/trend-indicator.tsx +149 -6
- package/registry/ui/updated-at.tsx +170 -16
- package/registry/ui/waterfall-chart.tsx +415 -102
- package/skills/vantage-design/SKILL.md +25 -27
- package/skills/vantage-design/references/extract.md +10 -13
- package/skills/vantage-design/references/format.md +12 -173
- package/skills/vantage-design/references/presets.md +0 -10
- package/templates/AGENTS.md +29 -15
- package/templates/agents/page.md +1 -1
- package/templates/agents/slide.md +7 -1
- package/dist/chunk-2N72FKLB.js.map +0 -1
- package/dist/chunk-57GHR5LN.js.map +0 -1
- package/dist/chunk-6OTTGAPO.js.map +0 -1
- package/dist/chunk-JXFCSF3K.js.map +0 -1
- package/dist/chunk-PDOBHMUQ.js.map +0 -1
- package/dist/chunk-QOXXOFGL.js.map +0 -1
|
@@ -29,6 +29,7 @@ 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 { useSyncExternalStore } from "react"
|
|
32
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"
|
|
@@ -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
|
/**
|
|
@@ -436,6 +568,32 @@ function useChartSurface<E extends Element>(
|
|
|
436
568
|
|
|
437
569
|
/* ---------- inlined from @squadbase/squadkit registry/lib/chart-options.ts (internal to @squadbase/vantage) ---------- */
|
|
438
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
|
+
|
|
439
597
|
type CartesianChartKind = "area" | "bar" | "line"
|
|
440
598
|
type CartesianStackType = "default" | "stacked" | "percent"
|
|
441
599
|
type CartesianCurveType = "linear" | "smooth" | "step"
|
|
@@ -620,7 +778,7 @@ interface CartesianChartConfig {
|
|
|
620
778
|
* all ticks read in one unit (¥0.5億 / ¥1億). Default: `axisFormatter` for every tick.
|
|
621
779
|
*/
|
|
622
780
|
axisTicks?: (top: number) => (value: number) => string
|
|
623
|
-
/** 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>`). */
|
|
624
782
|
locale?: string
|
|
625
783
|
type?: CartesianStackType
|
|
626
784
|
/** Bar only. `"vertical"` lays the categories down the y axis (horizontal bars). */
|
|
@@ -858,11 +1016,12 @@ function timeAxisLabelStep(values: readonly unknown[], maxLabels: number = TIME_
|
|
|
858
1016
|
*/
|
|
859
1017
|
function timeAxisLabels(
|
|
860
1018
|
values: readonly unknown[],
|
|
861
|
-
|
|
1019
|
+
localeArg?: string,
|
|
862
1020
|
shown: (i: number) => boolean = () => true,
|
|
863
1021
|
): string[] | null {
|
|
864
1022
|
const parsed = parseTimeIndex(values)
|
|
865
1023
|
if (!parsed) return null
|
|
1024
|
+
const locale = resolveLocale(localeArg)
|
|
866
1025
|
const { granularity, times } = parsed
|
|
867
1026
|
let prevYear: number | null = null
|
|
868
1027
|
return times.map((t, i) => {
|
|
@@ -885,9 +1044,10 @@ function timeAxisLabels(
|
|
|
885
1044
|
}
|
|
886
1045
|
|
|
887
1046
|
/** The tooltip titles for time-like x values (`2025年9月`, `2025年9月1日`), or `null` when they are not dates. */
|
|
888
|
-
function timeTooltipLabels(values: readonly unknown[],
|
|
1047
|
+
function timeTooltipLabels(values: readonly unknown[], localeArg?: string): string[] | null {
|
|
889
1048
|
const parsed = parseTimeIndex(values)
|
|
890
1049
|
if (!parsed) return null
|
|
1050
|
+
const locale = resolveLocale(localeArg)
|
|
891
1051
|
const options: Intl.DateTimeFormatOptions =
|
|
892
1052
|
parsed.granularity === "year"
|
|
893
1053
|
? { year: "numeric" }
|
|
@@ -1027,7 +1187,9 @@ function tooltipHtml(
|
|
|
1027
1187
|
rows: readonly TooltipRow[],
|
|
1028
1188
|
valueFormatter: (value: number) => string,
|
|
1029
1189
|
notes: readonly string[] = [],
|
|
1190
|
+
locale?: string,
|
|
1030
1191
|
): string {
|
|
1192
|
+
const t = chartText(locale)
|
|
1031
1193
|
const lines = rows.map(
|
|
1032
1194
|
(row) =>
|
|
1033
1195
|
`<div style="display:flex;align-items:center;gap:8px;line-height:20px">` +
|
|
@@ -1036,7 +1198,7 @@ function tooltipHtml(
|
|
|
1036
1198
|
`<span style="font-weight:600;font-variant-numeric:tabular-nums;margin-left:12px">${
|
|
1037
1199
|
row.value === null ? EMPTY_VALUE : escapeHtml(valueFormatter(row.value))
|
|
1038
1200
|
}${
|
|
1039
|
-
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>`
|
|
1040
1202
|
}</span></div>` +
|
|
1041
1203
|
(row.note === undefined
|
|
1042
1204
|
? ""
|
|
@@ -1050,8 +1212,10 @@ function tooltipHtml(
|
|
|
1050
1212
|
|
|
1051
1213
|
/* ------------------------------------------------- per-bar targets / notes */
|
|
1052
1214
|
|
|
1053
|
-
/** Default name of the per-bar target marker (legend, tooltip). */
|
|
1054
|
-
|
|
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
|
+
}
|
|
1055
1219
|
|
|
1056
1220
|
/** How far (px) the target marker reaches past each side of its bar. */
|
|
1057
1221
|
const TARGET_MARKER_OVERHANG = 4
|
|
@@ -1102,16 +1266,17 @@ function achievementRate(value: number | null | undefined, target: number | null
|
|
|
1102
1266
|
return (value / target) * 100
|
|
1103
1267
|
}
|
|
1104
1268
|
|
|
1105
|
-
/** 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. */
|
|
1106
1270
|
function barTargetNote(
|
|
1107
1271
|
label: string,
|
|
1108
1272
|
value: number | null,
|
|
1109
1273
|
target: number | null,
|
|
1110
1274
|
valueFormatter: (value: number) => string,
|
|
1275
|
+
locale?: string,
|
|
1111
1276
|
): string | null {
|
|
1112
1277
|
if (target === null) return null
|
|
1113
1278
|
const rate = achievementRate(value, target)
|
|
1114
|
-
return `${label} ${valueFormatter(target)}${rate === null ? "" :
|
|
1279
|
+
return `${label} ${valueFormatter(target)}${rate === null ? "" : chartText(locale).targetRate(formatShare(rate))}`
|
|
1115
1280
|
}
|
|
1116
1281
|
|
|
1117
1282
|
/** Half the width of a bar in category-axis units, from `barCategoryGap` (`"20%"`; a px gap or none → ECharts' 20%). */
|
|
@@ -1203,11 +1368,12 @@ const TARGET_LEGEND_SERIES_ID = "__target-legend"
|
|
|
1203
1368
|
/** A flat tick (8×8 legend box, aspect kept): the per-bar target's legend icon. */
|
|
1204
1369
|
const TARGET_LEGEND_ICON = "path://M0,0 L100,0 L100,24 L0,24 Z"
|
|
1205
1370
|
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
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} →`
|
|
1211
1377
|
}
|
|
1212
1378
|
|
|
1213
1379
|
/** A comparison series: a thin, muted, solid line with no markers (60% of `--muted-foreground`). */
|
|
@@ -1247,8 +1413,10 @@ interface AxisExtent {
|
|
|
1247
1413
|
max: number
|
|
1248
1414
|
}
|
|
1249
1415
|
|
|
1250
|
-
/** The label of a stack's total (highlight and tooltip). */
|
|
1251
|
-
|
|
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
|
+
}
|
|
1252
1420
|
|
|
1253
1421
|
/** Per point, the sum of the stacked series; `null` where every series is missing. */
|
|
1254
1422
|
function stackTotalsOf(values: readonly (readonly (number | null)[])[]): (number | null)[] {
|
|
@@ -1370,8 +1538,8 @@ function indexGrain(values: readonly unknown[]): PeriodGrain | null {
|
|
|
1370
1538
|
|
|
1371
1539
|
/**
|
|
1372
1540
|
* The tooltip line under the main series' row when a comparison series is drawn: the arrow, the signed change and the
|
|
1373
|
-
* comparison's name, as `Delta` writes it ("↑ +12.4% 前年同月比"; a minus is U+2212).
|
|
1374
|
-
* 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.
|
|
1375
1543
|
*/
|
|
1376
1544
|
function comparisonNote(
|
|
1377
1545
|
current: number | null,
|
|
@@ -1379,16 +1547,18 @@ function comparisonNote(
|
|
|
1379
1547
|
comparison: Pick<CartesianComparisonConfig, "vs" | "label">,
|
|
1380
1548
|
grain?: PeriodGrain,
|
|
1381
1549
|
format?: ValueFormat,
|
|
1382
|
-
|
|
1550
|
+
localeArg?: string,
|
|
1383
1551
|
): string {
|
|
1552
|
+
const locale = resolveLocale(localeArg)
|
|
1384
1553
|
const result = computeComparison(
|
|
1385
1554
|
current,
|
|
1386
1555
|
comparison.vs === "custom"
|
|
1387
1556
|
? { previous, vs: "custom", label: comparison.label ?? "", grain }
|
|
1388
1557
|
: { previous, vs: comparison.vs, label: comparison.label, grain },
|
|
1558
|
+
{ locale },
|
|
1389
1559
|
)
|
|
1390
1560
|
const text = formatChange(result, defaultChangeDisplay(format), locale, format)
|
|
1391
|
-
if (text === null) return
|
|
1561
|
+
if (text === null) return `${chartText(locale).noComparison} ${result.label}`
|
|
1392
1562
|
const arrow = result.direction === "up" ? "↑" : result.direction === "down" ? "↓" : "→"
|
|
1393
1563
|
return `${arrow} ${text.replace(/^-/, "−")} ${result.label}`
|
|
1394
1564
|
}
|
|
@@ -1431,7 +1601,6 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1431
1601
|
toneColors,
|
|
1432
1602
|
valueFormatter,
|
|
1433
1603
|
axisFormatter = valueFormatter,
|
|
1434
|
-
locale = DEFAULT_LOCALE,
|
|
1435
1604
|
type = "default",
|
|
1436
1605
|
layout = "horizontal",
|
|
1437
1606
|
curveType = "linear",
|
|
@@ -1468,7 +1637,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1468
1637
|
chartOptions,
|
|
1469
1638
|
reserveActions = false,
|
|
1470
1639
|
} = cfg
|
|
1471
|
-
const
|
|
1640
|
+
const locale = resolveLocale(cfg.locale)
|
|
1641
|
+
const text = chartText(locale)
|
|
1642
|
+
const provisionalLabel = cfg.provisionalLabel ?? text.provisional
|
|
1472
1643
|
|
|
1473
1644
|
const stacked = kind !== "line" && type !== "default"
|
|
1474
1645
|
const percent = kind !== "line" && type === "percent"
|
|
@@ -1617,9 +1788,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1617
1788
|
// `barOptions.targetKey` (a target per bar) takes over from the single `target` line.
|
|
1618
1789
|
const perBarTarget = kind === "bar" && Boolean(targetKey)
|
|
1619
1790
|
const hasTarget = !perBarTarget && target !== undefined && Number.isFinite(target)
|
|
1620
|
-
const targetText = hasTarget ? (targetLabel ??
|
|
1791
|
+
const targetText = hasTarget ? (targetLabel ?? `${text.target} ${axisFormatter(target)}`) : ""
|
|
1621
1792
|
const highlightText = (value: number) =>
|
|
1622
|
-
shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${
|
|
1793
|
+
shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${text.total} ` : ""}${axisFormatter(value)}`
|
|
1623
1794
|
/** Whether `p`'s label sits within the target's value band and under the target label (at the right end). */
|
|
1624
1795
|
const nearTargetLabel = (p: HighlightPoint, side: "under" | "over") => {
|
|
1625
1796
|
if (!hasTarget || !plotWidth || kind === "bar" || labels.length < 2) return false
|
|
@@ -1645,7 +1816,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1645
1816
|
// One series (or a stack, against its total) can be compared to a target; grouped series and 100% stacks cannot.
|
|
1646
1817
|
const barTargets: (number | null)[] | null =
|
|
1647
1818
|
perBarTarget && targetKey && !percent && (stacked || categories.length === 1) ? barTargetValues(data, targetKey) : null
|
|
1648
|
-
const barTargetName = targetLabel ??
|
|
1819
|
+
const barTargetName = targetLabel ?? text.target
|
|
1649
1820
|
const barTargetColor = labelColor ?? guideColor
|
|
1650
1821
|
|
|
1651
1822
|
const markPointFor = (c: number, color: string | undefined) =>
|
|
@@ -1859,7 +2030,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1859
2030
|
}
|
|
1860
2031
|
|
|
1861
2032
|
/* -- forecast: dashed continuation (+ optional band) of one series */
|
|
1862
|
-
const forecastLabel = forecast?.label ??
|
|
2033
|
+
const forecastLabel = forecast?.label ?? text.forecast
|
|
1863
2034
|
const forecastBandAlpha = isDarkSurface(labelBackground) ? FORECAST_BAND_ALPHA_DARK : FORECAST_BAND_ALPHA_LIGHT
|
|
1864
2035
|
const forecastValues: (number | null)[] = []
|
|
1865
2036
|
if (forecast) {
|
|
@@ -2335,7 +2506,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
2335
2506
|
})
|
|
2336
2507
|
}
|
|
2337
2508
|
if (stackTotals && i <= lastActual && rows.length > 1) {
|
|
2338
|
-
rows.push({ name:
|
|
2509
|
+
rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
|
|
2339
2510
|
}
|
|
2340
2511
|
const provisional = provStart !== -1 && i >= provStart && i <= lastActual
|
|
2341
2512
|
if (provisional) {
|
|
@@ -2351,19 +2522,19 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
2351
2522
|
...noteFor(at),
|
|
2352
2523
|
})
|
|
2353
2524
|
})
|
|
2354
|
-
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 })
|
|
2355
2526
|
}
|
|
2356
2527
|
const title = tooltipTitles[i] ?? labels[i] ?? ""
|
|
2357
2528
|
const lines: string[] = []
|
|
2358
2529
|
if (barTargets && i <= lastActual) {
|
|
2359
2530
|
const total = stackTotals ? (stackTotals[i] ?? null) : (values[0]?.[i] ?? null)
|
|
2360
|
-
const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter)
|
|
2531
|
+
const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter, locale)
|
|
2361
2532
|
if (note) lines.push(note)
|
|
2362
2533
|
}
|
|
2363
2534
|
for (const note of notes) {
|
|
2364
2535
|
if (note.to === undefined ? note.from === i : note.from <= i && i <= note.to) lines.push(note.label)
|
|
2365
2536
|
}
|
|
2366
|
-
return tooltipHtml(provisional ?
|
|
2537
|
+
return tooltipHtml(provisional ? text.provisionalTitle(title, provisionalLabel) : title, rows, valueFormatter, lines, locale)
|
|
2367
2538
|
},
|
|
2368
2539
|
},
|
|
2369
2540
|
xAxis: horizontalBars ? valueAxis : categoryAxis,
|
|
@@ -2455,8 +2626,6 @@ interface DonutOptionSlice {
|
|
|
2455
2626
|
/** How many of the largest slices get an outside label (「自然検索 41%」) by default. */
|
|
2456
2627
|
const DONUT_OUTSIDE_LABELS = 2
|
|
2457
2628
|
|
|
2458
|
-
const DEFAULT_TOTAL_LABEL = "合計"
|
|
2459
|
-
|
|
2460
2629
|
const sliceValue = (value: number) => (Number.isFinite(value) && value > 0 ? value : 0)
|
|
2461
2630
|
|
|
2462
2631
|
/** A slice's share of `total` as text: whole percent, `<1%` for a sliver, `0%` for nothing. */
|
|
@@ -2489,15 +2658,16 @@ function donutLegendItems(slices: readonly DonutOptionSlice[]): DonutLegendItem[
|
|
|
2489
2658
|
}
|
|
2490
2659
|
|
|
2491
2660
|
/**
|
|
2492
|
-
* 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
|
|
2493
2662
|
* (`amount`, the big line), the unit (a small line under it) and both as one
|
|
2494
2663
|
* string (`value`, 「9.2万 セッション」).
|
|
2495
2664
|
*/
|
|
2496
2665
|
function donutCenterText(
|
|
2497
2666
|
total: number,
|
|
2498
2667
|
valueFormatter: (value: number) => string,
|
|
2499
|
-
{ totalLabel
|
|
2668
|
+
{ totalLabel, unit, locale }: { totalLabel?: string; unit?: string; locale?: string } = {},
|
|
2500
2669
|
): { label: string; value: string; amount: string; unit?: string } {
|
|
2670
|
+
totalLabel ??= chartText(locale).total
|
|
2501
2671
|
const amount = valueFormatter(total)
|
|
2502
2672
|
return { label: totalLabel, value: unit ? `${amount} ${unit}` : amount, amount, ...(unit ? { unit } : {}) }
|
|
2503
2673
|
}
|
|
@@ -2725,8 +2895,10 @@ function hasLabel(labels: Readonly<Record<string, string>> | undefined, key: str
|
|
|
2725
2895
|
|
|
2726
2896
|
/* ------------------------------------------------------------ provisional */
|
|
2727
2897
|
|
|
2728
|
-
/** The default label for points that are not final yet. */
|
|
2729
|
-
|
|
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
|
+
}
|
|
2730
2902
|
|
|
2731
2903
|
/**
|
|
2732
2904
|
* Position of the first provisional point: the first entry of `indexValues`
|
|
@@ -2864,9 +3036,10 @@ function resolveValueFormatter(
|
|
|
2864
3036
|
function resolveAxisFormatter(
|
|
2865
3037
|
valueFormatter: ValueFormatter | undefined,
|
|
2866
3038
|
format: ValueFormat | undefined,
|
|
2867
|
-
|
|
3039
|
+
localeArg?: string,
|
|
2868
3040
|
): ValueFormatter {
|
|
2869
3041
|
if (valueFormatter) return valueFormatter
|
|
3042
|
+
const locale = resolveLocale(localeArg)
|
|
2870
3043
|
const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
|
|
2871
3044
|
if (spec.type !== "compact" && spec.type !== "number" && spec.type !== "currency") return toValueFormatter(format, locale)
|
|
2872
3045
|
const currency = "currency" in spec ? spec.currency : undefined
|
|
@@ -2875,40 +3048,75 @@ function resolveAxisFormatter(
|
|
|
2875
3048
|
return (value) => formatValue(value, short, locale)
|
|
2876
3049
|
}
|
|
2877
3050
|
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
{ char: "万", size: 1e4 },
|
|
2883
|
-
] as const
|
|
3051
|
+
interface CompactUnit {
|
|
3052
|
+
token: string
|
|
3053
|
+
size: number
|
|
3054
|
+
}
|
|
2884
3055
|
|
|
2885
|
-
const
|
|
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
|
+
}
|
|
3090
|
+
|
|
3091
|
+
const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
2886
3092
|
|
|
2887
3093
|
/**
|
|
2888
3094
|
* The value-axis tick formatter for an axis whose largest tick is `top`: every tick reads in the unit of
|
|
2889
|
-
* that largest tick, so
|
|
2890
|
-
* Only the
|
|
2891
|
-
* 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.
|
|
2892
3099
|
*/
|
|
2893
3100
|
function resolveAxisTickFormatter(
|
|
2894
3101
|
valueFormatter: ValueFormatter | undefined,
|
|
2895
3102
|
format: ValueFormat | undefined,
|
|
2896
|
-
|
|
3103
|
+
localeArg?: string,
|
|
2897
3104
|
): (top: number) => ValueFormatter {
|
|
3105
|
+
const locale = resolveLocale(localeArg)
|
|
2898
3106
|
const base = resolveAxisFormatter(valueFormatter, format, locale)
|
|
2899
3107
|
const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
|
|
2900
|
-
const
|
|
2901
|
-
|
|
2902
|
-
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
|
|
2903
3110
|
const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 3 })
|
|
3111
|
+
const numberToken = new RegExp(`\\d[\\d,]*(?:\\.\\d+)?(?:${units.map((u) => escapeRegExp(u.token)).join("|")})?`)
|
|
2904
3112
|
return (top) => {
|
|
2905
|
-
const unit =
|
|
3113
|
+
const unit = units.find((u) => Math.abs(top) >= u.size)
|
|
2906
3114
|
if (!unit) return base
|
|
2907
3115
|
return (value) => {
|
|
2908
3116
|
const text = base(value)
|
|
2909
3117
|
if (value === 0 || !Number.isFinite(value)) return text
|
|
2910
|
-
const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.
|
|
2911
|
-
return text.replace(
|
|
3118
|
+
const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.token}`
|
|
3119
|
+
return text.replace(numberToken, () => scaled)
|
|
2912
3120
|
}
|
|
2913
3121
|
}
|
|
2914
3122
|
}
|
|
@@ -2937,8 +3145,20 @@ function clamp(value: number, min: number, max: number): number {
|
|
|
2937
3145
|
/** How many categories a chart colours individually; the rest fold into「その他」. */
|
|
2938
3146
|
const MAX_CHART_SERIES = 5
|
|
2939
3147
|
|
|
2940
|
-
/** The series name the folded categories get. */
|
|
2941
|
-
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
|
+
}
|
|
2942
3162
|
|
|
2943
3163
|
interface AggregatedItems<Item extends { name: string; value: number }> {
|
|
2944
3164
|
/** The `n` largest items, in their given order. All items when there are `n` or fewer. */
|
|
@@ -2958,7 +3178,7 @@ interface AggregatedItems<Item extends { name: string; value: number }> {
|
|
|
2958
3178
|
function aggregateTopN<Item extends { name: string; value: number }>(
|
|
2959
3179
|
items: readonly Item[],
|
|
2960
3180
|
n: number = MAX_CHART_SERIES,
|
|
2961
|
-
otherLabel: string =
|
|
3181
|
+
otherLabel: string = otherSeriesLabel(),
|
|
2962
3182
|
): AggregatedItems<Item> {
|
|
2963
3183
|
if (items.length <= n) return { top: [...items], rest: [], other: null }
|
|
2964
3184
|
const ranked = items
|
|
@@ -2987,7 +3207,7 @@ function aggregateSeries(
|
|
|
2987
3207
|
data: readonly Record<string, unknown>[],
|
|
2988
3208
|
categories: readonly string[],
|
|
2989
3209
|
n: number = MAX_CHART_SERIES,
|
|
2990
|
-
otherLabel: string =
|
|
3210
|
+
otherLabel: string = otherSeriesLabel(),
|
|
2991
3211
|
): AggregatedSeries {
|
|
2992
3212
|
const totals = categories.map((name) => ({
|
|
2993
3213
|
name,
|
|
@@ -3019,12 +3239,14 @@ function aggregateSeriesKeeping(
|
|
|
3019
3239
|
categories: readonly string[],
|
|
3020
3240
|
keep: string | undefined,
|
|
3021
3241
|
n: number = MAX_CHART_SERIES,
|
|
3242
|
+
otherLabel: string = otherSeriesLabel(),
|
|
3022
3243
|
): AggregatedSeries {
|
|
3023
|
-
if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n)
|
|
3244
|
+
if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n, otherLabel)
|
|
3024
3245
|
const aggregated = aggregateSeries(
|
|
3025
3246
|
data,
|
|
3026
3247
|
categories.filter((c) => c !== keep),
|
|
3027
3248
|
n,
|
|
3249
|
+
otherLabel,
|
|
3028
3250
|
)
|
|
3029
3251
|
return { ...aggregated, categories: [...aggregated.categories, keep] }
|
|
3030
3252
|
}
|
|
@@ -3060,7 +3282,7 @@ function seriesColorsWithComparison(
|
|
|
3060
3282
|
* Colour per drawn series: the record's token for that category, else the
|
|
3061
3283
|
* array's token at the category's position in `allCategories` (cycled), else
|
|
3062
3284
|
* the token palette by drawn position. `otherCategory` — and a category the
|
|
3063
|
-
* data itself names
|
|
3285
|
+
* data itself names「その他」/ "Other" (`OTHER_SERIES_LABELS`) — always gets `otherColor`.
|
|
3064
3286
|
*/
|
|
3065
3287
|
function seriesColorsFor(
|
|
3066
3288
|
categories: readonly string[],
|
|
@@ -3072,7 +3294,7 @@ function seriesColorsFor(
|
|
|
3072
3294
|
const list = Array.isArray(colors) ? (colors as ChartSeriesColor[]) : undefined
|
|
3073
3295
|
const record = !Array.isArray(colors) && colors ? (colors as Partial<Record<string, ChartSeriesColor>>) : undefined
|
|
3074
3296
|
return categories.map((category, i) => {
|
|
3075
|
-
if ((otherCategory !== null && category === otherCategory) || category
|
|
3297
|
+
if ((otherCategory !== null && category === otherCategory) || isOtherName(category)) return otherColor
|
|
3076
3298
|
const fromRecord = record?.[category]
|
|
3077
3299
|
if (fromRecord) return fromRecord
|
|
3078
3300
|
if (list && list.length > 0) {
|
|
@@ -3119,7 +3341,7 @@ interface DonutSlicesConfig {
|
|
|
3119
3341
|
*/
|
|
3120
3342
|
function donutSlices(
|
|
3121
3343
|
data: readonly Record<string, unknown>[],
|
|
3122
|
-
{ index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel =
|
|
3344
|
+
{ index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = otherSeriesLabel() }: DonutSlicesConfig,
|
|
3123
3345
|
): DonutSlice[] {
|
|
3124
3346
|
const items = data
|
|
3125
3347
|
.map((row, i) => {
|
|
@@ -3132,7 +3354,8 @@ function donutSlices(
|
|
|
3132
3354
|
const slices: DonutSlice[] = top.map((item, rank) => ({
|
|
3133
3355
|
name: item.name,
|
|
3134
3356
|
value: item.value,
|
|
3135
|
-
|
|
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]!,
|
|
3136
3359
|
isOther: false,
|
|
3137
3360
|
row: item.row,
|
|
3138
3361
|
}))
|
|
@@ -3205,7 +3428,7 @@ interface BarListRowsConfig extends BarListOptions {
|
|
|
3205
3428
|
*/
|
|
3206
3429
|
function barListRows<Row extends object>(
|
|
3207
3430
|
data: readonly Row[],
|
|
3208
|
-
{ 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 = {},
|
|
3209
3432
|
): BarListRow<Row>[] {
|
|
3210
3433
|
const items = data.map((row): BarListRow<Row> => {
|
|
3211
3434
|
const cells = row as Record<string, unknown>
|
|
@@ -3251,19 +3474,21 @@ const BAR_LIST_OTHER_TINT = 30
|
|
|
3251
3474
|
|
|
3252
3475
|
/**
|
|
3253
3476
|
* The fill of each BarList row: all real rows in the same tint of `color`
|
|
3254
|
-
* (`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`
|
|
3255
3478
|
* (`BAR_LIST_OTHER_TINT`) — the palest fill, a remainder never outweighs a
|
|
3256
3479
|
* ranked row. A legacy per-row `color` (or a threshold tone) replaces `color`
|
|
3257
3480
|
* for that row. A tint, not the solid token, so the label printed over the bar
|
|
3258
3481
|
* keeps its contrast.
|
|
3259
3482
|
*/
|
|
3260
3483
|
function barListFills(
|
|
3261
|
-
rows: readonly Pick<BarListRow, "isOther" | "color">[],
|
|
3484
|
+
rows: readonly (Pick<BarListRow, "isOther" | "color"> & { name?: string })[],
|
|
3262
3485
|
color: ChartColor,
|
|
3263
3486
|
otherColor: string,
|
|
3264
3487
|
): string[] {
|
|
3265
3488
|
return rows.map((row) =>
|
|
3266
|
-
row.isOther
|
|
3489
|
+
row.isOther || isOtherName(row.name)
|
|
3490
|
+
? tintCss(otherColor, BAR_LIST_OTHER_TINT)
|
|
3491
|
+
: tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
|
|
3267
3492
|
)
|
|
3268
3493
|
}
|
|
3269
3494
|
|
|
@@ -3479,11 +3704,30 @@ interface ChartSummaryInput {
|
|
|
3479
3704
|
format?: ValueFormat
|
|
3480
3705
|
/** Wins over `format`. */
|
|
3481
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
|
+
},
|
|
3482
3726
|
}
|
|
3483
3727
|
|
|
3484
3728
|
/**
|
|
3485
3729
|
* One-sentence summary built from the rows, for `summary="auto"`:
|
|
3486
|
-
* 「<title>: 最新 <index> は
|
|
3730
|
+
* 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」("<title>: Latest <index>: <value>, up from the previous point"). The direction is
|
|
3487
3731
|
* computed from the last two non-empty points of the first series — never
|
|
3488
3732
|
* written by hand, so it cannot contradict the data. `undefined` when there is
|
|
3489
3733
|
* no usable point.
|
|
@@ -3499,26 +3743,29 @@ function buildChartSummary(input: ChartSummaryInput): string | undefined {
|
|
|
3499
3743
|
}
|
|
3500
3744
|
const latest = points[points.length - 1]
|
|
3501
3745
|
if (!latest) return undefined
|
|
3502
|
-
const
|
|
3746
|
+
const locale = resolveLocale(input.locale)
|
|
3747
|
+
const t = SUMMARY_TEXT[uiLanguage(locale)]
|
|
3748
|
+
const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact", locale)
|
|
3503
3749
|
const prev = points[points.length - 2]
|
|
3504
|
-
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
|
|
3505
3751
|
const head = input.title ? `${input.title}: ` : ""
|
|
3506
|
-
return `${head}
|
|
3752
|
+
return `${head}${t.latest(latest.label, text(latest.value))}${direction}`
|
|
3507
3753
|
}
|
|
3508
3754
|
|
|
3509
3755
|
/**
|
|
3510
|
-
* `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」 from the slices
|
|
3511
|
-
* (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.
|
|
3512
3758
|
*/
|
|
3513
3759
|
function buildShareSummary(
|
|
3514
3760
|
slices: readonly { name: string; value: number }[],
|
|
3515
3761
|
title?: string,
|
|
3762
|
+
locale?: string,
|
|
3516
3763
|
): string | undefined {
|
|
3517
3764
|
const total = slices.reduce((sum, s) => sum + s.value, 0)
|
|
3518
3765
|
const top = slices[0]
|
|
3519
3766
|
if (!top || total <= 0) return undefined
|
|
3520
3767
|
const share = `${Math.round((top.value / total) * 1000) / 10}%`
|
|
3521
|
-
return `${title ? `${title}: ` : ""}
|
|
3768
|
+
return `${title ? `${title}: ` : ""}${SUMMARY_TEXT[uiLanguage(resolveLocale(locale))].top(top.name, share)}`
|
|
3522
3769
|
}
|
|
3523
3770
|
|
|
3524
3771
|
/* ---------- end of inlined code ---------- */
|
|
@@ -3656,12 +3903,49 @@ export interface ScatterSeries {
|
|
|
3656
3903
|
points: ScatterPoint[]
|
|
3657
3904
|
}
|
|
3658
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
|
+
|
|
3659
3939
|
/**
|
|
3660
3940
|
* Points → series. Series keep their order of first appearance (that order is the
|
|
3661
3941
|
* colour order). More than `max` of them: the `max` with the most points are kept
|
|
3662
3942
|
* and the rest fold into one trailing「その他」series.
|
|
3663
3943
|
*/
|
|
3664
|
-
export function scatterSeries(
|
|
3944
|
+
export function scatterSeries(
|
|
3945
|
+
points: readonly ScatterPoint[],
|
|
3946
|
+
max: number = MAX_CHART_SERIES,
|
|
3947
|
+
otherLabel: string = otherSeriesLabel(),
|
|
3948
|
+
): ScatterSeries[] {
|
|
3665
3949
|
const order: string[] = []
|
|
3666
3950
|
const counts = new Map<string, number>()
|
|
3667
3951
|
for (const p of points) {
|
|
@@ -3676,7 +3960,7 @@ export function scatterSeries(points: readonly ScatterPoint[], max: number = MAX
|
|
|
3676
3960
|
const result: ScatterSeries[] = order
|
|
3677
3961
|
.filter((name) => kept.has(name))
|
|
3678
3962
|
.map((name) => ({ name, isOther: false, points: points.filter((p) => p.series === name) }))
|
|
3679
|
-
result.push({ name:
|
|
3963
|
+
result.push({ name: otherLabel, isOther: true, points: points.filter((p) => !kept.has(p.series)) })
|
|
3680
3964
|
return result
|
|
3681
3965
|
}
|
|
3682
3966
|
|
|
@@ -3703,20 +3987,22 @@ function pointTitle(point: ScatterPoint, labels?: Record<string, string>): strin
|
|
|
3703
3987
|
return labelOf(point.name || point.series, labels)
|
|
3704
3988
|
}
|
|
3705
3989
|
|
|
3706
|
-
/** `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. */
|
|
3707
3991
|
export function buildScatterSummary(
|
|
3708
3992
|
points: readonly ScatterPoint[],
|
|
3709
3993
|
yName: string,
|
|
3710
3994
|
yFormatter: ValueFormatter,
|
|
3711
3995
|
title?: string,
|
|
3712
3996
|
labels?: Record<string, string>,
|
|
3997
|
+
locale?: string,
|
|
3713
3998
|
): string | undefined {
|
|
3714
3999
|
if (points.length === 0) return undefined
|
|
4000
|
+
const t = textOf(locale)
|
|
3715
4001
|
let best = points[0]!
|
|
3716
4002
|
for (const p of points) if (p.y > best.y) best = p
|
|
3717
4003
|
const head = title ? `${title}: ` : ""
|
|
3718
|
-
const who = best.name ?
|
|
3719
|
-
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)}`
|
|
3720
4006
|
}
|
|
3721
4007
|
|
|
3722
4008
|
/** Cap of the screen-reader table (a scatter can hold thousands of dots). */
|
|
@@ -3734,17 +4020,20 @@ export function scatterDataTable(
|
|
|
3734
4020
|
formatters: { x: ValueFormatter; y: ValueFormatter; size: ValueFormatter }
|
|
3735
4021
|
labels?: Record<string, string>
|
|
3736
4022
|
referenceLines?: readonly ScatterReferenceLine[]
|
|
4023
|
+
/** Language of the headers and caption. Default the page's locale (`<html lang>`). */
|
|
4024
|
+
locale?: string
|
|
3737
4025
|
},
|
|
3738
4026
|
): ChartDataTable {
|
|
4027
|
+
const t = textOf(config.locale)
|
|
3739
4028
|
const label = (key: string) => labelOf(key, config.labels)
|
|
3740
|
-
// Column headers: the caller's `labels`, else a plain
|
|
4029
|
+
// Column headers: the caller's `labels`, else a plain name (not the data key).
|
|
3741
4030
|
const column = (key: string, fallback: string) => config.labels?.[key] ?? fallback
|
|
3742
4031
|
const columns = [
|
|
3743
|
-
...(config.index ? [column(config.index,
|
|
3744
|
-
...(config.series ? [column(config.series,
|
|
3745
|
-
column(config.x,
|
|
3746
|
-
column(config.y,
|
|
3747
|
-
...(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)] : []),
|
|
3748
4037
|
]
|
|
3749
4038
|
const shown = points.slice(0, SCATTER_TABLE_MAX_ROWS)
|
|
3750
4039
|
const rows = shown.map((p, i) => {
|
|
@@ -3758,15 +4047,16 @@ export function scatterDataTable(
|
|
|
3758
4047
|
// The first column is the row header; without an `index` column, number the dots.
|
|
3759
4048
|
return config.index ? cells : [String(i + 1), ...cells]
|
|
3760
4049
|
})
|
|
3761
|
-
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
|
|
3762
4052
|
// The guide lines carry information the dots do not: read them out with the table.
|
|
3763
4053
|
const guides = (config.referenceLines ?? []).map((line) => {
|
|
3764
4054
|
const key = line.axis === "x" ? config.x : config.y
|
|
3765
4055
|
const text = config.formatters[line.axis](line.value)
|
|
3766
|
-
return line.label ?
|
|
4056
|
+
return line.label ? t.guide(line.label, label(key), text) : `${label(key)} ${text}`
|
|
3767
4057
|
})
|
|
3768
4058
|
return {
|
|
3769
|
-
caption: guides.length > 0 ?
|
|
4059
|
+
caption: guides.length > 0 ? t.caption(base, guides) : base,
|
|
3770
4060
|
columns: config.index ? columns : ["#", ...columns],
|
|
3771
4061
|
rows,
|
|
3772
4062
|
}
|
|
@@ -4118,27 +4408,28 @@ function ScatterChartImpl<T extends Record<string, unknown>>(
|
|
|
4118
4408
|
ref: ForwardedRef<HTMLDivElement>,
|
|
4119
4409
|
) {
|
|
4120
4410
|
const { ref: frameRef, width: plotWidth, tokens } = useChartSurface(ref)
|
|
4411
|
+
const locale = useLocale()
|
|
4121
4412
|
// Tooltips and the table read in full; axis ticks read short.
|
|
4122
4413
|
const formatters = useMemo(
|
|
4123
4414
|
() => ({
|
|
4124
|
-
x: resolveValueFormatter(valueFormatter, xFormat ?? format),
|
|
4125
|
-
y: resolveValueFormatter(valueFormatter, yFormat ?? format),
|
|
4126
|
-
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),
|
|
4127
4418
|
}),
|
|
4128
|
-
[valueFormatter, xFormat, yFormat, sizeFormat, format],
|
|
4419
|
+
[valueFormatter, xFormat, yFormat, sizeFormat, format, locale],
|
|
4129
4420
|
)
|
|
4130
4421
|
const axisFormatters = useMemo(
|
|
4131
4422
|
() => ({
|
|
4132
|
-
x: resolveAxisFormatter(valueFormatter, xFormat ?? format),
|
|
4133
|
-
y: resolveAxisFormatter(valueFormatter, yFormat ?? format),
|
|
4423
|
+
x: resolveAxisFormatter(valueFormatter, xFormat ?? format, locale),
|
|
4424
|
+
y: resolveAxisFormatter(valueFormatter, yFormat ?? format, locale),
|
|
4134
4425
|
}),
|
|
4135
|
-
[valueFormatter, xFormat, yFormat, format],
|
|
4426
|
+
[valueFormatter, xFormat, yFormat, format, locale],
|
|
4136
4427
|
)
|
|
4137
4428
|
const points = useMemo(
|
|
4138
4429
|
() => scatterPoints(data, { x, y, size, index, series: seriesKey, labelKey }),
|
|
4139
4430
|
[data, x, y, size, index, seriesKey, labelKey],
|
|
4140
4431
|
)
|
|
4141
|
-
const series = useMemo(() => scatterSeries(points), [points])
|
|
4432
|
+
const series = useMemo(() => scatterSeries(points, undefined, otherSeriesLabel(locale)), [points, locale])
|
|
4142
4433
|
const palette = useMemo(
|
|
4143
4434
|
() => resolveChartColors(scatterSeriesColors(series, colors).map(chartFillName), tokens),
|
|
4144
4435
|
[series, colors, tokens],
|
|
@@ -4175,14 +4466,14 @@ function ScatterChartImpl<T extends Record<string, unknown>>(
|
|
|
4175
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],
|
|
4176
4467
|
)
|
|
4177
4468
|
const dataTable = useMemo(
|
|
4178
|
-
() => scatterDataTable(points, { x, y, size, index: index ?? labelKey, series: seriesKey, formatters, labels, referenceLines }),
|
|
4179
|
-
[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],
|
|
4180
4471
|
)
|
|
4181
4472
|
const resolvedSummary = useMemo(
|
|
4182
|
-
() => (summary === "auto" ? buildScatterSummary(points, y, formatters.y, title, labels) : summary),
|
|
4183
|
-
[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],
|
|
4184
4475
|
)
|
|
4185
|
-
const label = ariaLabel ??
|
|
4476
|
+
const label = ariaLabel ?? textOf(locale).chart(labelOf(y, labels), labelOf(x, labels))
|
|
4186
4477
|
return (
|
|
4187
4478
|
<ChartFrame
|
|
4188
4479
|
{...chartRootProps(rest)}
|