@squadbase/vantage 0.6.2-dev1.5aae04e → 0.6.2-dev1.81c1bbe

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 (140) hide show
  1. package/dist/chunk-2IAPIOPS.js +152 -0
  2. package/dist/chunk-2IAPIOPS.js.map +1 -0
  3. package/dist/chunk-57GHR5LN.js +163 -0
  4. package/dist/chunk-57GHR5LN.js.map +1 -0
  5. package/dist/{chunk-XPQSTZUU.js → chunk-7S52M2SL.js} +4 -3
  6. package/dist/chunk-7S52M2SL.js.map +1 -0
  7. package/dist/chunk-JXFCSF3K.js +673 -0
  8. package/dist/chunk-JXFCSF3K.js.map +1 -0
  9. package/dist/{chunk-HKSVYMYJ.js → chunk-RU6WKEOG.js} +56 -927
  10. package/dist/chunk-RU6WKEOG.js.map +1 -0
  11. package/dist/components/index.d.ts +3 -243
  12. package/dist/components/index.js +5 -2
  13. package/dist/doc/index.d.ts +194 -2
  14. package/dist/doc/index.js +334 -3
  15. package/dist/doc/index.js.map +1 -1
  16. package/dist/format-U0cQl069.d.ts +243 -0
  17. package/dist/slide/index.d.ts +177 -1
  18. package/dist/slide/index.js +864 -1
  19. package/dist/slide/index.js.map +1 -1
  20. package/dist/table/index.js +5 -2
  21. package/dist/table/index.js.map +1 -1
  22. package/dist/ui/index.d.ts +4 -1
  23. package/dist/ui/index.js +3 -3
  24. package/dist/ui/index.js.map +1 -1
  25. package/docs/en/components.md +11 -2
  26. package/docs/en/doc/doc-action-items.md +49 -0
  27. package/docs/en/doc/doc-callout.md +38 -0
  28. package/docs/en/doc/doc-data-table.md +60 -0
  29. package/docs/en/doc/doc-executive-summary.md +64 -0
  30. package/docs/en/doc/doc-figure.md +56 -0
  31. package/docs/en/doc/doc-key-findings.md +61 -0
  32. package/docs/en/doc/doc-kpi-scorecard.md +80 -0
  33. package/docs/en/doc/doc-report-header.md +46 -0
  34. package/docs/en/parts/area-chart.md +99 -0
  35. package/docs/en/parts/bar-chart.md +91 -0
  36. package/docs/en/parts/bar-list.md +50 -0
  37. package/docs/en/parts/category-bar.md +37 -0
  38. package/docs/en/parts/chart-frame.md +39 -0
  39. package/docs/en/parts/donut-chart.md +72 -0
  40. package/docs/en/parts/funnel-steps.md +2 -1
  41. package/docs/en/parts/heatmap.md +53 -0
  42. package/docs/en/parts/legend.md +35 -0
  43. package/docs/en/parts/line-chart.md +94 -0
  44. package/docs/en/parts/progress-circle.md +33 -0
  45. package/docs/en/parts/sample-data.md +39 -0
  46. package/docs/en/parts/scatter-chart.md +56 -0
  47. package/docs/en/parts/tracker.md +36 -0
  48. package/docs/en/parts/updated-at.md +30 -0
  49. package/docs/en/parts/waterfall-chart.md +56 -0
  50. package/docs/en/slide/slide-chart-insight.md +52 -0
  51. package/docs/en/slide/slide-cover.md +39 -0
  52. package/docs/en/slide/slide-exec-summary.md +47 -0
  53. package/docs/en/slide/slide-kpi-grid.md +55 -0
  54. package/docs/en/slide/slide-kpi-hero.md +58 -0
  55. package/docs/en/slide/slide-next-actions.md +49 -0
  56. package/docs/en/slide/slide-scorecard-table.md +54 -0
  57. package/docs/en/ui/alert-dialog.md +42 -0
  58. package/docs/en/ui/avatar.md +31 -0
  59. package/docs/en/ui/combobox.md +74 -0
  60. package/docs/en/ui/drawer.md +41 -0
  61. package/docs/en/ui/empty.md +1 -1
  62. package/docs/en/ui/error-state.md +4 -3
  63. package/docs/en/ui/field.md +42 -0
  64. package/docs/en/ui/hover-card.md +21 -0
  65. package/docs/en/ui/kbd.md +17 -0
  66. package/docs/en/ui/loading.md +1 -1
  67. package/docs/en/ui/pagination.md +36 -0
  68. package/docs/en/ui/radio-group.md +21 -0
  69. package/docs/en/ui/toast.md +44 -0
  70. package/docs/en/ui/toggle-group.md +4 -3
  71. package/docs/index.json +697 -77
  72. package/docs/ja/components.md +10 -1
  73. package/docs/ja/doc/doc-action-items.md +49 -0
  74. package/docs/ja/doc/doc-callout.md +38 -0
  75. package/docs/ja/doc/doc-data-table.md +60 -0
  76. package/docs/ja/doc/doc-executive-summary.md +64 -0
  77. package/docs/ja/doc/doc-figure.md +56 -0
  78. package/docs/ja/doc/doc-key-findings.md +61 -0
  79. package/docs/ja/doc/doc-kpi-scorecard.md +80 -0
  80. package/docs/ja/doc/doc-report-header.md +46 -0
  81. package/docs/ja/parts/area-chart.md +49 -25
  82. package/docs/ja/parts/bar-chart.md +50 -24
  83. package/docs/ja/parts/bar-list.md +31 -8
  84. package/docs/ja/parts/chart-frame.md +36 -0
  85. package/docs/ja/parts/donut-chart.md +45 -26
  86. package/docs/ja/parts/funnel-steps.md +27 -8
  87. package/docs/ja/parts/heatmap.md +45 -0
  88. package/docs/ja/parts/legend.md +36 -0
  89. package/docs/ja/parts/line-chart.md +48 -22
  90. package/docs/ja/parts/sample-data.md +61 -0
  91. package/docs/ja/parts/scatter-chart.md +69 -0
  92. package/docs/ja/parts/updated-at.md +27 -0
  93. package/docs/ja/parts/waterfall-chart.md +61 -0
  94. package/docs/ja/slide/slide-chart-insight.md +52 -0
  95. package/docs/ja/slide/slide-cover.md +39 -0
  96. package/docs/ja/slide/slide-exec-summary.md +47 -0
  97. package/docs/ja/slide/slide-kpi-grid.md +55 -0
  98. package/docs/ja/slide/slide-kpi-hero.md +58 -0
  99. package/docs/ja/slide/slide-next-actions.md +49 -0
  100. package/docs/ja/slide/slide-scorecard-table.md +54 -0
  101. package/docs/ja/ui/empty.md +1 -1
  102. package/docs/ja/ui/error-state.md +4 -3
  103. package/docs/ja/ui/loading.md +1 -1
  104. package/docs/ja/ui/toggle-group.md +4 -3
  105. package/package.json +2 -2
  106. package/registry/ui/area-chart.tsx +45 -19
  107. package/registry/ui/bar-chart.tsx +45 -19
  108. package/registry/ui/bar-list.tsx +44 -17
  109. package/registry/ui/box-plot-chart.tsx +45 -19
  110. package/registry/ui/category-bar.tsx +44 -17
  111. package/registry/ui/chart-frame.tsx +44 -17
  112. package/registry/ui/delta.tsx +4 -4
  113. package/registry/ui/donut-chart.tsx +46 -19
  114. package/registry/ui/echart.tsx +171 -54
  115. package/registry/ui/heatmap.tsx +45 -19
  116. package/registry/ui/histogram-chart.tsx +45 -19
  117. package/registry/ui/legend.tsx +44 -17
  118. package/registry/ui/line-chart.tsx +45 -19
  119. package/registry/ui/page-shell.tsx +7 -7
  120. package/registry/ui/paired-chart.tsx +45 -19
  121. package/registry/ui/sample-data.tsx +1 -1
  122. package/registry/ui/scatter-chart.tsx +45 -19
  123. package/registry/ui/slider.tsx +1 -1
  124. package/registry/ui/status-badge.tsx +8 -8
  125. package/registry/ui/tooltip.tsx +6 -2
  126. package/registry/ui/tracker.tsx +9 -3
  127. package/registry/ui/trend-indicator.tsx +2 -2
  128. package/registry/ui/waterfall-chart.tsx +45 -19
  129. package/skills/vantage-design/SKILL.md +27 -25
  130. package/skills/vantage-design/references/extract.md +16 -14
  131. package/skills/vantage-design/references/format.md +6 -5
  132. package/skills/vantage-design/references/presets.md +60 -110
  133. package/skills/vantage-slide/SKILL.md +54 -36
  134. package/templates/AGENTS.md +2 -2
  135. package/templates/agents/doc.md +45 -0
  136. package/templates/agents/page.md +22 -1
  137. package/templates/agents/slide.md +55 -2
  138. package/theme.css +16 -15
  139. package/dist/chunk-HKSVYMYJ.js.map +0 -1
  140. package/dist/chunk-XPQSTZUU.js.map +0 -1
