@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.
Files changed (168) 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-6OTTGAPO.js +44 -0
  6. package/dist/chunk-6OTTGAPO.js.map +1 -0
  7. package/dist/{chunk-XPQSTZUU.js → chunk-7S52M2SL.js} +4 -3
  8. package/dist/chunk-7S52M2SL.js.map +1 -0
  9. package/dist/chunk-JXFCSF3K.js +673 -0
  10. package/dist/chunk-JXFCSF3K.js.map +1 -0
  11. package/dist/{chunk-HKSVYMYJ.js → chunk-QOXXOFGL.js} +61 -929
  12. package/dist/chunk-QOXXOFGL.js.map +1 -0
  13. package/dist/components/index.d.ts +11 -247
  14. package/dist/components/index.js +5 -2
  15. package/dist/delta-text-CvwBGJ95.d.ts +15 -0
  16. package/dist/doc/index.d.ts +215 -2
  17. package/dist/doc/index.js +300 -3
  18. package/dist/doc/index.js.map +1 -1
  19. package/dist/format-U0cQl069.d.ts +243 -0
  20. package/dist/slide/index.d.ts +272 -1
  21. package/dist/slide/index.js +966 -1
  22. package/dist/slide/index.js.map +1 -1
  23. package/dist/table/index.js +5 -2
  24. package/dist/table/index.js.map +1 -1
  25. package/dist/ui/index.d.ts +4 -1
  26. package/dist/ui/index.js +3 -3
  27. package/dist/ui/index.js.map +1 -1
  28. package/docs/en/components.md +13 -2
  29. package/docs/en/doc/delta-text.md +40 -0
  30. package/docs/en/doc/doc-action-items.md +49 -0
  31. package/docs/en/doc/doc-callout.md +38 -0
  32. package/docs/en/doc/doc-data-table.md +60 -0
  33. package/docs/en/doc/doc-executive-summary.md +64 -0
  34. package/docs/en/doc/doc-figure.md +56 -0
  35. package/docs/en/doc/doc-key-findings.md +61 -0
  36. package/docs/en/doc/doc-kpi-scorecard.md +80 -0
  37. package/docs/en/doc/doc-notes-and-sources.md +43 -0
  38. package/docs/en/doc/doc-report-header.md +46 -0
  39. package/docs/en/parts/area-chart.md +99 -0
  40. package/docs/en/parts/bar-chart.md +91 -0
  41. package/docs/en/parts/bar-list.md +50 -0
  42. package/docs/en/parts/category-bar.md +37 -0
  43. package/docs/en/parts/chart-frame.md +39 -0
  44. package/docs/en/parts/delta.md +4 -3
  45. package/docs/en/parts/donut-chart.md +72 -0
  46. package/docs/en/parts/funnel-steps.md +2 -1
  47. package/docs/en/parts/heatmap.md +53 -0
  48. package/docs/en/parts/kpi-card.md +2 -1
  49. package/docs/en/parts/legend.md +35 -0
  50. package/docs/en/parts/line-chart.md +94 -0
  51. package/docs/en/parts/progress-circle.md +33 -0
  52. package/docs/en/parts/sample-data.md +39 -0
  53. package/docs/en/parts/scatter-chart.md +56 -0
  54. package/docs/en/parts/tracker.md +36 -0
  55. package/docs/en/parts/updated-at.md +30 -0
  56. package/docs/en/parts/waterfall-chart.md +56 -0
  57. package/docs/en/slide/slide-chart-full.md +48 -0
  58. package/docs/en/slide/slide-chart-insight.md +52 -0
  59. package/docs/en/slide/slide-columns.md +46 -0
  60. package/docs/en/slide/slide-comparison.md +68 -0
  61. package/docs/en/slide/slide-cover.md +39 -0
  62. package/docs/en/slide/slide-exec-summary.md +47 -0
  63. package/docs/en/slide/slide-heading.md +38 -0
  64. package/docs/en/slide/slide-kpi-grid.md +55 -0
  65. package/docs/en/slide/slide-kpi-hero.md +58 -0
  66. package/docs/en/slide/slide-next-actions.md +49 -0
  67. package/docs/en/slide/slide-scorecard-table.md +54 -0
  68. package/docs/en/slide/slide-section.md +44 -0
  69. package/docs/en/slide/slide-source-line.md +34 -0
  70. package/docs/en/ui/alert-dialog.md +42 -0
  71. package/docs/en/ui/avatar.md +31 -0
  72. package/docs/en/ui/combobox.md +74 -0
  73. package/docs/en/ui/drawer.md +41 -0
  74. package/docs/en/ui/empty.md +1 -1
  75. package/docs/en/ui/error-state.md +4 -3
  76. package/docs/en/ui/field.md +42 -0
  77. package/docs/en/ui/hover-card.md +21 -0
  78. package/docs/en/ui/kbd.md +17 -0
  79. package/docs/en/ui/loading.md +1 -1
  80. package/docs/en/ui/pagination.md +36 -0
  81. package/docs/en/ui/radio-group.md +21 -0
  82. package/docs/en/ui/toast.md +44 -0
  83. package/docs/en/ui/toggle-group.md +4 -3
  84. package/docs/index.json +857 -77
  85. package/docs/ja/components.md +12 -1
  86. package/docs/ja/doc/delta-text.md +40 -0
  87. package/docs/ja/doc/doc-action-items.md +49 -0
  88. package/docs/ja/doc/doc-callout.md +38 -0
  89. package/docs/ja/doc/doc-data-table.md +60 -0
  90. package/docs/ja/doc/doc-executive-summary.md +64 -0
  91. package/docs/ja/doc/doc-figure.md +56 -0
  92. package/docs/ja/doc/doc-key-findings.md +61 -0
  93. package/docs/ja/doc/doc-kpi-scorecard.md +80 -0
  94. package/docs/ja/doc/doc-notes-and-sources.md +43 -0
  95. package/docs/ja/doc/doc-report-header.md +46 -0
  96. package/docs/ja/parts/area-chart.md +49 -25
  97. package/docs/ja/parts/bar-chart.md +50 -24
  98. package/docs/ja/parts/bar-list.md +31 -8
  99. package/docs/ja/parts/chart-frame.md +36 -0
  100. package/docs/ja/parts/delta.md +5 -4
  101. package/docs/ja/parts/donut-chart.md +45 -26
  102. package/docs/ja/parts/funnel-steps.md +27 -8
  103. package/docs/ja/parts/heatmap.md +45 -0
  104. package/docs/ja/parts/kpi-card.md +2 -1
  105. package/docs/ja/parts/legend.md +36 -0
  106. package/docs/ja/parts/line-chart.md +48 -22
  107. package/docs/ja/parts/sample-data.md +61 -0
  108. package/docs/ja/parts/scatter-chart.md +69 -0
  109. package/docs/ja/parts/updated-at.md +27 -0
  110. package/docs/ja/parts/waterfall-chart.md +61 -0
  111. package/docs/ja/slide/slide-chart-full.md +48 -0
  112. package/docs/ja/slide/slide-chart-insight.md +52 -0
  113. package/docs/ja/slide/slide-columns.md +46 -0
  114. package/docs/ja/slide/slide-comparison.md +68 -0
  115. package/docs/ja/slide/slide-cover.md +39 -0
  116. package/docs/ja/slide/slide-exec-summary.md +47 -0
  117. package/docs/ja/slide/slide-heading.md +38 -0
  118. package/docs/ja/slide/slide-kpi-grid.md +55 -0
  119. package/docs/ja/slide/slide-kpi-hero.md +58 -0
  120. package/docs/ja/slide/slide-next-actions.md +49 -0
  121. package/docs/ja/slide/slide-scorecard-table.md +54 -0
  122. package/docs/ja/slide/slide-section.md +44 -0
  123. package/docs/ja/slide/slide-source-line.md +34 -0
  124. package/docs/ja/ui/empty.md +1 -1
  125. package/docs/ja/ui/error-state.md +4 -3
  126. package/docs/ja/ui/loading.md +1 -1
  127. package/docs/ja/ui/toggle-group.md +4 -3
  128. package/package.json +2 -2
  129. package/registry/ui/area-chart.tsx +88 -26
  130. package/registry/ui/attention-list.tsx +303 -0
  131. package/registry/ui/bar-chart.tsx +88 -26
  132. package/registry/ui/bar-list.tsx +87 -24
  133. package/registry/ui/box-plot-chart.tsx +3906 -0
  134. package/registry/ui/category-bar.tsx +54 -19
  135. package/registry/ui/chart-frame.tsx +56 -21
  136. package/registry/ui/dashboard-card.tsx +2 -2
  137. package/registry/ui/delta.tsx +11 -8
  138. package/registry/ui/donut-chart.tsx +89 -26
  139. package/registry/ui/echart.tsx +194 -44
  140. package/registry/ui/funnel-steps.tsx +33 -5
  141. package/registry/ui/heatmap.tsx +55 -21
  142. package/registry/ui/histogram-chart.tsx +3986 -0
  143. package/registry/ui/kpi-card.tsx +41 -14
  144. package/registry/ui/legend.tsx +87 -24
  145. package/registry/ui/line-chart.tsx +88 -26
  146. package/registry/ui/page-shell.tsx +9 -9
  147. package/registry/ui/paired-chart.tsx +3827 -0
  148. package/registry/ui/sample-data.tsx +1 -1
  149. package/registry/ui/scatter-chart.tsx +88 -26
  150. package/registry/ui/section-header.tsx +2 -2
  151. package/registry/ui/slider.tsx +1 -1
  152. package/registry/ui/status-badge.tsx +8 -8
  153. package/registry/ui/tooltip.tsx +6 -2
  154. package/registry/ui/tracker.tsx +9 -3
  155. package/registry/ui/trend-indicator.tsx +2 -2
  156. package/registry/ui/waterfall-chart.tsx +88 -26
  157. package/skills/vantage-design/SKILL.md +27 -25
  158. package/skills/vantage-design/references/extract.md +16 -14
  159. package/skills/vantage-design/references/format.md +6 -5
  160. package/skills/vantage-design/references/presets.md +60 -110
  161. package/skills/vantage-slide/SKILL.md +54 -36
  162. package/templates/AGENTS.md +2 -2
  163. package/templates/agents/doc.md +48 -0
  164. package/templates/agents/page.md +22 -1
  165. package/templates/agents/slide.md +70 -2
  166. package/theme.css +16 -15
  167. package/dist/chunk-HKSVYMYJ.js.map +0 -1
  168. package/dist/chunk-XPQSTZUU.js.map +0 -1
