@squadbase/vantage 0.6.2-dev1.b28a4df → 0.6.2-dev1.b40ce36

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.
Files changed (213) hide show
  1. package/dist/{build-X5NVCQKT.js → build-A23M4GCH.js} +7 -7
  2. package/dist/{build-X5NVCQKT.js.map → build-A23M4GCH.js.map} +1 -1
  3. package/dist/{check-ZBIH422T.js → check-FBHT6W6J.js} +5 -5
  4. package/dist/{check-ZBIH422T.js.map → check-FBHT6W6J.js.map} +1 -1
  5. package/dist/chunk-2IAPIOPS.js +152 -0
  6. package/dist/chunk-2IAPIOPS.js.map +1 -0
  7. package/dist/chunk-57GHR5LN.js +163 -0
  8. package/dist/chunk-57GHR5LN.js.map +1 -0
  9. package/dist/{chunk-7DZGJQ3P.js → chunk-5C2SEDPH.js} +21 -4
  10. package/dist/chunk-5C2SEDPH.js.map +1 -0
  11. package/dist/chunk-6OTTGAPO.js +44 -0
  12. package/dist/chunk-6OTTGAPO.js.map +1 -0
  13. package/dist/{chunk-XPQSTZUU.js → chunk-7S52M2SL.js} +4 -3
  14. package/dist/chunk-7S52M2SL.js.map +1 -0
  15. package/dist/{chunk-V3LF7BE7.js → chunk-ANZLVSEP.js} +4 -5
  16. package/dist/chunk-ANZLVSEP.js.map +1 -0
  17. package/dist/{chunk-XQUSGIDG.js → chunk-CKWPK4RO.js} +10 -3
  18. package/dist/chunk-CKWPK4RO.js.map +1 -0
  19. package/dist/chunk-JXFCSF3K.js +673 -0
  20. package/dist/chunk-JXFCSF3K.js.map +1 -0
  21. package/dist/{chunk-UNCSBSO4.js → chunk-QOXXOFGL.js} +112 -927
  22. package/dist/chunk-QOXXOFGL.js.map +1 -0
  23. package/dist/{chunk-CEXAQQ5W.js → chunk-S5MDTHH7.js} +180 -18
  24. package/dist/chunk-S5MDTHH7.js.map +1 -0
  25. package/dist/{chunk-2ICNHUG6.js → chunk-SDJTTMLK.js} +64 -7
  26. package/dist/chunk-SDJTTMLK.js.map +1 -0
  27. package/dist/{chunk-WAMZWZJB.js → chunk-SHZM3EXB.js} +19 -8
  28. package/dist/chunk-SHZM3EXB.js.map +1 -0
  29. package/dist/{chunk-JKLLFVEU.js → chunk-UWSQUHX6.js} +4 -99
  30. package/dist/chunk-UWSQUHX6.js.map +1 -0
  31. package/dist/cli.js +6 -6
  32. package/dist/client/index.d.ts +2 -0
  33. package/dist/client/index.js +8 -2
  34. package/dist/client/index.js.map +1 -1
  35. package/dist/components/index.d.ts +13 -249
  36. package/dist/components/index.js +5 -2
  37. package/dist/delta-text-CvwBGJ95.d.ts +15 -0
  38. package/dist/{dev-4XEDTELV.js → dev-H4I2JL63.js} +7 -7
  39. package/dist/{dev-4XEDTELV.js.map → dev-H4I2JL63.js.map} +1 -1
  40. package/dist/doc/index.d.ts +215 -2
  41. package/dist/doc/index.js +307 -3
  42. package/dist/doc/index.js.map +1 -1
  43. package/dist/{doctor-5E2VEZPK.js → doctor-B2O3GJDU.js} +5 -5
  44. package/dist/{doctor-5E2VEZPK.js.map → doctor-B2O3GJDU.js.map} +1 -1
  45. package/dist/format-U0cQl069.d.ts +243 -0
  46. package/dist/query/index.js +2 -2
  47. package/dist/{routes-3SGAFDMC.js → routes-PVKNT777.js} +4 -4
  48. package/dist/{routes-3SGAFDMC.js.map → routes-PVKNT777.js.map} +1 -1
  49. package/dist/slide/index.d.ts +272 -1
  50. package/dist/slide/index.js +972 -2
  51. package/dist/slide/index.js.map +1 -1
  52. package/dist/table/index.js +7 -4
  53. package/dist/table/index.js.map +1 -1
  54. package/dist/ui/index.d.ts +4 -1
  55. package/dist/ui/index.js +3 -3
  56. package/dist/ui/index.js.map +1 -1
  57. package/dist/{upgrade-JHXZBGQS.js → upgrade-6DT7ADRP.js} +4 -4
  58. package/dist/{upgrade-JHXZBGQS.js.map → upgrade-6DT7ADRP.js.map} +1 -1
  59. package/dist/vite/index.js +3 -3
  60. package/docs/en/changelog.md +2 -0
  61. package/docs/en/components.md +13 -2
  62. package/docs/en/doc/delta-text.md +40 -0
  63. package/docs/en/doc/doc-action-items.md +49 -0
  64. package/docs/en/doc/doc-callout.md +38 -0
  65. package/docs/en/doc/doc-data-table.md +60 -0
  66. package/docs/en/doc/doc-executive-summary.md +64 -0
  67. package/docs/en/doc/doc-figure.md +56 -0
  68. package/docs/en/doc/doc-key-findings.md +61 -0
  69. package/docs/en/doc/doc-kpi-scorecard.md +80 -0
  70. package/docs/en/doc/doc-notes-and-sources.md +43 -0
  71. package/docs/en/doc/doc-report-header.md +46 -0
  72. package/docs/en/pages-and-metadata.md +15 -0
  73. package/docs/en/parts/area-chart.md +99 -0
  74. package/docs/en/parts/bar-chart.md +91 -0
  75. package/docs/en/parts/bar-list.md +50 -0
  76. package/docs/en/parts/category-bar.md +37 -0
  77. package/docs/en/parts/chart-frame.md +39 -0
  78. package/docs/en/parts/delta.md +4 -3
  79. package/docs/en/parts/donut-chart.md +72 -0
  80. package/docs/en/parts/funnel-steps.md +2 -1
  81. package/docs/en/parts/heatmap.md +53 -0
  82. package/docs/en/parts/kpi-card.md +2 -1
  83. package/docs/en/parts/legend.md +35 -0
  84. package/docs/en/parts/line-chart.md +94 -0
  85. package/docs/en/parts/progress-circle.md +33 -0
  86. package/docs/en/parts/sample-data.md +39 -0
  87. package/docs/en/parts/scatter-chart.md +56 -0
  88. package/docs/en/parts/tracker.md +36 -0
  89. package/docs/en/parts/updated-at.md +30 -0
  90. package/docs/en/parts/waterfall-chart.md +56 -0
  91. package/docs/en/slide/slide-chart-full.md +48 -0
  92. package/docs/en/slide/slide-chart-insight.md +52 -0
  93. package/docs/en/slide/slide-columns.md +46 -0
  94. package/docs/en/slide/slide-comparison.md +68 -0
  95. package/docs/en/slide/slide-cover.md +39 -0
  96. package/docs/en/slide/slide-exec-summary.md +47 -0
  97. package/docs/en/slide/slide-heading.md +38 -0
  98. package/docs/en/slide/slide-kpi-grid.md +55 -0
  99. package/docs/en/slide/slide-kpi-hero.md +58 -0
  100. package/docs/en/slide/slide-next-actions.md +49 -0
  101. package/docs/en/slide/slide-scorecard-table.md +54 -0
  102. package/docs/en/slide/slide-section.md +44 -0
  103. package/docs/en/slide/slide-source-line.md +34 -0
  104. package/docs/en/ui/alert-dialog.md +42 -0
  105. package/docs/en/ui/avatar.md +31 -0
  106. package/docs/en/ui/combobox.md +74 -0
  107. package/docs/en/ui/drawer.md +41 -0
  108. package/docs/en/ui/empty.md +1 -1
  109. package/docs/en/ui/error-state.md +4 -3
  110. package/docs/en/ui/field.md +42 -0
  111. package/docs/en/ui/hover-card.md +21 -0
  112. package/docs/en/ui/kbd.md +17 -0
  113. package/docs/en/ui/loading.md +1 -1
  114. package/docs/en/ui/pagination.md +36 -0
  115. package/docs/en/ui/radio-group.md +21 -0
  116. package/docs/en/ui/toast.md +44 -0
  117. package/docs/en/ui/toggle-group.md +4 -3
  118. package/docs/index.json +857 -77
  119. package/docs/ja/changelog.md +2 -0
  120. package/docs/ja/components.md +12 -1
  121. package/docs/ja/doc/delta-text.md +40 -0
  122. package/docs/ja/doc/doc-action-items.md +49 -0
  123. package/docs/ja/doc/doc-callout.md +38 -0
  124. package/docs/ja/doc/doc-data-table.md +60 -0
  125. package/docs/ja/doc/doc-executive-summary.md +64 -0
  126. package/docs/ja/doc/doc-figure.md +56 -0
  127. package/docs/ja/doc/doc-key-findings.md +61 -0
  128. package/docs/ja/doc/doc-kpi-scorecard.md +80 -0
  129. package/docs/ja/doc/doc-notes-and-sources.md +43 -0
  130. package/docs/ja/doc/doc-report-header.md +46 -0
  131. package/docs/ja/page-types/slide.md +1 -0
  132. package/docs/ja/pages-and-metadata.md +15 -0
  133. package/docs/ja/parts/area-chart.md +49 -25
  134. package/docs/ja/parts/bar-chart.md +50 -24
  135. package/docs/ja/parts/bar-list.md +31 -8
  136. package/docs/ja/parts/chart-frame.md +36 -0
  137. package/docs/ja/parts/delta.md +5 -4
  138. package/docs/ja/parts/donut-chart.md +45 -26
  139. package/docs/ja/parts/funnel-steps.md +27 -8
  140. package/docs/ja/parts/heatmap.md +45 -0
  141. package/docs/ja/parts/kpi-card.md +2 -1
  142. package/docs/ja/parts/legend.md +36 -0
  143. package/docs/ja/parts/line-chart.md +48 -22
  144. package/docs/ja/parts/sample-data.md +61 -0
  145. package/docs/ja/parts/scatter-chart.md +69 -0
  146. package/docs/ja/parts/updated-at.md +27 -0
  147. package/docs/ja/parts/waterfall-chart.md +61 -0
  148. package/docs/ja/slide/slide-chart-full.md +48 -0
  149. package/docs/ja/slide/slide-chart-insight.md +52 -0
  150. package/docs/ja/slide/slide-columns.md +46 -0
  151. package/docs/ja/slide/slide-comparison.md +68 -0
  152. package/docs/ja/slide/slide-cover.md +39 -0
  153. package/docs/ja/slide/slide-exec-summary.md +47 -0
  154. package/docs/ja/slide/slide-heading.md +38 -0
  155. package/docs/ja/slide/slide-kpi-grid.md +55 -0
  156. package/docs/ja/slide/slide-kpi-hero.md +58 -0
  157. package/docs/ja/slide/slide-next-actions.md +49 -0
  158. package/docs/ja/slide/slide-scorecard-table.md +54 -0
  159. package/docs/ja/slide/slide-section.md +44 -0
  160. package/docs/ja/slide/slide-source-line.md +34 -0
  161. package/docs/ja/ui/empty.md +1 -1
  162. package/docs/ja/ui/error-state.md +4 -3
  163. package/docs/ja/ui/loading.md +1 -1
  164. package/docs/ja/ui/toggle-group.md +4 -3
  165. package/package.json +2 -2
  166. package/registry/ui/area-chart.tsx +88 -26
  167. package/registry/ui/attention-list.tsx +303 -0
  168. package/registry/ui/bar-chart.tsx +88 -26
  169. package/registry/ui/bar-list.tsx +87 -24
  170. package/registry/ui/box-plot-chart.tsx +3906 -0
  171. package/registry/ui/category-bar.tsx +54 -19
  172. package/registry/ui/chart-frame.tsx +56 -21
  173. package/registry/ui/dashboard-card.tsx +2 -2
  174. package/registry/ui/delta.tsx +11 -8
  175. package/registry/ui/donut-chart.tsx +89 -26
  176. package/registry/ui/echart.tsx +194 -44
  177. package/registry/ui/funnel-steps.tsx +33 -5
  178. package/registry/ui/heatmap.tsx +55 -21
  179. package/registry/ui/histogram-chart.tsx +3986 -0
  180. package/registry/ui/kpi-card.tsx +41 -14
  181. package/registry/ui/legend.tsx +87 -24
  182. package/registry/ui/line-chart.tsx +88 -26
  183. package/registry/ui/page-shell.tsx +9 -9
  184. package/registry/ui/paired-chart.tsx +3827 -0
  185. package/registry/ui/sample-data.tsx +1 -1
  186. package/registry/ui/scatter-chart.tsx +88 -26
  187. package/registry/ui/section-header.tsx +2 -2
  188. package/registry/ui/slider.tsx +1 -1
  189. package/registry/ui/status-badge.tsx +8 -8
  190. package/registry/ui/tooltip.tsx +6 -2
  191. package/registry/ui/tracker.tsx +9 -3
  192. package/registry/ui/trend-indicator.tsx +2 -2
  193. package/registry/ui/waterfall-chart.tsx +88 -26
  194. package/skills/vantage-design/SKILL.md +27 -25
  195. package/skills/vantage-design/references/extract.md +16 -14
  196. package/skills/vantage-design/references/format.md +6 -5
  197. package/skills/vantage-design/references/presets.md +60 -110
  198. package/skills/vantage-pitfalls/SKILL.md +5 -0
  199. package/skills/vantage-slide/SKILL.md +54 -36
  200. package/templates/AGENTS.md +4 -2
  201. package/templates/agents/doc.md +48 -0
  202. package/templates/agents/page.md +22 -1
  203. package/templates/agents/slide.md +70 -2
  204. package/theme.css +24 -20
  205. package/dist/chunk-2ICNHUG6.js.map +0 -1
  206. package/dist/chunk-7DZGJQ3P.js.map +0 -1
  207. package/dist/chunk-CEXAQQ5W.js.map +0 -1
  208. package/dist/chunk-JKLLFVEU.js.map +0 -1
  209. package/dist/chunk-UNCSBSO4.js.map +0 -1
  210. package/dist/chunk-V3LF7BE7.js.map +0 -1
  211. package/dist/chunk-WAMZWZJB.js.map +0 -1
  212. package/dist/chunk-XPQSTZUU.js.map +0 -1
  213. package/dist/chunk-XQUSGIDG.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
