@squadbase/vantage 0.6.2-dev1.48b34e1 → 0.6.2-dev1.51e401a
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/{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-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/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/doc/index.d.ts +14 -14
- package/dist/doc/index.js +198 -84
- package/dist/doc/index.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/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-add-feature/SKILL.md +5 -0
- package/templates/AGENTS.md +10 -8
- package/templates/agents/api.md +7 -0
- package/templates/agents/page.md +36 -0
- package/dist/chunk-2N72FKLB.js.map +0 -1
- package/dist/chunk-57GHR5LN.js.map +0 -1
- package/dist/chunk-6OTTGAPO.js.map +0 -1
- package/dist/chunk-JXFCSF3K.js.map +0 -1
- package/dist/chunk-PDOBHMUQ.js.map +0 -1
- package/dist/chunk-QOXXOFGL.js.map +0 -1
|
@@ -20,6 +20,7 @@ import { ChartFrame } from "@squadbase/vantage/components"
|
|
|
20
20
|
import { EChart } from "@squadbase/vantage/components"
|
|
21
21
|
import { EMPTY_VALUE } from "@squadbase/vantage/components"
|
|
22
22
|
import type { ChartCommonProps, ValueFormatter } from "@squadbase/vantage/components"
|
|
23
|
+
import { useSyncExternalStore } from "react"
|
|
23
24
|
import { createContext, useCallback, useEffect, useRef, useState, type RefObject } from "react"
|
|
24
25
|
import type { ChartTone } from "@squadbase/vantage/components"
|
|
25
26
|
import { type ReactNode } from "react"
|
|
@@ -29,6 +30,137 @@ import type { ChartThreshold, ChartAnnotation, ChartEventPayload, ChartForecast,
|
|
|
29
30
|
import { formatValue, toValueFormatter } from "@squadbase/vantage/components"
|
|
30
31
|
import type { ChartColor, ChartColors, ChartSeriesColor } from "@squadbase/vantage/components"
|
|
31
32
|
|
|
33
|
+
/* ---------- inlined from @squadbase/squadkit registry/lib/locale.ts (internal to @squadbase/vantage) ---------- */
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The display language of the parts and the defaults that follow it.
|
|
37
|
+
*
|
|
38
|
+
* The language comes from `<html lang>`: a tag starting with `ja` reads Japanese, anything else English.
|
|
39
|
+
* Without a usable `lang` (missing, invalid, or no `document` — SSR, Node) the locale is `FALLBACK_LOCALE`
|
|
40
|
+
* (`en-US`). A `locale` passed explicitly (a prop, an argument) always wins.
|
|
41
|
+
*
|
|
42
|
+
* Parts read the locale through `useLocale` (`hooks/use-locale`) and pass it down; the pure helpers here
|
|
43
|
+
* and in `format` / `comparison` / `chart-*` take it as an argument and fall back to `documentLocale()`.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** The language of the built-in wording: Japanese or English. */
|
|
47
|
+
type UiLanguage = "ja" | "en"
|
|
48
|
+
|
|
49
|
+
/** The locale used when `<html lang>` gives none (and on the server). */
|
|
50
|
+
const FALLBACK_LOCALE = "en-US"
|
|
51
|
+
|
|
52
|
+
/** `"ja"` when `locale` starts with `ja` (`ja`, `ja-JP`), else `"en"`. A missing locale is `"en"`. */
|
|
53
|
+
function uiLanguage(locale: string | null | undefined): UiLanguage {
|
|
54
|
+
return typeof locale === "string" && locale.trim().toLowerCase().startsWith("ja") ? "ja" : "en"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A BCP 47 tag in its canonical form (`en-gb` → `en-GB`), or `FALLBACK_LOCALE` when it is empty or invalid. */
|
|
58
|
+
function normalizeLocale(tag: string | null | undefined): string {
|
|
59
|
+
const value = tag?.trim()
|
|
60
|
+
if (!value) return FALLBACK_LOCALE
|
|
61
|
+
try {
|
|
62
|
+
return Intl.getCanonicalLocales(value)[0] ?? FALLBACK_LOCALE
|
|
63
|
+
} catch {
|
|
64
|
+
return FALLBACK_LOCALE
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The locale of the page: `document.documentElement.lang`, canonicalized. `FALLBACK_LOCALE` when it is empty or
|
|
70
|
+
* invalid, or there is no `document`. Parts read it through `useLocale`, never while rendering.
|
|
71
|
+
*/
|
|
72
|
+
function documentLocale(): string {
|
|
73
|
+
if (typeof document === "undefined") return FALLBACK_LOCALE
|
|
74
|
+
return normalizeLocale(document.documentElement?.getAttribute("lang"))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `locale` when given, else the page's locale (`documentLocale()`). */
|
|
78
|
+
function resolveLocale(locale?: string | null): string {
|
|
79
|
+
return locale || documentLocale()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Picks the wording table of the locale's language. */
|
|
83
|
+
function pickLocale<T>(table: { ja: T; en: T }, locale?: string | null): T {
|
|
84
|
+
return table[uiLanguage(resolveLocale(locale))]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The currency `format="currency"` uses without a `currency`: JPY for Japanese, USD otherwise. */
|
|
88
|
+
function defaultCurrency(locale?: string | null): string {
|
|
89
|
+
return uiLanguage(resolveLocale(locale)) === "ja" ? "JPY" : "USD"
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The month a fiscal year starts in (1–12) when none is given: April for Japanese, January otherwise. */
|
|
93
|
+
function defaultFiscalYearStartMonth(locale?: string | null): number {
|
|
94
|
+
return uiLanguage(resolveLocale(locale)) === "ja" ? 4 : 1
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A day of the week, 0 = Sunday … 6 = Saturday (the `Date#getDay` / date-fns numbering). */
|
|
98
|
+
type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6
|
|
99
|
+
|
|
100
|
+
/** Locales whose week starts on Sunday when the runtime has no week data. */
|
|
101
|
+
const SUNDAY_FIRST = new Set(["en", "en-US", "en-CA"])
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The first day of the week: Monday for Japanese; otherwise the locale's convention from `Intl.Locale`
|
|
105
|
+
* (`en-US` Sunday, `en-GB` Monday). Without week data in the runtime: Sunday for `en` / `en-US` / `en-CA`,
|
|
106
|
+
* Monday for the rest.
|
|
107
|
+
*/
|
|
108
|
+
function weekStartsOn(locale?: string | null): WeekDay {
|
|
109
|
+
const tag = normalizeLocale(resolveLocale(locale))
|
|
110
|
+
if (uiLanguage(tag) === "ja") return 1
|
|
111
|
+
try {
|
|
112
|
+
const info = new Intl.Locale(tag) as Intl.Locale & {
|
|
113
|
+
getWeekInfo?: () => { firstDay: number }
|
|
114
|
+
weekInfo?: { firstDay: number }
|
|
115
|
+
}
|
|
116
|
+
const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay
|
|
117
|
+
if (typeof firstDay === "number" && firstDay >= 1 && firstDay <= 7) return (firstDay % 7) as WeekDay
|
|
118
|
+
} catch {
|
|
119
|
+
// Fall through to the table below.
|
|
120
|
+
}
|
|
121
|
+
return SUNDAY_FIRST.has(tag) ? 0 : 1
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* ---------- inlined from @squadbase/squadkit registry/hooks/use-locale.ts (internal to @squadbase/vantage) ---------- */
|
|
125
|
+
|
|
126
|
+
const listeners = new Set<() => void>()
|
|
127
|
+
let observer: MutationObserver | null = null
|
|
128
|
+
|
|
129
|
+
/** One observer on `<html lang>` for every subscribed part. */
|
|
130
|
+
function subscribe(onChange: () => void): () => void {
|
|
131
|
+
if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {}
|
|
132
|
+
listeners.add(onChange)
|
|
133
|
+
if (!observer) {
|
|
134
|
+
observer = new MutationObserver(() => {
|
|
135
|
+
for (const listener of [...listeners]) listener()
|
|
136
|
+
})
|
|
137
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] })
|
|
138
|
+
}
|
|
139
|
+
return () => {
|
|
140
|
+
listeners.delete(onChange)
|
|
141
|
+
if (listeners.size === 0 && observer) {
|
|
142
|
+
observer.disconnect()
|
|
143
|
+
observer = null
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const serverLocale = () => FALLBACK_LOCALE
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The page's locale (`<html lang>`), following changes made while the page runs. On the server and during
|
|
152
|
+
* hydration it is `FALLBACK_LOCALE` (`en-US`), so the markup matches; it switches right after.
|
|
153
|
+
*/
|
|
154
|
+
function useDocumentLocale(): string {
|
|
155
|
+
return useSyncExternalStore(subscribe, documentLocale, serverLocale)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The locale a part renders in: its `locale` prop when given, else the page's (`useDocumentLocale`). */
|
|
159
|
+
function useLocale(locale?: string | null): string {
|
|
160
|
+
const page = useDocumentLocale()
|
|
161
|
+
return locale || page
|
|
162
|
+
}
|
|
163
|
+
|
|
32
164
|
/* ---------- inlined from @squadbase/squadkit registry/lib/chart-theme.ts (internal to @squadbase/vantage) ---------- */
|
|
33
165
|
|
|
34
166
|
/**
|
|
@@ -427,6 +559,32 @@ function useChartSurface<E extends Element>(
|
|
|
427
559
|
|
|
428
560
|
/* ---------- inlined from @squadbase/squadkit registry/lib/chart-options.ts (internal to @squadbase/vantage) ---------- */
|
|
429
561
|
|
|
562
|
+
/** The built-in words of the option builders, by language. */
|
|
563
|
+
const CHART_TEXT = {
|
|
564
|
+
ja: {
|
|
565
|
+
target: "目標",
|
|
566
|
+
forecast: "予測",
|
|
567
|
+
total: "合計",
|
|
568
|
+
provisional: "速報",
|
|
569
|
+
noComparison: "比較なし",
|
|
570
|
+
share: (share: string) => `(${share})`,
|
|
571
|
+
targetRate: (rate: string) => `(達成率 ${rate})`,
|
|
572
|
+
provisionalTitle: (title: string, label: string) => `${title}(${label})`,
|
|
573
|
+
},
|
|
574
|
+
en: {
|
|
575
|
+
target: "Target",
|
|
576
|
+
forecast: "Forecast",
|
|
577
|
+
total: "Total",
|
|
578
|
+
provisional: "Preliminary",
|
|
579
|
+
noComparison: "No comparison",
|
|
580
|
+
share: (share: string) => ` (${share})`,
|
|
581
|
+
targetRate: (rate: string) => ` (${rate} achieved)`,
|
|
582
|
+
provisionalTitle: (title: string, label: string) => `${title} (${label})`,
|
|
583
|
+
},
|
|
584
|
+
} satisfies Record<UiLanguage, unknown>
|
|
585
|
+
|
|
586
|
+
const chartText = (locale?: string) => CHART_TEXT[uiLanguage(resolveLocale(locale))]
|
|
587
|
+
|
|
430
588
|
type CartesianChartKind = "area" | "bar" | "line"
|
|
431
589
|
type CartesianStackType = "default" | "stacked" | "percent"
|
|
432
590
|
type CartesianCurveType = "linear" | "smooth" | "step"
|
|
@@ -611,7 +769,7 @@ interface CartesianChartConfig {
|
|
|
611
769
|
* all ticks read in one unit (¥0.5億 / ¥1億). Default: `axisFormatter` for every tick.
|
|
612
770
|
*/
|
|
613
771
|
axisTicks?: (top: number) => (value: number) => string
|
|
614
|
-
/** Locale of the time-axis labels. Default
|
|
772
|
+
/** Locale of the time-axis labels and the built-in words (total, target, forecast). Default the page's (`<html lang>`). */
|
|
615
773
|
locale?: string
|
|
616
774
|
type?: CartesianStackType
|
|
617
775
|
/** Bar only. `"vertical"` lays the categories down the y axis (horizontal bars). */
|
|
@@ -849,11 +1007,12 @@ function timeAxisLabelStep(values: readonly unknown[], maxLabels: number = TIME_
|
|
|
849
1007
|
*/
|
|
850
1008
|
function timeAxisLabels(
|
|
851
1009
|
values: readonly unknown[],
|
|
852
|
-
|
|
1010
|
+
localeArg?: string,
|
|
853
1011
|
shown: (i: number) => boolean = () => true,
|
|
854
1012
|
): string[] | null {
|
|
855
1013
|
const parsed = parseTimeIndex(values)
|
|
856
1014
|
if (!parsed) return null
|
|
1015
|
+
const locale = resolveLocale(localeArg)
|
|
857
1016
|
const { granularity, times } = parsed
|
|
858
1017
|
let prevYear: number | null = null
|
|
859
1018
|
return times.map((t, i) => {
|
|
@@ -876,9 +1035,10 @@ function timeAxisLabels(
|
|
|
876
1035
|
}
|
|
877
1036
|
|
|
878
1037
|
/** The tooltip titles for time-like x values (`2025年9月`, `2025年9月1日`), or `null` when they are not dates. */
|
|
879
|
-
function timeTooltipLabels(values: readonly unknown[],
|
|
1038
|
+
function timeTooltipLabels(values: readonly unknown[], localeArg?: string): string[] | null {
|
|
880
1039
|
const parsed = parseTimeIndex(values)
|
|
881
1040
|
if (!parsed) return null
|
|
1041
|
+
const locale = resolveLocale(localeArg)
|
|
882
1042
|
const options: Intl.DateTimeFormatOptions =
|
|
883
1043
|
parsed.granularity === "year"
|
|
884
1044
|
? { year: "numeric" }
|
|
@@ -1018,7 +1178,9 @@ function tooltipHtml(
|
|
|
1018
1178
|
rows: readonly TooltipRow[],
|
|
1019
1179
|
valueFormatter: (value: number) => string,
|
|
1020
1180
|
notes: readonly string[] = [],
|
|
1181
|
+
locale?: string,
|
|
1021
1182
|
): string {
|
|
1183
|
+
const t = chartText(locale)
|
|
1022
1184
|
const lines = rows.map(
|
|
1023
1185
|
(row) =>
|
|
1024
1186
|
`<div style="display:flex;align-items:center;gap:8px;line-height:20px">` +
|
|
@@ -1027,7 +1189,7 @@ function tooltipHtml(
|
|
|
1027
1189
|
`<span style="font-weight:600;font-variant-numeric:tabular-nums;margin-left:12px">${
|
|
1028
1190
|
row.value === null ? EMPTY_VALUE : escapeHtml(valueFormatter(row.value))
|
|
1029
1191
|
}${
|
|
1030
|
-
row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px"
|
|
1192
|
+
row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px">${escapeHtml(t.share(formatShare(row.share)))}</span>`
|
|
1031
1193
|
}</span></div>` +
|
|
1032
1194
|
(row.note === undefined
|
|
1033
1195
|
? ""
|
|
@@ -1041,8 +1203,10 @@ function tooltipHtml(
|
|
|
1041
1203
|
|
|
1042
1204
|
/* ------------------------------------------------- per-bar targets / notes */
|
|
1043
1205
|
|
|
1044
|
-
/** Default name of the per-bar target marker (legend, tooltip). */
|
|
1045
|
-
|
|
1206
|
+
/** Default name of the per-bar target marker (legend, tooltip) in `locale`: "Target" / 「目標」. Default the page's locale. */
|
|
1207
|
+
function barTargetLabel(locale?: string): string {
|
|
1208
|
+
return chartText(locale).target
|
|
1209
|
+
}
|
|
1046
1210
|
|
|
1047
1211
|
/** How far (px) the target marker reaches past each side of its bar. */
|
|
1048
1212
|
const TARGET_MARKER_OVERHANG = 4
|
|
@@ -1093,16 +1257,17 @@ function achievementRate(value: number | null | undefined, target: number | null
|
|
|
1093
1257
|
return (value / target) * 100
|
|
1094
1258
|
}
|
|
1095
1259
|
|
|
1096
|
-
/** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3
|
|
1260
|
+
/** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3%)」 / "Target $12M (85.3% achieved)". `null` without a target. */
|
|
1097
1261
|
function barTargetNote(
|
|
1098
1262
|
label: string,
|
|
1099
1263
|
value: number | null,
|
|
1100
1264
|
target: number | null,
|
|
1101
1265
|
valueFormatter: (value: number) => string,
|
|
1266
|
+
locale?: string,
|
|
1102
1267
|
): string | null {
|
|
1103
1268
|
if (target === null) return null
|
|
1104
1269
|
const rate = achievementRate(value, target)
|
|
1105
|
-
return `${label} ${valueFormatter(target)}${rate === null ? "" :
|
|
1270
|
+
return `${label} ${valueFormatter(target)}${rate === null ? "" : chartText(locale).targetRate(formatShare(rate))}`
|
|
1106
1271
|
}
|
|
1107
1272
|
|
|
1108
1273
|
/** Half the width of a bar in category-axis units, from `barCategoryGap` (`"20%"`; a px gap or none → ECharts' 20%). */
|
|
@@ -1194,11 +1359,12 @@ const TARGET_LEGEND_SERIES_ID = "__target-legend"
|
|
|
1194
1359
|
/** A flat tick (8×8 legend box, aspect kept): the per-bar target's legend icon. */
|
|
1195
1360
|
const TARGET_LEGEND_ICON = "path://M0,0 L100,0 L100,24 L0,24 Z"
|
|
1196
1361
|
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1362
|
+
/**
|
|
1363
|
+
* The label at the top of a forecast's boundary line: the forecast's name (as in the legend) and an arrow toward the forecast side.
|
|
1364
|
+
* Without a `label`, "Forecast" / 「予測」 in `locale` (default the page's).
|
|
1365
|
+
*/
|
|
1366
|
+
function forecastBoundaryText(label?: string, locale?: string): string {
|
|
1367
|
+
return `${label ?? chartText(locale).forecast} →`
|
|
1202
1368
|
}
|
|
1203
1369
|
|
|
1204
1370
|
/** A comparison series: a thin, muted, solid line with no markers (60% of `--muted-foreground`). */
|
|
@@ -1238,8 +1404,10 @@ interface AxisExtent {
|
|
|
1238
1404
|
max: number
|
|
1239
1405
|
}
|
|
1240
1406
|
|
|
1241
|
-
/** The label of a stack's total (highlight and tooltip). */
|
|
1242
|
-
|
|
1407
|
+
/** The label of a stack's total (highlight and tooltip) in `locale`: "Total" / 「合計」. Default the page's locale. */
|
|
1408
|
+
function stackTotalLabel(locale?: string): string {
|
|
1409
|
+
return chartText(locale).total
|
|
1410
|
+
}
|
|
1243
1411
|
|
|
1244
1412
|
/** Per point, the sum of the stacked series; `null` where every series is missing. */
|
|
1245
1413
|
function stackTotalsOf(values: readonly (readonly (number | null)[])[]): (number | null)[] {
|
|
@@ -1361,8 +1529,8 @@ function indexGrain(values: readonly unknown[]): PeriodGrain | null {
|
|
|
1361
1529
|
|
|
1362
1530
|
/**
|
|
1363
1531
|
* The tooltip line under the main series' row when a comparison series is drawn: the arrow, the signed change and the
|
|
1364
|
-
* comparison's name, as `Delta` writes it ("↑ +12.4% 前年同月比"; a minus is U+2212).
|
|
1365
|
-
* either value is missing or the baseline is 0.
|
|
1532
|
+
* comparison's name, as `Delta` writes it ("↑ +12.4% vs. same month last year" / "↑ +12.4% 前年同月比"; a minus is U+2212).
|
|
1533
|
+
* "No comparison vs. same month last year" / "比較なし 前年同月比" when either value is missing or the baseline is 0.
|
|
1366
1534
|
*/
|
|
1367
1535
|
function comparisonNote(
|
|
1368
1536
|
current: number | null,
|
|
@@ -1370,16 +1538,18 @@ function comparisonNote(
|
|
|
1370
1538
|
comparison: Pick<CartesianComparisonConfig, "vs" | "label">,
|
|
1371
1539
|
grain?: PeriodGrain,
|
|
1372
1540
|
format?: ValueFormat,
|
|
1373
|
-
|
|
1541
|
+
localeArg?: string,
|
|
1374
1542
|
): string {
|
|
1543
|
+
const locale = resolveLocale(localeArg)
|
|
1375
1544
|
const result = computeComparison(
|
|
1376
1545
|
current,
|
|
1377
1546
|
comparison.vs === "custom"
|
|
1378
1547
|
? { previous, vs: "custom", label: comparison.label ?? "", grain }
|
|
1379
1548
|
: { previous, vs: comparison.vs, label: comparison.label, grain },
|
|
1549
|
+
{ locale },
|
|
1380
1550
|
)
|
|
1381
1551
|
const text = formatChange(result, defaultChangeDisplay(format), locale, format)
|
|
1382
|
-
if (text === null) return
|
|
1552
|
+
if (text === null) return `${chartText(locale).noComparison} ${result.label}`
|
|
1383
1553
|
const arrow = result.direction === "up" ? "↑" : result.direction === "down" ? "↓" : "→"
|
|
1384
1554
|
return `${arrow} ${text.replace(/^-/, "−")} ${result.label}`
|
|
1385
1555
|
}
|
|
@@ -1422,7 +1592,6 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1422
1592
|
toneColors,
|
|
1423
1593
|
valueFormatter,
|
|
1424
1594
|
axisFormatter = valueFormatter,
|
|
1425
|
-
locale = DEFAULT_LOCALE,
|
|
1426
1595
|
type = "default",
|
|
1427
1596
|
layout = "horizontal",
|
|
1428
1597
|
curveType = "linear",
|
|
@@ -1459,7 +1628,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1459
1628
|
chartOptions,
|
|
1460
1629
|
reserveActions = false,
|
|
1461
1630
|
} = cfg
|
|
1462
|
-
const
|
|
1631
|
+
const locale = resolveLocale(cfg.locale)
|
|
1632
|
+
const text = chartText(locale)
|
|
1633
|
+
const provisionalLabel = cfg.provisionalLabel ?? text.provisional
|
|
1463
1634
|
|
|
1464
1635
|
const stacked = kind !== "line" && type !== "default"
|
|
1465
1636
|
const percent = kind !== "line" && type === "percent"
|
|
@@ -1608,9 +1779,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1608
1779
|
// `barOptions.targetKey` (a target per bar) takes over from the single `target` line.
|
|
1609
1780
|
const perBarTarget = kind === "bar" && Boolean(targetKey)
|
|
1610
1781
|
const hasTarget = !perBarTarget && target !== undefined && Number.isFinite(target)
|
|
1611
|
-
const targetText = hasTarget ? (targetLabel ??
|
|
1782
|
+
const targetText = hasTarget ? (targetLabel ?? `${text.target} ${axisFormatter(target)}`) : ""
|
|
1612
1783
|
const highlightText = (value: number) =>
|
|
1613
|
-
shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${
|
|
1784
|
+
shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${text.total} ` : ""}${axisFormatter(value)}`
|
|
1614
1785
|
/** Whether `p`'s label sits within the target's value band and under the target label (at the right end). */
|
|
1615
1786
|
const nearTargetLabel = (p: HighlightPoint, side: "under" | "over") => {
|
|
1616
1787
|
if (!hasTarget || !plotWidth || kind === "bar" || labels.length < 2) return false
|
|
@@ -1636,7 +1807,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1636
1807
|
// One series (or a stack, against its total) can be compared to a target; grouped series and 100% stacks cannot.
|
|
1637
1808
|
const barTargets: (number | null)[] | null =
|
|
1638
1809
|
perBarTarget && targetKey && !percent && (stacked || categories.length === 1) ? barTargetValues(data, targetKey) : null
|
|
1639
|
-
const barTargetName = targetLabel ??
|
|
1810
|
+
const barTargetName = targetLabel ?? text.target
|
|
1640
1811
|
const barTargetColor = labelColor ?? guideColor
|
|
1641
1812
|
|
|
1642
1813
|
const markPointFor = (c: number, color: string | undefined) =>
|
|
@@ -1850,7 +2021,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1850
2021
|
}
|
|
1851
2022
|
|
|
1852
2023
|
/* -- forecast: dashed continuation (+ optional band) of one series */
|
|
1853
|
-
const forecastLabel = forecast?.label ??
|
|
2024
|
+
const forecastLabel = forecast?.label ?? text.forecast
|
|
1854
2025
|
const forecastBandAlpha = isDarkSurface(labelBackground) ? FORECAST_BAND_ALPHA_DARK : FORECAST_BAND_ALPHA_LIGHT
|
|
1855
2026
|
const forecastValues: (number | null)[] = []
|
|
1856
2027
|
if (forecast) {
|
|
@@ -2326,7 +2497,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
2326
2497
|
})
|
|
2327
2498
|
}
|
|
2328
2499
|
if (stackTotals && i <= lastActual && rows.length > 1) {
|
|
2329
|
-
rows.push({ name:
|
|
2500
|
+
rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
|
|
2330
2501
|
}
|
|
2331
2502
|
const provisional = provStart !== -1 && i >= provStart && i <= lastActual
|
|
2332
2503
|
if (provisional) {
|
|
@@ -2342,19 +2513,19 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
2342
2513
|
...noteFor(at),
|
|
2343
2514
|
})
|
|
2344
2515
|
})
|
|
2345
|
-
if (stackTotals && rows.length > 1) rows.push({ name:
|
|
2516
|
+
if (stackTotals && rows.length > 1) rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
|
|
2346
2517
|
}
|
|
2347
2518
|
const title = tooltipTitles[i] ?? labels[i] ?? ""
|
|
2348
2519
|
const lines: string[] = []
|
|
2349
2520
|
if (barTargets && i <= lastActual) {
|
|
2350
2521
|
const total = stackTotals ? (stackTotals[i] ?? null) : (values[0]?.[i] ?? null)
|
|
2351
|
-
const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter)
|
|
2522
|
+
const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter, locale)
|
|
2352
2523
|
if (note) lines.push(note)
|
|
2353
2524
|
}
|
|
2354
2525
|
for (const note of notes) {
|
|
2355
2526
|
if (note.to === undefined ? note.from === i : note.from <= i && i <= note.to) lines.push(note.label)
|
|
2356
2527
|
}
|
|
2357
|
-
return tooltipHtml(provisional ?
|
|
2528
|
+
return tooltipHtml(provisional ? text.provisionalTitle(title, provisionalLabel) : title, rows, valueFormatter, lines, locale)
|
|
2358
2529
|
},
|
|
2359
2530
|
},
|
|
2360
2531
|
xAxis: horizontalBars ? valueAxis : categoryAxis,
|
|
@@ -2446,8 +2617,6 @@ interface DonutOptionSlice {
|
|
|
2446
2617
|
/** How many of the largest slices get an outside label (「自然検索 41%」) by default. */
|
|
2447
2618
|
const DONUT_OUTSIDE_LABELS = 2
|
|
2448
2619
|
|
|
2449
|
-
const DEFAULT_TOTAL_LABEL = "合計"
|
|
2450
|
-
|
|
2451
2620
|
const sliceValue = (value: number) => (Number.isFinite(value) && value > 0 ? value : 0)
|
|
2452
2621
|
|
|
2453
2622
|
/** A slice's share of `total` as text: whole percent, `<1%` for a sliver, `0%` for nothing. */
|
|
@@ -2480,15 +2649,16 @@ function donutLegendItems(slices: readonly DonutOptionSlice[]): DonutLegendItem[
|
|
|
2480
2649
|
}
|
|
2481
2650
|
|
|
2482
2651
|
/**
|
|
2483
|
-
* The default center label: what is totalled (「合計」), the formatted total
|
|
2652
|
+
* The default center label: what is totalled (「合計」 / "Total" in `locale`, default the page's), the formatted total
|
|
2484
2653
|
* (`amount`, the big line), the unit (a small line under it) and both as one
|
|
2485
2654
|
* string (`value`, 「9.2万 セッション」).
|
|
2486
2655
|
*/
|
|
2487
2656
|
function donutCenterText(
|
|
2488
2657
|
total: number,
|
|
2489
2658
|
valueFormatter: (value: number) => string,
|
|
2490
|
-
{ totalLabel
|
|
2659
|
+
{ totalLabel, unit, locale }: { totalLabel?: string; unit?: string; locale?: string } = {},
|
|
2491
2660
|
): { label: string; value: string; amount: string; unit?: string } {
|
|
2661
|
+
totalLabel ??= chartText(locale).total
|
|
2492
2662
|
const amount = valueFormatter(total)
|
|
2493
2663
|
return { label: totalLabel, value: unit ? `${amount} ${unit}` : amount, amount, ...(unit ? { unit } : {}) }
|
|
2494
2664
|
}
|
|
@@ -2716,8 +2886,10 @@ function hasLabel(labels: Readonly<Record<string, string>> | undefined, key: str
|
|
|
2716
2886
|
|
|
2717
2887
|
/* ------------------------------------------------------------ provisional */
|
|
2718
2888
|
|
|
2719
|
-
/** The default label for points that are not final yet. */
|
|
2720
|
-
|
|
2889
|
+
/** The default label for points that are not final yet, in `locale`: "Preliminary" / 「速報」. Default the page's locale. */
|
|
2890
|
+
function defaultProvisionalLabel(locale?: string): string {
|
|
2891
|
+
return chartText(locale).provisional
|
|
2892
|
+
}
|
|
2721
2893
|
|
|
2722
2894
|
/**
|
|
2723
2895
|
* Position of the first provisional point: the first entry of `indexValues`
|
|
@@ -2855,9 +3027,10 @@ function resolveValueFormatter(
|
|
|
2855
3027
|
function resolveAxisFormatter(
|
|
2856
3028
|
valueFormatter: ValueFormatter | undefined,
|
|
2857
3029
|
format: ValueFormat | undefined,
|
|
2858
|
-
|
|
3030
|
+
localeArg?: string,
|
|
2859
3031
|
): ValueFormatter {
|
|
2860
3032
|
if (valueFormatter) return valueFormatter
|
|
3033
|
+
const locale = resolveLocale(localeArg)
|
|
2861
3034
|
const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
|
|
2862
3035
|
if (spec.type !== "compact" && spec.type !== "number" && spec.type !== "currency") return toValueFormatter(format, locale)
|
|
2863
3036
|
const currency = "currency" in spec ? spec.currency : undefined
|
|
@@ -2866,40 +3039,75 @@ function resolveAxisFormatter(
|
|
|
2866
3039
|
return (value) => formatValue(value, short, locale)
|
|
2867
3040
|
}
|
|
2868
3041
|
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
]
|
|
3042
|
+
interface CompactUnit {
|
|
3043
|
+
token: string
|
|
3044
|
+
size: number
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
const compactUnitCache = new Map<string, CompactUnit[] | null>()
|
|
3048
|
+
|
|
3049
|
+
/**
|
|
3050
|
+
* The compact-notation unit words of a locale and their sizes, largest first: `兆` / `億` / `万` in ja,
|
|
3051
|
+
* `T` / `B` / `M` / `K` in en. `null` when the locale's compact form puts anything between the number and its
|
|
3052
|
+
* word (`1,5 Mio.`), where a token cannot be swapped in place.
|
|
3053
|
+
*/
|
|
3054
|
+
function compactUnits(locale: string): CompactUnit[] | null {
|
|
3055
|
+
const cached = compactUnitCache.get(locale)
|
|
3056
|
+
if (cached !== undefined) return cached
|
|
3057
|
+
let units: CompactUnit[] | null = []
|
|
3058
|
+
try {
|
|
3059
|
+
const compact = new Intl.NumberFormat(locale, { notation: "compact" })
|
|
3060
|
+
const seen = new Set<string>()
|
|
3061
|
+
for (let exp = 3; exp <= 15; exp++) {
|
|
3062
|
+
const parts = compact.formatToParts(10 ** exp)
|
|
3063
|
+
const at = parts.findIndex((p) => p.type === "compact")
|
|
3064
|
+
if (at === -1) continue
|
|
3065
|
+
if (parts[at - 1]?.type !== "integer" || at !== parts.length - 1) {
|
|
3066
|
+
units = null
|
|
3067
|
+
break
|
|
3068
|
+
}
|
|
3069
|
+
const token = parts[at]!.value
|
|
3070
|
+
if (seen.has(token)) continue
|
|
3071
|
+
seen.add(token)
|
|
3072
|
+
units.push({ token, size: 10 ** exp })
|
|
3073
|
+
}
|
|
3074
|
+
} catch {
|
|
3075
|
+
units = null
|
|
3076
|
+
}
|
|
3077
|
+
const sorted = units && units.length > 0 ? units.sort((a, b) => b.size - a.size) : null
|
|
3078
|
+
compactUnitCache.set(locale, sorted)
|
|
3079
|
+
return sorted
|
|
3080
|
+
}
|
|
2875
3081
|
|
|
2876
|
-
const
|
|
3082
|
+
const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
2877
3083
|
|
|
2878
3084
|
/**
|
|
2879
3085
|
* The value-axis tick formatter for an axis whose largest tick is `top`: every tick reads in the unit of
|
|
2880
|
-
* that largest tick, so
|
|
2881
|
-
* Only the
|
|
2882
|
-
* any other format —
|
|
3086
|
+
* that largest tick, so units never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`;
|
|
3087
|
+
* `$0.5M` / `$1M` / `$1.5M`, not `$500K` / `$1M`). Only the short forms (`compact` / `number` / `currency`,
|
|
3088
|
+
* no `valueFormatter`) have units to align; any other format — an axis whose largest tick is under the smallest
|
|
3089
|
+
* unit (1万 / 1K), or a locale whose compact form cannot be aligned in place — keeps `resolveAxisFormatter`'s output as it is.
|
|
2883
3090
|
*/
|
|
2884
3091
|
function resolveAxisTickFormatter(
|
|
2885
3092
|
valueFormatter: ValueFormatter | undefined,
|
|
2886
3093
|
format: ValueFormat | undefined,
|
|
2887
|
-
|
|
3094
|
+
localeArg?: string,
|
|
2888
3095
|
): (top: number) => ValueFormatter {
|
|
3096
|
+
const locale = resolveLocale(localeArg)
|
|
2889
3097
|
const base = resolveAxisFormatter(valueFormatter, format, locale)
|
|
2890
3098
|
const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
|
|
2891
|
-
const
|
|
2892
|
-
|
|
2893
|
-
if (!aligns) return () => base
|
|
3099
|
+
const units = !valueFormatter && (spec.type === "compact" || spec.type === "number" || spec.type === "currency") ? compactUnits(locale) : null
|
|
3100
|
+
if (!units) return () => base
|
|
2894
3101
|
const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 3 })
|
|
3102
|
+
const numberToken = new RegExp(`\\d[\\d,]*(?:\\.\\d+)?(?:${units.map((u) => escapeRegExp(u.token)).join("|")})?`)
|
|
2895
3103
|
return (top) => {
|
|
2896
|
-
const unit =
|
|
3104
|
+
const unit = units.find((u) => Math.abs(top) >= u.size)
|
|
2897
3105
|
if (!unit) return base
|
|
2898
3106
|
return (value) => {
|
|
2899
3107
|
const text = base(value)
|
|
2900
3108
|
if (value === 0 || !Number.isFinite(value)) return text
|
|
2901
|
-
const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.
|
|
2902
|
-
return text.replace(
|
|
3109
|
+
const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.token}`
|
|
3110
|
+
return text.replace(numberToken, () => scaled)
|
|
2903
3111
|
}
|
|
2904
3112
|
}
|
|
2905
3113
|
}
|
|
@@ -2928,8 +3136,20 @@ function clamp(value: number, min: number, max: number): number {
|
|
|
2928
3136
|
/** How many categories a chart colours individually; the rest fold into「その他」. */
|
|
2929
3137
|
const MAX_CHART_SERIES = 5
|
|
2930
3138
|
|
|
2931
|
-
/** The series name the folded categories get. */
|
|
2932
|
-
const
|
|
3139
|
+
/** The series name the folded categories get, by language. */
|
|
3140
|
+
const OTHER_SERIES_LABELS: Record<UiLanguage, string> = { ja: "その他", en: "Other" }
|
|
3141
|
+
|
|
3142
|
+
/** The series name the folded categories get in `locale` ("Other" / "その他"). Default the page's locale (`<html lang>`). */
|
|
3143
|
+
function otherSeriesLabel(locale?: string): string {
|
|
3144
|
+
return OTHER_SERIES_LABELS[uiLanguage(resolveLocale(locale))]
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
const OTHER_NAMES = new Set(Object.values(OTHER_SERIES_LABELS))
|
|
3148
|
+
|
|
3149
|
+
/** Whether a category / row is the "other" bucket by name (「その他」 or "Other", whatever the page language). */
|
|
3150
|
+
function isOtherName(name: string | null | undefined): boolean {
|
|
3151
|
+
return typeof name === "string" && OTHER_NAMES.has(name)
|
|
3152
|
+
}
|
|
2933
3153
|
|
|
2934
3154
|
interface AggregatedItems<Item extends { name: string; value: number }> {
|
|
2935
3155
|
/** The `n` largest items, in their given order. All items when there are `n` or fewer. */
|
|
@@ -2949,7 +3169,7 @@ interface AggregatedItems<Item extends { name: string; value: number }> {
|
|
|
2949
3169
|
function aggregateTopN<Item extends { name: string; value: number }>(
|
|
2950
3170
|
items: readonly Item[],
|
|
2951
3171
|
n: number = MAX_CHART_SERIES,
|
|
2952
|
-
otherLabel: string =
|
|
3172
|
+
otherLabel: string = otherSeriesLabel(),
|
|
2953
3173
|
): AggregatedItems<Item> {
|
|
2954
3174
|
if (items.length <= n) return { top: [...items], rest: [], other: null }
|
|
2955
3175
|
const ranked = items
|
|
@@ -2978,7 +3198,7 @@ function aggregateSeries(
|
|
|
2978
3198
|
data: readonly Record<string, unknown>[],
|
|
2979
3199
|
categories: readonly string[],
|
|
2980
3200
|
n: number = MAX_CHART_SERIES,
|
|
2981
|
-
otherLabel: string =
|
|
3201
|
+
otherLabel: string = otherSeriesLabel(),
|
|
2982
3202
|
): AggregatedSeries {
|
|
2983
3203
|
const totals = categories.map((name) => ({
|
|
2984
3204
|
name,
|
|
@@ -3010,12 +3230,14 @@ function aggregateSeriesKeeping(
|
|
|
3010
3230
|
categories: readonly string[],
|
|
3011
3231
|
keep: string | undefined,
|
|
3012
3232
|
n: number = MAX_CHART_SERIES,
|
|
3233
|
+
otherLabel: string = otherSeriesLabel(),
|
|
3013
3234
|
): AggregatedSeries {
|
|
3014
|
-
if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n)
|
|
3235
|
+
if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n, otherLabel)
|
|
3015
3236
|
const aggregated = aggregateSeries(
|
|
3016
3237
|
data,
|
|
3017
3238
|
categories.filter((c) => c !== keep),
|
|
3018
3239
|
n,
|
|
3240
|
+
otherLabel,
|
|
3019
3241
|
)
|
|
3020
3242
|
return { ...aggregated, categories: [...aggregated.categories, keep] }
|
|
3021
3243
|
}
|
|
@@ -3051,7 +3273,7 @@ function seriesColorsWithComparison(
|
|
|
3051
3273
|
* Colour per drawn series: the record's token for that category, else the
|
|
3052
3274
|
* array's token at the category's position in `allCategories` (cycled), else
|
|
3053
3275
|
* the token palette by drawn position. `otherCategory` — and a category the
|
|
3054
|
-
* data itself names
|
|
3276
|
+
* data itself names「その他」/ "Other" (`OTHER_SERIES_LABELS`) — always gets `otherColor`.
|
|
3055
3277
|
*/
|
|
3056
3278
|
function seriesColorsFor(
|
|
3057
3279
|
categories: readonly string[],
|
|
@@ -3063,7 +3285,7 @@ function seriesColorsFor(
|
|
|
3063
3285
|
const list = Array.isArray(colors) ? (colors as ChartSeriesColor[]) : undefined
|
|
3064
3286
|
const record = !Array.isArray(colors) && colors ? (colors as Partial<Record<string, ChartSeriesColor>>) : undefined
|
|
3065
3287
|
return categories.map((category, i) => {
|
|
3066
|
-
if ((otherCategory !== null && category === otherCategory) || category
|
|
3288
|
+
if ((otherCategory !== null && category === otherCategory) || isOtherName(category)) return otherColor
|
|
3067
3289
|
const fromRecord = record?.[category]
|
|
3068
3290
|
if (fromRecord) return fromRecord
|
|
3069
3291
|
if (list && list.length > 0) {
|
|
@@ -3110,7 +3332,7 @@ interface DonutSlicesConfig {
|
|
|
3110
3332
|
*/
|
|
3111
3333
|
function donutSlices(
|
|
3112
3334
|
data: readonly Record<string, unknown>[],
|
|
3113
|
-
{ index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel =
|
|
3335
|
+
{ index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = otherSeriesLabel() }: DonutSlicesConfig,
|
|
3114
3336
|
): DonutSlice[] {
|
|
3115
3337
|
const items = data
|
|
3116
3338
|
.map((row, i) => {
|
|
@@ -3123,7 +3345,8 @@ function donutSlices(
|
|
|
3123
3345
|
const slices: DonutSlice[] = top.map((item, rank) => ({
|
|
3124
3346
|
name: item.name,
|
|
3125
3347
|
value: item.value,
|
|
3126
|
-
|
|
3348
|
+
// A row the data itself names「その他」/ "Other" is the same remainder: grey, like the folded slice.
|
|
3349
|
+
color: isOtherName(item.name) ? otherColor : palette[rank % palette.length]!,
|
|
3127
3350
|
isOther: false,
|
|
3128
3351
|
row: item.row,
|
|
3129
3352
|
}))
|
|
@@ -3196,7 +3419,7 @@ interface BarListRowsConfig extends BarListOptions {
|
|
|
3196
3419
|
*/
|
|
3197
3420
|
function barListRows<Row extends object>(
|
|
3198
3421
|
data: readonly Row[],
|
|
3199
|
-
{ index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel =
|
|
3422
|
+
{ index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = otherSeriesLabel(), exclude }: BarListRowsConfig = {},
|
|
3200
3423
|
): BarListRow<Row>[] {
|
|
3201
3424
|
const items = data.map((row): BarListRow<Row> => {
|
|
3202
3425
|
const cells = row as Record<string, unknown>
|
|
@@ -3242,19 +3465,21 @@ const BAR_LIST_OTHER_TINT = 30
|
|
|
3242
3465
|
|
|
3243
3466
|
/**
|
|
3244
3467
|
* The fill of each BarList row: all real rows in the same tint of `color`
|
|
3245
|
-
* (`BAR_LIST_TINT`), the folded「その他」row in a tint of `otherColor`
|
|
3468
|
+
* (`BAR_LIST_TINT`), the folded「その他」row (and a row the data names「その他」/ "Other") in a tint of `otherColor`
|
|
3246
3469
|
* (`BAR_LIST_OTHER_TINT`) — the palest fill, a remainder never outweighs a
|
|
3247
3470
|
* ranked row. A legacy per-row `color` (or a threshold tone) replaces `color`
|
|
3248
3471
|
* for that row. A tint, not the solid token, so the label printed over the bar
|
|
3249
3472
|
* keeps its contrast.
|
|
3250
3473
|
*/
|
|
3251
3474
|
function barListFills(
|
|
3252
|
-
rows: readonly Pick<BarListRow, "isOther" | "color">[],
|
|
3475
|
+
rows: readonly (Pick<BarListRow, "isOther" | "color"> & { name?: string })[],
|
|
3253
3476
|
color: ChartColor,
|
|
3254
3477
|
otherColor: string,
|
|
3255
3478
|
): string[] {
|
|
3256
3479
|
return rows.map((row) =>
|
|
3257
|
-
row.isOther
|
|
3480
|
+
row.isOther || isOtherName(row.name)
|
|
3481
|
+
? tintCss(otherColor, BAR_LIST_OTHER_TINT)
|
|
3482
|
+
: tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
|
|
3258
3483
|
)
|
|
3259
3484
|
}
|
|
3260
3485
|
|
|
@@ -3470,11 +3695,30 @@ interface ChartSummaryInput {
|
|
|
3470
3695
|
format?: ValueFormat
|
|
3471
3696
|
/** Wins over `format`. */
|
|
3472
3697
|
valueFormatter?: ValueFormatter
|
|
3698
|
+
/** Language of the sentence. Default the page's locale (`<html lang>`). */
|
|
3699
|
+
locale?: string
|
|
3700
|
+
}
|
|
3701
|
+
|
|
3702
|
+
const SUMMARY_TEXT = {
|
|
3703
|
+
ja: {
|
|
3704
|
+
up: "、前の点より増加",
|
|
3705
|
+
down: "、前の点より減少",
|
|
3706
|
+
flat: "、前の点と横ばい",
|
|
3707
|
+
latest: (label: string, value: string) => `最新 ${label} は ${value}`,
|
|
3708
|
+
top: (name: string, share: string) => `最大は ${name}(${share})`,
|
|
3709
|
+
},
|
|
3710
|
+
en: {
|
|
3711
|
+
up: ", up from the previous point",
|
|
3712
|
+
down: ", down from the previous point",
|
|
3713
|
+
flat: ", flat from the previous point",
|
|
3714
|
+
latest: (label: string, value: string) => `Latest ${label}: ${value}`,
|
|
3715
|
+
top: (name: string, share: string) => `Largest: ${name} (${share})`,
|
|
3716
|
+
},
|
|
3473
3717
|
}
|
|
3474
3718
|
|
|
3475
3719
|
/**
|
|
3476
3720
|
* One-sentence summary built from the rows, for `summary="auto"`:
|
|
3477
|
-
* 「<title>: 最新 <index> は
|
|
3721
|
+
* 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」("<title>: Latest <index>: <value>, up from the previous point"). The direction is
|
|
3478
3722
|
* computed from the last two non-empty points of the first series — never
|
|
3479
3723
|
* written by hand, so it cannot contradict the data. `undefined` when there is
|
|
3480
3724
|
* no usable point.
|
|
@@ -3490,26 +3734,29 @@ function buildChartSummary(input: ChartSummaryInput): string | undefined {
|
|
|
3490
3734
|
}
|
|
3491
3735
|
const latest = points[points.length - 1]
|
|
3492
3736
|
if (!latest) return undefined
|
|
3493
|
-
const
|
|
3737
|
+
const locale = resolveLocale(input.locale)
|
|
3738
|
+
const t = SUMMARY_TEXT[uiLanguage(locale)]
|
|
3739
|
+
const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact", locale)
|
|
3494
3740
|
const prev = points[points.length - 2]
|
|
3495
|
-
const direction = !prev ? "" : latest.value > prev.value ?
|
|
3741
|
+
const direction = !prev ? "" : latest.value > prev.value ? t.up : latest.value < prev.value ? t.down : t.flat
|
|
3496
3742
|
const head = input.title ? `${input.title}: ` : ""
|
|
3497
|
-
return `${head}
|
|
3743
|
+
return `${head}${t.latest(latest.label, text(latest.value))}${direction}`
|
|
3498
3744
|
}
|
|
3499
3745
|
|
|
3500
3746
|
/**
|
|
3501
|
-
* `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」 from the slices
|
|
3502
|
-
* (largest first). `undefined` when there is nothing to describe.
|
|
3747
|
+
* `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」("<title>: Largest: <name> (<share>)") from the slices
|
|
3748
|
+
* (largest first). `undefined` when there is nothing to describe. `locale` picks the language; default the page's.
|
|
3503
3749
|
*/
|
|
3504
3750
|
function buildShareSummary(
|
|
3505
3751
|
slices: readonly { name: string; value: number }[],
|
|
3506
3752
|
title?: string,
|
|
3753
|
+
locale?: string,
|
|
3507
3754
|
): string | undefined {
|
|
3508
3755
|
const total = slices.reduce((sum, s) => sum + s.value, 0)
|
|
3509
3756
|
const top = slices[0]
|
|
3510
3757
|
if (!top || total <= 0) return undefined
|
|
3511
3758
|
const share = `${Math.round((top.value / total) * 1000) / 10}%`
|
|
3512
|
-
return `${title ? `${title}: ` : ""}
|
|
3759
|
+
return `${title ? `${title}: ` : ""}${SUMMARY_TEXT[uiLanguage(resolveLocale(locale))].top(top.name, share)}`
|
|
3513
3760
|
}
|
|
3514
3761
|
|
|
3515
3762
|
/* ---------- inlined from @squadbase/squadkit registry/lib/distribution.ts (internal to @squadbase/vantage) ---------- */
|
|
@@ -3691,6 +3938,51 @@ export interface BoxPlotChartProps extends Omit<ChartCommonProps, "data" | "inde
|
|
|
3691
3938
|
|
|
3692
3939
|
/* ------------------------------------------------------------------ model */
|
|
3693
3940
|
|
|
3941
|
+
const TEXT = {
|
|
3942
|
+
ja: {
|
|
3943
|
+
value: "値",
|
|
3944
|
+
valueInSentence: "値",
|
|
3945
|
+
medianOf: (name: string, label: string, median: string) => `${name}の${label}の中央値は ${median}`,
|
|
3946
|
+
medianRange: (label: string, top: string, topMedian: string, low: string, lowMedian: string) =>
|
|
3947
|
+
`${label}の中央値が最も高いのは ${top}(${topMedian})、最も低いのは ${low}(${lowMedian})`,
|
|
3948
|
+
outliers: (n: number) => `外れ値は ${n} 件`,
|
|
3949
|
+
sentenceJoin: "。",
|
|
3950
|
+
columns: ["群", "件数", "最小(ひげ)", "第1四分位", "中央値", "第3四分位", "最大(ひげ)", "外れ値"],
|
|
3951
|
+
listJoin: "、",
|
|
3952
|
+
none: "なし",
|
|
3953
|
+
tooltipTitle: (name: string, label: string, count: number) => `${name}(${label}・${count}件)`,
|
|
3954
|
+
max: "最大(ひげ)",
|
|
3955
|
+
q3: "第3四分位",
|
|
3956
|
+
median: "中央値",
|
|
3957
|
+
q1: "第1四分位",
|
|
3958
|
+
min: "最小(ひげ)",
|
|
3959
|
+
outlierRow: "外れ値",
|
|
3960
|
+
chart: (label: string) => `${label}の箱ひげ図`,
|
|
3961
|
+
},
|
|
3962
|
+
en: {
|
|
3963
|
+
value: "Value",
|
|
3964
|
+
valueInSentence: "value",
|
|
3965
|
+
medianOf: (name: string, label: string, median: string) => `Median ${label} for ${name}: ${median}`,
|
|
3966
|
+
medianRange: (label: string, top: string, topMedian: string, low: string, lowMedian: string) =>
|
|
3967
|
+
`Highest median ${label}: ${top} (${topMedian}); lowest: ${low} (${lowMedian})`,
|
|
3968
|
+
outliers: (n: number) => (n === 1 ? "1 outlier" : `${n} outliers`),
|
|
3969
|
+
sentenceJoin: ". ",
|
|
3970
|
+
columns: ["Group", "Count", "Min (whisker)", "Q1", "Median", "Q3", "Max (whisker)", "Outliers"],
|
|
3971
|
+
listJoin: ", ",
|
|
3972
|
+
none: "None",
|
|
3973
|
+
tooltipTitle: (name: string, label: string, count: number) => `${name} (${label}, n=${count})`,
|
|
3974
|
+
max: "Max (whisker)",
|
|
3975
|
+
q3: "Q3",
|
|
3976
|
+
median: "Median",
|
|
3977
|
+
q1: "Q1",
|
|
3978
|
+
min: "Min (whisker)",
|
|
3979
|
+
outlierRow: "Outliers",
|
|
3980
|
+
chart: (label: string) => `Box plot of ${label}`,
|
|
3981
|
+
},
|
|
3982
|
+
}
|
|
3983
|
+
|
|
3984
|
+
const textOf = (locale?: string) => TEXT[uiLanguage(resolveLocale(locale))]
|
|
3985
|
+
|
|
3694
3986
|
export interface BoxPlotEntry {
|
|
3695
3987
|
name: string
|
|
3696
3988
|
stats: BoxStats | null
|
|
@@ -3701,27 +3993,38 @@ export function boxPlotEntries(groups: readonly BoxPlotGroup[], whisker?: number
|
|
|
3701
3993
|
return groups.map((g) => ({ name: g.name, stats: boxStats(g.values, whisker) }))
|
|
3702
3994
|
}
|
|
3703
3995
|
|
|
3704
|
-
/**
|
|
3705
|
-
|
|
3996
|
+
/**
|
|
3997
|
+
* `summary="auto"`: the groups with the highest / lowest median and the outlier count. `undefined` with no usable group.
|
|
3998
|
+
* `locale` picks the language; default the page's (`<html lang>`).
|
|
3999
|
+
*/
|
|
4000
|
+
export function buildBoxPlotSummary(
|
|
4001
|
+
entries: readonly BoxPlotEntry[],
|
|
4002
|
+
formatter: ValueFormatter,
|
|
4003
|
+
valueLabel?: string,
|
|
4004
|
+
locale?: string,
|
|
4005
|
+
): string | undefined {
|
|
4006
|
+
const t = textOf(locale)
|
|
4007
|
+
const label = valueLabel ?? t.valueInSentence
|
|
3706
4008
|
const real = entries.filter((e): e is BoxPlotEntry & { stats: BoxStats } => e.stats !== null)
|
|
3707
4009
|
if (real.length === 0) return undefined
|
|
3708
4010
|
const top = real.reduce((a, b) => (b.stats.median > a.stats.median ? b : a))
|
|
3709
4011
|
const low = real.reduce((a, b) => (b.stats.median < a.stats.median ? b : a))
|
|
3710
4012
|
const parts = [
|
|
3711
4013
|
real.length === 1
|
|
3712
|
-
?
|
|
3713
|
-
:
|
|
4014
|
+
? t.medianOf(top.name, label, formatter(top.stats.median))
|
|
4015
|
+
: t.medianRange(label, top.name, formatter(top.stats.median), low.name, formatter(low.stats.median)),
|
|
3714
4016
|
]
|
|
3715
4017
|
const outliers = real.reduce((sum, e) => sum + e.stats.outliers.length, 0)
|
|
3716
|
-
if (outliers > 0) parts.push(
|
|
3717
|
-
return parts.join(
|
|
4018
|
+
if (outliers > 0) parts.push(t.outliers(outliers))
|
|
4019
|
+
return parts.join(t.sentenceJoin)
|
|
3718
4020
|
}
|
|
3719
4021
|
|
|
3720
4022
|
/** The screen-reader table: one row per group with n, the quartiles, the whisker ends and the outliers. */
|
|
3721
|
-
export function boxPlotDataTable(entries: readonly BoxPlotEntry[], formatter: ValueFormatter): ChartDataTable {
|
|
4023
|
+
export function boxPlotDataTable(entries: readonly BoxPlotEntry[], formatter: ValueFormatter, locale?: string): ChartDataTable {
|
|
4024
|
+
const t = textOf(locale)
|
|
3722
4025
|
const f = (n: number | undefined) => (n === undefined ? EMPTY_VALUE : formatter(n))
|
|
3723
4026
|
return {
|
|
3724
|
-
columns: [
|
|
4027
|
+
columns: [...t.columns],
|
|
3725
4028
|
rows: entries.map(({ name, stats: s }) => [
|
|
3726
4029
|
name,
|
|
3727
4030
|
s ? `${s.count}` : EMPTY_VALUE,
|
|
@@ -3730,7 +4033,7 @@ export function boxPlotDataTable(entries: readonly BoxPlotEntry[], formatter: Va
|
|
|
3730
4033
|
f(s?.median),
|
|
3731
4034
|
f(s?.q3),
|
|
3732
4035
|
f(s?.whiskerHigh),
|
|
3733
|
-
s ? (s.outliers.length > 0 ? s.outliers.map(formatter).join(
|
|
4036
|
+
s ? (s.outliers.length > 0 ? s.outliers.map(formatter).join(t.listJoin) : t.none) : EMPTY_VALUE,
|
|
3734
4037
|
]),
|
|
3735
4038
|
}
|
|
3736
4039
|
}
|
|
@@ -3747,6 +4050,8 @@ export interface BoxPlotOptionConfig {
|
|
|
3747
4050
|
showTooltip?: boolean
|
|
3748
4051
|
labelColor?: string
|
|
3749
4052
|
chartOptions?: EChartsOption
|
|
4053
|
+
/** Language of the tooltip. Default the page's locale (`<html lang>`). */
|
|
4054
|
+
locale?: string
|
|
3750
4055
|
}
|
|
3751
4056
|
|
|
3752
4057
|
const ROW_STYLE = "display:flex;align-items:center;gap:8px;line-height:20px"
|
|
@@ -3792,7 +4097,8 @@ export function boxPlotRenderer(color: string, outliers: readonly (readonly numb
|
|
|
3792
4097
|
|
|
3793
4098
|
/** The ECharts option: one `custom` series, a box per group. Groups with no value draw nothing. */
|
|
3794
4099
|
export function buildBoxPlotOption(config: BoxPlotOptionConfig): EChartsOption {
|
|
3795
|
-
const
|
|
4100
|
+
const t = textOf(config.locale)
|
|
4101
|
+
const { entries, valueFormatter, showTooltip = true, valueLabel = t.value } = config
|
|
3796
4102
|
const axisFormatter = config.axisFormatter ?? valueFormatter
|
|
3797
4103
|
const color = config.color || "#888"
|
|
3798
4104
|
// [index, whisker low, Q1, median, Q3, whisker high, min, max]: the last two stretch the value axis over the outliers.
|
|
@@ -3813,13 +4119,13 @@ export function buildBoxPlotOption(config: BoxPlotOptionConfig): EChartsOption {
|
|
|
3813
4119
|
`<div style="${ROW_STYLE}"><span style="flex:1;opacity:.8">${escapeHtml(name)}</span>` +
|
|
3814
4120
|
`<span style="font-weight:500;font-variant-numeric:tabular-nums;margin-left:12px">${escapeHtml(text)}</span></div>`
|
|
3815
4121
|
return (
|
|
3816
|
-
`<div style="font-weight:500;margin-bottom:4px">${escapeHtml(entry.name
|
|
3817
|
-
line(
|
|
3818
|
-
line(
|
|
3819
|
-
line(
|
|
3820
|
-
line(
|
|
3821
|
-
line(
|
|
3822
|
-
(s.outliers.length > 0 ? line(
|
|
4122
|
+
`<div style="font-weight:500;margin-bottom:4px">${escapeHtml(t.tooltipTitle(entry.name, valueLabel, s.count))}</div>` +
|
|
4123
|
+
line(t.max, valueFormatter(s.whiskerHigh)) +
|
|
4124
|
+
line(t.q3, valueFormatter(s.q3)) +
|
|
4125
|
+
line(t.median, valueFormatter(s.median)) +
|
|
4126
|
+
line(t.q1, valueFormatter(s.q1)) +
|
|
4127
|
+
line(t.min, valueFormatter(s.whiskerLow)) +
|
|
4128
|
+
(s.outliers.length > 0 ? line(t.outlierRow, s.outliers.map(valueFormatter).join(t.listJoin)) : "")
|
|
3823
4129
|
)
|
|
3824
4130
|
},
|
|
3825
4131
|
},
|
|
@@ -3871,19 +4177,21 @@ export const BoxPlotChart = forwardRef(function BoxPlotChart(
|
|
|
3871
4177
|
ref: ForwardedRef<HTMLDivElement>,
|
|
3872
4178
|
) {
|
|
3873
4179
|
const { ref: frameRef, tokens } = useChartSurface(ref)
|
|
3874
|
-
const
|
|
4180
|
+
const locale = useLocale()
|
|
4181
|
+
const t = TEXT[uiLanguage(locale)]
|
|
4182
|
+
const valueFormatter = useMemo(() => resolveValueFormatter(valueFormatterProp, format, locale), [valueFormatterProp, format, locale])
|
|
3875
4183
|
const entries = useMemo(() => boxPlotEntries(groups, whisker), [groups, whisker])
|
|
3876
4184
|
const [color = ""] = useMemo(() => resolveChartColors(["chart-1"], tokens), [tokens])
|
|
3877
4185
|
const option = useMemo(
|
|
3878
|
-
() => buildBoxPlotOption({ entries, color, valueFormatter, valueLabel, showTooltip, labelColor: tokens?.foreground ?? undefined, chartOptions }),
|
|
3879
|
-
[entries, color, valueFormatter, valueLabel, showTooltip, tokens, chartOptions],
|
|
4186
|
+
() => buildBoxPlotOption({ entries, color, valueFormatter, valueLabel, showTooltip, labelColor: tokens?.foreground ?? undefined, chartOptions, locale }),
|
|
4187
|
+
[entries, color, valueFormatter, valueLabel, showTooltip, tokens, chartOptions, locale],
|
|
3880
4188
|
)
|
|
3881
|
-
const dataTable = useMemo(() => boxPlotDataTable(entries, valueFormatter), [entries, valueFormatter])
|
|
4189
|
+
const dataTable = useMemo(() => boxPlotDataTable(entries, valueFormatter, locale), [entries, valueFormatter, locale])
|
|
3882
4190
|
const resolvedSummary = useMemo(
|
|
3883
|
-
() => (summary === "auto" ? buildBoxPlotSummary(entries, valueFormatter, valueLabel) : summary),
|
|
3884
|
-
[summary, entries, valueFormatter, valueLabel],
|
|
4191
|
+
() => (summary === "auto" ? buildBoxPlotSummary(entries, valueFormatter, valueLabel, locale) : summary),
|
|
4192
|
+
[summary, entries, valueFormatter, valueLabel, locale],
|
|
3885
4193
|
)
|
|
3886
|
-
const label = ariaLabel ??
|
|
4194
|
+
const label = ariaLabel ?? t.chart(valueLabel ?? t.valueInSentence)
|
|
3887
4195
|
return (
|
|
3888
4196
|
<ChartFrame
|
|
3889
4197
|
{...chartRootProps(rest)}
|