@@ -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) ---------- */
@@ -85,6 +85,8 @@ const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--ch
85
85
  interface ChartTokens {
86
86
  /** Resolved `--chart-1..5`, in order. */
87
87
  series: string[]
88
+ /** Computed `font-family` of the container, so canvas text matches the DOM. Optional. */
89
+ fontFamily?: string
88
90
  /** Body text: titles and the default `textStyle`. */
89
91
  foreground: string | null
90
92
  /** Secondary text: axis labels, legend, subtitles. */
@@ -93,10 +95,23 @@ interface ChartTokens {
93
95
  border: string | null
94
96
  /** Tooltip surface. */
95
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
96
102
  /** Tooltip text. */
97
103
  popoverForeground: string | null
98
104
  }
99
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
+
100
115
  let normalizeContext: CanvasRenderingContext2D | null | undefined
101
116
 
102
117
  /** The forms `fillStyle` serializes legacy colours to, and zrender can parse. */
@@ -152,14 +167,19 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
152
167
  const color = read(token)
153
168
  if (color) series.push(color)
154
169
  }
155
- return {
170
+ const tokens: ChartTokens = {
156
171
  series,
172
+ colors: Object.fromEntries(OPTION_COLOR_TOKENS.map((name) => [name, styles.getPropertyValue(name).trim()])),
173
+ surface: readChartSurface(target),
174
+ ...(styles.fontFamily ? { fontFamily: styles.fontFamily } : {}),
157
175
  foreground: read("--foreground"),
158
176
  mutedForeground: read("--muted-foreground"),
159
177
  border: read("--border"),
160
178
  popover: read("--popover"),
161
179
  popoverForeground: read("--popover-foreground"),
162
180
  }
181
+ tokenScopes.set(tokens, target)
182
+ return tokens
163
183
  }