- return {
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
- themeObserver.observe(document.documentElement, { attributes: true, attributeFilter })
176
- if (document.body) themeObserver.observe(document.body, { attributes: true, attributeFilter })
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: { color: tokens.popoverForeground ?? undefined, fontSize: 12 },
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 off the document root (`--positive`, …); `var(--token)` is read off the document root; any
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
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue(`--${color.trim()}`)) ?? ""
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: ChartTokens | null,
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
- const budget = Math.max(80, (plotWidth ? Math.min(LEGEND_LINE_BUDGET, plotWidth) : LEGEND_LINE_BUDGET) - reserve)
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 ? Math.max(2, Math.min(TIME_AXIS_MAX_LABELS, Math.floor(plotWidth / TIME_LABEL_WIDTH))) : undefined,
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
- picked.length === 2 && pointGap !== null && Math.abs(picked[1]!.index - picked[0]!.index) * pointGap < HIGHLIGHT_LABEL_WIDTH
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
- right: reserveActions ? ACTIONS_RESERVE : 0,
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
- for (const p of list) {
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, tokens)
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])
@@ -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
- return {
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
- themeObserver.observe(document.documentElement, { attributes: true, attributeFilter })
189
- if (document.body) themeObserver.observe(document.body, { attributes: true, attributeFilter })
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: { color: tokens.popoverForeground ?? undefined, fontSize: 12 },
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 off the document root (`--positive`, …); `var(--token)` is read off the document root; any
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
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue(`--${color.trim()}`)) ?? ""
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: ChartTokens | null,
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
- const budget = Math.max(80, (plotWidth ? Math.min(LEGEND_LINE_BUDGET, plotWidth) : LEGEND_LINE_BUDGET) - reserve)
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 ? Math.max(2, Math.min(TIME_AXIS_MAX_LABELS, Math.floor(plotWidth / TIME_LABEL_WIDTH))) : undefined,
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
- picked.length === 2 && pointGap !== null && Math.abs(picked[1]!.index - picked[0]!.index) * pointGap < HIGHLIGHT_LABEL_WIDTH
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
- right: reserveActions ? ACTIONS_RESERVE : 0,
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
- for (const p of list) {
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.