@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
|
@@ -32,13 +32,146 @@ import { cn } from "@squadbase/vantage/ui"
|
|
|
32
32
|
import { ChartFrame } from "@squadbase/vantage/components"
|
|
33
33
|
import { formatPoints, type ValueFormat } from "@squadbase/vantage/components"
|
|
34
34
|
import type { ChartColorToken, ChartCommonProps } from "@squadbase/vantage/components"
|
|
35
|
-
import {
|
|
35
|
+
import { useSyncExternalStore } from "react"
|
|
36
|
+
import { EMPTY_VALUE, formatValue, toValueFormatter } from "@squadbase/vantage/components"
|
|
36
37
|
import type { ChartColor, ChartColors, ChartSeriesColor, ChartTone, ValueFormatter } from "@squadbase/vantage/components"
|
|
37
38
|
import { useMemo, useRef, type ReactNode } from "react"
|
|
38
39
|
import type { EChartsOption } from "@squadbase/vantage/components"
|
|
39
40
|
import { computeComparison, defaultChangeDisplay, formatChange, toneForValue, type ComparisonKind, type PeriodGrain } from "@squadbase/vantage/components"
|
|
41
|
+
import { DEFAULT_LOCALE } from "@squadbase/vantage/components"
|
|
40
42
|
import type { ChartThreshold, ChartAnnotation, ChartEventPayload, ChartForecast, ChartForecastRow, ChartHighlight, NumericKeys } from "@squadbase/vantage/components"
|
|
41
43
|
|
|
44
|
+
/* ---------- inlined from @squadbase/squadkit registry/lib/locale.ts (internal to @squadbase/vantage) ---------- */
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The display language of the parts and the defaults that follow it.
|
|
48
|
+
*
|
|
49
|
+
* The language comes from `<html lang>`: a tag starting with `ja` reads Japanese, anything else English.
|
|
50
|
+
* Without a usable `lang` (missing, invalid, or no `document` — SSR, Node) the locale is `FALLBACK_LOCALE`
|
|
51
|
+
* (`en-US`). A `locale` passed explicitly (a prop, an argument) always wins.
|
|
52
|
+
*
|
|
53
|
+
* Parts read the locale through `useLocale` (`hooks/use-locale`) and pass it down; the pure helpers here
|
|
54
|
+
* and in `format` / `comparison` / `chart-*` take it as an argument and fall back to `documentLocale()`.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/** The language of the built-in wording: Japanese or English. */
|
|
58
|
+
type UiLanguage = "ja" | "en"
|
|
59
|
+
|
|
60
|
+
/** The locale used when `<html lang>` gives none (and on the server). */
|
|
61
|
+
const FALLBACK_LOCALE = "en-US"
|
|
62
|
+
|
|
63
|
+
/** `"ja"` when `locale` starts with `ja` (`ja`, `ja-JP`), else `"en"`. A missing locale is `"en"`. */
|
|
64
|
+
function uiLanguage(locale: string | null | undefined): UiLanguage {
|
|
65
|
+
return typeof locale === "string" && locale.trim().toLowerCase().startsWith("ja") ? "ja" : "en"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A BCP 47 tag in its canonical form (`en-gb` → `en-GB`), or `FALLBACK_LOCALE` when it is empty or invalid. */
|
|
69
|
+
function normalizeLocale(tag: string | null | undefined): string {
|
|
70
|
+
const value = tag?.trim()
|
|
71
|
+
if (!value) return FALLBACK_LOCALE
|
|
72
|
+
try {
|
|
73
|
+
return Intl.getCanonicalLocales(value)[0] ?? FALLBACK_LOCALE
|
|
74
|
+
} catch {
|
|
75
|
+
return FALLBACK_LOCALE
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The locale of the page: `document.documentElement.lang`, canonicalized. `FALLBACK_LOCALE` when it is empty or
|
|
81
|
+
* invalid, or there is no `document`. Parts read it through `useLocale`, never while rendering.
|
|
82
|
+
*/
|
|
83
|
+
function documentLocale(): string {
|
|
84
|
+
if (typeof document === "undefined") return FALLBACK_LOCALE
|
|
85
|
+
return normalizeLocale(document.documentElement?.getAttribute("lang"))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `locale` when given, else the page's locale (`documentLocale()`). */
|
|
89
|
+
function resolveLocale(locale?: string | null): string {
|
|
90
|
+
return locale || documentLocale()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Picks the wording table of the locale's language. */
|
|
94
|
+
function pickLocale<T>(table: { ja: T; en: T }, locale?: string | null): T {
|
|
95
|
+
return table[uiLanguage(resolveLocale(locale))]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The currency `format="currency"` uses without a `currency`: JPY for Japanese, USD otherwise. */
|
|
99
|
+
function defaultCurrency(locale?: string | null): string {
|
|
100
|
+
return uiLanguage(resolveLocale(locale)) === "ja" ? "JPY" : "USD"
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The month a fiscal year starts in (1–12) when none is given: April for Japanese, January otherwise. */
|
|
104
|
+
function defaultFiscalYearStartMonth(locale?: string | null): number {
|
|
105
|
+
return uiLanguage(resolveLocale(locale)) === "ja" ? 4 : 1
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A day of the week, 0 = Sunday … 6 = Saturday (the `Date#getDay` / date-fns numbering). */
|
|
109
|
+
type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6
|
|
110
|
+
|
|
111
|
+
/** Locales whose week starts on Sunday when the runtime has no week data. */
|
|
112
|
+
const SUNDAY_FIRST = new Set(["en", "en-US", "en-CA"])
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The first day of the week: Monday for Japanese; otherwise the locale's convention from `Intl.Locale`
|
|
116
|
+
* (`en-US` Sunday, `en-GB` Monday). Without week data in the runtime: Sunday for `en` / `en-US` / `en-CA`,
|
|
117
|
+
* Monday for the rest.
|
|
118
|
+
*/
|
|
119
|
+
function weekStartsOn(locale?: string | null): WeekDay {
|
|
120
|
+
const tag = normalizeLocale(resolveLocale(locale))
|
|
121
|
+
if (uiLanguage(tag) === "ja") return 1
|
|
122
|
+
try {
|
|
123
|
+
const info = new Intl.Locale(tag) as Intl.Locale & {
|
|
124
|
+
getWeekInfo?: () => { firstDay: number }
|
|
125
|
+
weekInfo?: { firstDay: number }
|
|
126
|
+
}
|
|
127
|
+
const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay
|
|
128
|
+
if (typeof firstDay === "number" && firstDay >= 1 && firstDay <= 7) return (firstDay % 7) as WeekDay
|
|
129
|
+
} catch {
|
|
130
|
+
// Fall through to the table below.
|
|
131
|
+
}
|
|
132
|
+
return SUNDAY_FIRST.has(tag) ? 0 : 1
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* ---------- inlined from @squadbase/squadkit registry/hooks/use-locale.ts (internal to @squadbase/vantage) ---------- */
|
|
136
|
+
|
|
137
|
+
const listeners = new Set<() => void>()
|
|
138
|
+
let observer: MutationObserver | null = null
|
|
139
|
+
|
|
140
|
+
/** One observer on `<html lang>` for every subscribed part. */
|
|
141
|
+
function subscribe(onChange: () => void): () => void {
|
|
142
|
+
if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {}
|
|
143
|
+
listeners.add(onChange)
|
|
144
|
+
if (!observer) {
|
|
145
|
+
observer = new MutationObserver(() => {
|
|
146
|
+
for (const listener of [...listeners]) listener()
|
|
147
|
+
})
|
|
148
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] })
|
|
149
|
+
}
|
|
150
|
+
return () => {
|
|
151
|
+
listeners.delete(onChange)
|
|
152
|
+
if (listeners.size === 0 && observer) {
|
|
153
|
+
observer.disconnect()
|
|
154
|
+
observer = null
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const serverLocale = () => FALLBACK_LOCALE
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The page's locale (`<html lang>`), following changes made while the page runs. On the server and during
|
|
163
|
+
* hydration it is `FALLBACK_LOCALE` (`en-US`), so the markup matches; it switches right after.
|
|
164
|
+
*/
|
|
165
|
+
function useDocumentLocale(): string {
|
|
166
|
+
return useSyncExternalStore(subscribe, documentLocale, serverLocale)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The locale a part renders in: its `locale` prop when given, else the page's (`useDocumentLocale`). */
|
|
170
|
+
function useLocale(locale?: string | null): string {
|
|
171
|
+
const page = useDocumentLocale()
|
|
172
|
+
return locale || page
|
|
173
|
+
}
|
|
174
|
+
|
|
42
175
|
/* ---------- inlined from @squadbase/squadkit registry/lib/chart-utils.ts (internal to @squadbase/vantage) ---------- */
|
|
43
176
|
|
|
44
177
|
const ROOT_PROP_KEYS = new Set(["id", "style", "role", "tabIndex", "lang", "hidden"])
|
|
@@ -146,9 +279,10 @@ function resolveValueFormatter(
|
|
|
146
279
|
function resolveAxisFormatter(
|
|
147
280
|
valueFormatter: ValueFormatter | undefined,
|
|
148
281
|
format: ValueFormat | undefined,
|
|
149
|
-
|
|
282
|
+
localeArg?: string,
|
|
150
283
|
): ValueFormatter {
|
|
151
284
|
if (valueFormatter) return valueFormatter
|
|
285
|
+
const locale = resolveLocale(localeArg)
|
|
152
286
|
const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
|
|
153
287
|
if (spec.type !== "compact" && spec.type !== "number" && spec.type !== "currency") return toValueFormatter(format, locale)
|
|
154
288
|
const currency = "currency" in spec ? spec.currency : undefined
|
|
@@ -157,40 +291,75 @@ function resolveAxisFormatter(
|
|
|
157
291
|
return (value) => formatValue(value, short, locale)
|
|
158
292
|
}
|
|
159
293
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
]
|
|
294
|
+
interface CompactUnit {
|
|
295
|
+
token: string
|
|
296
|
+
size: number
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const compactUnitCache = new Map<string, CompactUnit[] | null>()
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The compact-notation unit words of a locale and their sizes, largest first: `兆` / `億` / `万` in ja,
|
|
303
|
+
* `T` / `B` / `M` / `K` in en. `null` when the locale's compact form puts anything between the number and its
|
|
304
|
+
* word (`1,5 Mio.`), where a token cannot be swapped in place.
|
|
305
|
+
*/
|
|
306
|
+
function compactUnits(locale: string): CompactUnit[] | null {
|
|
307
|
+
const cached = compactUnitCache.get(locale)
|
|
308
|
+
if (cached !== undefined) return cached
|
|
309
|
+
let units: CompactUnit[] | null = []
|
|
310
|
+
try {
|
|
311
|
+
const compact = new Intl.NumberFormat(locale, { notation: "compact" })
|
|
312
|
+
const seen = new Set<string>()
|
|
313
|
+
for (let exp = 3; exp <= 15; exp++) {
|
|
314
|
+
const parts = compact.formatToParts(10 ** exp)
|
|
315
|
+
const at = parts.findIndex((p) => p.type === "compact")
|
|
316
|
+
if (at === -1) continue
|
|
317
|
+
if (parts[at - 1]?.type !== "integer" || at !== parts.length - 1) {
|
|
318
|
+
units = null
|
|
319
|
+
break
|
|
320
|
+
}
|
|
321
|
+
const token = parts[at]!.value
|
|
322
|
+
if (seen.has(token)) continue
|
|
323
|
+
seen.add(token)
|
|
324
|
+
units.push({ token, size: 10 ** exp })
|
|
325
|
+
}
|
|
326
|
+
} catch {
|
|
327
|
+
units = null
|
|
328
|
+
}
|
|
329
|
+
const sorted = units && units.length > 0 ? units.sort((a, b) => b.size - a.size) : null
|
|
330
|
+
compactUnitCache.set(locale, sorted)
|
|
331
|
+
return sorted
|
|
332
|
+
}
|
|
166
333
|
|
|
167
|
-
const
|
|
334
|
+
const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
168
335
|
|
|
169
336
|
/**
|
|
170
337
|
* The value-axis tick formatter for an axis whose largest tick is `top`: every tick reads in the unit of
|
|
171
|
-
* that largest tick, so
|
|
172
|
-
* Only the
|
|
173
|
-
* any other format —
|
|
338
|
+
* that largest tick, so units never mix on one axis (`¥0.5億` / `¥1億` / `¥1.5億`, not `¥5,000万` / `¥1億`;
|
|
339
|
+
* `$0.5M` / `$1M` / `$1.5M`, not `$500K` / `$1M`). Only the short forms (`compact` / `number` / `currency`,
|
|
340
|
+
* no `valueFormatter`) have units to align; any other format — an axis whose largest tick is under the smallest
|
|
341
|
+
* unit (1万 / 1K), or a locale whose compact form cannot be aligned in place — keeps `resolveAxisFormatter`'s output as it is.
|
|
174
342
|
*/
|
|
175
343
|
function resolveAxisTickFormatter(
|
|
176
344
|
valueFormatter: ValueFormatter | undefined,
|
|
177
345
|
format: ValueFormat | undefined,
|
|
178
|
-
|
|
346
|
+
localeArg?: string,
|
|
179
347
|
): (top: number) => ValueFormatter {
|
|
348
|
+
const locale = resolveLocale(localeArg)
|
|
180
349
|
const base = resolveAxisFormatter(valueFormatter, format, locale)
|
|
181
350
|
const spec = format === undefined ? { type: "compact" as const } : typeof format === "string" ? { type: format } : format
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
if (!aligns) return () => base
|
|
351
|
+
const units = !valueFormatter && (spec.type === "compact" || spec.type === "number" || spec.type === "currency") ? compactUnits(locale) : null
|
|
352
|
+
if (!units) return () => base
|
|
185
353
|
const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 3 })
|
|
354
|
+
const numberToken = new RegExp(`\\d[\\d,]*(?:\\.\\d+)?(?:${units.map((u) => escapeRegExp(u.token)).join("|")})?`)
|
|
186
355
|
return (top) => {
|
|
187
|
-
const unit =
|
|
356
|
+
const unit = units.find((u) => Math.abs(top) >= u.size)
|
|
188
357
|
if (!unit) return base
|
|
189
358
|
return (value) => {
|
|
190
359
|
const text = base(value)
|
|
191
360
|
if (value === 0 || !Number.isFinite(value)) return text
|
|
192
|
-
const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.
|
|
193
|
-
return text.replace(
|
|
361
|
+
const scaled = `${number.format(Math.abs(value) / unit.size)}${unit.token}`
|
|
362
|
+
return text.replace(numberToken, () => scaled)
|
|
194
363
|
}
|
|
195
364
|
}
|
|
196
365
|
}
|
|
@@ -219,8 +388,20 @@ function clamp(value: number, min: number, max: number): number {
|
|
|
219
388
|
/** How many categories a chart colours individually; the rest fold into「その他」. */
|
|
220
389
|
const MAX_CHART_SERIES = 5
|
|
221
390
|
|
|
222
|
-
/** The series name the folded categories get. */
|
|
223
|
-
const
|
|
391
|
+
/** The series name the folded categories get, by language. */
|
|
392
|
+
const OTHER_SERIES_LABELS: Record<UiLanguage, string> = { ja: "その他", en: "Other" }
|
|
393
|
+
|
|
394
|
+
/** The series name the folded categories get in `locale` ("Other" / "その他"). Default the page's locale (`<html lang>`). */
|
|
395
|
+
function otherSeriesLabel(locale?: string): string {
|
|
396
|
+
return OTHER_SERIES_LABELS[uiLanguage(resolveLocale(locale))]
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const OTHER_NAMES = new Set(Object.values(OTHER_SERIES_LABELS))
|
|
400
|
+
|
|
401
|
+
/** Whether a category / row is the "other" bucket by name (「その他」 or "Other", whatever the page language). */
|
|
402
|
+
function isOtherName(name: string | null | undefined): boolean {
|
|
403
|
+
return typeof name === "string" && OTHER_NAMES.has(name)
|
|
404
|
+
}
|
|
224
405
|
|
|
225
406
|
interface AggregatedItems<Item extends { name: string; value: number }> {
|
|
226
407
|
/** The `n` largest items, in their given order. All items when there are `n` or fewer. */
|
|
@@ -240,7 +421,7 @@ interface AggregatedItems<Item extends { name: string; value: number }> {
|
|
|
240
421
|
function aggregateTopN<Item extends { name: string; value: number }>(
|
|
241
422
|
items: readonly Item[],
|
|
242
423
|
n: number = MAX_CHART_SERIES,
|
|
243
|
-
otherLabel: string =
|
|
424
|
+
otherLabel: string = otherSeriesLabel(),
|
|
244
425
|
): AggregatedItems<Item> {
|
|
245
426
|
if (items.length <= n) return { top: [...items], rest: [], other: null }
|
|
246
427
|
const ranked = items
|
|
@@ -269,7 +450,7 @@ function aggregateSeries(
|
|
|
269
450
|
data: readonly Record<string, unknown>[],
|
|
270
451
|
categories: readonly string[],
|
|
271
452
|
n: number = MAX_CHART_SERIES,
|
|
272
|
-
otherLabel: string =
|
|
453
|
+
otherLabel: string = otherSeriesLabel(),
|
|
273
454
|
): AggregatedSeries {
|
|
274
455
|
const totals = categories.map((name) => ({
|
|
275
456
|
name,
|
|
@@ -301,12 +482,14 @@ function aggregateSeriesKeeping(
|
|
|
301
482
|
categories: readonly string[],
|
|
302
483
|
keep: string | undefined,
|
|
303
484
|
n: number = MAX_CHART_SERIES,
|
|
485
|
+
otherLabel: string = otherSeriesLabel(),
|
|
304
486
|
): AggregatedSeries {
|
|
305
|
-
if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n)
|
|
487
|
+
if (keep === undefined || !categories.includes(keep)) return aggregateSeries(data, categories, n, otherLabel)
|
|
306
488
|
const aggregated = aggregateSeries(
|
|
307
489
|
data,
|
|
308
490
|
categories.filter((c) => c !== keep),
|
|
309
491
|
n,
|
|
492
|
+
otherLabel,
|
|
310
493
|
)
|
|
311
494
|
return { ...aggregated, categories: [...aggregated.categories, keep] }
|
|
312
495
|
}
|
|
@@ -342,7 +525,7 @@ function seriesColorsWithComparison(
|
|
|
342
525
|
* Colour per drawn series: the record's token for that category, else the
|
|
343
526
|
* array's token at the category's position in `allCategories` (cycled), else
|
|
344
527
|
* the token palette by drawn position. `otherCategory` — and a category the
|
|
345
|
-
* data itself names
|
|
528
|
+
* data itself names「その他」/ "Other" (`OTHER_SERIES_LABELS`) — always gets `otherColor`.
|
|
346
529
|
*/
|
|
347
530
|
function seriesColorsFor(
|
|
348
531
|
categories: readonly string[],
|
|
@@ -354,7 +537,7 @@ function seriesColorsFor(
|
|
|
354
537
|
const list = Array.isArray(colors) ? (colors as ChartSeriesColor[]) : undefined
|
|
355
538
|
const record = !Array.isArray(colors) && colors ? (colors as Partial<Record<string, ChartSeriesColor>>) : undefined
|
|
356
539
|
return categories.map((category, i) => {
|
|
357
|
-
if ((otherCategory !== null && category === otherCategory) || category
|
|
540
|
+
if ((otherCategory !== null && category === otherCategory) || isOtherName(category)) return otherColor
|
|
358
541
|
const fromRecord = record?.[category]
|
|
359
542
|
if (fromRecord) return fromRecord
|
|
360
543
|
if (list && list.length > 0) {
|
|
@@ -401,7 +584,7 @@ interface DonutSlicesConfig {
|
|
|
401
584
|
*/
|
|
402
585
|
function donutSlices(
|
|
403
586
|
data: readonly Record<string, unknown>[],
|
|
404
|
-
{ index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel =
|
|
587
|
+
{ index, value, colors, maxSlices = MAX_CHART_SERIES, otherColor, otherLabel = otherSeriesLabel() }: DonutSlicesConfig,
|
|
405
588
|
): DonutSlice[] {
|
|
406
589
|
const items = data
|
|
407
590
|
.map((row, i) => {
|
|
@@ -414,7 +597,8 @@ function donutSlices(
|
|
|
414
597
|
const slices: DonutSlice[] = top.map((item, rank) => ({
|
|
415
598
|
name: item.name,
|
|
416
599
|
value: item.value,
|
|
417
|
-
|
|
600
|
+
// A row the data itself names「その他」/ "Other" is the same remainder: grey, like the folded slice.
|
|
601
|
+
color: isOtherName(item.name) ? otherColor : palette[rank % palette.length]!,
|
|
418
602
|
isOther: false,
|
|
419
603
|
row: item.row,
|
|
420
604
|
}))
|
|
@@ -487,7 +671,7 @@ interface BarListRowsConfig extends BarListOptions {
|
|
|
487
671
|
*/
|
|
488
672
|
function barListRows<Row extends object>(
|
|
489
673
|
data: readonly Row[],
|
|
490
|
-
{ index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel =
|
|
674
|
+
{ index = "name", category = "value", sortOrder = "descending", maxItems = MAX_CHART_SERIES, otherLabel = otherSeriesLabel(), exclude }: BarListRowsConfig = {},
|
|
491
675
|
): BarListRow<Row>[] {
|
|
492
676
|
const items = data.map((row): BarListRow<Row> => {
|
|
493
677
|
const cells = row as Record<string, unknown>
|
|
@@ -533,19 +717,21 @@ const BAR_LIST_OTHER_TINT = 30
|
|
|
533
717
|
|
|
534
718
|
/**
|
|
535
719
|
* The fill of each BarList row: all real rows in the same tint of `color`
|
|
536
|
-
* (`BAR_LIST_TINT`), the folded「その他」row in a tint of `otherColor`
|
|
720
|
+
* (`BAR_LIST_TINT`), the folded「その他」row (and a row the data names「その他」/ "Other") in a tint of `otherColor`
|
|
537
721
|
* (`BAR_LIST_OTHER_TINT`) — the palest fill, a remainder never outweighs a
|
|
538
722
|
* ranked row. A legacy per-row `color` (or a threshold tone) replaces `color`
|
|
539
723
|
* for that row. A tint, not the solid token, so the label printed over the bar
|
|
540
724
|
* keeps its contrast.
|
|
541
725
|
*/
|
|
542
726
|
function barListFills(
|
|
543
|
-
rows: readonly Pick<BarListRow, "isOther" | "color">[],
|
|
727
|
+
rows: readonly (Pick<BarListRow, "isOther" | "color"> & { name?: string })[],
|
|
544
728
|
color: ChartColor,
|
|
545
729
|
otherColor: string,
|
|
546
730
|
): string[] {
|
|
547
731
|
return rows.map((row) =>
|
|
548
|
-
row.isOther
|
|
732
|
+
row.isOther || isOtherName(row.name)
|
|
733
|
+
? tintCss(otherColor, BAR_LIST_OTHER_TINT)
|
|
734
|
+
: tintCss(chartColorToCss(row.color ?? color), BAR_LIST_TINT),
|
|
549
735
|
)
|
|
550
736
|
}
|
|
551
737
|
|
|
@@ -761,11 +947,30 @@ interface ChartSummaryInput {
|
|
|
761
947
|
format?: ValueFormat
|
|
762
948
|
/** Wins over `format`. */
|
|
763
949
|
valueFormatter?: ValueFormatter
|
|
950
|
+
/** Language of the sentence. Default the page's locale (`<html lang>`). */
|
|
951
|
+
locale?: string
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const SUMMARY_TEXT = {
|
|
955
|
+
ja: {
|
|
956
|
+
up: "、前の点より増加",
|
|
957
|
+
down: "、前の点より減少",
|
|
958
|
+
flat: "、前の点と横ばい",
|
|
959
|
+
latest: (label: string, value: string) => `最新 ${label} は ${value}`,
|
|
960
|
+
top: (name: string, share: string) => `最大は ${name}(${share})`,
|
|
961
|
+
},
|
|
962
|
+
en: {
|
|
963
|
+
up: ", up from the previous point",
|
|
964
|
+
down: ", down from the previous point",
|
|
965
|
+
flat: ", flat from the previous point",
|
|
966
|
+
latest: (label: string, value: string) => `Latest ${label}: ${value}`,
|
|
967
|
+
top: (name: string, share: string) => `Largest: ${name} (${share})`,
|
|
968
|
+
},
|
|
764
969
|
}
|
|
765
970
|
|
|
766
971
|
/**
|
|
767
972
|
* One-sentence summary built from the rows, for `summary="auto"`:
|
|
768
|
-
* 「<title>: 最新 <index> は
|
|
973
|
+
* 「<title>: 最新 <index> は <値>、前の点より増加/減少/横ばい」("<title>: Latest <index>: <value>, up from the previous point"). The direction is
|
|
769
974
|
* computed from the last two non-empty points of the first series — never
|
|
770
975
|
* written by hand, so it cannot contradict the data. `undefined` when there is
|
|
771
976
|
* no usable point.
|
|
@@ -781,30 +986,59 @@ function buildChartSummary(input: ChartSummaryInput): string | undefined {
|
|
|
781
986
|
}
|
|
782
987
|
const latest = points[points.length - 1]
|
|
783
988
|
if (!latest) return undefined
|
|
784
|
-
const
|
|
989
|
+
const locale = resolveLocale(input.locale)
|
|
990
|
+
const t = SUMMARY_TEXT[uiLanguage(locale)]
|
|
991
|
+
const text = input.valueFormatter ?? toValueFormatter(input.format ?? "compact", locale)
|
|
785
992
|
const prev = points[points.length - 2]
|
|
786
|
-
const direction = !prev ? "" : latest.value > prev.value ?
|
|
993
|
+
const direction = !prev ? "" : latest.value > prev.value ? t.up : latest.value < prev.value ? t.down : t.flat
|
|
787
994
|
const head = input.title ? `${input.title}: ` : ""
|
|
788
|
-
return `${head}
|
|
995
|
+
return `${head}${t.latest(latest.label, text(latest.value))}${direction}`
|
|
789
996
|
}
|
|
790
997
|
|
|
791
998
|
/**
|
|
792
|
-
* `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」 from the slices
|
|
793
|
-
* (largest first). `undefined` when there is nothing to describe.
|
|
999
|
+
* `summary="auto"` for a donut: 「<title>: 最大は <名前>(<構成比>)」("<title>: Largest: <name> (<share>)") from the slices
|
|
1000
|
+
* (largest first). `undefined` when there is nothing to describe. `locale` picks the language; default the page's.
|
|
794
1001
|
*/
|
|
795
1002
|
function buildShareSummary(
|
|
796
1003
|
slices: readonly { name: string; value: number }[],
|
|
797
1004
|
title?: string,
|
|
1005
|
+
locale?: string,
|
|
798
1006
|
): string | undefined {
|
|
799
1007
|
const total = slices.reduce((sum, s) => sum + s.value, 0)
|
|
800
1008
|
const top = slices[0]
|
|
801
1009
|
if (!top || total <= 0) return undefined
|
|
802
1010
|
const share = `${Math.round((top.value / total) * 1000) / 10}%`
|
|
803
|
-
return `${title ? `${title}: ` : ""}
|
|
1011
|
+
return `${title ? `${title}: ` : ""}${SUMMARY_TEXT[uiLanguage(resolveLocale(locale))].top(top.name, share)}`
|
|
804
1012
|
}
|
|
805
1013
|
|
|
806
1014
|
/* ---------- inlined from @squadbase/squadkit registry/lib/chart-options.ts (internal to @squadbase/vantage) ---------- */
|
|
807
1015
|
|
|
1016
|
+
/** The built-in words of the option builders, by language. */
|
|
1017
|
+
const CHART_TEXT = {
|
|
1018
|
+
ja: {
|
|
1019
|
+
target: "目標",
|
|
1020
|
+
forecast: "予測",
|
|
1021
|
+
total: "合計",
|
|
1022
|
+
provisional: "速報",
|
|
1023
|
+
noComparison: "比較なし",
|
|
1024
|
+
share: (share: string) => `(${share})`,
|
|
1025
|
+
targetRate: (rate: string) => `(達成率 ${rate})`,
|
|
1026
|
+
provisionalTitle: (title: string, label: string) => `${title}(${label})`,
|
|
1027
|
+
},
|
|
1028
|
+
en: {
|
|
1029
|
+
target: "Target",
|
|
1030
|
+
forecast: "Forecast",
|
|
1031
|
+
total: "Total",
|
|
1032
|
+
provisional: "Preliminary",
|
|
1033
|
+
noComparison: "No comparison",
|
|
1034
|
+
share: (share: string) => ` (${share})`,
|
|
1035
|
+
targetRate: (rate: string) => ` (${rate} achieved)`,
|
|
1036
|
+
provisionalTitle: (title: string, label: string) => `${title} (${label})`,
|
|
1037
|
+
},
|
|
1038
|
+
} satisfies Record<UiLanguage, unknown>
|
|
1039
|
+
|
|
1040
|
+
const chartText = (locale?: string) => CHART_TEXT[uiLanguage(resolveLocale(locale))]
|
|
1041
|
+
|
|
808
1042
|
type CartesianChartKind = "area" | "bar" | "line"
|
|
809
1043
|
type CartesianStackType = "default" | "stacked" | "percent"
|
|
810
1044
|
type CartesianCurveType = "linear" | "smooth" | "step"
|
|
@@ -989,7 +1223,7 @@ interface CartesianChartConfig {
|
|
|
989
1223
|
* all ticks read in one unit (¥0.5億 / ¥1億). Default: `axisFormatter` for every tick.
|
|
990
1224
|
*/
|
|
991
1225
|
axisTicks?: (top: number) => (value: number) => string
|
|
992
|
-
/** Locale of the time-axis labels. Default
|
|
1226
|
+
/** Locale of the time-axis labels and the built-in words (total, target, forecast). Default the page's (`<html lang>`). */
|
|
993
1227
|
locale?: string
|
|
994
1228
|
type?: CartesianStackType
|
|
995
1229
|
/** Bar only. `"vertical"` lays the categories down the y axis (horizontal bars). */
|
|
@@ -1227,11 +1461,12 @@ function timeAxisLabelStep(values: readonly unknown[], maxLabels: number = TIME_
|
|
|
1227
1461
|
*/
|
|
1228
1462
|
function timeAxisLabels(
|
|
1229
1463
|
values: readonly unknown[],
|
|
1230
|
-
|
|
1464
|
+
localeArg?: string,
|
|
1231
1465
|
shown: (i: number) => boolean = () => true,
|
|
1232
1466
|
): string[] | null {
|
|
1233
1467
|
const parsed = parseTimeIndex(values)
|
|
1234
1468
|
if (!parsed) return null
|
|
1469
|
+
const locale = resolveLocale(localeArg)
|
|
1235
1470
|
const { granularity, times } = parsed
|
|
1236
1471
|
let prevYear: number | null = null
|
|
1237
1472
|
return times.map((t, i) => {
|
|
@@ -1254,9 +1489,10 @@ function timeAxisLabels(
|
|
|
1254
1489
|
}
|
|
1255
1490
|
|
|
1256
1491
|
/** The tooltip titles for time-like x values (`2025年9月`, `2025年9月1日`), or `null` when they are not dates. */
|
|
1257
|
-
function timeTooltipLabels(values: readonly unknown[],
|
|
1492
|
+
function timeTooltipLabels(values: readonly unknown[], localeArg?: string): string[] | null {
|
|
1258
1493
|
const parsed = parseTimeIndex(values)
|
|
1259
1494
|
if (!parsed) return null
|
|
1495
|
+
const locale = resolveLocale(localeArg)
|
|
1260
1496
|
const options: Intl.DateTimeFormatOptions =
|
|
1261
1497
|
parsed.granularity === "year"
|
|
1262
1498
|
? { year: "numeric" }
|
|
@@ -1396,7 +1632,9 @@ function tooltipHtml(
|
|
|
1396
1632
|
rows: readonly TooltipRow[],
|
|
1397
1633
|
valueFormatter: (value: number) => string,
|
|
1398
1634
|
notes: readonly string[] = [],
|
|
1635
|
+
locale?: string,
|
|
1399
1636
|
): string {
|
|
1637
|
+
const t = chartText(locale)
|
|
1400
1638
|
const lines = rows.map(
|
|
1401
1639
|
(row) =>
|
|
1402
1640
|
`<div style="display:flex;align-items:center;gap:8px;line-height:20px">` +
|
|
@@ -1405,7 +1643,7 @@ function tooltipHtml(
|
|
|
1405
1643
|
`<span style="font-weight:600;font-variant-numeric:tabular-nums;margin-left:12px">${
|
|
1406
1644
|
row.value === null ? EMPTY_VALUE : escapeHtml(valueFormatter(row.value))
|
|
1407
1645
|
}${
|
|
1408
|
-
row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px"
|
|
1646
|
+
row.share === undefined ? "" : `<span style="opacity:.6;margin-left:4px">${escapeHtml(t.share(formatShare(row.share)))}</span>`
|
|
1409
1647
|
}</span></div>` +
|
|
1410
1648
|
(row.note === undefined
|
|
1411
1649
|
? ""
|
|
@@ -1419,8 +1657,10 @@ function tooltipHtml(
|
|
|
1419
1657
|
|
|
1420
1658
|
/* ------------------------------------------------- per-bar targets / notes */
|
|
1421
1659
|
|
|
1422
|
-
/** Default name of the per-bar target marker (legend, tooltip). */
|
|
1423
|
-
|
|
1660
|
+
/** Default name of the per-bar target marker (legend, tooltip) in `locale`: "Target" / 「目標」. Default the page's locale. */
|
|
1661
|
+
function barTargetLabel(locale?: string): string {
|
|
1662
|
+
return chartText(locale).target
|
|
1663
|
+
}
|
|
1424
1664
|
|
|
1425
1665
|
/** How far (px) the target marker reaches past each side of its bar. */
|
|
1426
1666
|
const TARGET_MARKER_OVERHANG = 4
|
|
@@ -1471,16 +1711,17 @@ function achievementRate(value: number | null | undefined, target: number | null
|
|
|
1471
1711
|
return (value / target) * 100
|
|
1472
1712
|
}
|
|
1473
1713
|
|
|
1474
|
-
/** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3
|
|
1714
|
+
/** The tooltip line of a bar with a target: 「目標 ¥1,200万(達成率 85.3%)」 / "Target $12M (85.3% achieved)". `null` without a target. */
|
|
1475
1715
|
function barTargetNote(
|
|
1476
1716
|
label: string,
|
|
1477
1717
|
value: number | null,
|
|
1478
1718
|
target: number | null,
|
|
1479
1719
|
valueFormatter: (value: number) => string,
|
|
1720
|
+
locale?: string,
|
|
1480
1721
|
): string | null {
|
|
1481
1722
|
if (target === null) return null
|
|
1482
1723
|
const rate = achievementRate(value, target)
|
|
1483
|
-
return `${label} ${valueFormatter(target)}${rate === null ? "" :
|
|
1724
|
+
return `${label} ${valueFormatter(target)}${rate === null ? "" : chartText(locale).targetRate(formatShare(rate))}`
|
|
1484
1725
|
}
|
|
1485
1726
|
|
|
1486
1727
|
/** Half the width of a bar in category-axis units, from `barCategoryGap` (`"20%"`; a px gap or none → ECharts' 20%). */
|
|
@@ -1572,11 +1813,12 @@ const TARGET_LEGEND_SERIES_ID = "__target-legend"
|
|
|
1572
1813
|
/** A flat tick (8×8 legend box, aspect kept): the per-bar target's legend icon. */
|
|
1573
1814
|
const TARGET_LEGEND_ICON = "path://M0,0 L100,0 L100,24 L0,24 Z"
|
|
1574
1815
|
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1816
|
+
/**
|
|
1817
|
+
* 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.
|
|
1818
|
+
* Without a `label`, "Forecast" / 「予測」 in `locale` (default the page's).
|
|
1819
|
+
*/
|
|
1820
|
+
function forecastBoundaryText(label?: string, locale?: string): string {
|
|
1821
|
+
return `${label ?? chartText(locale).forecast} →`
|
|
1580
1822
|
}
|
|
1581
1823
|
|
|
1582
1824
|
/** A comparison series: a thin, muted, solid line with no markers (60% of `--muted-foreground`). */
|
|
@@ -1616,8 +1858,10 @@ interface AxisExtent {
|
|
|
1616
1858
|
max: number
|
|
1617
1859
|
}
|
|
1618
1860
|
|
|
1619
|
-
/** The label of a stack's total (highlight and tooltip). */
|
|
1620
|
-
|
|
1861
|
+
/** The label of a stack's total (highlight and tooltip) in `locale`: "Total" / 「合計」. Default the page's locale. */
|
|
1862
|
+
function stackTotalLabel(locale?: string): string {
|
|
1863
|
+
return chartText(locale).total
|
|
1864
|
+
}
|
|
1621
1865
|
|
|
1622
1866
|
/** Per point, the sum of the stacked series; `null` where every series is missing. */
|
|
1623
1867
|
function stackTotalsOf(values: readonly (readonly (number | null)[])[]): (number | null)[] {
|
|
@@ -1739,8 +1983,8 @@ function indexGrain(values: readonly unknown[]): PeriodGrain | null {
|
|
|
1739
1983
|
|
|
1740
1984
|
/**
|
|
1741
1985
|
* The tooltip line under the main series' row when a comparison series is drawn: the arrow, the signed change and the
|
|
1742
|
-
* comparison's name, as `Delta` writes it ("↑ +12.4% 前年同月比"; a minus is U+2212).
|
|
1743
|
-
* either value is missing or the baseline is 0.
|
|
1986
|
+
* comparison's name, as `Delta` writes it ("↑ +12.4% vs. same month last year" / "↑ +12.4% 前年同月比"; a minus is U+2212).
|
|
1987
|
+
* "No comparison vs. same month last year" / "比較なし 前年同月比" when either value is missing or the baseline is 0.
|
|
1744
1988
|
*/
|
|
1745
1989
|
function comparisonNote(
|
|
1746
1990
|
current: number | null,
|
|
@@ -1748,16 +1992,18 @@ function comparisonNote(
|
|
|
1748
1992
|
comparison: Pick<CartesianComparisonConfig, "vs" | "label">,
|
|
1749
1993
|
grain?: PeriodGrain,
|
|
1750
1994
|
format?: ValueFormat,
|
|
1751
|
-
|
|
1995
|
+
localeArg?: string,
|
|
1752
1996
|
): string {
|
|
1997
|
+
const locale = resolveLocale(localeArg)
|
|
1753
1998
|
const result = computeComparison(
|
|
1754
1999
|
current,
|
|
1755
2000
|
comparison.vs === "custom"
|
|
1756
2001
|
? { previous, vs: "custom", label: comparison.label ?? "", grain }
|
|
1757
2002
|
: { previous, vs: comparison.vs, label: comparison.label, grain },
|
|
2003
|
+
{ locale },
|
|
1758
2004
|
)
|
|
1759
2005
|
const text = formatChange(result, defaultChangeDisplay(format), locale, format)
|
|
1760
|
-
if (text === null) return
|
|
2006
|
+
if (text === null) return `${chartText(locale).noComparison} ${result.label}`
|
|
1761
2007
|
const arrow = result.direction === "up" ? "↑" : result.direction === "down" ? "↓" : "→"
|
|
1762
2008
|
return `${arrow} ${text.replace(/^-/, "−")} ${result.label}`
|
|
1763
2009
|
}
|
|
@@ -1800,7 +2046,6 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1800
2046
|
toneColors,
|
|
1801
2047
|
valueFormatter,
|
|
1802
2048
|
axisFormatter = valueFormatter,
|
|
1803
|
-
locale = DEFAULT_LOCALE,
|
|
1804
2049
|
type = "default",
|
|
1805
2050
|
layout = "horizontal",
|
|
1806
2051
|
curveType = "linear",
|
|
@@ -1837,7 +2082,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1837
2082
|
chartOptions,
|
|
1838
2083
|
reserveActions = false,
|
|
1839
2084
|
} = cfg
|
|
1840
|
-
const
|
|
2085
|
+
const locale = resolveLocale(cfg.locale)
|
|
2086
|
+
const text = chartText(locale)
|
|
2087
|
+
const provisionalLabel = cfg.provisionalLabel ?? text.provisional
|
|
1841
2088
|
|
|
1842
2089
|
const stacked = kind !== "line" && type !== "default"
|
|
1843
2090
|
const percent = kind !== "line" && type === "percent"
|
|
@@ -1986,9 +2233,9 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
1986
2233
|
// `barOptions.targetKey` (a target per bar) takes over from the single `target` line.
|
|
1987
2234
|
const perBarTarget = kind === "bar" && Boolean(targetKey)
|
|
1988
2235
|
const hasTarget = !perBarTarget && target !== undefined && Number.isFinite(target)
|
|
1989
|
-
const targetText = hasTarget ? (targetLabel ??
|
|
2236
|
+
const targetText = hasTarget ? (targetLabel ?? `${text.target} ${axisFormatter(target)}`) : ""
|
|
1990
2237
|
const highlightText = (value: number) =>
|
|
1991
|
-
shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${
|
|
2238
|
+
shareHighlight ? `${Math.round(value)}%` : `${stackTotals ? `${text.total} ` : ""}${axisFormatter(value)}`
|
|
1992
2239
|
/** Whether `p`'s label sits within the target's value band and under the target label (at the right end). */
|
|
1993
2240
|
const nearTargetLabel = (p: HighlightPoint, side: "under" | "over") => {
|
|
1994
2241
|
if (!hasTarget || !plotWidth || kind === "bar" || labels.length < 2) return false
|
|
@@ -2014,7 +2261,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
2014
2261
|
// One series (or a stack, against its total) can be compared to a target; grouped series and 100% stacks cannot.
|
|
2015
2262
|
const barTargets: (number | null)[] | null =
|
|
2016
2263
|
perBarTarget && targetKey && !percent && (stacked || categories.length === 1) ? barTargetValues(data, targetKey) : null
|
|
2017
|
-
const barTargetName = targetLabel ??
|
|
2264
|
+
const barTargetName = targetLabel ?? text.target
|
|
2018
2265
|
const barTargetColor = labelColor ?? guideColor
|
|
2019
2266
|
|
|
2020
2267
|
const markPointFor = (c: number, color: string | undefined) =>
|
|
@@ -2228,7 +2475,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
2228
2475
|
}
|
|
2229
2476
|
|
|
2230
2477
|
/* -- forecast: dashed continuation (+ optional band) of one series */
|
|
2231
|
-
const forecastLabel = forecast?.label ??
|
|
2478
|
+
const forecastLabel = forecast?.label ?? text.forecast
|
|
2232
2479
|
const forecastBandAlpha = isDarkSurface(labelBackground) ? FORECAST_BAND_ALPHA_DARK : FORECAST_BAND_ALPHA_LIGHT
|
|
2233
2480
|
const forecastValues: (number | null)[] = []
|
|
2234
2481
|
if (forecast) {
|
|
@@ -2704,7 +2951,7 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
2704
2951
|
})
|
|
2705
2952
|
}
|
|
2706
2953
|
if (stackTotals && i <= lastActual && rows.length > 1) {
|
|
2707
|
-
rows.push({ name:
|
|
2954
|
+
rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
|
|
2708
2955
|
}
|
|
2709
2956
|
const provisional = provStart !== -1 && i >= provStart && i <= lastActual
|
|
2710
2957
|
if (provisional) {
|
|
@@ -2720,19 +2967,19 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
2720
2967
|
...noteFor(at),
|
|
2721
2968
|
})
|
|
2722
2969
|
})
|
|
2723
|
-
if (stackTotals && rows.length > 1) rows.push({ name:
|
|
2970
|
+
if (stackTotals && rows.length > 1) rows.push({ name: text.total, color: "transparent", value: stackTotals[i] ?? null })
|
|
2724
2971
|
}
|
|
2725
2972
|
const title = tooltipTitles[i] ?? labels[i] ?? ""
|
|
2726
2973
|
const lines: string[] = []
|
|
2727
2974
|
if (barTargets && i <= lastActual) {
|
|
2728
2975
|
const total = stackTotals ? (stackTotals[i] ?? null) : (values[0]?.[i] ?? null)
|
|
2729
|
-
const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter)
|
|
2976
|
+
const note = barTargetNote(barTargetName, total, barTargets[i] ?? null, valueFormatter, locale)
|
|
2730
2977
|
if (note) lines.push(note)
|
|
2731
2978
|
}
|
|
2732
2979
|
for (const note of notes) {
|
|
2733
2980
|
if (note.to === undefined ? note.from === i : note.from <= i && i <= note.to) lines.push(note.label)
|
|
2734
2981
|
}
|
|
2735
|
-
return tooltipHtml(provisional ?
|
|
2982
|
+
return tooltipHtml(provisional ? text.provisionalTitle(title, provisionalLabel) : title, rows, valueFormatter, lines, locale)
|
|
2736
2983
|
},
|
|
2737
2984
|
},
|
|
2738
2985
|
xAxis: horizontalBars ? valueAxis : categoryAxis,
|
|
@@ -2824,8 +3071,6 @@ interface DonutOptionSlice {
|
|
|
2824
3071
|
/** How many of the largest slices get an outside label (「自然検索 41%」) by default. */
|
|
2825
3072
|
const DONUT_OUTSIDE_LABELS = 2
|
|
2826
3073
|
|
|
2827
|
-
const DEFAULT_TOTAL_LABEL = "合計"
|
|
2828
|
-
|
|
2829
3074
|
const sliceValue = (value: number) => (Number.isFinite(value) && value > 0 ? value : 0)
|
|
2830
3075
|
|
|
2831
3076
|
/** A slice's share of `total` as text: whole percent, `<1%` for a sliver, `0%` for nothing. */
|
|
@@ -2858,15 +3103,16 @@ function donutLegendItems(slices: readonly DonutOptionSlice[]): DonutLegendItem[
|
|
|
2858
3103
|
}
|
|
2859
3104
|
|
|
2860
3105
|
/**
|
|
2861
|
-
* The default center label: what is totalled (「合計」), the formatted total
|
|
3106
|
+
* The default center label: what is totalled (「合計」 / "Total" in `locale`, default the page's), the formatted total
|
|
2862
3107
|
* (`amount`, the big line), the unit (a small line under it) and both as one
|
|
2863
3108
|
* string (`value`, 「9.2万 セッション」).
|
|
2864
3109
|
*/
|
|
2865
3110
|
function donutCenterText(
|
|
2866
3111
|
total: number,
|
|
2867
3112
|
valueFormatter: (value: number) => string,
|
|
2868
|
-
{ totalLabel
|
|
3113
|
+
{ totalLabel, unit, locale }: { totalLabel?: string; unit?: string; locale?: string } = {},
|
|
2869
3114
|
): { label: string; value: string; amount: string; unit?: string } {
|
|
3115
|
+
totalLabel ??= chartText(locale).total
|
|
2870
3116
|
const amount = valueFormatter(total)
|
|
2871
3117
|
return { label: totalLabel, value: unit ? `${amount} ${unit}` : amount, amount, ...(unit ? { unit } : {}) }
|
|
2872
3118
|
}
|
|
@@ -3094,8 +3340,10 @@ function hasLabel(labels: Readonly<Record<string, string>> | undefined, key: str
|
|
|
3094
3340
|
|
|
3095
3341
|
/* ------------------------------------------------------------ provisional */
|
|
3096
3342
|
|
|
3097
|
-
/** The default label for points that are not final yet. */
|
|
3098
|
-
|
|
3343
|
+
/** The default label for points that are not final yet, in `locale`: "Preliminary" / 「速報」. Default the page's locale. */
|
|
3344
|
+
function defaultProvisionalLabel(locale?: string): string {
|
|
3345
|
+
return chartText(locale).provisional
|
|
3346
|
+
}
|
|
3099
3347
|
|
|
3100
3348
|
/**
|
|
3101
3349
|
* Position of the first provisional point: the first entry of `indexValues`
|
|
@@ -3272,8 +3520,28 @@ export function resolveFunnelFormatter(
|
|
|
3272
3520
|
valueFormatter: ((value: number) => string) | undefined,
|
|
3273
3521
|
formatValue: ((value: number) => string) | undefined,
|
|
3274
3522
|
format?: ValueFormat,
|
|
3523
|
+
locale?: string,
|
|
3275
3524
|
): (value: number) => string {
|
|
3276
|
-
return resolveValueFormatter(valueFormatter ?? formatValue, format)
|
|
3525
|
+
return resolveValueFormatter(valueFormatter ?? formatValue, format, locale)
|
|
3526
|
+
}
|
|
3527
|
+
|
|
3528
|
+
const TEXT = {
|
|
3529
|
+
ja: {
|
|
3530
|
+
step: "ステップ",
|
|
3531
|
+
value: "値",
|
|
3532
|
+
previous: "前期",
|
|
3533
|
+
previousHeader: "前期",
|
|
3534
|
+
change: (label: string, points: string) => `(${label}比 ${points})`,
|
|
3535
|
+
funnel: "ファネル",
|
|
3536
|
+
},
|
|
3537
|
+
en: {
|
|
3538
|
+
step: "Step",
|
|
3539
|
+
value: "Value",
|
|
3540
|
+
previous: "prior period",
|
|
3541
|
+
previousHeader: "Prior period",
|
|
3542
|
+
change: (label: string, points: string) => `(vs. ${label} ${points})`,
|
|
3543
|
+
funnel: "Funnel",
|
|
3544
|
+
},
|
|
3277
3545
|
}
|
|
3278
3546
|
|
|
3279
3547
|
/**
|
|
@@ -3397,7 +3665,9 @@ function FunnelStepsImpl<T extends object>(
|
|
|
3397
3665
|
) {
|
|
3398
3666
|
const [mounted, setMounted] = useState(false)
|
|
3399
3667
|
const isHorizontal = direction === "horizontal"
|
|
3400
|
-
const
|
|
3668
|
+
const locale = useLocale()
|
|
3669
|
+
const t = TEXT[uiLanguage(locale)]
|
|
3670
|
+
const format = resolveFunnelFormatter(valueFormatter, formatValue, valueFormat, locale)
|
|
3401
3671
|
|
|
3402
3672
|
useEffect(() => {
|
|
3403
3673
|
if (animate) setMounted(true)
|
|
@@ -3412,7 +3682,7 @@ function FunnelStepsImpl<T extends object>(
|
|
|
3412
3682
|
const values = steps.map((s) => s.value)
|
|
3413
3683
|
// `comparison` puts the rates on screen too.
|
|
3414
3684
|
const previous = comparison?.values
|
|
3415
|
-
const comparisonLabel = comparison?.label ??
|
|
3685
|
+
const comparisonLabel = comparison?.label ?? t.previous
|
|
3416
3686
|
const showRates = showConversion || previous !== undefined
|
|
3417
3687
|
// Only with the rates on screen: the step behind the weakest rate (or, with `comparison`,
|
|
3418
3688
|
// the rate that fell the most) is the one to look at.
|
|
@@ -3422,7 +3692,11 @@ function FunnelStepsImpl<T extends object>(
|
|
|
3422
3692
|
"label",
|
|
3423
3693
|
previous ? ["value", "previous"] : ["value"],
|
|
3424
3694
|
format,
|
|
3425
|
-
{
|
|
3695
|
+
{
|
|
3696
|
+
label: labels?.[index] ?? t.step,
|
|
3697
|
+
value: (categories?.[0] && labels?.[categories[0]]) || t.value,
|
|
3698
|
+
previous: comparison?.label ?? t.previousHeader,
|
|
3699
|
+
},
|
|
3426
3700
|
)
|
|
3427
3701
|
|
|
3428
3702
|
const list = (
|
|
@@ -3527,7 +3801,7 @@ function FunnelStepsImpl<T extends object>(
|
|
|
3527
3801
|
→ {computeConversionRate(steps[index + 1]!.value, step.value)}
|
|
3528
3802
|
{previous && (
|
|
3529
3803
|
<span data-slot="funnel-change" className="ml-1 tabular-nums">
|
|
3530
|
-
(
|
|
3804
|
+
{t.change(comparisonLabel, formatPoints(conversionChange(values, previous, index + 1), locale))}
|
|
3531
3805
|
</span>
|
|
3532
3806
|
)}
|
|
3533
3807
|
</span>
|
|
@@ -3550,7 +3824,7 @@ function FunnelStepsImpl<T extends object>(
|
|
|
3550
3824
|
// Loading / empty keep a fixed height; the steps themselves are as tall as they are.
|
|
3551
3825
|
height={ready ? "auto" : FRAME_HEIGHT[size ?? "md"]}
|
|
3552
3826
|
variant="list"
|
|
3553
|
-
ariaLabel={ariaLabel ??
|
|
3827
|
+
ariaLabel={ariaLabel ?? t.funnel}
|
|
3554
3828
|
plotRole="group"
|
|
3555
3829
|
dataTable={dataTable}
|
|
3556
3830
|
className={className}
|