@@ -22,7 +22,7 @@ import { forwardRef, useEffect, useMemo, useState, type ForwardedRef, type React
22
22
  import { ChartFrame } from "@squadbase/vantage/components"
23
23
  import { EChart } from "@squadbase/vantage/components"
24
24
  import type { ChartCommonProps, ChartEventPayload, ChartSeriesColor } from "@squadbase/vantage/components"
25
- import { createContext, useCallback, useRef } from "react"
25
+ import { createContext, useCallback, useRef, type RefObject } from "react"
26
26
  import type { ChartTone } from "@squadbase/vantage/components"
27
27
  import { DEFAULT_LOCALE, EMPTY_VALUE, formatValue, toValueFormatter, type ValueFormat } from "@squadbase/vantage/components"
28
28
  import type { ChartColor, ChartColors, ValueFormatter } from "@squadbase/vantage/components"
@@ -64,10 +64,23 @@ interface ChartTokens {
64
64
  border: string | null
65
65
  /** Tooltip surface. */
66
66
  popover: string | null
67
+ /** Semantic and surface dependencies used by typed chart options. Optional for hand-built tokens. */
68
+ colors?: Record<string, string>
69
+ /** Actual inherited painting surface (may change without a token changing). */
70
+ surface?: string | null
67
71
  /** Tooltip text. */
68
72
  popoverForeground: string | null
69
73
  }
70
74
 
75
+ // Keep scope out of the public token shape. A resolved snapshot remembers where arbitrary
76
+ // var(--token) expressions must be read, without retaining unmounted charts globally.
77
+ const tokenScopes = new WeakMap<ChartTokens, Element>()
78
+ const OPTION_COLOR_TOKENS = [
79
+ "--background", "--card", "--muted", "--primary", "--destructive",
80
+ ...["positive", "negative", "warning", "neutral", "info"].flatMap((tone) =>
81
+ ["", "-fill", "-subtle"].map((suffix) => `--${tone}${suffix}`)),
82
+ ]
83
+
71
84
  let normalizeContext: CanvasRenderingContext2D | null | undefined
72
85
 
73
86
  /** The forms `fillStyle` serializes legacy colours to, and zrender can parse. */
@@ -123,8 +136,10 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
123
136
  const color = read(token)
124
137
  if (color) series.push(color)
125
138
  }
