@squadbase/vantage 0.6.2-dev1.48b34e1 → 0.6.2-dev1.56da87f
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 +3 -2
- 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
|
@@ -19,6 +19,7 @@ import { ChartFrame } from "@squadbase/vantage/components"
|
|
|
19
19
|
import { EChart } from "@squadbase/vantage/components"
|
|
20
20
|
import { toValueFormatter } from "@squadbase/vantage/components"
|
|
21
21
|
import type { ChartCommonProps, ValueFormatter } from "@squadbase/vantage/components"
|
|
22
|
+
import { useSyncExternalStore } from "react"
|
|
22
23
|
import { createContext, useCallback, useEffect, useRef, useState, type RefObject } from "react"
|
|
23
24
|
import type { ChartTone } from "@squadbase/vantage/components"
|
|
24
25
|
import { type ReactNode } from "react"
|
|
@@ -28,6 +29,137 @@ import type { ChartThreshold, ChartAnnotation, ChartEventPayload, ChartForecast,
|
|
|
28
29
|
import { formatValue } from "@squadbase/vantage/components"
|
|
29
30
|
import type { ChartColor, ChartColors, ChartSeriesColor } from "@squadbase/vantage/components"
|
|
30
31
|
|
|
32
|
+
/* ---------- inlined from @squadbase/squadkit registry/lib/locale.ts (internal to @squadbase/vantage) ---------- */
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The display language of the parts and the defaults that follow it.
|
|
36
|
+
*
|
|
37
|
+
* The language comes from `<html lang>`: a tag starting with `ja` reads Japanese, anything else English.
|
|
38
|
+
* Without a usable `lang` (missing, invalid, or no `document` — SSR, Node) the locale is `FALLBACK_LOCALE`
|
|
39
|
+
* (`en-US`). A `locale` passed explicitly (a prop, an argument) always wins.
|
|
40
|
+
*
|
|
41
|
+
* Parts read the locale through `useLocale` (`hooks/use-locale`) and pass it down; the pure helpers here
|
|
42
|
+
* and in `format` / `comparison` / `chart-*` take it as an argument and fall back to `documentLocale()`.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** The language of the built-in wording: Japanese or English. */
|
|
46
|
+
type UiLanguage = "ja" | "en"
|
|
47
|
+
|
|
48
|
+
/** The locale used when `<html lang>` gives none (and on the server). */
|
|
49
|
+
const FALLBACK_LOCALE = "en-US"
|
|
50
|
+
|
|
51
|
+
/** `"ja"` when `locale` starts with `ja` (`ja`, `ja-JP`), else `"en"`. A missing locale is `"en"`. */
|
|
52
|
+
function uiLanguage(locale: string | null | undefined): UiLanguage {
|
|
53
|
+
return typeof locale === "string" && locale.trim().toLowerCase().startsWith("ja") ? "ja" : "en"
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A BCP 47 tag in its canonical form (`en-gb` → `en-GB`), or `FALLBACK_LOCALE` when it is empty or invalid. */
|
|
57
|
+
function normalizeLocale(tag: string | null | undefined): string {
|
|
58
|
+
const value = tag?.trim()
|
|
59
|
+
if (!value) return FALLBACK_LOCALE
|
|
60
|
+
try {
|
|
61
|
+
return Intl.getCanonicalLocales(value)[0] ?? FALLBACK_LOCALE
|
|
62
|
+
} catch {
|
|
63
|
+
return FALLBACK_LOCALE
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The locale of the page: `document.documentElement.lang`, canonicalized. `FALLBACK_LOCALE` when it is empty or
|
|
69
|
+
* invalid, or there is no `document`. Parts read it through `useLocale`, never while rendering.
|
|
70
|
+
*/
|
|
71
|
+
function documentLocale(): string {
|
|
72
|
+
if (typeof document === "undefined") return FALLBACK_LOCALE
|
|
73
|
+
return normalizeLocale(document.documentElement?.getAttribute("lang"))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `locale` when given, else the page's locale (`documentLocale()`). */
|
|
77
|
+
function resolveLocale(locale?: string | null): string {
|
|
78
|
+
return locale || documentLocale()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Picks the wording table of the locale's language. */
|
|
82
|
+
function pickLocale<T>(table: { ja: T; en: T }, locale?: string | null): T {
|
|
83
|
+
return table[uiLanguage(resolveLocale(locale))]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The currency `format="currency"` uses without a `currency`: JPY for Japanese, USD otherwise. */
|
|
87
|
+
function defaultCurrency(locale?: string | null): string {
|
|
88
|
+
return uiLanguage(resolveLocale(locale)) === "ja" ? "JPY" : "USD"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The month a fiscal year starts in (1–12) when none is given: April for Japanese, January otherwise. */
|
|
92
|
+
function defaultFiscalYearStartMonth(locale?: string | null): number {
|
|
93
|
+
return uiLanguage(resolveLocale(locale)) === "ja" ? 4 : 1
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A day of the week, 0 = Sunday … 6 = Saturday (the `Date#getDay` / date-fns numbering). */
|
|
97
|
+
type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6
|
|
98
|
+
|
|
99
|
+
/** Locales whose week starts on Sunday when the runtime has no week data. */
|
|
100
|
+
const SUNDAY_FIRST = new Set(["en", "en-US", "en-CA"])
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The first day of the week: Monday for Japanese; otherwise the locale's convention from `Intl.Locale`
|
|
104
|
+
* (`en-US` Sunday, `en-GB` Monday). Without week data in the runtime: Sunday for `en` / `en-US` / `en-CA`,
|
|
105
|
+
* Monday for the rest.
|
|
106
|
+
*/
|
|
107
|
+
function weekStartsOn(locale?: string | null): WeekDay {
|
|
108
|
+
const tag = normalizeLocale(resolveLocale(locale))
|
|
109
|
+
if (uiLanguage(tag) === "ja") return 1
|
|
110
|
+
try {
|
|
111
|
+
const info = new Intl.Locale(tag) as Intl.Locale & {
|
|
112
|
+
getWeekInfo?: () => { firstDay: number }
|
|
113
|
+
weekInfo?: { firstDay: number }
|
|
114
|
+
}
|
|
115
|
+
const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay
|
|
116
|
+
if (typeof firstDay === "number" && firstDay >= 1 && firstDay <= 7) return (firstDay % 7) as WeekDay
|
|
117
|
+
} catch {
|
|
118
|
+
// Fall through to the table below.
|
|
119
|
+
}
|
|
120
|
+
return SUNDAY_FIRST.has(tag) ? 0 : 1
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* ---------- inlined from @squadbase/squadkit registry/hooks/use-locale.ts (internal to @squadbase/vantage) ---------- */
|
|
124
|
+
|
|
125
|
+
const listeners = new Set<() => void>()
|
|
126
|
+
let observer: MutationObserver | null = null
|
|
127
|
+
|
|
128
|
+
/** One observer on `<html lang>` for every subscribed part. */
|
|
129
|
+
function subscribe(onChange: () => void): () => void {
|
|
130
|
+
if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {}
|
|
131
|
+
listeners.add(onChange)
|
|
132
|
+
if (!observer) {
|
|
133
|
+
observer = new MutationObserver(() => {
|
|
134
|
+
for (const listener of [...listeners]) listener()
|
|
135
|
+
})
|
|
136
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] })
|
|
137
|
+
}
|
|
138
|
+
return () => {
|
|
139
|
+
listeners.delete(onChange)
|
|
140
|
+
if (listeners.size === 0 && observer) {
|
|
141
|
+
observer.disconnect()
|
|
142
|
+
observer = null
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const serverLocale = () => FALLBACK_LOCALE
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The page's locale (`<html lang>`), following changes made while the page runs. On the server and during
|
|
151
|
+
* hydration it is `FALLBACK_LOCALE` (`en-US`), so the markup matches; it switches right after.
|
|
152
|
+
*/
|
|
153
|
+
function useDocumentLocale(): string {
|
|
154
|
+
return useSyncExternalStore(subscribe, documentLocale, serverLocale)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The locale a part renders in: its `locale` prop when given, else the page's (`useDocumentLocale`). */
|
|
158
|
+
function useLocale(locale?: string | null): string {
|
|
159
|
+
const page = useDocumentLocale()
|
|
160
|
+
return locale || page
|
|
161
|
+
}
|
|
162
|
+
|
|
31
163
|
/* ---------- inlined from @squadbase/squadkit registry/lib/chart-theme.ts (internal to @squadbase/vantage) ---------- */
|
|
32
164
|
|
|
33
165
|
/**
|
|
@@ -426,6 +558,32 @@ function useChartSurface<E extends Element>(
|
|
|
426
558
|
|
|
427
559
|
/* ---------- inlined from @squadbase/squadkit registry/lib/chart-options.ts (internal to @squadbase/vantage) ---------- */
|
|
428
560
|
|
|
561
|
+
/** The built-in words of the option builders, by language. */
|
|
562
|
+
const CHART_TEXT = {
|
|
563
|
+
ja: {
|
|
564
|
+
target: "目標",
|
|
565
|
+
forecast: "予測",
|
|
566
|
+
total: "合計",
|
|
567
|
+
provisional: "速報",
|
|
568
|
+
noComparison: "比較なし",
|
|
569
|
+
share: (share: string) => `(${share})`,
|
|
570
|
+
targetRate: (rate: string) => `(達成率 ${rate})`,
|
|
571
|
+
provisionalTitle: (title: string, label: string) => `${title}(${label})`,
|
|
572
|
+
},
|
|
573
|
+
en: {
|
|
574
|
+
target: "Target",
|
|
575
|
+
forecast: "Forecast",
|
|
576
|
+
total: "Total",
|
|
577
|
+
provisional: "Preliminary",
|
|
578
|
+
noComparison: "No comparison",
|
|
579
|
+
share: (share: string) => ` (${share})`,
|
|
580
|
+
targetRate: (rate: string) => ` (${rate} achieved)`,
|
|
581
|
+
provisionalTitle: (title: string, label: string) => `${title} (${label})`,
|
|
582
|
+
},
|
|
583
|
+
} satisfies Record<UiLanguage, unknown>
|
|
584
|
+
|
|
585
|
+
const chartText = (locale?: string) => CHART_TEXT[uiLanguage(resolveLocale(locale))]
|
|
586
|
+
|
|
429
587
|
type CartesianChartKind = "area" | "bar" | "line"
|
|
430
588
|
type CartesianStackType = "default" | "stacked" | "percent"
|
|
431
589
|
type CartesianCurveType = "linear" | "smooth" | "step"
|
|
@@ -610,7 +768,7 @@ interface CartesianChartConfig {
|
|
|
610
768
|
* all ticks read in one unit (¥0.5億 / ¥1億). Default: `axisFormatter` for every tick.
|
|
611
769
|
*/
|
|
612
770
|
axisTicks?: (top: number) => (value: number) => string
|
|
613
|
-
/** Locale of the time-axis labels. Default
|
|
771
|
+
/** Locale of the time-axis labels and the built-in words (total, target, forecast). Default the page's (`<html lang>`). */
|
|
614
772
|
locale?: string
|
|
615
773
|
type?: CartesianStackType
|
|
616
774
|
/** Bar only. `"vertical"` lays the categories down the y axis (horizontal bars). */
|
|
@@ -848,11 +1006,12 @@ function timeAxisLabelStep(values: readonly unknown[], maxLabels: number = TIME_
|
|
|
848
1006
|
*/
|
|
849
1007
|
function timeAxisLabels(
|
|
850
1008
|
values: readonly unknown[],
|
|
851
|
-
|
|
1009
|
+
localeArg?: string,
|
|
852
1010
|
shown: (i: number) => boolean = () => true,
|
|
853
1011
|
): string[] | null {
|
|
854
1012
|
const parsed = parseTimeIndex(values)
|
|
855
1013
|
if (!parsed) return null
|
|
1014
|
+
const locale = resolveLocale(localeArg)
|
|
856
1015
|
const { granularity, times } = parsed
|
|
857
1016
|
let prevYear: number | null = null
|
|
858
1017
|
return times.map((t, i) => {
|
|
@@ -875,9 +1034,10 @@ function timeAxisLabels(
|
|
|
875
1034
|
}
|
|
876
1035
|
|
|
877
1036
|
/** The tooltip titles for time-like x values (`2025年9月`, `2025年9月1日`), or `null` when they are not dates. */
|
|
878
|
-
function timeTooltipLabels(values: readonly unknown[],
|
|
1037
|
+
function timeTooltipLabels(values: readonly unknown[], localeArg?: string): string[] | null {
|
|
879
1038
|
const parsed = parseTimeIndex(values)
|
|
880
1039
|
if (!parsed) return null
|
|
1040
|
+
const locale = resolveLocale(localeArg)
|
|
881
1041
|
const options: Intl.DateTimeFormatOptions =
|
|
882
1042
|
parsed.granularity === "year"
|
|
883
1043
|
? { year: "numeric" }
|
|
@@ -1017,7 +1177,9 @@ function tooltipHtml(
|
|
|
1017
1177
|
rows: readonly TooltipRow[],
|
|
1018
1178
|
valueFormatter: (value: number) => string,
|
|
1019
1179
|
notes: readonly string[] = [],
|
|
1180
|
+
locale?: string,
|
|
1020
1181
|
): string {
|
|
1182
|
+
const t = chartText(locale)
|
|
1021
1183
|
const lines = rows.map(
|
|
1022
1184
|
(row) =>
|
|
1023
1185
|
`<div style="display:flex;align-items:center;gap:8px;line-height:20px">` +
|
|
@@ -1026,7 +1188,7 @@ function tooltipHtml(
|
|
|
1026
1188
|
`<span style="font-weight:600;font-variant-numeric:tabular-nums;margin-left:12px">${
|
|
1027
1189
|
row.value === null ? EMPTY_VALUE : escapeHtml(valueFormatter(row.value))
|
|
1028
1190
|
}${
|
|
1029
|
-
row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px"
|
|
1191
|
+
row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px">${escapeHtml(t.share(formatShare(row.share)))}</span>`
|
|
1030
1192
|
}</span></div>` +
|
|
1031
1193
|
(row.note === undefined
|
|
1032
1194
|
? ""
|
|
@@ -1040,8 +1202,10 @@ function tooltipHtml(
|
|
|
1040
1202
|
|
|
1041
1203
|
/* ------------------------------------------------- per-bar targets / notes */
|
|
1042
1204
|
|
|
1043
|
-
/** Default name of the per-bar target marker (legend, tooltip). */
|
|
1044
|
-
|
|
1205
|
+
/** Default name of the per-bar target marker (legend, tooltip) in `locale`: "Target" / 「目標」. Default the page's locale. */
|
|
1206
|
+
function barTargetLabel(locale?: string): string {
|
|
1207
|
+
return chartText(locale).target
|
|
1208
|
+
}
|
|
1045
1209
|
|
|
1046
1210
|
/** How far (px) the target marker reaches past each side of its bar. */
|
|
1047
1211
|
const TARGET_MARKER_OVERHANG = 4
|
|
@@ -1092,16 +1256,17 @@ function achievementRate(value: number | null | undefined, target: number | null
|
|
|
1092
1256
|
return (value / target) * 100
|
|
1093
1257
|
}
|
|
1094
1258
|
|
|
1095
|
-
/** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3
|
|
1259
|
+
/** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3%)」 / "Target $12M (85.3% achieved)". `null` without a target. */
|
|
1096
1260
|
function barTargetNote(
|
|
1097
1261
|
label: string,
|
|
1098
1262
|
value: number | null,
|
|
1099
1263
|
target: number | null,
|
|
1100
1264
|
valueFormatter: (value: number) => string,
|
|
1265
|
+
locale?: string,
|
|
1101
1266
|
): string | null {
|
|
1102
1267
|
if (target === null) return null
|
|
1103
1268
|
const rate = achievementRate(value, target)
|
|
1104
|
-
return `${label} ${valueFormatter(target)}${rate === null ? "" :
|
|
1269
|
+
return `${label} ${valueFormatter(target)}${rate === null ? "" : chartText(locale).targetRate(formatShare(rate))}`
|
|
1105
1270
|
}
|
|
1106
1271
|
|
|
1107
1272
|
/** Half the width of a bar in category-axis units, from `barCategoryGap` (`"20%"`; a px gap or none → ECharts' 20%). */
|
|
@@ -1193,11 +1358,12 @@ const TARGET_LEGEND_SERIES_ID = "__target-legend"
|
|
|
1193
1358
|
/** A flat tick (8×8 legend box, aspect kept): the per-bar target's legend icon. */
|
|
1194
1359
|
const TARGET_LEGEND_ICON = "path://M0,0 L100,0 L100,24 L0,24 Z"
|
|
1195
1360
|
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1361
|
+
/**
|
|
1362
|
+
* 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.
|
|
1363
|
+
* Without a `label`, "Forecast" / 「予測」 in `locale` (default the page's).
|
|
1364
|
+
*/
|
|
1365
|
+
function forecastBoundaryText(label?: string, locale?: string): string {
|
|
1366
|
+
return `${label ?? chartText(locale).forecast} →`
|
|
1201
1367
|
}
|
|
1202
1368
|
|
|
1203
1369
|
/** A comparison series: a thin, muted, solid line with no markers (60% of `--muted-foreground`). */
|
|
@@ -1237,8 +1403,10 @@ interface AxisExtent {
|
|
|
1237
1403
|
max: number
|
|
1238
1404
|
}
|
|
1239
1405
|
|
|
1240
|
-
/** The label of a stack's total (highlight and tooltip). */
|
|
1241
|
-
|
|
1406
|
+
/** The label of a stack's total (highlight and tooltip) in `locale`: "Total" / 「合計」. Default the page's locale. */
|
|
1407
|
+
function stackTotalLabel(locale?: string): string {
|
|
1408
|
+
return chartText(locale).total
|
|
1409
|
+
}
|
|
1242
1410
|
|
|
1243
1411
|
/** Per point, the sum of the stacked series; `null` where every series is missing. */
|
|
1244
1412
|
function stackTotalsOf(values: readonly (readonly (number | null)[])[]): (number | null)[] {
|
|
@@ -1360,8 +1528,8 @@ function indexGrain(values: readonly unknown[]): PeriodGrain | null {
|
|
|
1360
1528
|
|
|
1361
1529
|
/**
|
|
1362
1530
|
* The tooltip line under the main series' row when a comparison series is drawn: the arrow, the signed change and the
|
|
1363
|
-
* comparison's name, as `Delta` writes it ("↑ +12.4% 前年同月比"; a minus is U+2212).
|
|
1364
|
-
* either value is missing or the baseline is 0.
|
|
1531
|
+
* comparison's name, as `Delta` writes it ("↑ +12.4% vs. same month last year" / "↑ +12.4% 前年同月比"; a minus is U+2212).
|
|
1532
|
+
* "No comparison vs. same month last year" / "比較なし 前年同月比" when either value is missing or the baseline is 0.
|
|
1365
1533
|
*/
|
|
1366
1534
|
function comparisonNote(
|
|
1367
1535
|
current: number | null,
|
|
@@ -1369,16 +1537,18 @@ function comparisonNote(
|
|
|
1369
1537
|
comparison: Pick<CartesianComparisonConfig, "vs" | "label">,
|
|
1370
1538
|
grain?: PeriodGrain,
|
|
1371
1539
|
format?: ValueFormat,
|
|
1372
|
-
|
|
1540
|
+
localeArg?: string,
|
|
1373
1541
|
): string {
|
|
1542
|
+
const locale = resolveLocale(localeArg)
|
|
1374
1543
|
const result = computeComparison(
|
|
1375
1544
|
current,
|
|
1376
1545
|
comparison.vs === "custom"
|
|
1377
1546
|
? { previous, vs: "custom", label: comparison.label ?? "", grain }
|
|
1378
1547
|
: { previous, vs: comparison.vs, label: comparison.label, grain },
|
|
1548
|
+
{ locale },
|
|
1379
1549
|
)
|
|
1380
1550
|
const text = formatChange(result, defaultChangeDisplay(format), locale, format)
|
|
1381
|
-
if (text === null) return
|
|
1551
|
+
if (text === null) return `${chartText(locale).noComparison} ${result.label}`
|
|
1382
1552
|
const arrow = result.direction === "up" ? "↑" : result.direction === "down" ? "↓" : "→"
|
|
1383
1553
|
return `${arrow} ${text.replace(/^-/, "−")} ${result.label}`
|
|
1384
1554
|
}
|
|
@@ -1421,7 +1591,6 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1421
1591
|
toneColors,
|
|
1422
1592
|
valueFormatter,
|
|
1423
1593
|
axisFormatter = valueFormatter,
|
|
1424
|
-
locale = DEFAULT_LOCALE,
|
|
1425
1594
|
type = "default",
|
|
1426
1595
|
layout = "horizontal",
|
|
1427
1596
|
curveType = "linear",
|
|
@@ -1458,7 +1627,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1458
1627
|
chartOptions,
|
|
1459
1628
|
reserveActions = false,
|
|
1460
1629
|
} = cfg
|
|
1461
|
-
const
|
|
1630
|
+
const locale = resolveLocale(cfg.locale)
|
|
1631
|
+
const text = chartText(locale)
|
|
1632
|
+
const provisionalLabel = cfg.provisionalLabel ?? text.provisional
|
|
1462
1633
|
|
|
1463
1634
|
const stacked = kind !== "line" && type !== "default"
|
|
1464
1635
|
const percent = kind !== "line" && type === "percent"
|
|
@@ -1607,9 +1778,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1607
1778
|
// `barOptions.targetKey` (a target per bar) takes over from the single `target` line.
|
|
1608
1779
|
const perBarTarget = kind === "bar" && Boolean(targetKey)
|
|
1609
1780
|
const hasTarget = !perBarTarget && target !== undefined && Number.isFinite(target)
|
|
1610
|
-
const targetText = hasTarget ? (targetLabel ??
|
|
1781
|
+
const targetText = hasTarget ? (targetLabel ?? `${text.target} ${axisFormatter(target)}`) : ""
|
|
1611
1782
|
const highlightText = (value: number) =>
|
|
1612
|
-
shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${
|
|
1783
|
+
shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${text.total} ` : ""}${axisFormatter(value)}`
|
|
1613
1784
|
/** Whether `p`'s label sits within the target's value band and under the target label (at the right end). */
|
|
1614
1785
|
const nearTargetLabel = (p: HighlightPoint, side: "under" | "over") => {
|
|
1615
1786
|
if (!hasTarget || !plotWidth || kind === "bar" || labels.length < 2) return false
|
|
@@ -1635,7 +1806,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1635
1806
|
// One series (or a stack, against its total) can be compared to a target; grouped series and 100% stacks cannot.
|
|
1636
1807
|
const barTargets: (number | null)[] | null =
|
|
1637
1808
|
perBarTarget && targetKey && !percent && (stacked || categories.length === 1) ? barTargetValues(data, targetKey) : null
|
|
1638
|
-
const barTargetName = targetLabel ??
|
|
1809
|
+
const barTargetName = targetLabel ?? text.target
|
|
1639
1810
|
const barTargetColor = labelColor ?? guideColor
|
|
1640
1811
|
|
|
1641
1812
|
const markPointFor = (c: number, color: string | undefined) =>
|
|
@@ -1849,7 +2020,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1849
2020
|
}
|
|
1850
2021
|
|
|
1851
2022
|
/* -- forecast: dashed continuation (+ optional band) of one series */
|
|
1852
|
-
const forecastLabel = forecast?.label ??
|
|
2023
|
+
const forecastLabel = forecast?.label ?? text.forecast
|
|
1853
2024
|
const forecastBandAlpha = isDarkSurface(labelBackground) ? FORECAST_BAND_ALPHA_DARK : FORECAST_BAND_ALPHA_LIGHT
|
|
1854
2025
|
const forecastValues: (number | null)[] = []
|
|
1855
2026
|
if (forecast) {
|
|
@@ -2325,7 +2496,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
2325
2496
|
})
|
|
2326
2497
|
}
|
|
2327
2498
|
if (stackTotals && i <= lastActual && rows.length > 1) {
|
|
2328
|
-
rows.push({ name:
|
|
2499
|
+
rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
|
|
2329
2500
|
}
|
|
2330
2501
|
const provisional = provStart !== -1 && i >= provStart && i <= lastActual
|
|
2331
2502
|
if (provisional) {
|
|
@@ -2341,19 +2512,19 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
2341
2512
|
...noteFor(at),
|
|
2342
2513
|
})
|
|
2343
2514
|
})
|
|
2344
|
-
if (stackTotals && rows.length > 1) rows.push({ name:
|
|
2515
|
+
if (stackTotals && rows.length > 1) rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
|
|
2345
2516
|
}
|
|
2346
2517
|
const title = tooltipTitles[i] ?? labels[i] ?? ""
|
|
2347
2518
|
const lines: string[] = []
|
|
2348
2519
|
if (barTargets && i <= lastActual) {
|
|
2349
2520
|
const total = stackTotals ? (stackTotals[i] ?? null) : (values[0]?.[i] ?? null)
|
|
2350
|
-
const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter)
|
|
2521
|
+
const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter, locale)
|
|
2351
2522
|
if (note) lines.push(note)
|
|
2352
2523
|
}
|
|
2353
2524
|
for (const note of notes) {
|
|
2354
2525
|
if (note.to === undefined ? note.from === i : note.from <= i && i <= note.to) lines.push(note.label)
|
|
2355
2526
|
}
|
|
2356
|
-
return tooltipHtml(provisional ?
|
|
2527
|
+
return tooltipHtml(provisional ? text.provisionalTitle(title, provisionalLabel) : title, rows, valueFormatter, lines, locale)
|
|
2357
2528
|
},
|
|
2358
2529
|
},
|
|
2359
2530
|
xAxis: horizontalBars ? valueAxis : categoryAxis,
|
|
@@ -2445,8 +2616,6 @@ interface DonutOptionSlice {
|
|
|
2445
2616
|
/** How many of the largest slices get an outside label (「自然検索 41%」) by default. */
|
|
2446
2617
|
const DONUT_OUTSIDE_LABELS = 2
|
|
2447
2618
|
|
|
2448
|
-
const DEFAULT_TOTAL_LABEL = "合計"
|
|
2449
|
-
|
|
2450
2619
|
const sliceValue = (value: number) => (Number.isFinite(value) && value > 0 ? value : 0)
|
|
2451
2620
|
|
|
2452
2621
|
/** A slice's share of `total` as text: whole percent, `<1%` for a sliver, `0%` for nothing. */
|
|
@@ -2479,15 +2648,16 @@ function donutLegendItems(slices: readonly DonutOptionSlice[]): DonutLegendItem[
|
|
|
2479
2648
|
}
|
|
2480
2649
|
|
|
2481
2650
|
/**
|
|
2482
|
-
* The default center label: what is totalled (「合計」), the formatted total
|
|
2651
|
+
* The default center label: what is totalled (「合計」 / "Total" in `locale`, default the page's), the formatted total
|
|
2483
2652
|
* (`amount`, the big line), the unit (a small line under it) and both as one
|
|
2484
2653
|
* string (`value`, 「9.2万 セッション」).
|
|
2485
2654
|
*/
|
|
2486
2655
|
function donutCenterText(
|
|
2487
2656
|
total: number,
|
|
2488
2657
|
valueFormatter: (value: number) => string,
|
|
2489
|
-
{ totalLabel
|
|
2658
|
+
{ totalLabel, unit, locale }: { totalLabel?: string; unit?: string; locale?: string } = {},
|
|
2490
2659
|
): { label: string; value: string; amount: string; unit?: string } {
|
|
2660
|
+
totalLabel ??= chartText(locale).total
|
|
2491
2661
|
const amount = valueFormatter(total)
|
|
2492
2662
|
return { label: totalLabel, value: unit ? `${amount} ${unit}` : amount, amount, ...(unit ? { unit } : {}) }
|
|
2493
2663
|
}
|
|
@@ -2715,8 +2885,10 @@ function hasLabel(labels: Readonly<Record<string, string>> | undefined, key: str
|
|
|
2715
2885
|
|
|
2716
2886
|
/* ------------------------------------------------------------ provisional */
|
|
2717
2887
|
|
|
2718
|
-
/** The default label for points that are not final yet. */
|
|
2719
|
-
|
|
2888
|
+
/** The default label for points that are not final yet, in `locale`: "Preliminary" / 「速報」. Default the page's locale. */
|
|
2889
|
+
function defaultProvisionalLabel(locale?: string): string {
|
|
2890
|
+
return chartText(locale).provisional
|
|
2891
|
+
}
|
|
2720
2892
|
|
|
2721
2893
|
/**
|
|
2722
2894
|
* Position of the first provisional point: the first entry of `indexValues`
|
|
@@ -2854,9 +3026,10 @@ function resolveValueFormatter(
|
|
|
2854
3026
|
function resolveAxisFormatter(
|
|
2855
3027
|
valueFormatter: ValueFormatter | undefined,
|
|
2856
3028
|
format: ValueFormat | undefined,
|
|
2857
|
-
|
|
3029
|
+
localeArg?: string,
|
|
2858
3030
|
): ValueFormatter {
|
|
2859
3031
|
if (valueFormatter) return valueFormatter
|
|
3032
|
+
const locale = resolveLocale(localeArg)
|
|
2860
3033
|
const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
|
|
2861
3034
|
if (spec.type !== "compact" && spec.type !== "number" && spec.type !== "currency") return toValueFormatter(format, locale)
|
|
2862
3035
|
const currency = "currency" in spec ? spec.currency : undefined
|
|
@@ -2865,40 +3038,75 @@ function resolveAxisFormatter(
|
|
|
2865
3038
|
return (value) => formatValue(value, short, locale)
|
|
2866
3039
|
}
|
|
2867
3040
|
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
{ char: "万", size: 1e4 },
|
|
2873
|
-
] as const
|
|
3041
|
+
interface CompactUnit {
|
|
3042
|
+
token: string
|
|
3043
|
+
size: number
|
|
3044
|
+
}
|
|
2874
3045
|
|
|
2875
|
-
const
|
|
3046
|
+
const compactUnitCache = new Map<string, CompactUnit[] | null>()
|
|
3047
|
+
|
|
3048
|
+
/**
|
|
3049
|
+
* The compact-notation unit words of a locale and their sizes, largest first: `兆` / `億` / `万` in ja,
|
|
3050
|
+
* `T` / `B` / `M` / `K` in en. `null` when the locale's compact form puts anything between the number and its
|
|
3051
|
+
* word (`1,5 Mio.`), where a token cannot be swapped in place.
|
|
3052
|
+
*/
|
|
3053
|
+
function compactUnits(locale: string): CompactUnit[] | null {
|
|
3054
|
+
const cached = compactUnitCache.get(locale)
|
|
3055
|
+
if (cached !== undefined) return cached
|
|
3056
|
+
let units: CompactUnit[] | null = []
|
|
3057
|
+
try {
|
|
3058
|
+
const compact = new Intl.NumberFormat(locale, { notation: "compact" })
|
|
3059
|
+
const seen = new Set<string>()
|
|
3060
|
+
for (let exp = 3; exp <= 15; exp++) {
|
|
3061
|
+
const parts = compact.formatToParts(10 ** exp)
|
|
3062
|
+
const at = parts.findIndex((p) => p.type === "compact")
|
|
3063
|
+
if (at === -1) continue
|
|
3064
|
+
if (parts[at - 1]?.type !== "integer" || at !== parts.length - 1) {
|
|
3065
|
+
units = null
|
|
3066
|
+
break
|
|
3067
|
+
}
|
|
3068
|
+
const token = parts[at]!.value
|
|
3069
|
+
if (seen.has(token)) continue
|
|
3070
|
+
seen.add(token)
|
|
3071
|
+
units.push({ token, size: 10 ** exp })
|
|
3072
|
+
}
|
|
3073
|
+
} catch {
|
|
3074
|
+
units = null
|
|
3075
|
+
}
|
|
3076
|
+
const sorted = units && units.length > 0 ? units.sort((a, b) => b.size - a.size) : null
|
|
3077
|
+
compactUnitCache.set(locale, sorted)
|
|
3078
|
+
return sorted
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
2876
3082
|
|
|
2877
3083
|
/**
|
|
2878
3084
|
* The value-axis tick formatter for an axis whose largest tick is `top`: every tick reads in the unit of
|
|
2879
|
-
* that largest tick, so
|
|
2880
|
-
* Only the
|
|
2881
|
-
* any other format —
|
|
3085
|
+
* that largest tick, so units never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`;
|
|
3086
|
+
* `$0.5M` / `$1M` / `$1.5M`, not `$500K` / `$1M`). Only the short forms (`compact` / `number` / `currency`,
|
|
3087
|
+
* no `valueFormatter`) have units to align; any other format — an axis whose largest tick is under the smallest
|
|
3088
|
+
* unit (1万 / 1K), or a locale whose compact form cannot be aligned in place — keeps `resolveAxisFormatter`'s output as it is.
|
|
2882
3089
|
*/
|
|
2883
3090
|
function resolveAxisTickFormatter(
|
|
2884
3091
|
valueFormatter: ValueFormatter | undefined,
|
|
2885
3092
|
format: ValueFormat | undefined,
|
|
2886
|
-
|
|
3093
|
+
localeArg?: string,
|
|
2887
3094
|
): (top: number) => ValueFormatter {
|
|
3095
|
+
const locale = resolveLocale(localeArg)
|
|
2888
3096
|
const base = resolveAxisFormatter(valueFormatter, format, locale)
|
|
2889
3097
|
const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
|
|
2890
|
-
const
|
|
2891
|
-
|
|
2892
|
-
if (!aligns) return () => base
|
|
3098
|
+
const units = !valueFormatter && (spec.type === "compact" || spec.type === "number" || spec.type === "currency") ? compactUnits(locale) : null
|
|
3099
|
+
if (!units) return () => base
|
|
2893
3100
|
const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 3 })
|
|
3101
|
+
const numberToken = new RegExp(`\\d[\\d,]*(?:\\.\\d+)?(?:${units.map((u) => escapeRegExp(u.token)).join("|")})?`)
|
|
2894
3102
|
return (top) => {
|
|
2895
|
-
const unit =
|
|
3103
|
+
const unit = units.find((u) => Math.abs(top) >= u.size)
|
|
2896
3104
|
if (!unit) return base
|
|
2897
3105
|
return (value) => {
|
|
2898
3106
|
const text = base(value)
|
|
2899
3107
|
if (value === 0 || !Number.isFinite(value)) return text
|
|
2900
|
-
const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.
|
|
2901
|
-
return text.replace(
|
|
3108
|
+
const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.token}`
|
|
3109
|
+
return text.replace(numberToken, () => scaled)
|
|
2902
3110
|
}
|
|
2903
3111
|
}
|
|
2904
3112
|
}
|
|
@@ -2927,8 +3135,20 @@ function clamp(value: number, min: number, max: number): number {
|
|
|
2927
3135
|
/** How many categories a chart colours individually; the rest fold into「その他」. */
|
|
2928
3136
|
const MAX_CHART_SERIES = 5
|
|
2929
3137
|
|
|
2930
|
-
/** The series name the folded categories get. */
|
|
2931
|
-
const
|
|
3138
|
+
/** The series name the folded categories get, by language. */
|
|
3139
|
+
const OTHER_SERIES_LABELS: Record<UiLanguage, string> = { ja: "その他", en: "Other" }
|
|
3140
|
+
|
|
3141
|
+
/** The series name the folded categories get in `locale` ("Other" / "その他"). Default the page's locale (`<html lang>`). */
|
|
3142
|
+
function otherSeriesLabel(locale?: string): string {
|
|
3143
|
+
return OTHER_SERIES_LABELS[uiLanguage(resolveLocale(locale))]
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
const OTHER_NAMES = new Set(Object.values(OTHER_SERIES_LABELS))
|
|
3147
|
+
|
|
3148
|
+
/** Whether a category / row is the "other" bucket by name (「その他」 or "Other", whatever the page language). */
|
|
3149
|
+
function isOtherName(name: string | null | undefined): boolean {
|
|
3150
|
+
return typeof name === "string" && OTHER_NAMES.has(name)
|
|
3151
|
+
}
|
|
2932
3152
|
|
|
2933
3153
|
interface AggregatedItems<Item extends { name: string; value: number }> {
|
|
2934
3154
|
/** The `n` largest items, in their given order. All items when there are `n` or fewer. */
|
|
@@ -2948,7 +3168,7 @@ interface AggregatedItems<Item extends { name: string; value: number }> {
|
|
|
2948
3168
|
function aggregateTopN<Item extends { name: string; value: number }>(
|
|
2949
3169
|
items: readonly Item[],
|
|
2950
3170
|
n: number = MAX_CHART_SERIES,
|
|
2951
|
-
otherLabel: string =
|
|
3171
|
+
otherLabel: string = otherSeriesLabel(),
|
|
2952
3172
|
): AggregatedItems<Item> {
|
|
2953
3173
|
if (items.length <= n) return { top: [...items], rest: [], other: null }
|
|
2954
3174
|
const ranked = items
|
|
@@ -2977,7 +3197,7 @@ function aggregateSeries(
|
|
|
2977
3197
|
data: readonly Record<string, unknown>[],
|
|
2978
3198
|
categories: readonly string[],
|
|
2979
3199
|
n: number = MAX_CHART_SERIES,
|
|
2980
|
-
otherLabel: string =
|
|
3200
|
+
otherLabel: string = otherSeriesLabel(),
|
|
2981
3201
|
): AggregatedSeries {
|
|
2982
3202
|
const totals = categories.map((name) => ({
|
|
2983
3203
|
name,
|
|
@@ -3009,12 +3229,14 @@ function aggregateSeriesKeeping(
|
|
|
3009
3229
|
categories: readonly string[],
|
|
3010
3230
|
keep: string | undefined,
|
|
3011
3231
|
n: number = MAX_CHART_SERIES,
|
|
3232
|
+
otherLabel: string = otherSeriesLabel(),
|
|
3012
3233
|
): AggregatedSeries {
|
|
3013
|
-
if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n)
|
|
3234
|
+
if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n, otherLabel)
|
|
3014
3235
|
const aggregated = aggregateSeries(
|
|
3015
3236
|
data,
|
|
3016
3237
|
categories.filter((c) => c !== keep),
|
|
3017
3238
|
n,
|
|
3239
|
+
otherLabel,
|
|
3018
3240
|
)
|
|
3019
3241
|
return { ...aggregated, categories: [...aggregated.categories, keep] }
|
|
3020
3242
|
}
|
|
@@ -3050,7 +3272,7 @@ function seriesColorsWithComparison(
|
|
|
3050
3272
|
* Colour per drawn series: the record's token for that category, else the
|
|
3051
3273
|
* array's token at the category's position in `allCategories` (cycled), else
|
|
3052
3274
|
* the token palette by drawn position. `otherCategory` — and a category the
|
|
3053
|
-
* data itself names
|
|
3275
|
+
* data itself names「その他」/ "Other" (`OTHER_SERIES_LABELS`) — always gets `otherColor`.
|
|
3054
3276
|
*/
|
|
3055
3277
|
function seriesColorsFor(
|
|
3056
3278
|
categories: readonly string[],
|
|
@@ -3062,7 +3284,7 @@ function seriesColorsFor(
|
|
|
3062
3284
|
const list = Array.isArray(colors) ? (colors as ChartSeriesColor[]) : undefined
|
|
3063
3285
|
const record = !Array.isArray(colors) && colors ? (colors as Partial<Record<string, ChartSeriesColor>>) : undefined
|
|
3064
3286
|
return categories.map((category, i) => {
|
|
3065
|
-
if ((otherCategory !== null && category === otherCategory) || category
|
|
3287
|
+
if ((otherCategory !== null && category === otherCategory) || isOtherName(category)) return otherColor
|
|
3066
3288
|
const fromRecord = record?.[category]
|
|
3067
3289
|
if (fromRecord) return fromRecord
|
|
3068
3290
|
if (list && list.length > 0) {
|
|
@@ -3109,7 +3331,7 @@ interface DonutSlicesConfig {
|
|
|
3109
3331
|
*/
|
|
3110
3332
|
function donutSlices(
|
|
3111
3333
|
data: readonly Record<string, unknown>[],
|
|
3112
|
-
{ index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel =
|
|
3334
|
+
{ index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = otherSeriesLabel() }: DonutSlicesConfig,
|
|
3113
3335
|
): DonutSlice[] {
|
|
3114
3336
|
const items = data
|
|
3115
3337
|
.map((row, i) => {
|
|
@@ -3122,7 +3344,8 @@ function donutSlices(
|
|
|
3122
3344
|
const slices: DonutSlice[] = top.map((item, rank) => ({
|
|
3123
3345
|
name: item.name,
|
|
3124
3346
|
value: item.value,
|
|
3125
|
-
|
|
3347
|
+
// A row the data itself names「その他」/ "Other" is the same remainder: grey, like the folded slice.
|
|
3348
|
+
color: isOtherName(item.name) ? otherColor : palette[rank % palette.length]!,
|
|
3126
3349
|
isOther: false,
|
|
3127
3350
|
row: item.row,
|
|
3128
3351
|
}))
|
|
@@ -3195,7 +3418,7 @@ interface BarListRowsConfig extends BarListOptions {
|
|
|
3195
3418
|
*/
|
|
3196
3419
|
function barListRows<Row extends object>(
|
|
3197
3420
|
data: readonly Row[],
|
|
3198
|
-
{ index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel =
|
|
3421
|
+
{ index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = otherSeriesLabel(), exclude }: BarListRowsConfig = {},
|
|
3199
3422
|
): BarListRow<Row>[] {
|
|
3200
3423
|
const items = data.map((row): BarListRow<Row> => {
|
|
3201
3424
|
const cells = row as Record<string, unknown>
|
|
@@ -3241,19 +3464,21 @@ const BAR_LIST_OTHER_TINT = 30
|
|
|
3241
3464
|
|
|
3242
3465
|
/**
|
|
3243
3466
|
* The fill of each BarList row: all real rows in the same tint of `color`
|
|
3244
|
-
* (`BAR_LIST_TINT`), the folded「その他」row in a tint of `otherColor`
|
|
3467
|
+
* (`BAR_LIST_TINT`), the folded「その他」row (and a row the data names「その他」/ "Other") in a tint of `otherColor`
|
|
3245
3468
|
* (`BAR_LIST_OTHER_TINT`) — the palest fill, a remainder never outweighs a
|
|
3246
3469
|
* ranked row. A legacy per-row `color` (or a threshold tone) replaces `color`
|
|
3247
3470
|
* for that row. A tint, not the solid token, so the label printed over the bar
|
|
3248
3471
|
* keeps its contrast.
|
|
3249
3472
|
*/
|
|
3250
3473
|
function barListFills(
|
|
3251
|
-
rows: readonly Pick<BarListRow, "isOther" | "color">[],
|
|
3474
|
+
rows: readonly (Pick<BarListRow, "isOther" | "color"> & { name?: string })[],
|
|
3252
3475
|
color: ChartColor,
|
|
3253
3476
|
otherColor: string,
|
|
3254
3477
|
): string[] {
|
|
3255
3478
|
return rows.map((row) =>
|
|
3256
|
-
row.isOther
|
|
3479
|
+
row.isOther || isOtherName(row.name)
|
|
3480
|
+
? tintCss(otherColor, BAR_LIST_OTHER_TINT)
|
|
3481
|
+
: tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
|
|
3257
3482
|
)
|
|
3258
3483
|
}
|
|
3259
3484
|
|
|
@@ -3469,11 +3694,30 @@ interface ChartSummaryInput {
|
|
|
3469
3694
|
format?: ValueFormat
|
|
3470
3695
|
/** Wins over `format`. */
|
|
3471
3696
|
valueFormatter?: ValueFormatter
|
|
3697
|
+
/** Language of the sentence. Default the page's locale (`<html lang>`). */
|
|
3698
|
+
locale?: string
|
|
3699
|
+
}
|
|
3700
|
+
|
|
3701
|
+
const SUMMARY_TEXT = {
|
|
3702
|
+
ja: {
|
|
3703
|
+
up: "、前の点より増加",
|
|
3704
|
+
down: "、前の点より減少",
|
|
3705
|
+
flat: "、前の点と横ばい",
|
|
3706
|
+
latest: (label: string, value: string) => `最新 ${label} は ${value}`,
|
|
3707
|
+
top: (name: string, share: string) => `最大は ${name}(${share})`,
|
|
3708
|
+
},
|
|
3709
|
+
en: {
|
|
3710
|
+
up: ", up from the previous point",
|
|
3711
|
+
down: ", down from the previous point",
|
|
3712
|
+
flat: ", flat from the previous point",
|
|
3713
|
+
latest: (label: string, value: string) => `Latest ${label}: ${value}`,
|
|
3714
|
+
top: (name: string, share: string) => `Largest: ${name} (${share})`,
|
|
3715
|
+
},
|
|
3472
3716
|
}
|
|
3473
3717
|
|
|
3474
3718
|
/**
|
|
3475
3719
|
* One-sentence summary built from the rows, for `summary="auto"`:
|
|
3476
|
-
* 「<title>: 最新 <index> は
|
|
3720
|
+
* 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」("<title>: Latest <index>: <value>, up from the previous point"). The direction is
|
|
3477
3721
|
* computed from the last two non-empty points of the first series — never
|
|
3478
3722
|
* written by hand, so it cannot contradict the data. `undefined` when there is
|
|
3479
3723
|
* no usable point.
|
|
@@ -3489,26 +3733,29 @@ function buildChartSummary(input: ChartSummaryInput): string | undefined {
|
|
|
3489
3733
|
}
|
|
3490
3734
|
const latest = points[points.length - 1]
|
|
3491
3735
|
if (!latest) return undefined
|
|
3492
|
-
const
|
|
3736
|
+
const locale = resolveLocale(input.locale)
|
|
3737
|
+
const t = SUMMARY_TEXT[uiLanguage(locale)]
|
|
3738
|
+
const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact", locale)
|
|
3493
3739
|
const prev = points[points.length - 2]
|
|
3494
|
-
const direction = !prev ? "" : latest.value > prev.value ?
|
|
3740
|
+
const direction = !prev ? "" : latest.value > prev.value ? t.up : latest.value < prev.value ? t.down : t.flat
|
|
3495
3741
|
const head = input.title ? `${input.title}: ` : ""
|
|
3496
|
-
return `${head}
|
|
3742
|
+
return `${head}${t.latest(latest.label, text(latest.value))}${direction}`
|
|
3497
3743
|
}
|
|
3498
3744
|
|
|
3499
3745
|
/**
|
|
3500
|
-
* `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」 from the slices
|
|
3501
|
-
* (largest first). `undefined` when there is nothing to describe.
|
|
3746
|
+
* `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」("<title>: Largest: <name> (<share>)") from the slices
|
|
3747
|
+
* (largest first). `undefined` when there is nothing to describe. `locale` picks the language; default the page's.
|
|
3502
3748
|
*/
|
|
3503
3749
|
function buildShareSummary(
|
|
3504
3750
|
slices: readonly { name: string; value: number }[],
|
|
3505
3751
|
title?: string,
|
|
3752
|
+
locale?: string,
|
|
3506
3753
|
): string | undefined {
|
|
3507
3754
|
const total = slices.reduce((sum, s) => sum + s.value, 0)
|
|
3508
3755
|
const top = slices[0]
|
|
3509
3756
|
if (!top || total <= 0) return undefined
|
|
3510
3757
|
const share = `${Math.round((top.value / total) * 1000) / 10}%`
|
|
3511
|
-
return `${title ? `${title}: ` : ""}
|
|
3758
|
+
return `${title ? `${title}: ` : ""}${SUMMARY_TEXT[uiLanguage(resolveLocale(locale))].top(top.name, share)}`
|
|
3512
3759
|
}
|
|
3513
3760
|
|
|
3514
3761
|
/* ---------- inlined from @squadbase/squadkit registry/lib/distribution.ts (internal to @squadbase/vantage) ---------- */
|
|
@@ -3687,7 +3934,7 @@ export interface HistogramChartProps
|
|
|
3687
3934
|
max?: number
|
|
3688
3935
|
/** しきい値の線(任意)。階級の途中にも引ける。 */
|
|
3689
3936
|
threshold?: HistogramThreshold
|
|
3690
|
-
/**
|
|
3937
|
+
/** 件数の単位。既定は日本語のページで「件」、それ以外は単位なし。 */
|
|
3691
3938
|
countUnit?: string
|
|
3692
3939
|
/** 横軸の値の名前(データ表の先頭列)。既定「値」。例「解決日数」 */
|
|
3693
3940
|
valueLabel?: string
|
|
@@ -3699,9 +3946,45 @@ export interface HistogramChartProps
|
|
|
3699
3946
|
|
|
3700
3947
|
/* ------------------------------------------------------------------ model */
|
|
3701
3948
|
|
|
3949
|
+
const TEXT = {
|
|
3950
|
+
ja: {
|
|
3951
|
+
countUnit: "件",
|
|
3952
|
+
atOrAbove: (from: string) => `${from}以上`,
|
|
3953
|
+
range: (from: string, to: string) => `${from}〜${to}`,
|
|
3954
|
+
count: (n: number, unit: string) => `${n}${unit}`,
|
|
3955
|
+
countHeader: (unit: string) => `${unit}数`,
|
|
3956
|
+
share: "構成比",
|
|
3957
|
+
value: "値",
|
|
3958
|
+
valueInSentence: "値",
|
|
3959
|
+
threshold: "しきい値",
|
|
3960
|
+
peak: (total: number, bin: string, count: number, unit: string) => `${total}${unit}のうち最も多いのは ${bin}(${count}${unit})`,
|
|
3961
|
+
overThreshold: (name: string, value: string, count: string, share: string) => `${name}(${value})以上は ${count}(${share})`,
|
|
3962
|
+
sentenceJoin: "。",
|
|
3963
|
+
chart: (label: string) => `${label}のヒストグラム`,
|
|
3964
|
+
},
|
|
3965
|
+
en: {
|
|
3966
|
+
countUnit: "",
|
|
3967
|
+
atOrAbove: (from: string) => `${from}+`,
|
|
3968
|
+
range: (from: string, to: string) => `${from}–${to}`,
|
|
3969
|
+
count: (n: number, unit: string) => (unit ? `${n} ${unit}` : `${n}`),
|
|
3970
|
+
countHeader: () => "Count",
|
|
3971
|
+
share: "Share",
|
|
3972
|
+
value: "Value",
|
|
3973
|
+
valueInSentence: "values",
|
|
3974
|
+
threshold: "Threshold",
|
|
3975
|
+
peak: (total: number, bin: string, count: number, unit: string) => `Most common: ${bin} (${unit ? `${count} ${unit}` : count} of ${total})`,
|
|
3976
|
+
overThreshold: (name: string, value: string, count: string, share: string) => `At or above ${name} (${value}): ${count} (${share})`,
|
|
3977
|
+
sentenceJoin: ". ",
|
|
3978
|
+
chart: (label: string) => `Histogram of ${label}`,
|
|
3979
|
+
},
|
|
3980
|
+
}
|
|
3981
|
+
|
|
3982
|
+
const textOf = (locale?: string) => TEXT[uiLanguage(resolveLocale(locale))]
|
|
3983
|
+
|
|
3702
3984
|
/** 階級の名前。`10〜15`、上限超えは `30以上`。 */
|
|
3703
|
-
export function binLabel(bin: HistogramBin, formatter: ValueFormatter): string {
|
|
3704
|
-
|
|
3985
|
+
export function binLabel(bin: HistogramBin, formatter: ValueFormatter, locale?: string): string {
|
|
3986
|
+
const t = textOf(locale)
|
|
3987
|
+
return bin.overflow ? t.atOrAbove(formatter(bin.from)) : t.range(formatter(bin.from), formatter(bin.to))
|
|
3705
3988
|
}
|
|
3706
3989
|
|
|
3707
3990
|
/** しきい値が来る位置を階級の添字(小数。階級の左端 = 添字 − 0.5)で返す。範囲の外は `null`。 */
|
|
@@ -3727,27 +4010,30 @@ const percent = (n: number, total: number) => (total === 0 ? "0%" : `${Math.roun
|
|
|
3727
4010
|
export function buildHistogramSummary(
|
|
3728
4011
|
bins: readonly HistogramBin[],
|
|
3729
4012
|
formatter: ValueFormatter,
|
|
3730
|
-
config: { total: number; countUnit: string; atOrAbove?: number; threshold?: HistogramThreshold },
|
|
4013
|
+
config: { total: number; countUnit: string; atOrAbove?: number; threshold?: HistogramThreshold; locale?: string },
|
|
3731
4014
|
): string | undefined {
|
|
3732
4015
|
if (bins.length === 0 || config.total === 0) return undefined
|
|
4016
|
+
const t = textOf(config.locale)
|
|
4017
|
+
const count = (n: number) => t.count(n, config.countUnit)
|
|
3733
4018
|
const peak = bins.reduce((a, b) => (b.count > a.count ? b : a))
|
|
3734
|
-
const parts = [
|
|
4019
|
+
const parts = [t.peak(config.total, binLabel(peak, formatter, config.locale), peak.count, config.countUnit)]
|
|
3735
4020
|
if (config.threshold && config.atOrAbove !== undefined) {
|
|
3736
|
-
const name = config.threshold.label ??
|
|
3737
|
-
parts.push(
|
|
4021
|
+
const name = config.threshold.label ?? t.threshold
|
|
4022
|
+
parts.push(t.overThreshold(name, formatter(config.threshold.value), count(config.atOrAbove), percent(config.atOrAbove, config.total)))
|
|
3738
4023
|
}
|
|
3739
|
-
return parts.join(
|
|
4024
|
+
return parts.join(t.sentenceJoin)
|
|
3740
4025
|
}
|
|
3741
4026
|
|
|
3742
4027
|
/** The screen-reader table: one row per class. */
|
|
3743
4028
|
export function histogramDataTable(
|
|
3744
4029
|
bins: readonly HistogramBin[],
|
|
3745
4030
|
formatter: ValueFormatter,
|
|
3746
|
-
config: { total: number; countUnit: string; valueLabel?: string },
|
|
4031
|
+
config: { total: number; countUnit: string; valueLabel?: string; locale?: string },
|
|
3747
4032
|
): ChartDataTable {
|
|
4033
|
+
const t = textOf(config.locale)
|
|
3748
4034
|
return {
|
|
3749
|
-
columns: [config.valueLabel ??
|
|
3750
|
-
rows: bins.map((b) => [binLabel(b, formatter), `${b.count}`, percent(b.count, config.total)]),
|
|
4035
|
+
columns: [config.valueLabel ?? t.value, t.countHeader(config.countUnit), t.share],
|
|
4036
|
+
rows: bins.map((b) => [binLabel(b, formatter, config.locale), `${b.count}`, percent(b.count, config.total)]),
|
|
3751
4037
|
}
|
|
3752
4038
|
}
|
|
3753
4039
|
|
|
@@ -3810,13 +4096,17 @@ export interface HistogramOptionConfig {
|
|
|
3810
4096
|
labelColor?: string
|
|
3811
4097
|
lineColor?: string
|
|
3812
4098
|
chartOptions?: EChartsOption
|
|
4099
|
+
/** Language of the class names, tooltip and threshold label. Default the page's locale (`<html lang>`). */
|
|
4100
|
+
locale?: string
|
|
3813
4101
|
}
|
|
3814
4102
|
|
|
3815
4103
|
const ROW_STYLE = "display:flex;align-items:center;gap:8px;line-height:20px"
|
|
3816
4104
|
|
|
3817
4105
|
/** The ECharts option: gap-less bars over the classes, the overflow class in the neutral fill, an optional threshold line. */
|
|
3818
4106
|
export function buildHistogramOption(config: HistogramOptionConfig): EChartsOption {
|
|
3819
|
-
const {
|
|
4107
|
+
const { locale } = config
|
|
4108
|
+
const t = textOf(locale)
|
|
4109
|
+
const { bins, total, colors, valueFormatter, threshold, showTooltip = true, countUnit = t.countUnit } = config
|
|
3820
4110
|
const pos = threshold ? thresholdPosition(bins, threshold.value) : null
|
|
3821
4111
|
const option = {
|
|
3822
4112
|
...chartMotion(false),
|
|
@@ -3836,15 +4126,15 @@ export function buildHistogramOption(config: HistogramOptionConfig): EChartsOpti
|
|
|
3836
4126
|
`<span style="flex:1;opacity:.8">${escapeHtml(name)}</span>` +
|
|
3837
4127
|
`<span style="font-weight:500;font-variant-numeric:tabular-nums;margin-left:12px">${escapeHtml(text)}</span></div>`
|
|
3838
4128
|
return (
|
|
3839
|
-
`<div style="font-weight:500;margin-bottom:4px">${escapeHtml(binLabel(bin, valueFormatter))}</div>` +
|
|
3840
|
-
line(
|
|
3841
|
-
line(
|
|
4129
|
+
`<div style="font-weight:500;margin-bottom:4px">${escapeHtml(binLabel(bin, valueFormatter, locale))}</div>` +
|
|
4130
|
+
line(t.countHeader(countUnit), t.count(bin.count, countUnit)) +
|
|
4131
|
+
line(t.share, percent(bin.count, total))
|
|
3842
4132
|
)
|
|
3843
4133
|
},
|
|
3844
4134
|
},
|
|
3845
4135
|
xAxis: {
|
|
3846
4136
|
type: "category" as const,
|
|
3847
|
-
data: bins.map((b) => binLabel(b, valueFormatter)),
|
|
4137
|
+
data: bins.map((b) => binLabel(b, valueFormatter, locale)),
|
|
3848
4138
|
axisTick: { show: false },
|
|
3849
4139
|
axisLabel: { hideOverlap: true },
|
|
3850
4140
|
},
|
|
@@ -3879,7 +4169,7 @@ export function buildHistogramOption(config: HistogramOptionConfig): EChartsOpti
|
|
|
3879
4169
|
// [階級の添字, 0(y の位置は使わない), 階級の中での位置(左端 -0.5 〜 右端 0.5)]
|
|
3880
4170
|
data: [thresholdPoint(bins, threshold.value)],
|
|
3881
4171
|
renderItem: thresholdRenderer({
|
|
3882
|
-
text: `${threshold.label ??
|
|
4172
|
+
text: `${threshold.label ?? t.threshold} ${valueFormatter(threshold.value)}`,
|
|
3883
4173
|
lineColor: config.lineColor,
|
|
3884
4174
|
labelColor: config.labelColor,
|
|
3885
4175
|
}),
|
|
@@ -3902,7 +4192,7 @@ export const HistogramChart = forwardRef(function HistogramChart(
|
|
|
3902
4192
|
start,
|
|
3903
4193
|
max,
|
|
3904
4194
|
threshold,
|
|
3905
|
-
countUnit
|
|
4195
|
+
countUnit: countUnitProp,
|
|
3906
4196
|
valueLabel,
|
|
3907
4197
|
format,
|
|
3908
4198
|
valueFormatter: valueFormatterProp,
|
|
@@ -3922,10 +4212,13 @@ export const HistogramChart = forwardRef(function HistogramChart(
|
|
|
3922
4212
|
ref: ForwardedRef<HTMLDivElement>,
|
|
3923
4213
|
) {
|
|
3924
4214
|
const { ref: frameRef, tokens } = useChartSurface(ref)
|
|
4215
|
+
const locale = useLocale()
|
|
4216
|
+
const t = TEXT[uiLanguage(locale)]
|
|
4217
|
+
const countUnit = countUnitProp ?? t.countUnit
|
|
3925
4218
|
// Class names read in full (`10〜15日`); the format default is a plain number, not the compact form.
|
|
3926
4219
|
const valueFormatter = useMemo(
|
|
3927
|
-
() => (valueFormatterProp ?? (format === undefined ? toValueFormatter("number") : resolveValueFormatter(undefined, format))),
|
|
3928
|
-
[valueFormatterProp, format],
|
|
4220
|
+
() => (valueFormatterProp ?? (format === undefined ? toValueFormatter("number", locale) : resolveValueFormatter(undefined, format, locale))),
|
|
4221
|
+
[valueFormatterProp, format, locale],
|
|
3929
4222
|
)
|
|
3930
4223
|
const bins = useMemo(() => histogramBins(values, { binWidth, start, max }), [values, binWidth, start, max])
|
|
3931
4224
|
const total = useMemo(() => bins.reduce((sum, b) => sum + b.count, 0), [bins])
|
|
@@ -3946,10 +4239,14 @@ export const HistogramChart = forwardRef(function HistogramChart(
|
|
|
3946
4239
|
labelColor: tokens?.foreground ?? undefined,
|
|
3947
4240
|
lineColor: tokens?.mutedForeground ?? undefined,
|
|
3948
4241
|
chartOptions,
|
|
4242
|
+
locale,
|
|
3949
4243
|
}),
|
|
3950
|
-
[bins, total, colors, valueFormatter, countUnit, threshold, showTooltip, tokens, chartOptions],
|
|
4244
|
+
[bins, total, colors, valueFormatter, countUnit, threshold, showTooltip, tokens, chartOptions, locale],
|
|
4245
|
+
)
|
|
4246
|
+
const dataTable = useMemo(
|
|
4247
|
+
() => histogramDataTable(bins, valueFormatter, { total, countUnit, valueLabel, locale }),
|
|
4248
|
+
[bins, valueFormatter, total, countUnit, valueLabel, locale],
|
|
3951
4249
|
)
|
|
3952
|
-
const dataTable = useMemo(() => histogramDataTable(bins, valueFormatter, { total, countUnit, valueLabel }), [bins, valueFormatter, total, countUnit, valueLabel])
|
|
3953
4250
|
const resolvedSummary = useMemo(
|
|
3954
4251
|
() =>
|
|
3955
4252
|
summary === "auto"
|
|
@@ -3958,11 +4255,12 @@ export const HistogramChart = forwardRef(function HistogramChart(
|
|
|
3958
4255
|
countUnit,
|
|
3959
4256
|
threshold,
|
|
3960
4257
|
atOrAbove: threshold ? countAtOrAbove(values, threshold.value, bins[0]?.from) : undefined,
|
|
4258
|
+
locale,
|
|
3961
4259
|
})
|
|
3962
4260
|
: summary,
|
|
3963
|
-
[summary, bins, valueFormatter, total, countUnit, threshold, values],
|
|
4261
|
+
[summary, bins, valueFormatter, total, countUnit, threshold, values, locale],
|
|
3964
4262
|
)
|
|
3965
|
-
const label = ariaLabel ??
|
|
4263
|
+
const label = ariaLabel ?? t.chart(valueLabel ?? t.valueInSentence)
|
|
3966
4264
|
return (
|
|
3967
4265
|
<ChartFrame
|
|
3968
4266
|
{...chartRootProps(rest)}
|