164
184
 
165
185
  /** Structural equality, so a theme toggle that changes nothing doesn't re-init. */
@@ -167,8 +187,13 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
167
187
  if (a === b) return true
168
188
  if (!a || !b) return false
169
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) &&
170
194
  a.series.length === b.series.length &&
171
195
  a.series.every((color, i) => color === b.series[i]) &&
196
+ a.fontFamily === b.fontFamily &&
172
197
  a.foreground === b.foreground &&
173
198
  a.mutedForeground === b.mutedForeground &&
174
199
  a.border === b.border &&
@@ -191,7 +216,7 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
191
216
  * attributes alone leaves the chart painted in the previous palette until a
192
217
  * reload — the tokens in the DOM are new, the canvas is not.
193
218
  */
194
- function watchChartTokens(onChange: () => void): () => void {
219
+ function watchChartTokens(onChange: () => void, scope?: Element | null): () => void {
195
220
  if (typeof document === "undefined") return () => {}
196
221
  // Style edits arrive as bursts of mutations; coalesce them into one read, and
197
222
  // take it after the next frame so a swapped stylesheet has been applied.
@@ -206,8 +231,11 @@ function watchChartTokens(onChange: () => void): () => void {
206
231
 
207
232
  const themeObserver = new MutationObserver(schedule)
208
233
  const attributeFilter = ["class", "style", "data-theme"]
209
- themeObserver.observe(document.documentElement, { attributes: true, attributeFilter })
210
- 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
+ }
211
239
 
212
240
  const styleObserver = new MutationObserver(schedule)
213
241
  if (document.head) {
@@ -263,7 +291,7 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
263
291
  // Let the page background show through: charts sit on cards, not on a canvas.
264
292
  backgroundColor: "transparent",
265
293
  ...(tokens.series.length > 0 ? { color: tokens.series } : {}),
266
- textStyle: { color: text },
294
+ textStyle: { color: text, ...(tokens.fontFamily ? { fontFamily: tokens.fontFamily } : {}) },
267
295
  title: { textStyle: { color: text }, subtextStyle: { color: muted } },
268
296
  legend: { textStyle: { color: muted } },
269
297
  categoryAxis: axisTheme(tokens, false),
@@ -279,7 +307,11 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
279
307
  padding: [8, 12],
280
308
  // Rounded card with a soft shadow; in dark mode the shadow barely shows, so the border carries the edge.
281
309
  extraCssText: "border-radius:8px;box-shadow:var(--shadow-card, 0 4px 12px rgb(0 0 0 / 0.08));",
282
- textStyle: { color: tokens.popoverForeground ?? undefined, fontSize: 12 },
310
+ textStyle: {
311
+ color: tokens.popoverForeground ?? undefined,
312
+ fontSize: 12,
313
+ ...(tokens.fontFamily ? { fontFamily: tokens.fontFamily } : {}),
314
+ },
283
315
  axisPointer: { lineStyle: { color: line }, crossStyle: { color: line } },
284
316
  },
285
317
  }
@@ -291,22 +323,23 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
291
323
  * components that build an ECharts option from token colours themselves (the
292
324
  * typed charts), so their palette follows light/dark like `EChart`'s theme.
293
325
  */
294
- function useChartTokens(): ChartTokens | null {
326
+ function useChartTokens(scope?: Element | null | RefObject<Element | null>): ChartTokens | null {
295
327
  const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
296
328
  // Compared here, not inside a `setTokens` updater: an updater that reads the DOM is
297
329
  // replayed by React on every rebase and returns a fresh object each time, so the state
298
330
  // never settles ("Maximum update depth exceeded" once the tokens change after mount).
299
331
  const tokensRef = useRef(tokens)
300
332
  useEffect(() => {
333
+ const target = scope && "current" in scope ? scope.current : scope
301
334
  const sync = () => {
302
- const next = readChartTokens()
335
+ const next = readChartTokens(target)
303
336
  if (sameChartTokens(tokensRef.current, next)) return
304
337
  tokensRef.current = next
305
338
  setTokens(next)
306
339
  }
307
340
  sync()
308
- return watchChartTokens(sync)
309
- }, [])
341
+ return watchChartTokens(sync, target)
342
+ }, [scope])
310
343
  return tokens
311
344
  }