126
- return {
139
+ const tokens: ChartTokens = {
127
140
  series,
141
+ colors: Object.fromEntries(OPTION_COLOR_TOKENS.map((name) => [name, styles.getPropertyValue(name).trim()])),
142
+ surface: readChartSurface(target),
128
143
  ...(styles.fontFamily ? { fontFamily: styles.fontFamily } : {}),
129
144
  foreground: read("--foreground"),
130
145
  mutedForeground: read("--muted-foreground"),
@@ -132,6 +147,8 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
132
147
  popover: read("--popover"),
133
148
  popoverForeground: read("--popover-foreground"),
134
149
  }
150
+ tokenScopes.set(tokens, target)
151
+ return tokens
135
152
  }
136
153
 
137
154
  /** Structural equality, so a theme toggle that changes nothing doesn't re-init. */
@@ -139,6 +156,10 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
139
156
  if (a === b) return true
140
157
  if (!a || !b) return false
141
158
  return (
159
+ tokenScopes.get(a) === tokenScopes.get(b) &&
160
+ a.surface === b.surface &&
161
+ Object.keys(a.colors ?? {}).length === Object.keys(b.colors ?? {}).length &&
162
+ Object.entries(a.colors ?? {}).every(([name, value]) => b.colors?.[name] === value) &&
142
163
  a.series.length === b.series.length &&
143
164
  a.series.every((color, i) => color === b.series[i]) &&
144
165
  a.fontFamily === b.fontFamily &&
@@ -164,7 +185,7 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
164
185
  * attributes alone leaves the chart painted in the previous palette until a
165
186
  * reload — the tokens in the DOM are new, the canvas is not.
166
187
  */
167
- function watchChartTokens(onChange: () => void): () => void {
188
+ function watchChartTokens(onChange: () => void, scope?: Element | null): () => void {
168
189
  if (typeof document === "undefined") return () => {}
169
190
  // Style edits arrive as bursts of mutations; coalesce them into one read, and
170
191
  // take it after the next frame so a swapped stylesheet has been applied.
@@ -179,8 +200,11 @@ function watchChartTokens(onChange: () => void): () => void {
179
200
 
180
201
  const themeObserver = new MutationObserver(schedule)
181
202
  const attributeFilter = ["class", "style", "data-theme"]
182
- themeObserver.observe(document.documentElement, { attributes: true, attributeFilter })
183
- if (document.body) themeObserver.observe(document.body, { attributes: true, attributeFilter })
203
+ // Only the chart and its ancestors can change its inherited theme. Never observe
204
+ // the document subtree: canvas/tooltip/rendering mutations would trigger every chart.
205
+ for (let node: Element | null = scope ?? document.body ?? document.documentElement; node; node = node.parentElement) {
206
+ themeObserver.observe(node, { attributes: true, attributeFilter })
207
+ }
184
208
 
185
209
  const styleObserver = new MutationObserver(schedule)
186
210
  if (document.head) {
@@ -268,22 +292,23 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
268
292
  * components that build an ECharts option from token colours themselves (the
269
293
  * typed charts), so their palette follows light/dark like `EChart`'s theme.
270
294
  */
271
- function useChartTokens(): ChartTokens | null {
295
+ function useChartTokens(scope?: Element | null | RefObject<Element | null>): ChartTokens | null {
272
296
  const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
273
297
  // Compared here, not inside a `setTokens` updater: an updater that reads the DOM is
274
298
  // replayed by React on every rebase and returns a fresh object each time, so the state
275
299
  // never settles ("Maximum update depth exceeded" once the tokens change after mount).
276
300
  const tokensRef = useRef(tokens)
277
301
  useEffect(() => {
302
+ const target = scope && "current" in scope ? scope.current : scope
278
303
  const sync = () => {
279
- const next = readChartTokens()
304
+ const next = readChartTokens(target)
280
305
  if (sameChartTokens(tokensRef.current, next)) return
281
306
  tokensRef.current = next
282
307
  setTokens(next)
283
308
  }
284
309
  sync()
285
- return watchChartTokens(sync)
286
- }, [])
310
+ return watchChartTokens(sync, target)
311
+ }, [scope])
287
312
  return tokens
288
313
  }
289
314
 
@@ -295,7 +320,7 @@ const EMBEDDED_VAR_RE = /var\(\s*(--[\w-]+)\s*\)/g
295
320
  /**
296
321
  * Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
297
322
  * the resolved series tokens; `"positive"` / `"warning"` / `"negative"` / `"neutral"` (and the area
298
- * variants `"positive-fill"`, …) are read off the document root (`--positive`, …); `var(--token)` is read off the document root; any
323
+ * variants `"positive-fill"`, …) are read in the snapshot's inherited scope (`--positive`, …); `var(--token)` uses the same scope; any
299
324
  * other CSS colour is normalized like the tokens are. A colour that can't be
300
325
  * resolved comes back as `""`, which ECharts treats as "use the palette".
301
326
  */
@@ -306,12 +331,13 @@ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | nul
306
331
  // Meaning colours are the theme tokens of the same name: same path as `var(--positive)`.
307
332
  if (TONE_NAME_RE.test(color.trim())) {
308
333
  if (typeof document === "undefined") return ""
309
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue(`--${color.trim()}`)) ?? ""
334
+ const target = (tokens && tokenScopes.get(tokens)) ?? document.documentElement
335
+ return toCanvasColor(getComputedStyle(target).getPropertyValue(`--${color.trim()}`)) ?? ""
310
336
  }
311
337
  if (typeof document === "undefined") return color
312
338
  let value = color.trim()
313
339
  const cssVar = CSS_VAR_RE.exec(value)
314
- const styles = getComputedStyle(document.documentElement)
340
+ const styles = getComputedStyle((tokens && tokenScopes.get(tokens)) ?? document.documentElement)
315
341
  if (cssVar) {
316
342
  value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
317
343
  } else if (value.includes("var(")) {
@@ -357,7 +383,7 @@ function readChartSurface(el: Element | null): string | null {
357
383
  if (color) return color
358
384
  }
359
385
  }
360
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue("--background"))
386
+ return toCanvasColor(getComputedStyle(el ?? document.documentElement).getPropertyValue("--background"))
361
387
  }
362
388
 
363
389
  /** Widths are bucketed so a resize only rebuilds the option when the label budget can change. */
@@ -371,9 +397,10 @@ const WIDTH_BUCKET = 40
371
397
  */
372
398
  function useChartSurface<E extends Element>(
373
399
  forwarded: ForwardedRef<E>,
374
- tokens: ChartTokens | null,
375
- ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined } {
400
+ tokens?: ChartTokens | null,
401
+ ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined; tokens: ChartTokens | null } {
376
402
  const [node, setNode] = useState<E | null>(null)
403
+ const scopedTokens = useChartTokens(node)
377
404
  const [surface, setSurface] = useState<string | undefined>(undefined)
378
405
  const [width, setWidth] = useState<number | undefined>(undefined)
379
406
  const ref = useCallback(
@@ -387,7 +414,7 @@ function useChartSurface<E extends Element>(
387
414
  useEffect(() => {
388
415
  if (!node) return
389
416
  setSurface(readChartSurface(node) ?? undefined)
390
- }, [node, tokens])
417
+ }, [node, tokens, scopedTokens])
391
418
  useEffect(() => {
392
419
  if (!node || typeof ResizeObserver === "undefined") return
393
420
  const update = (w: number) => setWidth(Math.floor(w / WIDTH_BUCKET) * WIDTH_BUCKET)
@@ -396,7 +423,7 @@ function useChartSurface<E extends Element>(
396
423
  observer.observe(node)
397
424
  return () => observer.disconnect()
398
425
  }, [node])
399
- return { ref, surface, width }
426
+ return { ref, surface, width, tokens: scopedTokens }
400
427
  }
401
428
 
402
429
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-utils.ts (internal to @squadbase/vantage) ---------- */
@@ -3579,7 +3606,7 @@ function DonutChartImpl<T extends Record<string, unknown>>(
3579
3606
  const showLegend = donutOptions?.showLegend ?? true
3580
3607
  const valueFormatter = useMemo(() => resolveValueFormatter(valueFormatterProp, format), [valueFormatterProp, format])
3581
3608
 
3582
- const tokens = useChartTokens()
3609
+ const { ref: frameRef, tokens } = useChartSurface(ref)
3583
3610
  const slices = useMemo(
3584
3611
  () => donutSlices(data, { index: labelKey, value: valueKey, colors, maxSlices, otherColor: CHART_OTHER_COLOR }),
3585
3612
  [data, labelKey, valueKey, colors, maxSlices],
@@ -3661,7 +3688,7 @@ function DonutChartImpl<T extends Record<string, unknown>>(
3661
3688
  return (
3662
3689
  <ChartFrame
3663
3690
  {...chartRootProps(rest)}
3664
- ref={ref}
3691
+ ref={frameRef}
3665
3692
  title={title}
3666
3693
  summary={resolvedSummary}
3667
3694
  isLoading={isLoading ?? loading ?? false}
@@ -58,7 +58,7 @@ import { Check, Copy, Download } from "lucide-react"
58
58
  import { cn } from "@squadbase/vantage/ui"
59
59
  import { Button } from "@squadbase/vantage/ui"
60
60
  import type { ChartActionsOptions } from "@squadbase/vantage/components"
61
- import { createContext, type ForwardedRef } from "react"
61
+ import { createContext, type ForwardedRef, type RefObject } from "react"
62
62
  import type { ChartTone } from "@squadbase/vantage/components"
63
63
 
64
64
  /* ---------- inlined from @squadbase/squadkit registry/lib/chart-theme.ts (internal to @squadbase/vantage) ---------- */
@@ -95,10 +95,23 @@ interface ChartTokens {
95
95
  border: string | null
96
96
  /** Tooltip surface. */
97
97
  popover: string | null
98
+ /** Semantic and surface dependencies used by typed chart options. Optional for hand-built tokens. */
99
+ colors?: Record<string, string>
100
+ /** Actual inherited painting surface (may change without a token changing). */
101
+ surface?: string | null
98
102
  /** Tooltip text. */
99
103
  popoverForeground: string | null
100
104
  }
101
105
 
106
+ // Keep scope out of the public token shape. A resolved snapshot remembers where arbitrary
107
+ // var(--token) expressions must be read, without retaining unmounted charts globally.
108
+ const tokenScopes = new WeakMap<ChartTokens, Element>()
109
+ const OPTION_COLOR_TOKENS = [
110
+ "--background", "--card", "--muted", "--primary", "--destructive",
111
+ ...["positive", "negative", "warning", "neutral", "info"].flatMap((tone) =>
112
+ ["", "-fill", "-subtle"].map((suffix) => `--${tone}${suffix}`)),
113
+ ]
114
+
102
115
  let normalizeContext: CanvasRenderingContext2D | null | undefined
103
116
 
104
117
  /** The forms `fillStyle` serializes legacy colours to, and zrender can parse. */
@@ -154,8 +167,10 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
154
167
  const color = read(token)
155
168
  if (color) series.push(color)
156
169
  }
157
- return {
170
+ const tokens: ChartTokens = {
158
171
  series,
172
+ colors: Object.fromEntries(OPTION_COLOR_TOKENS.map((name) => [name, styles.getPropertyValue(name).trim()])),
173
+ surface: readChartSurface(target),
159
174
  ...(styles.fontFamily ? { fontFamily: styles.fontFamily } : {}),
160
175
  foreground: read("--foreground"),
161
176
  mutedForeground: read("--muted-foreground"),
@@ -163,6 +178,8 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
163
178
  popover: read("--popover"),
164
179
  popoverForeground: read("--popover-foreground"),
165
180
  }
181
+ tokenScopes.set(tokens, target)
182
+ return tokens
166
183
  }
167
184
 
168
185
  /** Structural equality, so a theme toggle that changes nothing doesn't re-init. */
@@ -170,6 +187,10 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
170
187
  if (a === b) return true
171
188
  if (!a || !b) return false
172
189
  return (
190
+ tokenScopes.get(a) === tokenScopes.get(b) &&
191
+ a.surface === b.surface &&
192
+ Object.keys(a.colors ?? {}).length === Object.keys(b.colors ?? {}).length &&
193
+ Object.entries(a.colors ?? {}).every(([name, value]) => b.colors?.[name] === value) &&
173
194
  a.series.length === b.series.length &&
174
195
  a.series.every((color, i) => color === b.series[i]) &&
175
196
  a.fontFamily === b.fontFamily &&
@@ -195,7 +216,7 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
195
216
  * attributes alone leaves the chart painted in the previous palette until a
196
217
  * reload — the tokens in the DOM are new, the canvas is not.
197
218
  */
198
- function watchChartTokens(onChange: () => void): () => void {
219
+ function watchChartTokens(onChange: () => void, scope?: Element | null): () => void {
199
220
  if (typeof document === "undefined") return () => {}
200
221
  // Style edits arrive as bursts of mutations; coalesce them into one read, and
201
222
  // take it after the next frame so a swapped stylesheet has been applied.
@@ -210,8 +231,11 @@ function watchChartTokens(onChange: () => void): () => void {
210
231
 
211
232
  const themeObserver = new MutationObserver(schedule)
212
233
  const attributeFilter = ["class", "style", "data-theme"]
213
- themeObserver.observe(document.documentElement, { attributes: true, attributeFilter })
214
- if (document.body) themeObserver.observe(document.body, { attributes: true, attributeFilter })
234
+ // Only the chart and its ancestors can change its inherited theme. Never observe
235
+ // the document subtree: canvas/tooltip/rendering mutations would trigger every chart.
236
+ for (let node: Element | null = scope ?? document.body ?? document.documentElement; node; node = node.parentElement) {
237
+ themeObserver.observe(node, { attributes: true, attributeFilter })
238
+ }
215
239
 
216
240
  const styleObserver = new MutationObserver(schedule)
217
241
  if (document.head) {
@@ -299,22 +323,23 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
299
323
  * components that build an ECharts option from token colours themselves (the
300
324
  * typed charts), so their palette follows light/dark like `EChart`'s theme.
301
325
  */
302
- function useChartTokens(): ChartTokens | null {
326
+ function useChartTokens(scope?: Element | null | RefObject<Element | null>): ChartTokens | null {
303
327
  const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
304
328
  // Compared here, not inside a `setTokens` updater: an updater that reads the DOM is
305
329
  // replayed by React on every rebase and returns a fresh object each time, so the state
306
330
  // never settles ("Maximum update depth exceeded" once the tokens change after mount).
307
331
  const tokensRef = useRef(tokens)
308
332
  useEffect(() => {
333
+ const target = scope && "current" in scope ? scope.current : scope
309
334
  const sync = () => {
310
- const next = readChartTokens()
335
+ const next = readChartTokens(target)
311
336
  if (sameChartTokens(tokensRef.current, next)) return
312
337
  tokensRef.current = next
313
338
  setTokens(next)
314
339
  }
315
340
  sync()
316
- return watchChartTokens(sync)
317
- }, [])
341
+ return watchChartTokens(sync, target)
342
+ }, [scope])
318
343
  return tokens
319
344
  }
320
345
 
@@ -326,7 +351,7 @@ const EMBEDDED_VAR_RE = /var\(\s*(--[\w-]+)\s*\)/g
326
351
  /**
327
352
  * Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
328
353
  * the resolved series tokens; `"positive"` / `"warning"` / `"negative"` / `"neutral"` (and the area
329
- * variants `"positive-fill"`, …) are read off the document root (`--positive`, …); `var(--token)` is read off the document root; any
354
+ * variants `"positive-fill"`, …) are read in the snapshot's inherited scope (`--positive`, …); `var(--token)` uses the same scope; any
330
355
  * other CSS colour is normalized like the tokens are. A colour that can't be
331
356
  * resolved comes back as `""`, which ECharts treats as "use the palette".
332
357
  */
@@ -337,12 +362,13 @@ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | nul
337
362
  // Meaning colours are the theme tokens of the same name: same path as `var(--positive)`.
338
363
  if (TONE_NAME_RE.test(color.trim())) {
339
364
  if (typeof document === "undefined") return ""
340
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue(`--${color.trim()}`)) ?? ""
365
+ const target = (tokens && tokenScopes.get(tokens)) ?? document.documentElement
366
+ return toCanvasColor(getComputedStyle(target).getPropertyValue(`--${color.trim()}`)) ?? ""
341
367
  }
342
368
  if (typeof document === "undefined") return color
343
369
  let value = color.trim()
344
370
  const cssVar = CSS_VAR_RE.exec(value)
345
- const styles = getComputedStyle(document.documentElement)
371
+ const styles = getComputedStyle((tokens && tokenScopes.get(tokens)) ?? document.documentElement)
346
372
  if (cssVar) {
347
373
  value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
348
374
  } else if (value.includes("var(")) {
@@ -388,7 +414,7 @@ function readChartSurface(el: Element | null): string | null {
388
414
  if (color) return color
389
415
  }
390
416
  }
391
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue("--background"))
417
+ return toCanvasColor(getComputedStyle(el ?? document.documentElement).getPropertyValue("--background"))
392
418
  }
393
419
 
394
420
  /** Widths are bucketed so a resize only rebuilds the option when the label budget can change. */
@@ -402,9 +428,10 @@ const WIDTH_BUCKET = 40
402
428
  */
403
429
  function useChartSurface<E extends Element>(
404
430
  forwarded: ForwardedRef<E>,
405
- tokens: ChartTokens | null,
406
- ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined } {
431
+ tokens?: ChartTokens | null,
432
+ ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined; tokens: ChartTokens | null } {
407
433
  const [node, setNode] = useState<E | null>(null)
434
+ const scopedTokens = useChartTokens(node)
408
435
  const [surface, setSurface] = useState<string | undefined>(undefined)
409
436
  const [width, setWidth] = useState<number | undefined>(undefined)
410
437
  const ref = useCallback(
@@ -418,7 +445,7 @@ function useChartSurface<E extends Element>(
418
445
  useEffect(() => {
419
446
  if (!node) return
420
447
  setSurface(readChartSurface(node) ?? undefined)
421
- }, [node, tokens])
448
+ }, [node, tokens, scopedTokens])
422
449
  useEffect(() => {
423
450
  if (!node || typeof ResizeObserver === "undefined") return
424
451
  const update = (w: number) => setWidth(Math.floor(w / WIDTH_BUCKET) * WIDTH_BUCKET)
@@ -427,7 +454,7 @@ function useChartSurface<E extends Element>(
427
454
  observer.observe(node)
428
455
  return () => observer.disconnect()
429
456
  }, [node])
430
- return { ref, surface, width }
457
+ return { ref, surface, width, tokens: scopedTokens }
431
458
  }
432
459
 
433
460
  /* ---------- end of inlined code ---------- */
@@ -664,6 +691,116 @@ export function echartSetOptionOpts(prev: EChartsOption | null, next: EChartsOpt
664
691
  return { notMerge: false, replaceMerge: [...types], lazyUpdate: true }
665
692
  }
666
693
 
694
+ /**
695
+ * Component types that get a generated `id` when the option has none, so replaceMerge can match them across updates.
696
+ * Not `dataset` / `graphic` / `tooltip`: they are rebuilt on every update (there is nothing to transition).
697
+ */
698
+ const ID_TYPES = [
699
+ "title", "legend", "grid", "xAxis", "yAxis", "polar", "radiusAxis", "angleAxis", "radar",
700
+ "dataZoom", "visualMap", "geo", "singleAxis", "calendar",
701
+ ] as const
702
+
703
+ /**
704
+ * What `withStableIds` remembers between updates: per generated id, the shape last applied and how many
705
+ * times it changed (the id's `~v<n>` suffix); per component type, the order of its ids.
706
+ */
707
+ export interface StableIdState {
708
+ ids: Map<string, { shape: string; version: number }>
709
+ order: Map<string, string[]>
710
+ }
711
+ export const createStableIdState = (): StableIdState => ({ ids: new Map(), order: new Map() })
712
+
713
+ /**
714
+ * Shape of a component: which keys it has, recursively (arrays only as "array", values not at all —
715
+ * except `type`). A merge keeps keys the new option dropped, so a changed shape must not be merged.
716
+ */
717
+ function shapeOf(v: unknown): string {
718
+ if (Array.isArray(v)) return "[]"
719
+ if (!v || typeof v !== "object" || Object.getPrototypeOf(v) !== Object.prototype) return "."
720
+ const o = v as Record<string, unknown>
721
+ return `{${Object.keys(o)
722
+ .filter((k) => o[k] !== undefined)
723
+ .sort()
724
+ .map((k) => (k === "type" || k === "id" ? `${k}=${String(o[k])}` : `${k}:${shapeOf(o[k])}`))
725
+ .join(",")}}`
726
+ }
727
+
728
+ /**
729
+ * Gives every series (and the common components) an `id` it does not have yet —
730
+ * series: `series:<name ?? index>`, others `<type>:<index>` — so an update that
731
+ * only changes values is matched to the existing component and transitions
732
+ * instead of being rebuilt. If the component's shape changed since `state` last
733
+ * saw it (a key was removed or added, the type changed), the id gets a new
734
+ * `~v<n>` suffix so replaceMerge builds it from scratch: the result then always
735
+ * equals applying the option with `notMerge`. Explicit ids are kept; the input is not mutated.
736
+ */
737
+ export function withStableIds(option: EChartsOption, state: StableIdState = createStableIdState()): EChartsOption {
738
+ const out: Record<string, unknown> = { ...(option as Record<string, unknown>) }
739
+ const taken = new Set<string>()
740
+ const list = (v: unknown): Record<string, unknown>[] | null =>
741
+ Array.isArray(v) ? (v as Record<string, unknown>[]) : v && typeof v === "object" ? [v as Record<string, unknown>] : null
742
+ const give = (key: string, pick: (c: Record<string, unknown>, i: number) => string) => {
743
+ const items = list(out[key])
744
+ if (!items) return
745
+ const used = new Set<string>(items.flatMap((c) => (c && c.id != null ? [String(c.id)] : [])))
746
+ const bases = items.map((c, i) => {
747
+ if (!c || typeof c !== "object" || c.id != null) return null
748
+ let base = pick(c, i)
749
+ for (let n = 2; used.has(base) || taken.has(`${key}\u0000${base}`); n++) base = `${pick(c, i)}#${n}`
750
+ used.add(base)
751
+ taken.add(`${key}\u0000${base}`)
752
+ return base
753
+ })
754
+ // A merge fills the empty slots a removal left and keeps the slot order of what exists, so after any
755
+ // change in which components there are (or their order) it would not match a fresh render: rebuild them all.
756
+ const prevOrder = state.order.get(key) ?? []
757
+ const cur = bases.filter((b): b is string => b !== null)
758
+ const inOrder = cur.length === prevOrder.length && cur.every((b, i) => b === prevOrder[i])
759
+ state.order.set(key, cur)
760
+ out[key] = items.map((c, i) => {
761
+ const base = bases[i]
762
+ if (base == null) return c
763
+ const shape = shapeOf(c)
764
+ const prev = state.ids.get(base)
765
+ const version = !prev ? 0 : prev.shape === shape && inOrder ? prev.version : prev.version + 1
766
+ state.ids.set(base, { shape, version })
767
+ return { ...c, id: version ? `${base}~v${version}` : base }
768
+ })
769
+ }
770
+ give("series", (c, i) => `series:${typeof c.name === "string" && c.name ? c.name : i}`)
771
+ for (const key of ID_TYPES) give(key, (_c, i) => `${key}:${i}`)
772
+ return out as EChartsOption
773
+ }
774
+
775
+ /** Copy of the plain objects / arrays of an option (functions, class instances, Dates stay shared), to compare against later in-place edits. */
776
+ export function snapshotOption<T>(v: T): T {
777
+ if (Array.isArray(v)) return v.map(snapshotOption) as T
778
+ if (v && typeof v === "object") {
779
+ const proto = Object.getPrototypeOf(v)
780
+ if (proto === Object.prototype || proto === null) {
781
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, snapshotOption(x)])) as T
782
+ }
783
+ }
784
+ return v
785
+ }
786
+
787
+ /** Structural equality for option trees; functions and other non-plain objects compare by reference. */
788
+ export function optionEquals(a: unknown, b: unknown): boolean {
789
+ if (Object.is(a, b)) return true
790
+ if (typeof a !== "object" || typeof b !== "object" || !a || !b) return false
791
+ if (Array.isArray(a) !== Array.isArray(b)) return false
792
+ if (Array.isArray(a)) {
793
+ const bb = b as unknown[]
794
+ return a.length === bb.length && a.every((v, i) => optionEquals(v, bb[i]))
795
+ }
796
+ if (a instanceof Date || b instanceof Date) return a instanceof Date && b instanceof Date && a.getTime() === b.getTime()
797
+ const pa = Object.getPrototypeOf(a)
798
+ if ((pa !== Object.prototype && pa !== null) || Object.getPrototypeOf(b) !== pa) return false
799
+ const ka = Object.keys(a)
800
+ const kb = Object.keys(b)
801
+ return ka.length === kb.length && ka.every((k) => Object.hasOwn(b, k) && optionEquals((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]))
802
+ }
803
+
667
804
  /** `graphic` text doesn't inherit the global `textStyle`, so give it the inherited font unless it sets its own. */
668
805
  function withGraphicFont(option: EChartsOption, fontFamily: string | undefined): EChartsOption {
669
806
  const graphic = (option as { graphic?: unknown }).graphic
@@ -717,42 +854,13 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
717
854
  const containerRef = useRef<HTMLDivElement | null>(null)
718
855
  const [instance, setInstance] = useState<ECharts | null>(null)
719
856
  const [copied, setCopied] = useState(false)
720
- // 初回はレンダー前に読めるので、マウント直後の作り直しが起きない
721
- const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
857
+ const tokens = useChartTokens(containerRef)
722
858
  const reducedMotion = usePrefersReducedMotion()
723
859
  // What the current instance last received, to pick the merge mode of the next update.
724
860
  const appliedRef = useRef<EChartsOption | null>(null)
725
-
726
- // 明暗の切り替えとスタイルシートの差し替え(dev で styles.css を書き換えたときなど)を
727
- // 拾ってトークンを読み直す ― 何を見張るかは `watchChartTokens` 側が持つ。値が変わら
728
- // ないときは state を更新せず、再 init を防ぐ。比較は updater 関数の中ではなく ref で行う:
729
- // DOM を読む updater は React に何度も再生され、そのたびに新しいオブジェクトを返して
730
- // 再描画が止まらなくなる(変数を後から当てた直後に Maximum update depth exceeded)。
731
- const tokensRef = useRef(tokens)
732
- useEffect(() => {
733
- if (theme !== undefined) return
734
- // Colours come from the document root, but the font is whatever this container inherits: an app
735
- // can set its own font on a wrapper, and the canvas text has to match the DOM around it.
736
- const sync = () => {
737
- const base = readChartTokens()
738
- const font = containerRef.current ? getComputedStyle(containerRef.current).fontFamily : ""
739
- const next = base && font ? { ...base, fontFamily: font } : base
740
- if (sameChartTokens(tokensRef.current, next)) return
741
- tokensRef.current = next
742
- setTokens(next)
743
- }
744
- sync()
745
- const stop = watchChartTokens(sync)
746
- // A font set on a wrapper (class / inline style) isn't seen by the document-level watcher.
747
- const ancestors = new MutationObserver(sync)
748
- for (let node = containerRef.current?.parentElement; node; node = node.parentElement) {
749
- ancestors.observe(node, { attributes: true, attributeFilter: ["class", "style"] })
750
- }
751
- return () => {
752
- stop()
753
- ancestors.disconnect()
754
- }
755
- }, [theme])
861
+ // The option (before ids) as it was when applied, deep-copied; and the shape history behind the generated ids.
862
+ const snapshotRef = useRef<EChartsOption | null>(null)
863
+ const idStateRef = useRef<StableIdState>(createStableIdState())
756
864
 
757
865
  const autoTheme = useMemo(() => (tokens ? buildChartTheme(tokens) : undefined), [tokens])
758
866
  const resolvedTheme = theme ?? autoTheme
@@ -764,6 +872,7 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
764
872
  if (!container) return
765
873
  const inst = init(container, resolvedTheme, { renderer: "canvas" })
766
874
  appliedRef.current = null
875
+ snapshotRef.current = null
767
876
  // `data-rendering` stays on until the (animated) paint is done, so anything
768
877
  // capturing the page — printing a document — can wait for real pixels.
769
878
  container.dataset.rendering = ""
@@ -790,21 +899,29 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
790
899
  }, [resolvedTheme])
791
900
 
792
901
  useEffect(() => {
793
- if (!instance) return
902
+ if (!instance || instance.isDisposed()) return
903
+ const prepared = withGraphicFont(
904
+ withMotionPreference(option, reducedMotion),
905
+ theme === undefined ? tokens?.fontFamily : undefined,
906
+ )
907
+ // Same content as last time (a re-render that built an equal option): nothing to redraw.
908
+ // Compared with a snapshot, so an in-place edit of a nested array of the caller's option still counts as a change.
909
+ if (snapshotRef.current && optionEquals(snapshotRef.current, prepared)) return
794
910
  if (containerRef.current) containerRef.current.dataset.rendering = ""
795
- const next = withGraphicFont(withMotionPreference(option, reducedMotion), theme === undefined ? tokens?.fontFamily : undefined)
911
+ const next = withStableIds(prepared, idStateRef.current)
796
912
  instance.setOption(next, echartSetOptionOpts(appliedRef.current, next))
797
913
  appliedRef.current = next
914
+ snapshotRef.current = snapshotOption(prepared)
798
915
  }, [instance, option, reducedMotion, theme, tokens?.fontFamily])
799
916
 
800
917
  useEffect(() => {
801
- if (!instance) return
918
+ if (!instance || instance.isDisposed()) return
802
919
  if (loading) instance.showLoading()
803
920
  else instance.hideLoading()
804
921
  }, [instance, loading])
805
922
 
806
923
  useEffect(() => {
807
- if (!instance || !onEvents) return
924
+ if (!instance || instance.isDisposed() || !onEvents) return
808
925
  const entries = Object.entries(onEvents)
809
926
  for (const [event, handler] of entries) {
810
927
  instance.on(event, handler as EChartsEventHandler)