@squadbase/vantage 0.6.2-dev1.d594c56 → 0.6.2-dev1.e85faec
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-2IAPIOPS.js +152 -0
- package/dist/chunk-2IAPIOPS.js.map +1 -0
- package/dist/chunk-57GHR5LN.js +163 -0
- package/dist/chunk-57GHR5LN.js.map +1 -0
- package/dist/chunk-6OTTGAPO.js +44 -0
- package/dist/chunk-6OTTGAPO.js.map +1 -0
- package/dist/{chunk-XPQSTZUU.js → chunk-7S52M2SL.js} +4 -3
- package/dist/chunk-7S52M2SL.js.map +1 -0
- package/dist/chunk-JXFCSF3K.js +673 -0
- package/dist/chunk-JXFCSF3K.js.map +1 -0
- package/dist/{chunk-HKSVYMYJ.js → chunk-QOXXOFGL.js} +61 -929
- package/dist/chunk-QOXXOFGL.js.map +1 -0
- package/dist/components/index.d.ts +11 -247
- package/dist/components/index.js +5 -2
- package/dist/delta-text-CvwBGJ95.d.ts +15 -0
- package/dist/doc/index.d.ts +215 -2
- package/dist/doc/index.js +300 -3
- package/dist/doc/index.js.map +1 -1
- package/dist/format-U0cQl069.d.ts +243 -0
- package/dist/slide/index.d.ts +272 -1
- package/dist/slide/index.js +966 -1
- package/dist/slide/index.js.map +1 -1
- package/dist/table/index.js +5 -2
- package/dist/table/index.js.map +1 -1
- package/dist/ui/index.d.ts +4 -1
- package/dist/ui/index.js +3 -3
- package/dist/ui/index.js.map +1 -1
- package/docs/en/components.md +13 -2
- package/docs/en/doc/delta-text.md +40 -0
- package/docs/en/doc/doc-action-items.md +49 -0
- package/docs/en/doc/doc-callout.md +38 -0
- package/docs/en/doc/doc-data-table.md +60 -0
- package/docs/en/doc/doc-executive-summary.md +64 -0
- package/docs/en/doc/doc-figure.md +56 -0
- package/docs/en/doc/doc-key-findings.md +61 -0
- package/docs/en/doc/doc-kpi-scorecard.md +80 -0
- package/docs/en/doc/doc-notes-and-sources.md +43 -0
- package/docs/en/doc/doc-report-header.md +46 -0
- package/docs/en/parts/area-chart.md +99 -0
- package/docs/en/parts/bar-chart.md +91 -0
- package/docs/en/parts/bar-list.md +50 -0
- package/docs/en/parts/category-bar.md +37 -0
- package/docs/en/parts/chart-frame.md +39 -0
- package/docs/en/parts/delta.md +4 -3
- package/docs/en/parts/donut-chart.md +72 -0
- package/docs/en/parts/funnel-steps.md +2 -1
- package/docs/en/parts/heatmap.md +53 -0
- package/docs/en/parts/kpi-card.md +2 -1
- package/docs/en/parts/legend.md +35 -0
- package/docs/en/parts/line-chart.md +94 -0
- package/docs/en/parts/progress-circle.md +33 -0
- package/docs/en/parts/sample-data.md +39 -0
- package/docs/en/parts/scatter-chart.md +56 -0
- package/docs/en/parts/tracker.md +36 -0
- package/docs/en/parts/updated-at.md +30 -0
- package/docs/en/parts/waterfall-chart.md +56 -0
- package/docs/en/slide/slide-chart-full.md +48 -0
- package/docs/en/slide/slide-chart-insight.md +52 -0
- package/docs/en/slide/slide-columns.md +46 -0
- package/docs/en/slide/slide-comparison.md +68 -0
- package/docs/en/slide/slide-cover.md +39 -0
- package/docs/en/slide/slide-exec-summary.md +47 -0
- package/docs/en/slide/slide-heading.md +38 -0
- package/docs/en/slide/slide-kpi-grid.md +55 -0
- package/docs/en/slide/slide-kpi-hero.md +58 -0
- package/docs/en/slide/slide-next-actions.md +49 -0
- package/docs/en/slide/slide-scorecard-table.md +54 -0
- package/docs/en/slide/slide-section.md +44 -0
- package/docs/en/slide/slide-source-line.md +34 -0
- package/docs/en/ui/alert-dialog.md +42 -0
- package/docs/en/ui/avatar.md +31 -0
- package/docs/en/ui/combobox.md +74 -0
- package/docs/en/ui/drawer.md +41 -0
- package/docs/en/ui/empty.md +1 -1
- package/docs/en/ui/error-state.md +4 -3
- package/docs/en/ui/field.md +42 -0
- package/docs/en/ui/hover-card.md +21 -0
- package/docs/en/ui/kbd.md +17 -0
- package/docs/en/ui/loading.md +1 -1
- package/docs/en/ui/pagination.md +36 -0
- package/docs/en/ui/radio-group.md +21 -0
- package/docs/en/ui/toast.md +44 -0
- package/docs/en/ui/toggle-group.md +4 -3
- package/docs/index.json +857 -77
- package/docs/ja/components.md +12 -1
- package/docs/ja/doc/delta-text.md +40 -0
- package/docs/ja/doc/doc-action-items.md +49 -0
- package/docs/ja/doc/doc-callout.md +38 -0
- package/docs/ja/doc/doc-data-table.md +60 -0
- package/docs/ja/doc/doc-executive-summary.md +64 -0
- package/docs/ja/doc/doc-figure.md +56 -0
- package/docs/ja/doc/doc-key-findings.md +61 -0
- package/docs/ja/doc/doc-kpi-scorecard.md +80 -0
- package/docs/ja/doc/doc-notes-and-sources.md +43 -0
- package/docs/ja/doc/doc-report-header.md +46 -0
- package/docs/ja/parts/area-chart.md +49 -25
- package/docs/ja/parts/bar-chart.md +50 -24
- package/docs/ja/parts/bar-list.md +31 -8
- package/docs/ja/parts/chart-frame.md +36 -0
- package/docs/ja/parts/delta.md +5 -4
- package/docs/ja/parts/donut-chart.md +45 -26
- package/docs/ja/parts/funnel-steps.md +27 -8
- package/docs/ja/parts/heatmap.md +45 -0
- package/docs/ja/parts/kpi-card.md +2 -1
- package/docs/ja/parts/legend.md +36 -0
- package/docs/ja/parts/line-chart.md +48 -22
- package/docs/ja/parts/sample-data.md +61 -0
- package/docs/ja/parts/scatter-chart.md +69 -0
- package/docs/ja/parts/updated-at.md +27 -0
- package/docs/ja/parts/waterfall-chart.md +61 -0
- package/docs/ja/slide/slide-chart-full.md +48 -0
- package/docs/ja/slide/slide-chart-insight.md +52 -0
- package/docs/ja/slide/slide-columns.md +46 -0
- package/docs/ja/slide/slide-comparison.md +68 -0
- package/docs/ja/slide/slide-cover.md +39 -0
- package/docs/ja/slide/slide-exec-summary.md +47 -0
- package/docs/ja/slide/slide-heading.md +38 -0
- package/docs/ja/slide/slide-kpi-grid.md +55 -0
- package/docs/ja/slide/slide-kpi-hero.md +58 -0
- package/docs/ja/slide/slide-next-actions.md +49 -0
- package/docs/ja/slide/slide-scorecard-table.md +54 -0
- package/docs/ja/slide/slide-section.md +44 -0
- package/docs/ja/slide/slide-source-line.md +34 -0
- package/docs/ja/ui/empty.md +1 -1
- package/docs/ja/ui/error-state.md +4 -3
- package/docs/ja/ui/loading.md +1 -1
- package/docs/ja/ui/toggle-group.md +4 -3
- package/package.json +2 -2
- package/registry/ui/area-chart.tsx +88 -26
- package/registry/ui/attention-list.tsx +303 -0
- package/registry/ui/bar-chart.tsx +88 -26
- package/registry/ui/bar-list.tsx +87 -24
- package/registry/ui/box-plot-chart.tsx +3906 -0
- package/registry/ui/category-bar.tsx +54 -19
- package/registry/ui/chart-frame.tsx +56 -21
- package/registry/ui/dashboard-card.tsx +2 -2
- package/registry/ui/delta.tsx +11 -8
- package/registry/ui/donut-chart.tsx +89 -26
- package/registry/ui/echart.tsx +194 -44
- package/registry/ui/funnel-steps.tsx +33 -5
- package/registry/ui/heatmap.tsx +55 -21
- package/registry/ui/histogram-chart.tsx +3986 -0
- package/registry/ui/kpi-card.tsx +41 -14
- package/registry/ui/legend.tsx +87 -24
- package/registry/ui/line-chart.tsx +88 -26
- package/registry/ui/page-shell.tsx +9 -9
- package/registry/ui/paired-chart.tsx +3827 -0
- package/registry/ui/sample-data.tsx +1 -1
- package/registry/ui/scatter-chart.tsx +88 -26
- package/registry/ui/section-header.tsx +2 -2
- package/registry/ui/slider.tsx +1 -1
- package/registry/ui/status-badge.tsx +8 -8
- package/registry/ui/tooltip.tsx +6 -2
- package/registry/ui/tracker.tsx +9 -3
- package/registry/ui/trend-indicator.tsx +2 -2
- package/registry/ui/waterfall-chart.tsx +88 -26
- package/skills/vantage-design/SKILL.md +27 -25
- package/skills/vantage-design/references/extract.md +16 -14
- package/skills/vantage-design/references/format.md +6 -5
- package/skills/vantage-design/references/presets.md +60 -110
- package/skills/vantage-slide/SKILL.md +54 -36
- package/templates/AGENTS.md +2 -2
- package/templates/agents/doc.md +48 -0
- package/templates/agents/page.md +22 -1
- package/templates/agents/slide.md +70 -2
- package/theme.css +16 -15
- package/dist/chunk-HKSVYMYJ.js.map +0 -1
- package/dist/chunk-XPQSTZUU.js.map +0 -1
|
@@ -18,7 +18,7 @@ import { forwardRef, useMemo, type ForwardedRef, type ReactElement, type Ref } f
|
|
|
18
18
|
import { ChartFrame } from "@squadbase/vantage/components"
|
|
19
19
|
import { EChart } from "@squadbase/vantage/components"
|
|
20
20
|
import type { ChartThreshold } from "@squadbase/vantage/components"
|
|
21
|
-
import { createContext, useCallback, useEffect, useRef, useState } from "react"
|
|
21
|
+
import { createContext, useCallback, useEffect, useRef, useState, type RefObject } from "react"
|
|
22
22
|
import type { ChartTone } from "@squadbase/vantage/components"
|
|
23
23
|
import { DEFAULT_LOCALE, EMPTY_VALUE, formatValue, toValueFormatter, type ValueFormat } from "@squadbase/vantage/components"
|
|
24
24
|
import type { ChartColor, ChartColors, ChartSeriesColor, ValueFormatter } from "@squadbase/vantage/components"
|
|
@@ -51,6 +51,8 @@ const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--ch
|
|
|
51
51
|
interface ChartTokens {
|
|
52
52
|
/** Resolved `--chart-1..5`, in order. */
|
|
53
53
|
series: string[]
|
|
54
|
+
/** Computed `font-family` of the container, so canvas text matches the DOM. Optional. */
|
|
55
|
+
fontFamily?: string
|
|
54
56
|
/** Body text: titles and the default `textStyle`. */
|
|
55
57
|
foreground: string | null
|
|
56
58
|
/** Secondary text: axis labels, legend, subtitles. */
|
|
@@ -59,10 +61,23 @@ interface ChartTokens {
|
|
|
59
61
|
border: string | null
|
|
60
62
|
/** Tooltip surface. */
|
|
61
63
|
popover: string | null
|
|
64
|
+
/** Semantic and surface dependencies used by typed chart options. Optional for hand-built tokens. */
|
|
65
|
+
colors?: Record<string, string>
|
|
66
|
+
/** Actual inherited painting surface (may change without a token changing). */
|
|
67
|
+
surface?: string | null
|
|
62
68
|
/** Tooltip text. */
|
|
63
69
|
popoverForeground: string | null
|
|
64
70
|
}
|
|
65
71
|
|
|
72
|
+
// Keep scope out of the public token shape. A resolved snapshot remembers where arbitrary
|
|
73
|
+
// var(--token) expressions must be read, without retaining unmounted charts globally.
|
|
74
|
+
const tokenScopes = new WeakMap<ChartTokens, Element>()
|
|
75
|
+
const OPTION_COLOR_TOKENS = [
|
|
76
|
+
"--background", "--card", "--muted", "--primary", "--destructive",
|
|
77
|
+
...["positive", "negative", "warning", "neutral", "info"].flatMap((tone) =>
|
|
78
|
+
["", "-fill", "-subtle"].map((suffix) => `--${tone}${suffix}`)),
|
|
79
|
+
]
|
|
80
|
+
|
|
66
81
|
let normalizeContext: CanvasRenderingContext2D | null | undefined
|
|
67
82
|
|
|
68
83
|
/** The forms `fillStyle` serializes legacy colours to, and zrender can parse. */
|
|
@@ -118,14 +133,19 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
|
|
|
118
133
|
const color = read(token)
|
|
119
134
|
if (color) series.push(color)
|
|
120
135
|
}
|
|
121
|
-
|
|
136
|
+
const tokens: ChartTokens = {
|
|
122
137
|
series,
|
|
138
|
+
colors: Object.fromEntries(OPTION_COLOR_TOKENS.map((name) => [name, styles.getPropertyValue(name).trim()])),
|
|
139
|
+
surface: readChartSurface(target),
|
|
140
|
+
...(styles.fontFamily ? { fontFamily: styles.fontFamily } : {}),
|
|
123
141
|
foreground: read("--foreground"),
|
|
124
142
|
mutedForeground: read("--muted-foreground"),
|
|
125
143
|
border: read("--border"),
|
|
126
144
|
popover: read("--popover"),
|
|
127
145
|
popoverForeground: read("--popover-foreground"),
|
|
128
146
|
}
|
|
147
|
+
tokenScopes.set(tokens, target)
|
|
148
|
+
return tokens
|
|
129
149
|
}
|
|
130
150
|
|
|
131
151
|
/** Structural equality, so a theme toggle that changes nothing doesn't re-init. */
|
|
@@ -133,8 +153,13 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
|
|
|
133
153
|
if (a === b) return true
|
|
134
154
|
if (!a || !b) return false
|
|
135
155
|
return (
|
|
156
|
+
tokenScopes.get(a) === tokenScopes.get(b) &&
|
|
157
|
+
a.surface === b.surface &&
|
|
158
|
+
Object.keys(a.colors ?? {}).length === Object.keys(b.colors ?? {}).length &&
|
|
159
|
+
Object.entries(a.colors ?? {}).every(([name, value]) => b.colors?.[name] === value) &&
|
|
136
160
|
a.series.length === b.series.length &&
|
|
137
161
|
a.series.every((color, i) => color === b.series[i]) &&
|
|
162
|
+
a.fontFamily === b.fontFamily &&
|
|
138
163
|
a.foreground === b.foreground &&
|
|
139
164
|
a.mutedForeground === b.mutedForeground &&
|
|
140
165
|
a.border === b.border &&
|
|
@@ -157,7 +182,7 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
|
|
|
157
182
|
* attributes alone leaves the chart painted in the previous palette until a
|
|
158
183
|
* reload — the tokens in the DOM are new, the canvas is not.
|
|
159
184
|
*/
|
|
160
|
-
function watchChartTokens(onChange: () => void): () => void {
|
|
185
|
+
function watchChartTokens(onChange: () => void, scope?: Element | null): () => void {
|
|
161
186
|
if (typeof document === "undefined") return () => {}
|
|
162
187
|
// Style edits arrive as bursts of mutations; coalesce them into one read, and
|
|
163
188
|
// take it after the next frame so a swapped stylesheet has been applied.
|
|
@@ -172,8 +197,11 @@ function watchChartTokens(onChange: () => void): () => void {
|
|
|
172
197
|
|
|
173
198
|
const themeObserver = new MutationObserver(schedule)
|
|
174
199
|
const attributeFilter = ["class", "style", "data-theme"]
|
|
175
|
-
|
|
176
|
-
|
|
200
|
+
// Only the chart and its ancestors can change its inherited theme. Never observe
|
|
201
|
+
// the document subtree: canvas/tooltip/rendering mutations would trigger every chart.
|
|
202
|
+
for (let node: Element | null = scope ?? document.body ?? document.documentElement; node; node = node.parentElement) {
|
|
203
|
+
themeObserver.observe(node, { attributes: true, attributeFilter })
|
|
204
|
+
}
|
|
177
205
|
|
|
178
206
|
const styleObserver = new MutationObserver(schedule)
|
|
179
207
|
if (document.head) {
|
|
@@ -229,7 +257,7 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
|
|
|
229
257
|
// Let the page background show through: charts sit on cards, not on a canvas.
|
|
230
258
|
backgroundColor: "transparent",
|
|
231
259
|
...(tokens.series.length > 0 ? { color: tokens.series } : {}),
|
|
232
|
-
textStyle: { color: text },
|
|
260
|
+
textStyle: { color: text, ...(tokens.fontFamily ? { fontFamily: tokens.fontFamily } : {}) },
|
|
233
261
|
title: { textStyle: { color: text }, subtextStyle: { color: muted } },
|
|
234
262
|
legend: { textStyle: { color: muted } },
|
|
235
263
|
categoryAxis: axisTheme(tokens, false),
|
|
@@ -245,7 +273,11 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
|
|
|
245
273
|
padding: [8, 12],
|
|
246
274
|
// Rounded card with a soft shadow; in dark mode the shadow barely shows, so the border carries the edge.
|
|
247
275
|
extraCssText: "border-radius:8px;box-shadow:var(--shadow-card, 0 4px 12px rgb(0 0 0 / 0.08));",
|
|
248
|
-
textStyle: {
|
|
276
|
+
textStyle: {
|
|
277
|
+
color: tokens.popoverForeground ?? undefined,
|
|
278
|
+
fontSize: 12,
|
|
279
|
+
...(tokens.fontFamily ? { fontFamily: tokens.fontFamily } : {}),
|
|
280
|
+
},
|
|
249
281
|
axisPointer: { lineStyle: { color: line }, crossStyle: { color: line } },
|
|
250
282
|
},
|
|
251
283
|
}
|
|
@@ -257,22 +289,23 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
|
|
|
257
289
|
* components that build an ECharts option from token colours themselves (the
|
|
258
290
|
* typed charts), so their palette follows light/dark like `EChart`'s theme.
|
|
259
291
|
*/
|
|
260
|
-
function useChartTokens(): ChartTokens | null {
|
|
292
|
+
function useChartTokens(scope?: Element | null | RefObject<Element | null>): ChartTokens | null {
|
|
261
293
|
const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
|
|
262
294
|
// Compared here, not inside a `setTokens` updater: an updater that reads the DOM is
|
|
263
295
|
// replayed by React on every rebase and returns a fresh object each time, so the state
|
|
264
296
|
// never settles ("Maximum update depth exceeded" once the tokens change after mount).
|
|
265
297
|
const tokensRef = useRef(tokens)
|
|
266
298
|
useEffect(() => {
|
|
299
|
+
const target = scope && "current" in scope ? scope.current : scope
|
|
267
300
|
const sync = () => {
|
|
268
|
-
const next = readChartTokens()
|
|
301
|
+
const next = readChartTokens(target)
|
|
269
302
|
if (sameChartTokens(tokensRef.current, next)) return
|
|
270
303
|
tokensRef.current = next
|
|
271
304
|
setTokens(next)
|
|
272
305
|
}
|
|
273
306
|
sync()
|
|
274
|
-
return watchChartTokens(sync)
|
|
275
|
-
}, [])
|
|
307
|
+
return watchChartTokens(sync, target)
|
|
308
|
+
}, [scope])
|
|
276
309
|
return tokens
|
|
277
310
|
}
|
|
278
311
|
|
|
@@ -284,7 +317,7 @@ const EMBEDDED_VAR_RE = /var\(\s*(--[\w-]+)\s*\)/g
|
|
|
284
317
|
/**
|
|
285
318
|
* Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
|
|
286
319
|
* the resolved series tokens; `"positive"` / `"warning"` / `"negative"` / `"neutral"` (and the area
|
|
287
|
-
* variants `"positive-fill"`, …) are read
|
|
320
|
+
* variants `"positive-fill"`, …) are read in the snapshot's inherited scope (`--positive`, …); `var(--token)` uses the same scope; any
|
|
288
321
|
* other CSS colour is normalized like the tokens are. A colour that can't be
|
|
289
322
|
* resolved comes back as `""`, which ECharts treats as "use the palette".
|
|
290
323
|
*/
|
|
@@ -295,12 +328,13 @@ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | nul
|
|
|
295
328
|
// Meaning colours are the theme tokens of the same name: same path as `var(--positive)`.
|
|
296
329
|
if (TONE_NAME_RE.test(color.trim())) {
|
|
297
330
|
if (typeof document === "undefined") return ""
|
|
298
|
-
|
|
331
|
+
const target = (tokens && tokenScopes.get(tokens)) ?? document.documentElement
|
|
332
|
+
return toCanvasColor(getComputedStyle(target).getPropertyValue(`--${color.trim()}`)) ?? ""
|
|
299
333
|
}
|
|
300
334
|
if (typeof document === "undefined") return color
|
|
301
335
|
let value = color.trim()
|
|
302
336
|
const cssVar = CSS_VAR_RE.exec(value)
|
|
303
|
-
const styles = getComputedStyle(document.documentElement)
|
|
337
|
+
const styles = getComputedStyle((tokens && tokenScopes.get(tokens)) ?? document.documentElement)
|
|
304
338
|
if (cssVar) {
|
|
305
339
|
value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
|
|
306
340
|
} else if (value.includes("var(")) {
|
|
@@ -346,7 +380,7 @@ function readChartSurface(el: Element | null): string | null {
|
|
|
346
380
|
if (color) return color
|
|
347
381
|
}
|
|
348
382
|
}
|
|
349
|
-
return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue("--background"))
|
|
383
|
+
return toCanvasColor(getComputedStyle(el ?? document.documentElement).getPropertyValue("--background"))
|
|
350
384
|
}
|
|
351
385
|
|
|
352
386
|
/** Widths are bucketed so a resize only rebuilds the option when the label budget can change. */
|
|
@@ -360,9 +394,10 @@ const WIDTH_BUCKET = 40
|
|
|
360
394
|
*/
|
|
361
395
|
function useChartSurface<E extends Element>(
|
|
362
396
|
forwarded: ForwardedRef<E>,
|
|
363
|
-
tokens
|
|
364
|
-
): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined } {
|
|
397
|
+
tokens?: ChartTokens | null,
|
|
398
|
+
): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined; tokens: ChartTokens | null } {
|
|
365
399
|
const [node, setNode] = useState<E | null>(null)
|
|
400
|
+
const scopedTokens = useChartTokens(node)
|
|
366
401
|
const [surface, setSurface] = useState<string | undefined>(undefined)
|
|
367
402
|
const [width, setWidth] = useState<number | undefined>(undefined)
|
|
368
403
|
const ref = useCallback(
|
|
@@ -376,7 +411,7 @@ function useChartSurface<E extends Element>(
|
|
|
376
411
|
useEffect(() => {
|
|
377
412
|
if (!node) return
|
|
378
413
|
setSurface(readChartSurface(node) ?? undefined)
|
|
379
|
-
}, [node, tokens])
|
|
414
|
+
}, [node, tokens, scopedTokens])
|
|
380
415
|
useEffect(() => {
|
|
381
416
|
if (!node || typeof ResizeObserver === "undefined") return
|
|
382
417
|
const update = (w: number) => setWidth(Math.floor(w / WIDTH_BUCKET) * WIDTH_BUCKET)
|
|
@@ -385,7 +420,7 @@ function useChartSurface<E extends Element>(
|
|
|
385
420
|
observer.observe(node)
|
|
386
421
|
return () => observer.disconnect()
|
|
387
422
|
}, [node])
|
|
388
|
-
return { ref, surface, width }
|
|
423
|
+
return { ref, surface, width, tokens: scopedTokens }
|
|
389
424
|
}
|
|
390
425
|
|
|
391
426
|
/* ---------- inlined from @squadbase/squadkit registry/lib/chart-utils.ts (internal to @squadbase/vantage) ---------- */
|
|
@@ -1539,6 +1574,10 @@ const toDate = (t: ParsedTime) => new Date(t.year, t.month - 1, t.day)
|
|
|
1539
1574
|
|
|
1540
1575
|
/** How many x labels a time axis shows at most (the step is picked so the labels never crowd). */
|
|
1541
1576
|
const TIME_AXIS_MAX_LABELS = 6
|
|
1577
|
+
/** Under this plot width (px) the axis, legend and labels switch to their compact form. */
|
|
1578
|
+
const NARROW_PLOT_WIDTH = 400
|
|
1579
|
+
/** The most x labels a narrow plot shows (a 262px plot reads 4 at most). */
|
|
1580
|
+
const NARROW_TIME_AXIS_MAX_LABELS = 4
|
|
1542
1581
|
|
|
1543
1582
|
/** Calendar-friendly label steps per granularity (every 3 months, every week …). */
|
|
1544
1583
|
const TIME_AXIS_STEPS: Record<TimeGranularity, readonly number[]> = {
|
|
@@ -1984,7 +2023,9 @@ const LEGEND_LINE_BUDGET = 320
|
|
|
1984
2023
|
/** How many lines the plain legend takes, estimated from its labels (12px text + 8px dot + gaps). */
|
|
1985
2024
|
function legendLines(items: readonly string[], plotWidth?: number, reserve = 0): number {
|
|
1986
2025
|
const width = items.reduce((sum, name) => sum + [...name].length * 12 + 8 + 5 + 16, 0)
|
|
1987
|
-
|
|
2026
|
+
// A narrow plot has no room for the PNG buttons beside the legend: they move off its line, so no reserve.
|
|
2027
|
+
const narrow = plotWidth !== undefined && plotWidth < NARROW_PLOT_WIDTH
|
|
2028
|
+
const budget = Math.max(80, (plotWidth ? (narrow ? plotWidth : Math.min(LEGEND_LINE_BUDGET, plotWidth)) : LEGEND_LINE_BUDGET) - (narrow ? 0 : reserve))
|
|
1988
2029
|
return Math.min(3, Math.max(1, Math.ceil(width / budget)))
|
|
1989
2030
|
}
|
|
1990
2031
|
|
|
@@ -2239,7 +2280,15 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
2239
2280
|
? null
|
|
2240
2281
|
: timeAxisLabelStep(
|
|
2241
2282
|
rawIndex,
|
|
2242
|
-
plotWidth
|
|
2283
|
+
plotWidth
|
|
2284
|
+
? Math.max(
|
|
2285
|
+
2,
|
|
2286
|
+
Math.min(
|
|
2287
|
+
plotWidth < NARROW_PLOT_WIDTH ? NARROW_TIME_AXIS_MAX_LABELS : TIME_AXIS_MAX_LABELS,
|
|
2288
|
+
Math.floor(plotWidth / TIME_LABEL_WIDTH),
|
|
2289
|
+
),
|
|
2290
|
+
)
|
|
2291
|
+
: undefined,
|
|
2243
2292
|
)
|
|
2244
2293
|
// Plain (non-date) x labels of a line chart: thinned to every n-th so they never touch, never rotated.
|
|
2245
2294
|
const plainStep = (() => {
|
|
@@ -2285,8 +2334,12 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
2285
2334
|
: []
|
|
2286
2335
|
// On a narrow plot the max and the latest label would print over each other: keep the latest.
|
|
2287
2336
|
const pointGap = plotWidth && labels.length > 1 ? plotWidth / (kind === "bar" ? labels.length : labels.length - 1) : null
|
|
2337
|
+
// By default only the latest value is labelled; an explicit `highlight` keeps its max + latest.
|
|
2338
|
+
const latestOnly = cfg.highlight === undefined && !shareHighlight && picked.length === 2
|
|
2288
2339
|
const highlightPoints =
|
|
2289
|
-
|
|
2340
|
+
latestOnly
|
|
2341
|
+
? [picked[1]!]
|
|
2342
|
+
: picked.length === 2 && pointGap !== null && Math.abs(picked[1]!.index - picked[0]!.index) * pointGap < HIGHLIGHT_LABEL_WIDTH
|
|
2290
2343
|
? [picked[1]!]
|
|
2291
2344
|
: picked
|
|
2292
2345
|
|
|
@@ -2497,6 +2550,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
2497
2550
|
? { width: 0, color, opacity: 0 }
|
|
2498
2551
|
: { width: 2, color, opacity: dimSeries ? DIMMED : 1 },
|
|
2499
2552
|
itemStyle: { color, opacity: dimSeries ? DIMMED : 1 },
|
|
2553
|
+
// Hovering a series fades the others (not for a stack, where the bands read together).
|
|
2554
|
+
emphasis: stacked ? undefined : { focus: "series" as const },
|
|
2500
2555
|
areaStyle: areaStyle && dimSeries ? { ...areaStyle, opacity: DIMMED * 0.3 } : areaStyle,
|
|
2501
2556
|
markPoint: markPointFor(c, color),
|
|
2502
2557
|
markLine: undefined,
|
|
@@ -2943,7 +2998,10 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
2943
2998
|
legend: {
|
|
2944
2999
|
show: showLegend,
|
|
2945
3000
|
top: 0,
|
|
2946
|
-
|
|
3001
|
+
// Narrow: left-aligned and wrapping above the plot, so nothing is cut off at the right edge.
|
|
3002
|
+
...(plotWidth !== undefined && plotWidth < NARROW_PLOT_WIDTH
|
|
3003
|
+
? { left: 0, right: 0 }
|
|
3004
|
+
: { right: reserveActions ? ACTIONS_RESERVE : 0 }),
|
|
2947
3005
|
// Plain wraps onto a second line at narrow widths instead of hiding items behind arrows.
|
|
2948
3006
|
type: "plain",
|
|
2949
3007
|
icon: "circle",
|
|
@@ -3004,7 +3062,12 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
3004
3062
|
}
|
|
3005
3063
|
}
|
|
3006
3064
|
const shares = percent ? cartesianSeriesValues(i <= lastActual ? [row] : [], categories, "percent") : null
|
|
3007
|
-
|
|
3065
|
+
// Rows follow the legend order (categories first), not ECharts' own series order.
|
|
3066
|
+
const legendRank = (p: AxisTooltipParam) => {
|
|
3067
|
+
const at = categories.indexOf(p.seriesName ?? "")
|
|
3068
|
+
return at === -1 ? categories.length : at
|
|
3069
|
+
}
|
|
3070
|
+
for (const p of [...list].sort((a, b) => legendRank(a) - legendRank(b))) {
|
|
3008
3071
|
const color = typeof p.color === "string" ? p.color : "currentColor"
|
|
3009
3072
|
if (p.seriesId === FORECAST_SERIES_ID) {
|
|
3010
3073
|
// At and before the boundary the forecast repeats the actual value.
|
|
@@ -3518,9 +3581,8 @@ function BarChartImpl<T extends Record<string, unknown>>(
|
|
|
3518
3581
|
}: BarChartProps<T>,
|
|
3519
3582
|
ref: ForwardedRef<HTMLDivElement>,
|
|
3520
3583
|
) {
|
|
3521
|
-
const tokens = useChartTokens()
|
|
3522
3584
|
// Highlight labels are backed by the surface the chart sits on (a card, the page).
|
|
3523
|
-
const { ref: frameRef, surface, width: plotWidth } = useChartSurface(ref
|
|
3585
|
+
const { ref: frameRef, surface, width: plotWidth, tokens } = useChartSurface(ref)
|
|
3524
3586
|
// Ticks and on-plot labels read short (¥1億); the tooltip reads the value as `format` says.
|
|
3525
3587
|
const tooltipFormatter = useMemo(() => resolveValueFormatter(valueFormatter, format), [valueFormatter, format])
|
|
3526
3588
|
const axisFormatter = useMemo(() => resolveAxisFormatter(valueFormatter, format), [valueFormatter, format])
|
package/registry/ui/bar-list.tsx
CHANGED
|
@@ -31,7 +31,7 @@ import { cn } from "@squadbase/vantage/ui"
|
|
|
31
31
|
import { ChartFrame } from "@squadbase/vantage/components"
|
|
32
32
|
import { toneForValue } from "@squadbase/vantage/components"
|
|
33
33
|
import type { ChartColor, ChartCommonProps, ChartSeriesColor, ChartThreshold } from "@squadbase/vantage/components"
|
|
34
|
-
import { createContext, useCallback, useRef } from "react"
|
|
34
|
+
import { createContext, useCallback, useRef, type RefObject } from "react"
|
|
35
35
|
import type { ChartTone } from "@squadbase/vantage/components"
|
|
36
36
|
import { DEFAULT_LOCALE, EMPTY_VALUE, formatValue, toValueFormatter, type ValueFormat } from "@squadbase/vantage/components"
|
|
37
37
|
import type { ChartColors, ValueFormatter } from "@squadbase/vantage/components"
|
|
@@ -64,6 +64,8 @@ const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--ch
|
|
|
64
64
|
interface ChartTokens {
|
|
65
65
|
/** Resolved `--chart-1..5`, in order. */
|
|
66
66
|
series: string[]
|
|
67
|
+
/** Computed `font-family` of the container, so canvas text matches the DOM. Optional. */
|
|
68
|
+
fontFamily?: string
|
|
67
69
|
/** Body text: titles and the default `textStyle`. */
|
|
68
70
|
foreground: string | null
|
|
69
71
|
/** Secondary text: axis labels, legend, subtitles. */
|
|
@@ -72,10 +74,23 @@ interface ChartTokens {
|
|
|
72
74
|
border: string | null
|
|
73
75
|
/** Tooltip surface. */
|
|
74
76
|
popover: string | null
|
|
77
|
+
/** Semantic and surface dependencies used by typed chart options. Optional for hand-built tokens. */
|
|
78
|
+
colors?: Record<string, string>
|
|
79
|
+
/** Actual inherited painting surface (may change without a token changing). */
|
|
80
|
+
surface?: string | null
|
|
75
81
|
/** Tooltip text. */
|
|
76
82
|
popoverForeground: string | null
|
|
77
83
|
}
|
|
78
84
|
|
|
85
|
+
// Keep scope out of the public token shape. A resolved snapshot remembers where arbitrary
|
|
86
|
+
// var(--token) expressions must be read, without retaining unmounted charts globally.
|
|
87
|
+
const tokenScopes = new WeakMap<ChartTokens, Element>()
|
|
88
|
+
const OPTION_COLOR_TOKENS = [
|
|
89
|
+
"--background", "--card", "--muted", "--primary", "--destructive",
|
|
90
|
+
...["positive", "negative", "warning", "neutral", "info"].flatMap((tone) =>
|
|
91
|
+
["", "-fill", "-subtle"].map((suffix) => `--${tone}${suffix}`)),
|
|
92
|
+
]
|
|
93
|
+
|
|
79
94
|
let normalizeContext: CanvasRenderingContext2D | null | undefined
|
|
80
95
|
|
|
81
96
|
/** The forms `fillStyle` serializes legacy colours to, and zrender can parse. */
|
|
@@ -131,14 +146,19 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
|
|
|
131
146
|
const color = read(token)
|
|
132
147
|
if (color) series.push(color)
|
|
133
148
|
}
|
|
134
|
-
|
|
149
|
+
const tokens: ChartTokens = {
|
|
135
150
|
series,
|
|
151
|
+
colors: Object.fromEntries(OPTION_COLOR_TOKENS.map((name) => [name, styles.getPropertyValue(name).trim()])),
|
|
152
|
+
surface: readChartSurface(target),
|
|
153
|
+
...(styles.fontFamily ? { fontFamily: styles.fontFamily } : {}),
|
|
136
154
|
foreground: read("--foreground"),
|
|
137
155
|
mutedForeground: read("--muted-foreground"),
|
|
138
156
|
border: read("--border"),
|
|
139
157
|
popover: read("--popover"),
|
|
140
158
|
popoverForeground: read("--popover-foreground"),
|
|
141
159
|
}
|
|
160
|
+
tokenScopes.set(tokens, target)
|
|
161
|
+
return tokens
|
|
142
162
|
}
|
|
143
163
|
|
|
144
164
|
/** Structural equality, so a theme toggle that changes nothing doesn't re-init. */
|
|
@@ -146,8 +166,13 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
|
|
|
146
166
|
if (a === b) return true
|
|
147
167
|
if (!a || !b) return false
|
|
148
168
|
return (
|
|
169
|
+
tokenScopes.get(a) === tokenScopes.get(b) &&
|
|
170
|
+
a.surface === b.surface &&
|
|
171
|
+
Object.keys(a.colors ?? {}).length === Object.keys(b.colors ?? {}).length &&
|
|
172
|
+
Object.entries(a.colors ?? {}).every(([name, value]) => b.colors?.[name] === value) &&
|
|
149
173
|
a.series.length === b.series.length &&
|
|
150
174
|
a.series.every((color, i) => color === b.series[i]) &&
|
|
175
|
+
a.fontFamily === b.fontFamily &&
|
|
151
176
|
a.foreground === b.foreground &&
|
|
152
177
|
a.mutedForeground === b.mutedForeground &&
|
|
153
178
|
a.border === b.border &&
|
|
@@ -170,7 +195,7 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
|
|
|
170
195
|
* attributes alone leaves the chart painted in the previous palette until a
|
|
171
196
|
* reload — the tokens in the DOM are new, the canvas is not.
|
|
172
197
|
*/
|
|
173
|
-
function watchChartTokens(onChange: () => void): () => void {
|
|
198
|
+
function watchChartTokens(onChange: () => void, scope?: Element | null): () => void {
|
|
174
199
|
if (typeof document === "undefined") return () => {}
|
|
175
200
|
// Style edits arrive as bursts of mutations; coalesce them into one read, and
|
|
176
201
|
// take it after the next frame so a swapped stylesheet has been applied.
|
|
@@ -185,8 +210,11 @@ function watchChartTokens(onChange: () => void): () => void {
|
|
|
185
210
|
|
|
186
211
|
const themeObserver = new MutationObserver(schedule)
|
|
187
212
|
const attributeFilter = ["class", "style", "data-theme"]
|
|
188
|
-
|
|
189
|
-
|
|
213
|
+
// Only the chart and its ancestors can change its inherited theme. Never observe
|
|
214
|
+
// the document subtree: canvas/tooltip/rendering mutations would trigger every chart.
|
|
215
|
+
for (let node: Element | null = scope ?? document.body ?? document.documentElement; node; node = node.parentElement) {
|
|
216
|
+
themeObserver.observe(node, { attributes: true, attributeFilter })
|
|
217
|
+
}
|
|
190
218
|
|
|
191
219
|
const styleObserver = new MutationObserver(schedule)
|
|
192
220
|
if (document.head) {
|
|
@@ -242,7 +270,7 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
|
|
|
242
270
|
// Let the page background show through: charts sit on cards, not on a canvas.
|
|
243
271
|
backgroundColor: "transparent",
|
|
244
272
|
...(tokens.series.length > 0 ? { color: tokens.series } : {}),
|
|
245
|
-
textStyle: { color: text },
|
|
273
|
+
textStyle: { color: text, ...(tokens.fontFamily ? { fontFamily: tokens.fontFamily } : {}) },
|
|
246
274
|
title: { textStyle: { color: text }, subtextStyle: { color: muted } },
|
|
247
275
|
legend: { textStyle: { color: muted } },
|
|
248
276
|
categoryAxis: axisTheme(tokens, false),
|
|
@@ -258,7 +286,11 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
|
|
|
258
286
|
padding: [8, 12],
|
|
259
287
|
// Rounded card with a soft shadow; in dark mode the shadow barely shows, so the border carries the edge.
|
|
260
288
|
extraCssText: "border-radius:8px;box-shadow:var(--shadow-card, 0 4px 12px rgb(0 0 0 / 0.08));",
|
|
261
|
-
textStyle: {
|
|
289
|
+
textStyle: {
|
|
290
|
+
color: tokens.popoverForeground ?? undefined,
|
|
291
|
+
fontSize: 12,
|
|
292
|
+
...(tokens.fontFamily ? { fontFamily: tokens.fontFamily } : {}),
|
|
293
|
+
},
|
|
262
294
|
axisPointer: { lineStyle: { color: line }, crossStyle: { color: line } },
|
|
263
295
|
},
|
|
264
296
|
}
|
|
@@ -270,22 +302,23 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
|
|
|
270
302
|
* components that build an ECharts option from token colours themselves (the
|
|
271
303
|
* typed charts), so their palette follows light/dark like `EChart`'s theme.
|
|
272
304
|
*/
|
|
273
|
-
function useChartTokens(): ChartTokens | null {
|
|
305
|
+
function useChartTokens(scope?: Element | null | RefObject<Element | null>): ChartTokens | null {
|
|
274
306
|
const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
|
|
275
307
|
// Compared here, not inside a `setTokens` updater: an updater that reads the DOM is
|
|
276
308
|
// replayed by React on every rebase and returns a fresh object each time, so the state
|
|
277
309
|
// never settles ("Maximum update depth exceeded" once the tokens change after mount).
|
|
278
310
|
const tokensRef = useRef(tokens)
|
|
279
311
|
useEffect(() => {
|
|
312
|
+
const target = scope && "current" in scope ? scope.current : scope
|
|
280
313
|
const sync = () => {
|
|
281
|
-
const next = readChartTokens()
|
|
314
|
+
const next = readChartTokens(target)
|
|
282
315
|
if (sameChartTokens(tokensRef.current, next)) return
|
|
283
316
|
tokensRef.current = next
|
|
284
317
|
setTokens(next)
|
|
285
318
|
}
|
|
286
319
|
sync()
|
|
287
|
-
return watchChartTokens(sync)
|
|
288
|
-
}, [])
|
|
320
|
+
return watchChartTokens(sync, target)
|
|
321
|
+
}, [scope])
|
|
289
322
|
return tokens
|
|
290
323
|
}
|
|
291
324
|
|
|
@@ -297,7 +330,7 @@ const EMBEDDED_VAR_RE = /var\(\s*(--[\w-]+)\s*\)/g
|
|
|
297
330
|
/**
|
|
298
331
|
* Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
|
|
299
332
|
* the resolved series tokens; `"positive"` / `"warning"` / `"negative"` / `"neutral"` (and the area
|
|
300
|
-
* variants `"positive-fill"`, …) are read
|
|
333
|
+
* variants `"positive-fill"`, …) are read in the snapshot's inherited scope (`--positive`, …); `var(--token)` uses the same scope; any
|
|
301
334
|
* other CSS colour is normalized like the tokens are. A colour that can't be
|
|
302
335
|
* resolved comes back as `""`, which ECharts treats as "use the palette".
|
|
303
336
|
*/
|
|
@@ -308,12 +341,13 @@ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | nul
|
|
|
308
341
|
// Meaning colours are the theme tokens of the same name: same path as `var(--positive)`.
|
|
309
342
|
if (TONE_NAME_RE.test(color.trim())) {
|
|
310
343
|
if (typeof document === "undefined") return ""
|
|
311
|
-
|
|
344
|
+
const target = (tokens && tokenScopes.get(tokens)) ?? document.documentElement
|
|
345
|
+
return toCanvasColor(getComputedStyle(target).getPropertyValue(`--${color.trim()}`)) ?? ""
|
|
312
346
|
}
|
|
313
347
|
if (typeof document === "undefined") return color
|
|
314
348
|
let value = color.trim()
|
|
315
349
|
const cssVar = CSS_VAR_RE.exec(value)
|
|
316
|
-
const styles = getComputedStyle(document.documentElement)
|
|
350
|
+
const styles = getComputedStyle((tokens && tokenScopes.get(tokens)) ?? document.documentElement)
|
|
317
351
|
if (cssVar) {
|
|
318
352
|
value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
|
|
319
353
|
} else if (value.includes("var(")) {
|
|
@@ -359,7 +393,7 @@ function readChartSurface(el: Element | null): string | null {
|
|
|
359
393
|
if (color) return color
|
|
360
394
|
}
|
|
361
395
|
}
|
|
362
|
-
return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue("--background"))
|
|
396
|
+
return toCanvasColor(getComputedStyle(el ?? document.documentElement).getPropertyValue("--background"))
|
|
363
397
|
}
|
|
364
398
|
|
|
365
399
|
/** Widths are bucketed so a resize only rebuilds the option when the label budget can change. */
|
|
@@ -373,9 +407,10 @@ const WIDTH_BUCKET = 40
|
|
|
373
407
|
*/
|
|
374
408
|
function useChartSurface<E extends Element>(
|
|
375
409
|
forwarded: ForwardedRef<E>,
|
|
376
|
-
tokens
|
|
377
|
-
): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined } {
|
|
410
|
+
tokens?: ChartTokens | null,
|
|
411
|
+
): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined; tokens: ChartTokens | null } {
|
|
378
412
|
const [node, setNode] = useState<E | null>(null)
|
|
413
|
+
const scopedTokens = useChartTokens(node)
|
|
379
414
|
const [surface, setSurface] = useState<string | undefined>(undefined)
|
|
380
415
|
const [width, setWidth] = useState<number | undefined>(undefined)
|
|
381
416
|
const ref = useCallback(
|
|
@@ -389,7 +424,7 @@ function useChartSurface<E extends Element>(
|
|
|
389
424
|
useEffect(() => {
|
|
390
425
|
if (!node) return
|
|
391
426
|
setSurface(readChartSurface(node) ?? undefined)
|
|
392
|
-
}, [node, tokens])
|
|
427
|
+
}, [node, tokens, scopedTokens])
|
|
393
428
|
useEffect(() => {
|
|
394
429
|
if (!node || typeof ResizeObserver === "undefined") return
|
|
395
430
|
const update = (w: number) => setWidth(Math.floor(w / WIDTH_BUCKET) * WIDTH_BUCKET)
|
|
@@ -398,7 +433,7 @@ function useChartSurface<E extends Element>(
|
|
|
398
433
|
observer.observe(node)
|
|
399
434
|
return () => observer.disconnect()
|
|
400
435
|
}, [node])
|
|
401
|
-
return { ref, surface, width }
|
|
436
|
+
return { ref, surface, width, tokens: scopedTokens }
|
|
402
437
|
}
|
|
403
438
|
|
|
404
439
|
/* ---------- inlined from @squadbase/squadkit registry/lib/chart-utils.ts (internal to @squadbase/vantage) ---------- */
|
|
@@ -1552,6 +1587,10 @@ const toDate = (t: ParsedTime) => new Date(t.year, t.month - 1, t.day)
|
|
|
1552
1587
|
|
|
1553
1588
|
/** How many x labels a time axis shows at most (the step is picked so the labels never crowd). */
|
|
1554
1589
|
const TIME_AXIS_MAX_LABELS = 6
|
|
1590
|
+
/** Under this plot width (px) the axis, legend and labels switch to their compact form. */
|
|
1591
|
+
const NARROW_PLOT_WIDTH = 400
|
|
1592
|
+
/** The most x labels a narrow plot shows (a 262px plot reads 4 at most). */
|
|
1593
|
+
const NARROW_TIME_AXIS_MAX_LABELS = 4
|
|
1555
1594
|
|
|
1556
1595
|
/** Calendar-friendly label steps per granularity (every 3 months, every week …). */
|
|
1557
1596
|
const TIME_AXIS_STEPS: Record<TimeGranularity, readonly number[]> = {
|
|
@@ -1997,7 +2036,9 @@ const LEGEND_LINE_BUDGET = 320
|
|
|
1997
2036
|
/** How many lines the plain legend takes, estimated from its labels (12px text + 8px dot + gaps). */
|
|
1998
2037
|
function legendLines(items: readonly string[], plotWidth?: number, reserve = 0): number {
|
|
1999
2038
|
const width = items.reduce((sum, name) => sum + [...name].length * 12 + 8 + 5 + 16, 0)
|
|
2000
|
-
|
|
2039
|
+
// A narrow plot has no room for the PNG buttons beside the legend: they move off its line, so no reserve.
|
|
2040
|
+
const narrow = plotWidth !== undefined && plotWidth < NARROW_PLOT_WIDTH
|
|
2041
|
+
const budget = Math.max(80, (plotWidth ? (narrow ? plotWidth : Math.min(LEGEND_LINE_BUDGET, plotWidth)) : LEGEND_LINE_BUDGET) - (narrow ? 0 : reserve))
|
|
2001
2042
|
return Math.min(3, Math.max(1, Math.ceil(width / budget)))
|
|
2002
2043
|
}
|
|
2003
2044
|
|
|
@@ -2252,7 +2293,15 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
2252
2293
|
? null
|
|
2253
2294
|
: timeAxisLabelStep(
|
|
2254
2295
|
rawIndex,
|
|
2255
|
-
plotWidth
|
|
2296
|
+
plotWidth
|
|
2297
|
+
? Math.max(
|
|
2298
|
+
2,
|
|
2299
|
+
Math.min(
|
|
2300
|
+
plotWidth < NARROW_PLOT_WIDTH ? NARROW_TIME_AXIS_MAX_LABELS : TIME_AXIS_MAX_LABELS,
|
|
2301
|
+
Math.floor(plotWidth / TIME_LABEL_WIDTH),
|
|
2302
|
+
),
|
|
2303
|
+
)
|
|
2304
|
+
: undefined,
|
|
2256
2305
|
)
|
|
2257
2306
|
// Plain (non-date) x labels of a line chart: thinned to every n-th so they never touch, never rotated.
|
|
2258
2307
|
const plainStep = (() => {
|
|
@@ -2298,8 +2347,12 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
2298
2347
|
: []
|
|
2299
2348
|
// On a narrow plot the max and the latest label would print over each other: keep the latest.
|
|
2300
2349
|
const pointGap = plotWidth && labels.length > 1 ? plotWidth / (kind === "bar" ? labels.length : labels.length - 1) : null
|
|
2350
|
+
// By default only the latest value is labelled; an explicit `highlight` keeps its max + latest.
|
|
2351
|
+
const latestOnly = cfg.highlight === undefined && !shareHighlight && picked.length === 2
|
|
2301
2352
|
const highlightPoints =
|
|
2302
|
-
|
|
2353
|
+
latestOnly
|
|
2354
|
+
? [picked[1]!]
|
|
2355
|
+
: picked.length === 2 && pointGap !== null && Math.abs(picked[1]!.index - picked[0]!.index) * pointGap < HIGHLIGHT_LABEL_WIDTH
|
|
2303
2356
|
? [picked[1]!]
|
|
2304
2357
|
: picked
|
|
2305
2358
|
|
|
@@ -2510,6 +2563,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
2510
2563
|
? { width: 0, color, opacity: 0 }
|
|
2511
2564
|
: { width: 2, color, opacity: dimSeries ? DIMMED : 1 },
|
|
2512
2565
|
itemStyle: { color, opacity: dimSeries ? DIMMED : 1 },
|
|
2566
|
+
// Hovering a series fades the others (not for a stack, where the bands read together).
|
|
2567
|
+
emphasis: stacked ? undefined : { focus: "series" as const },
|
|
2513
2568
|
areaStyle: areaStyle && dimSeries ? { ...areaStyle, opacity: DIMMED * 0.3 } : areaStyle,
|
|
2514
2569
|
markPoint: markPointFor(c, color),
|
|
2515
2570
|
markLine: undefined,
|
|
@@ -2956,7 +3011,10 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
2956
3011
|
legend: {
|
|
2957
3012
|
show: showLegend,
|
|
2958
3013
|
top: 0,
|
|
2959
|
-
|
|
3014
|
+
// Narrow: left-aligned and wrapping above the plot, so nothing is cut off at the right edge.
|
|
3015
|
+
...(plotWidth !== undefined && plotWidth < NARROW_PLOT_WIDTH
|
|
3016
|
+
? { left: 0, right: 0 }
|
|
3017
|
+
: { right: reserveActions ? ACTIONS_RESERVE : 0 }),
|
|
2960
3018
|
// Plain wraps onto a second line at narrow widths instead of hiding items behind arrows.
|
|
2961
3019
|
type: "plain",
|
|
2962
3020
|
icon: "circle",
|
|
@@ -3017,7 +3075,12 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
|
3017
3075
|
}
|
|
3018
3076
|
}
|
|
3019
3077
|
const shares = percent ? cartesianSeriesValues(i <= lastActual ? [row] : [], categories, "percent") : null
|
|
3020
|
-
|
|
3078
|
+
// Rows follow the legend order (categories first), not ECharts' own series order.
|
|
3079
|
+
const legendRank = (p: AxisTooltipParam) => {
|
|
3080
|
+
const at = categories.indexOf(p.seriesName ?? "")
|
|
3081
|
+
return at === -1 ? categories.length : at
|
|
3082
|
+
}
|
|
3083
|
+
for (const p of [...list].sort((a, b) => legendRank(a) - legendRank(b))) {
|
|
3021
3084
|
const color = typeof p.color === "string" ? p.color : "currentColor"
|
|
3022
3085
|
if (p.seriesId === FORECAST_SERIES_ID) {
|
|
3023
3086
|
// At and before the boundary the forecast repeats the actual value.
|