312
345
 
@@ -318,7 +351,7 @@ const EMBEDDED_VAR_RE = /var\(\s*(--[\w-]+)\s*\)/g
318
351
  /**
319
352
  * Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
320
353
  * the resolved series tokens; `"positive"` / `"warning"` / `"negative"` / `"neutral"` (and the area
321
- * 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
322
355
  * other CSS colour is normalized like the tokens are. A colour that can't be
323
356
  * resolved comes back as `""`, which ECharts treats as "use the palette".
324
357
  */
@@ -329,12 +362,13 @@ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | nul
329
362
  // Meaning colours are the theme tokens of the same name: same path as `var(--positive)`.
330
363
  if (TONE_NAME_RE.test(color.trim())) {
331
364
  if (typeof document === "undefined") return ""
332
- 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()}`)) ?? ""
333
367
  }
334
368
  if (typeof document === "undefined") return color
335
369
  let value = color.trim()
336
370
  const cssVar = CSS_VAR_RE.exec(value)
337
- const styles = getComputedStyle(document.documentElement)
371
+ const styles = getComputedStyle((tokens && tokenScopes.get(tokens)) ?? document.documentElement)
338
372
  if (cssVar) {
339
373
  value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
340
374
  } else if (value.includes("var(")) {
@@ -380,7 +414,7 @@ function readChartSurface(el: Element | null): string | null {
380
414
  if (color) return color
381
415
  }
382
416
  }
383
- return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue("--background"))
417
+ return toCanvasColor(getComputedStyle(el ?? document.documentElement).getPropertyValue("--background"))
384
418
  }
385
419
 
386
420
  /** Widths are bucketed so a resize only rebuilds the option when the label budget can change. */
@@ -394,9 +428,10 @@ const WIDTH_BUCKET = 40
394
428
  */
395
429
  function useChartSurface<E extends Element>(
396
430
  forwarded: ForwardedRef<E>,
397
- tokens: ChartTokens | null,
398
- ): { 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 } {
399
433
  const [node, setNode] = useState<E | null>(null)
434
+ const scopedTokens = useChartTokens(node)
400
435
  const [surface, setSurface] = useState<string | undefined>(undefined)
401
436
  const [width, setWidth] = useState<number | undefined>(undefined)
402
437
  const ref = useCallback(
@@ -410,7 +445,7 @@ function useChartSurface<E extends Element>(
410
445
  useEffect(() => {
411
446
  if (!node) return
412
447
  setSurface(readChartSurface(node) ?? undefined)
413
- }, [node, tokens])
448
+ }, [node, tokens, scopedTokens])
414
449
  useEffect(() => {
415
450
  if (!node || typeof ResizeObserver === "undefined") return
416
451
  const update = (w: number) => setWidth(Math.floor(w / WIDTH_BUCKET) * WIDTH_BUCKET)
@@ -419,7 +454,7 @@ function useChartSurface<E extends Element>(
419
454
  observer.observe(node)
420
455
  return () => observer.disconnect()
421
456
  }, [node])
422
- return { ref, surface, width }
457
+ return { ref, surface, width, tokens: scopedTokens }
423
458
  }
424
459
 
425
460
  /* ---------- end of inlined code ---------- */
@@ -656,6 +691,128 @@ export function echartSetOptionOpts(prev: EChartsOption | null, next: EChartsOpt
656
691
  return { notMerge: false, replaceMerge: [...types], lazyUpdate: true }
657
692
  }
658
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
+
804
+ /** `graphic` text doesn't inherit the global `textStyle`, so give it the inherited font unless it sets its own. */
805
+ function withGraphicFont(option: EChartsOption, fontFamily: string | undefined): EChartsOption {
806
+ const graphic = (option as { graphic?: unknown }).graphic
807
+ if (!fontFamily || !Array.isArray(graphic)) return option
808
+ return {
809
+ ...option,
810
+ graphic: graphic.map((g: { type?: string; style?: Record<string, unknown> }) =>
811
+ g && g.type === "text" && g.style && !g.style.fontFamily ? { ...g, style: { ...g.style, fontFamily } } : g,
812
+ ),
813
+ } as EChartsOption
814
+ }
815
+
659
816
  /** `option` as applied: reduced motion forces `animation: false`; otherwise animation stays explicit so it can flip back. */
660
817
  export function withMotionPreference(option: EChartsOption, reducedMotion: boolean): EChartsOption {
661
818
  return { ...option, animation: reducedMotion ? false : (option.animation ?? true) }
@@ -697,29 +854,13 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
697
854
  const containerRef = useRef<HTMLDivElement | null>(null)
698
855
  const [instance, setInstance] = useState<ECharts | null>(null)
699
856
  const [copied, setCopied] = useState(false)
700
- // 初回はレンダー前に読めるので、マウント直後の作り直しが起きない
701
- const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
857
+ const tokens = useChartTokens(containerRef)
702
858
  const reducedMotion = usePrefersReducedMotion()
703
859
  // What the current instance last received, to pick the merge mode of the next update.
704
860
  const appliedRef = useRef<EChartsOption | null>(null)
705
-
706
- // 明暗の切り替えとスタイルシートの差し替え(dev で styles.css を書き換えたときなど)を
707
- // 拾ってトークンを読み直す ― 何を見張るかは `watchChartTokens` 側が持つ。値が変わら
708
- // ないときは state を更新せず、再 init を防ぐ。比較は updater 関数の中ではなく ref で行う:
709
- // DOM を読む updater は React に何度も再生され、そのたびに新しいオブジェクトを返して
710
- // 再描画が止まらなくなる(変数を後から当てた直後に Maximum update depth exceeded)。
711
- const tokensRef = useRef(tokens)
712
- useEffect(() => {
713
- if (theme !== undefined) return
714
- const sync = () => {
715
- const next = readChartTokens()
716
- if (sameChartTokens(tokensRef.current, next)) return
717
- tokensRef.current = next
718
- setTokens(next)
719
- }
720
- sync()
721
- return watchChartTokens(sync)
722
- }, [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())
723
864
 
724
865
  const autoTheme = useMemo(() => (tokens ? buildChartTheme(tokens) : undefined), [tokens])
725
866
  const resolvedTheme = theme ?? autoTheme
@@ -731,6 +872,7 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
731
872
  if (!container) return
732
873
  const inst = init(container, resolvedTheme, { renderer: "canvas" })
733
874
  appliedRef.current = null
875
+ snapshotRef.current = null
734
876
  // `data-rendering` stays on until the (animated) paint is done, so anything
735
877
  // capturing the page — printing a document — can wait for real pixels.
736
878
  container.dataset.rendering = ""
@@ -757,21 +899,29 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
757
899
  }, [resolvedTheme])
758
900
 
759
901
  useEffect(() => {
760
- 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
761
910
  if (containerRef.current) containerRef.current.dataset.rendering = ""
762
- const next = withMotionPreference(option, reducedMotion)
911
+ const next = withStableIds(prepared, idStateRef.current)
763
912
  instance.setOption(next, echartSetOptionOpts(appliedRef.current, next))
764
913
  appliedRef.current = next
765
- }, [instance, option, reducedMotion])
914
+ snapshotRef.current = snapshotOption(prepared)
915
+ }, [instance, option, reducedMotion, theme, tokens?.fontFamily])
766
916
 
767
917
  useEffect(() => {
768
- if (!instance) return
918
+ if (!instance || instance.isDisposed()) return
769
919
  if (loading) instance.showLoading()
770
920
  else instance.hideLoading()
771
921
  }, [instance, loading])
772
922
 
773
923
  useEffect(() => {
774
- if (!instance || !onEvents) return
924
+ if (!instance || instance.isDisposed() || !onEvents) return
775
925
  const entries = Object.entries(onEvents)
776
926
  for (const [event, handler] of entries) {
777
927
  instance.on(event, handler as EChartsEventHandler)
@@ -1190,6 +1190,10 @@ const toDate = (t: ParsedTime) => new Date(t.year, t.month - 1, t.day)
1190
1190
 
1191
1191
  /** How many x labels a time axis shows at most (the step is picked so the labels never crowd). */
1192
1192
  const TIME_AXIS_MAX_LABELS = 6
1193
+ /** Under this plot width (px) the axis, legend and labels switch to their compact form. */
1194
+ const NARROW_PLOT_WIDTH = 400
1195
+ /** The most x labels a narrow plot shows (a 262px plot reads 4 at most). */
1196
+ const NARROW_TIME_AXIS_MAX_LABELS = 4
1193
1197
 
1194
1198
  /** Calendar-friendly label steps per granularity (every 3 months, every week …). */
1195
1199
  const TIME_AXIS_STEPS: Record<TimeGranularity, readonly number[]> = {
@@ -1635,7 +1639,9 @@ const LEGEND_LINE_BUDGET = 320
1635
1639
  /** How many lines the plain legend takes, estimated from its labels (12px text + 8px dot + gaps). */
1636
1640
  function legendLines(items: readonly string[], plotWidth?: number, reserve = 0): number {
1637
1641
  const width = items.reduce((sum, name) => sum + [...name].length * 12 + 8 + 5 + 16, 0)
1638
- const budget = Math.max(80, (plotWidth ? Math.min(LEGEND_LINE_BUDGET, plotWidth) : LEGEND_LINE_BUDGET) - reserve)
1642
+ // A narrow plot has no room for the PNG buttons beside the legend: they move off its line, so no reserve.
1643
+ const narrow = plotWidth !== undefined && plotWidth < NARROW_PLOT_WIDTH
1644
+ const budget = Math.max(80, (plotWidth ? (narrow ? plotWidth : Math.min(LEGEND_LINE_BUDGET, plotWidth)) : LEGEND_LINE_BUDGET) - (narrow ? 0 : reserve))
1639
1645
  return Math.min(3, Math.max(1, Math.ceil(width / budget)))
1640
1646
  }
1641
1647
 
@@ -1890,7 +1896,15 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1890
1896
  ? null
1891
1897
  : timeAxisLabelStep(
1892
1898
  rawIndex,
1893
- plotWidth ? Math.max(2, Math.min(TIME_AXIS_MAX_LABELS, Math.floor(plotWidth / TIME_LABEL_WIDTH))) : undefined,
1899
+ plotWidth
1900
+ ? Math.max(
1901
+ 2,
1902
+ Math.min(
1903
+ plotWidth < NARROW_PLOT_WIDTH ? NARROW_TIME_AXIS_MAX_LABELS : TIME_AXIS_MAX_LABELS,
1904
+ Math.floor(plotWidth / TIME_LABEL_WIDTH),
1905
+ ),
1906
+ )
1907
+ : undefined,
1894
1908
  )
1895
1909
  // Plain (non-date) x labels of a line chart: thinned to every n-th so they never touch, never rotated.
1896
1910
  const plainStep = (() => {
@@ -1936,8 +1950,12 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
1936
1950
  : []
1937
1951
  // On a narrow plot the max and the latest label would print over each other: keep the latest.
1938
1952
  const pointGap = plotWidth && labels.length > 1 ? plotWidth / (kind === "bar" ? labels.length : labels.length - 1) : null
1953
+ // By default only the latest value is labelled; an explicit `highlight` keeps its max + latest.
1954
+ const latestOnly = cfg.highlight === undefined && !shareHighlight && picked.length === 2
1939
1955
  const highlightPoints =
1940
- picked.length === 2 && pointGap !== null && Math.abs(picked[1]!.index - picked[0]!.index) * pointGap < HIGHLIGHT_LABEL_WIDTH
1956
+ latestOnly
1957
+ ? [picked[1]!]
1958
+ : picked.length === 2 && pointGap !== null && Math.abs(picked[1]!.index - picked[0]!.index) * pointGap < HIGHLIGHT_LABEL_WIDTH
1941
1959
  ? [picked[1]!]
1942
1960
  : picked
1943
1961
 
@@ -2148,6 +2166,8 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2148
2166
  ? { width: 0, color, opacity: 0 }
2149
2167
  : { width: 2, color, opacity: dimSeries ? DIMMED : 1 },
2150
2168
  itemStyle: { color, opacity: dimSeries ? DIMMED : 1 },
2169
+ // Hovering a series fades the others (not for a stack, where the bands read together).
2170
+ emphasis: stacked ? undefined : { focus: "series" as const },
2151
2171
  areaStyle: areaStyle && dimSeries ? { ...areaStyle, opacity: DIMMED * 0.3 } : areaStyle,
2152
2172
  markPoint: markPointFor(c, color),
2153
2173
  markLine: undefined,
@@ -2594,7 +2614,10 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2594
2614
  legend: {
2595
2615
  show: showLegend,
2596
2616
  top: 0,
2597
- right: reserveActions ? ACTIONS_RESERVE : 0,
2617
+ // Narrow: left-aligned and wrapping above the plot, so nothing is cut off at the right edge.
2618
+ ...(plotWidth !== undefined && plotWidth < NARROW_PLOT_WIDTH
2619
+ ? { left: 0, right: 0 }
2620
+ : { right: reserveActions ? ACTIONS_RESERVE : 0 }),
2598
2621
  // Plain wraps onto a second line at narrow widths instead of hiding items behind arrows.
2599
2622
  type: "plain",
2600
2623
  icon: "circle",
@@ -2655,7 +2678,12 @@ function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
2655
2678
  }
2656
2679
  }
2657
2680
  const shares = percent ? cartesianSeriesValues(i <= lastActual ? [row] : [], categories, "percent") : null
2658
- for (const p of list) {
2681
+ // Rows follow the legend order (categories first), not ECharts' own series order.
2682
+ const legendRank = (p: AxisTooltipParam) => {
2683
+ const at = categories.indexOf(p.seriesName ?? "")
2684
+ return at === -1 ? categories.length : at
2685
+ }
2686
+ for (const p of [...list].sort((a, b) => legendRank(a) - legendRank(b))) {
2659
2687
  const color = typeof p.color === "string" ? p.color : "currentColor"
2660
2688
  if (p.seriesId === FORECAST_SERIES_ID) {
2661
2689
  // At and before the boundary the forecast repeats the actual value.