@squadbase/vantage 0.6.2-dev1.ac99e02 → 0.6.2-dev1.d7c0911

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 (141) hide show
  1. package/dist/{build-MFWRPYKW.js → build-X5NVCQKT.js} +4 -4
  2. package/dist/{build-MFWRPYKW.js.map → build-X5NVCQKT.js.map} +1 -1
  3. package/dist/{chunk-IBGOCWJB.js → chunk-2ICNHUG6.js} +186 -82
  4. package/dist/chunk-2ICNHUG6.js.map +1 -0
  5. package/dist/{chunk-5ZOP4SQR.js → chunk-2N72FKLB.js} +12 -7
  6. package/dist/chunk-2N72FKLB.js.map +1 -0
  7. package/dist/{chunk-PXRX5PEC.js → chunk-PDOBHMUQ.js} +3 -3
  8. package/dist/chunk-PDOBHMUQ.js.map +1 -0
  9. package/dist/{chunk-A2UUGASH.js → chunk-U736PVHY.js} +2 -2
  10. package/dist/chunk-U736PVHY.js.map +1 -0
  11. package/dist/{chunk-QEWCYW3I.js → chunk-WAMZWZJB.js} +13 -3
  12. package/dist/chunk-WAMZWZJB.js.map +1 -0
  13. package/dist/chunk-WRSX2MDK.js +7866 -0
  14. package/dist/chunk-WRSX2MDK.js.map +1 -0
  15. package/dist/{chunk-OB6UBJXF.js → chunk-XPQSTZUU.js} +125 -35
  16. package/dist/chunk-XPQSTZUU.js.map +1 -0
  17. package/dist/cli.js +3 -3
  18. package/dist/components/index.d.ts +1831 -99
  19. package/dist/components/index.js +4 -4
  20. package/dist/{dev-DCTOHQ6W.js → dev-4XEDTELV.js} +4 -4
  21. package/dist/{dev-DCTOHQ6W.js.map → dev-4XEDTELV.js.map} +1 -1
  22. package/dist/doc/index.js +2 -2
  23. package/dist/doc/index.js.map +1 -1
  24. package/dist/markdown/index.js +1 -1
  25. package/dist/markdown/index.js.map +1 -1
  26. package/dist/table/index.js +5 -5
  27. package/dist/table/index.js.map +1 -1
  28. package/dist/ui/index.d.ts +4 -1
  29. package/dist/ui/index.js +21 -111
  30. package/dist/ui/index.js.map +1 -1
  31. package/dist/{upgrade-DBBE6GIR.js → upgrade-JHXZBGQS.js} +3 -3
  32. package/dist/{upgrade-DBBE6GIR.js.map → upgrade-JHXZBGQS.js.map} +1 -1
  33. package/dist/vite/index.js +2 -2
  34. package/docs/en/components.md +29 -15
  35. package/docs/en/parts/data-table.md +22 -0
  36. package/docs/en/parts/delta.md +50 -0
  37. package/docs/en/parts/format.md +43 -0
  38. package/docs/en/parts/kpi-card.md +57 -0
  39. package/docs/en/parts/metric-value.md +3 -2
  40. package/docs/en/parts/sparkline.md +1 -1
  41. package/docs/en/parts/trend-indicator.md +17 -1
  42. package/docs/en/ui-and-theming.md +1 -1
  43. package/docs/index.json +92 -32
  44. package/docs/ja/changelog.md +5 -0
  45. package/docs/ja/components.md +27 -15
  46. package/docs/ja/parts/data-table.md +22 -0
  47. package/docs/ja/parts/delta.md +50 -0
  48. package/docs/ja/parts/format.md +44 -0
  49. package/docs/ja/parts/kpi-card.md +55 -0
  50. package/docs/ja/parts/metric-value.md +3 -2
  51. package/docs/ja/parts/sparkline.md +1 -1
  52. package/docs/ja/parts/trend-indicator.md +17 -1
  53. package/docs/ja/ui-and-theming.md +2 -2
  54. package/package.json +2 -1
  55. package/registry/ui/accordion.tsx +4 -2
  56. package/registry/ui/alert-dialog.tsx +3 -3
  57. package/registry/ui/alert.tsx +3 -1
  58. package/registry/ui/app-shell.tsx +7 -1
  59. package/registry/ui/area-chart.tsx +2781 -157
  60. package/registry/ui/avatar.tsx +1 -1
  61. package/registry/ui/badge.tsx +3 -1
  62. package/registry/ui/bar-chart.tsx +2780 -157
  63. package/registry/ui/bar-list.tsx +3471 -93
  64. package/registry/ui/breadcrumb.tsx +3 -1
  65. package/registry/ui/button.tsx +3 -1
  66. package/registry/ui/calendar.tsx +3 -1
  67. package/registry/ui/card.tsx +4 -2
  68. package/registry/ui/category-bar.tsx +990 -16
  69. package/registry/ui/chart-frame.tsx +1358 -0
  70. package/registry/ui/checkbox.tsx +3 -1
  71. package/registry/ui/collapsible.tsx +3 -1
  72. package/registry/ui/combobox.tsx +2 -2
  73. package/registry/ui/command.tsx +3 -1
  74. package/registry/ui/dashboard-card.tsx +69 -26
  75. package/registry/ui/data-table.tsx +678 -56
  76. package/registry/ui/date-range-picker.tsx +245 -64
  77. package/registry/ui/delta.tsx +188 -0
  78. package/registry/ui/dialog.tsx +5 -3
  79. package/registry/ui/donut-chart.tsx +2857 -173
  80. package/registry/ui/drawer.tsx +1 -1
  81. package/registry/ui/dropdown-menu.tsx +5 -3
  82. package/registry/ui/echart.tsx +448 -44
  83. package/registry/ui/field.tsx +1 -1
  84. package/registry/ui/filter-bar.tsx +33 -6
  85. package/registry/ui/funnel-steps.tsx +3316 -49
  86. package/registry/ui/heatmap.tsx +1752 -0
  87. package/registry/ui/hover-card.tsx +2 -2
  88. package/registry/ui/input-group.tsx +3 -1
  89. package/registry/ui/input.tsx +3 -1
  90. package/registry/ui/kbd.tsx +3 -1
  91. package/registry/ui/kpi-card.tsx +677 -0
  92. package/registry/ui/label.tsx +3 -1
  93. package/registry/ui/legend.tsx +3500 -0
  94. package/registry/ui/line-chart.tsx +2781 -151
  95. package/registry/ui/metric-value.tsx +9 -1
  96. package/registry/ui/multi-select.tsx +11 -5
  97. package/registry/ui/page-shell.tsx +104 -49
  98. package/registry/ui/pagination.tsx +3 -1
  99. package/registry/ui/placeholder.tsx +8 -2
  100. package/registry/ui/popover.tsx +4 -2
  101. package/registry/ui/progress-circle.tsx +607 -12
  102. package/registry/ui/progress.tsx +3 -1
  103. package/registry/ui/radio-group.tsx +1 -1
  104. package/registry/ui/sample-data.tsx +86 -0
  105. package/registry/ui/scatter-chart.tsx +4150 -0
  106. package/registry/ui/scroll-area.tsx +3 -2
  107. package/registry/ui/searchable-select.tsx +8 -3
  108. package/registry/ui/section-header.tsx +4 -2
  109. package/registry/ui/segmented-control.tsx +7 -2
  110. package/registry/ui/select.tsx +4 -2
  111. package/registry/ui/separator.tsx +3 -1
  112. package/registry/ui/sheet.tsx +4 -2
  113. package/registry/ui/sidebar.tsx +4 -2
  114. package/registry/ui/skeleton.tsx +4 -2
  115. package/registry/ui/slider.tsx +3 -1
  116. package/registry/ui/sparkline.tsx +84 -9
  117. package/registry/ui/spinner.tsx +3 -1
  118. package/registry/ui/states.tsx +34 -5
  119. package/registry/ui/status-badge.tsx +81 -39
  120. package/registry/ui/switch.tsx +3 -1
  121. package/registry/ui/table.tsx +4 -2
  122. package/registry/ui/tabs.tsx +5 -3
  123. package/registry/ui/textarea.tsx +3 -1
  124. package/registry/ui/toast.tsx +2 -2
  125. package/registry/ui/toggle-group.tsx +3 -1
  126. package/registry/ui/toggle.tsx +3 -1
  127. package/registry/ui/tooltip.tsx +4 -2
  128. package/registry/ui/tracker.tsx +624 -17
  129. package/registry/ui/trend-indicator.tsx +13 -1
  130. package/registry/ui/updated-at.tsx +122 -0
  131. package/registry/ui/waterfall-chart.tsx +4186 -0
  132. package/templates/AGENTS.md +13 -1
  133. package/theme.css +237 -136
  134. package/dist/chunk-5ZOP4SQR.js.map +0 -1
  135. package/dist/chunk-A2UUGASH.js.map +0 -1
  136. package/dist/chunk-AG6RZ2RR.js +0 -3208
  137. package/dist/chunk-AG6RZ2RR.js.map +0 -1
  138. package/dist/chunk-IBGOCWJB.js.map +0 -1
  139. package/dist/chunk-OB6UBJXF.js.map +0 -1
  140. package/dist/chunk-PXRX5PEC.js.map +0 -1
  141. package/dist/chunk-QEWCYW3I.js.map +0 -1
@@ -1,5 +1,7 @@
1
1
  // Copied by `vantage add ui echart`. This file is yours to edit.
2
- // Generated from @squadbase/vantage src/components/echart.tsx by `npm run registry` (in Vantage, edit src/, not registry/).
2
+ // Generated from @squadbase/squadkit registry/charts/echart.tsx by `npm run registry` (in Vantage, edit packages/squadkit, not registry/).
3
+ "use client"
4
+
3
5
  /**
4
6
  * EChart — a thin Apache ECharts wrapper. It owns the canvas lifecycle (init,
5
7
  * resize, dispose), forwards `option` and event handlers, drives the loading
@@ -11,16 +13,71 @@
11
13
  * have changed — a light/dark flip, or a stylesheet swap such as editing
12
14
  * `styles.css` while the dev server is running. `theme` opts out entirely;
13
15
  * `option.color` overrides just the palette and keeps the rest.
16
+ *
17
+ * Pitfall: the canvas can't read CSS. A colour written into the option as
18
+ * `"var(--chart-1)"`, `"chart-1"` or `"oklch(…)"` paints black (or nothing).
19
+ * Leave colours out and the tokens apply; when you must set one yourself, pass
20
+ * it through `useResolvedChartColors` and use the result:
21
+ *
22
+ * @example
23
+ * const [accent] = useResolvedChartColors(["chart-2"])
24
+ * const option = { series: [{ type: "line", data, lineStyle: { color: accent } }] }
25
+ * return <EChart option={option} />
26
+ *
27
+ * Updates merge instead of replacing (`replaceMerge` on every component type in
28
+ * play), so series with a stable `id` transition to their new values while
29
+ * anything dropped from the option is still removed. With
30
+ * `prefers-reduced-motion: reduce` every animation is off.
31
+ *
32
+ * ECharts is loaded modularly (`echarts/core`): only the bar / line / pie series (and `custom`, which draws
33
+ * the per-bar target marker),
34
+ * the components the typed charts emit and the canvas renderer are registered
35
+ * below. To draw anything else with `<EChart>` (scatter, funnel, dataZoom, …),
36
+ * register it once in your own code:
37
+ *
38
+ * @example
39
+ * import { use } from "echarts/core"
40
+ * import { ScatterChart } from "echarts/charts"
41
+ * use([ScatterChart])
14
42
  */
15
- import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react"
16
- import * as echarts from "echarts"
43
+ import { forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"
44
+ import { init, use } from "echarts/core"
45
+ import { BarChart, CustomChart, LineChart, PieChart } from "echarts/charts"
46
+ import {
47
+ GridComponent,
48
+ LegendComponent,
49
+ MarkAreaComponent,
50
+ MarkLineComponent,
51
+ MarkPointComponent,
52
+ TooltipComponent,
53
+ } from "echarts/components"
54
+ import { CanvasRenderer } from "echarts/renderers"
17
55
  import type { ECharts, EChartsOption } from "echarts"
18
56
  import { Check, Copy, Download } from "lucide-react"
19
57
 
20
58
  import { cn } from "@squadbase/vantage/ui"
21
59
  import { Button } from "@squadbase/vantage/ui"
60
+ import type { ChartActionsOptions } from "@squadbase/vantage/components"
61
+ import { createContext, type ForwardedRef } from "react"
62
+ import type { ChartTone } from "@squadbase/vantage/components"
22
63
 
23
- /* ---------- inlined from src/components/chart-theme.ts (internal to @squadbase/vantage) ---------- */
64
+ /* ---------- inlined from @squadbase/squadkit registry/lib/chart-theme.ts (internal to @squadbase/vantage) ---------- */
65
+
66
+ /**
67
+ * The colour of the folded「その他」series / slice / row. The ONLY place this
68
+ * grey is defined — every chart takes it from here. It is the most subdued
69
+ * colour in a chart, not the darkest: the theme's `--neutral` mixed halfway into
70
+ * `--background`, so it recedes in light and dark alike (and brand themes follow
71
+ * it). `--neutral-subtle` alone sits too close to the background to read as a
72
+ * slice. Canvas charts resolve it through `resolveChartColors`.
73
+ */
74
+ const CHART_OTHER_COLOR = "color-mix(in oklab, var(--neutral) 50%, var(--background))"
75
+
76
+ /**
77
+ * True inside `ChartFrame`. The frame's plot area carries the chart's single
78
+ * `role="img"` and label, so `EChart` drops its own when it sees this.
79
+ */
80
+ const ChartFrameContext = createContext(false)
24
81
 
25
82
  /** Series palette, in order. Missing tokens are skipped, not defaulted. */
26
83
  const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"] as const
@@ -185,10 +242,11 @@ function watchChartTokens(onChange: () => void): () => void {
185
242
  function axisTheme(tokens: ChartTokens, splitLine: boolean) {
186
243
  const line = tokens.border ?? undefined
187
244
  return {
188
- axisLine: { show: true, lineStyle: { color: line } },
189
- axisTick: { show: true, lineStyle: { color: line } },
190
- axisLabel: { show: true, color: tokens.mutedForeground ?? undefined },
191
- splitLine: { show: splitLine, lineStyle: { color: line ? [line] : undefined } },
245
+ axisLine: { show: false },
246
+ axisTick: { show: false },
247
+ axisLabel: { show: true, color: tokens.mutedForeground ?? undefined, fontSize: 12 },
248
+ // Faint horizontal rules only: the border colour at low opacity.
249
+ splitLine: { show: splitLine, lineStyle: { color: line ? [line] : undefined, opacity: 0.5, width: 1 } },
192
250
  splitArea: { show: false },
193
251
  }
194
252
  }
@@ -213,10 +271,15 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
213
271
  logAxis: axisTheme(tokens, true),
214
272
  timeAxis: axisTheme(tokens, true),
215
273
  tooltip: {
274
+ // ECharts' default 0.4s transform lags the tooltip ~300ms behind the cursor.
275
+ transitionDuration: 0,
216
276
  backgroundColor: tokens.popover ?? undefined,
217
277
  borderColor: line,
218
278
  borderWidth: 1,
219
- textStyle: { color: tokens.popoverForeground ?? undefined },
279
+ padding: [8, 12],
280
+ // Rounded card with a soft shadow; in dark mode the shadow barely shows, so the border carries the edge.
281
+ 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 },
220
283
  axisPointer: { lineStyle: { color: line }, crossStyle: { color: line } },
221
284
  },
222
285
  }
@@ -230,12 +293,16 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
230
293
  */
231
294
  function useChartTokens(): ChartTokens | null {
232
295
  const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
296
+ // Compared here, not inside a `setTokens` updater: an updater that reads the DOM is
297
+ // replayed by React on every rebase and returns a fresh object each time, so the state
298
+ // never settles ("Maximum update depth exceeded" once the tokens change after mount).
299
+ const tokensRef = useRef(tokens)
233
300
  useEffect(() => {
234
301
  const sync = () => {
235
- setTokens((prev) => {
236
- const next = readChartTokens()
237
- return sameChartTokens(prev, next) ? prev : next
238
- })
302
+ const next = readChartTokens()
303
+ if (sameChartTokens(tokensRef.current, next)) return
304
+ tokensRef.current = next
305
+ setTokens(next)
239
306
  }
240
307
  sync()
241
308
  return watchChartTokens(sync)
@@ -244,11 +311,14 @@ function useChartTokens(): ChartTokens | null {
244
311
  }
245
312
 
246
313
  const SERIES_TOKEN_NAME_RE = /^chart-([1-5])$/
314
+ const TONE_NAME_RE = /^(?:positive|warning|negative|neutral|info)(?:-fill)?$/
247
315
  const CSS_VAR_RE = /^var\(\s*(--[\w-]+)\s*(?:,\s*(.+))?\)$/
316
+ const EMBEDDED_VAR_RE = /var\(\s*(--[\w-]+)\s*\)/g
248
317
 
249
318
  /**
250
319
  * Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
251
- * the resolved series tokens; `var(--token)` is read off the document root; any
320
+ * 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
252
322
  * other CSS colour is normalized like the tokens are. A colour that can't be
253
323
  * resolved comes back as `""`, which ECharts treats as "use the palette".
254
324
  */
@@ -256,19 +326,120 @@ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | nul
256
326
  return colors.map((color) => {
257
327
  const token = SERIES_TOKEN_NAME_RE.exec(color.trim())
258
328
  if (token) return tokens?.series[Number(token[1]) - 1] ?? ""
329
+ // Meaning colours are the theme tokens of the same name: same path as `var(--positive)`.
330
+ if (TONE_NAME_RE.test(color.trim())) {
331
+ if (typeof document === "undefined") return ""
332
+ return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue(`--${color.trim()}`)) ?? ""
333
+ }
259
334
  if (typeof document === "undefined") return color
260
335
  let value = color.trim()
261
336
  const cssVar = CSS_VAR_RE.exec(value)
337
+ const styles = getComputedStyle(document.documentElement)
262
338
  if (cssVar) {
263
- const styles = getComputedStyle(document.documentElement)
264
339
  value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
340
+ } else if (value.includes("var(")) {
341
+ // A colour function over tokens (`color-mix(… var(--muted-foreground) …)`):
342
+ // substitute the tokens, then let the canvas resolve the rest.
343
+ value = value.replace(EMBEDDED_VAR_RE, (_, name: string) => styles.getPropertyValue(name).trim())
265
344
  }
266
345
  return toCanvasColor(value) ?? ""
267
346
  })
268
347
  }
269
348
 
349
+ /** Every meaning colour resolved for the canvas (`""` when it can't be resolved). */
350
+ function resolveToneColors(tokens: ChartTokens | null): Record<ChartTone, string> {
351
+ const [positive = "", warning = "", negative = "", neutral = ""] = resolveChartColors(
352
+ ["positive", "warning", "negative", "neutral"],
353
+ tokens,
354
+ )
355
+ return { positive, warning, negative, neutral }
356
+ }
357
+
358
+ /** The area (`--<tone>-fill`) colours of every meaning tone, resolved for the canvas (`""` when it can't be resolved). */
359
+ function resolveFillColors(tokens: ChartTokens | null): Record<ChartTone, string> {
360
+ const [positive = "", warning = "", negative = "", neutral = ""] = resolveChartColors(
361
+ ["positive-fill", "warning-fill", "negative-fill", "neutral-fill"],
362
+ tokens,
363
+ )
364
+ return { positive, warning, negative, neutral }
365
+ }
366
+
367
+ const TRANSPARENT_RE = /^(?:transparent|rgba\([^)]*,\s*0(?:\.0+)?\))$/i
368
+
369
+ /**
370
+ * The colour the chart is painted on: the first opaque `background-color` up
371
+ * from `el` (a card, the page), else `--background`. Canvas-safe, or `null`
372
+ * outside the browser.
373
+ */
374
+ function readChartSurface(el: Element | null): string | null {
375
+ if (typeof document === "undefined") return null
376
+ for (let node: Element | null = el; node; node = node.parentElement) {
377
+ const bg = getComputedStyle(node).backgroundColor.trim()
378
+ if (bg && !TRANSPARENT_RE.test(bg)) {
379
+ const color = toCanvasColor(bg)
380
+ if (color) return color
381
+ }
382
+ }
383
+ return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue("--background"))
384
+ }
385
+
386
+ /** Widths are bucketed so a resize only rebuilds the option when the label budget can change. */
387
+ const WIDTH_BUCKET = 40
388
+
389
+ /**
390
+ * The surface colour behind a chart (see `readChartSurface`), re-read when the
391
+ * tokens move (light/dark), and the plot's width (rounded down to 40px;
392
+ * `undefined` until measured). Returns a ref for the plot that also feeds the
393
+ * component's forwarded ref.
394
+ */
395
+ function useChartSurface<E extends Element>(
396
+ forwarded: ForwardedRef<E>,
397
+ tokens: ChartTokens | null,
398
+ ): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined } {
399
+ const [node, setNode] = useState<E | null>(null)
400
+ const [surface, setSurface] = useState<string | undefined>(undefined)
401
+ const [width, setWidth] = useState<number | undefined>(undefined)
402
+ const ref = useCallback(
403
+ (el: E | null) => {
404
+ setNode(el)
405
+ if (typeof forwarded === "function") forwarded(el)
406
+ else if (forwarded) forwarded.current = el
407
+ },
408
+ [forwarded],
409
+ )
410
+ useEffect(() => {
411
+ if (!node) return
412
+ setSurface(readChartSurface(node) ?? undefined)
413
+ }, [node, tokens])
414
+ useEffect(() => {
415
+ if (!node || typeof ResizeObserver === "undefined") return
416
+ const update = (w: number) => setWidth(Math.floor(w / WIDTH_BUCKET) * WIDTH_BUCKET)
417
+ update(node.getBoundingClientRect().width)
418
+ const observer = new ResizeObserver((entries) => update(entries[0]?.contentRect.width ?? 0))
419
+ observer.observe(node)
420
+ return () => observer.disconnect()
421
+ }, [node])
422
+ return { ref, surface, width }
423
+ }
424
+
270
425
  /* ---------- end of inlined code ---------- */
271
426
 
427
+ // What the typed charts (Bar / Line / Area / Donut) emit. Other series and
428
+ // components are the consumer's to `use()` — see the note at the top.
429
+ use([
430
+ BarChart,
431
+ CustomChart,
432
+ LineChart,
433
+ PieChart,
434
+ GridComponent,
435
+ LegendComponent,
436
+ MarkAreaComponent,
437
+ MarkLineComponent,
438
+ MarkPointComponent,
439
+ TooltipComponent,
440
+ CanvasRenderer,
441
+ ])
442
+
272
443
  export interface EChartProps {
273
444
  /**
274
445
  * ECharts のオプション。
@@ -278,7 +449,7 @@ export interface EChartProps {
278
449
  * literal が `string` に拡張され、ビルドが失敗する。
279
450
  *
280
451
  * @example
281
- * import type { EChartsOption } from "@squadbase/vantage/components"
452
+ * import type { EChartsOption } from "@/components/echart"
282
453
  *
283
454
  * const option: EChartsOption = {
284
455
  * xAxis: { type: "category", data: ["A", "B"] },
@@ -309,14 +480,205 @@ export interface EChartProps {
309
480
  onEvents?: Record<string, (params: unknown) => void>
310
481
  ariaLabel?: string
311
482
  className?: string
312
- /** true にするとチャート右上に PNG コピー / ダウンロードボタンを表示する */
313
- actions?: boolean
483
+ /**
484
+ * true にするとチャート右上に PNG コピー / ダウンロードボタンを表示する。
485
+ * `exportHeader` があれば PNG の上にその見出しを含める(`{ includeTitle: false }` で含めない)。
486
+ */
487
+ actions?: boolean | ChartActionsOptions
314
488
  /** ダウンロード時のファイル名(拡張子含む)。省略時は "chart.png" */
315
489
  fileName?: string
490
+ /**
491
+ * PNG の上に含める見出しと要約。型付きグラフは自分の `title` / `summary` を渡す。
492
+ * 自分で `ChartFrame` を組むときも同じものを渡すと、保存した画像だけで何のグラフか分かる。
493
+ */
494
+ exportHeader?: ChartExportHeader
495
+ }
496
+
497
+ /** The heading a saved PNG carries above the plot. */
498
+ export interface ChartExportHeader {
499
+ title?: string
500
+ summary?: string
501
+ }
502
+
503
+ const HEADER_PAD_X = 16
504
+ const HEADER_PAD_TOP = 16
505
+ const HEADER_PAD_BOTTOM = 12
506
+ const HEADER_LINE_HEIGHT = 20
507
+ const HEADER_GAP = 4
508
+ const HEADER_FONT_SIZE = 14
509
+
510
+ export interface ChartExportHeaderLine {
511
+ text: string
512
+ kind: "title" | "summary"
513
+ }
514
+
515
+ /**
516
+ * Break `text` into lines no wider than `maxWidth` (px, via `measure`), character
517
+ * by character so Japanese wraps without spaces. Always at least one line for
518
+ * non-empty text; a single over-wide character still gets its own line.
519
+ */
520
+ export function wrapChartText(text: string, maxWidth: number, measure: (text: string) => number): string[] {
521
+ const lines: string[] = []
522
+ let line = ""
523
+ for (const ch of text) {
524
+ if (ch === "\n") {
525
+ lines.push(line)
526
+ line = ""
527
+ continue
528
+ }
529
+ if (line !== "" && measure(line + ch) > maxWidth) {
530
+ lines.push(line)
531
+ line = ch.trim() === "" ? "" : ch
532
+ } else {
533
+ line += ch
534
+ }
535
+ }
536
+ if (line !== "" || lines.length === 0) lines.push(line)
537
+ return lines.filter((l, i) => l !== "" || i < lines.length - 1)
538
+ }
539
+
540
+ /**
541
+ * The lines and total height (CSS px) of the PNG's heading band for a plot
542
+ * `width` px wide. `measure` takes (text, kind) and returns its width in px.
543
+ * No title and no summary → no band (`height` 0).
544
+ */
545
+ export function chartExportHeaderLayout(
546
+ header: ChartExportHeader | undefined,
547
+ width: number,
548
+ measure: (text: string, kind: "title" | "summary") => number,
549
+ ): { lines: ChartExportHeaderLine[]; height: number } {
550
+ const title = header?.title?.trim()
551
+ const summary = header?.summary?.trim()
552
+ if (!title && !summary) return { lines: [], height: 0 }
553
+ const room = Math.max(40, width - HEADER_PAD_X * 2)
554
+ const lines: ChartExportHeaderLine[] = [
555
+ ...(title ? wrapChartText(title, room, (t) => measure(t, "title")).map((text) => ({ text, kind: "title" as const })) : []),
556
+ ...(summary ? wrapChartText(summary, room, (t) => measure(t, "summary")).map((text) => ({ text, kind: "summary" as const })) : []),
557
+ ]
558
+ const gap = title && summary ? HEADER_GAP : 0
559
+ return { lines, height: HEADER_PAD_TOP + lines.length * HEADER_LINE_HEIGHT + gap + HEADER_PAD_BOTTOM }
560
+ }
561
+
562
+ function loadImage(src: string): Promise<HTMLImageElement> {
563
+ return new Promise((resolve, reject) => {
564
+ const image = new Image()
565
+ image.onload = () => resolve(image)
566
+ image.onerror = () => reject(new Error("PNG の読み込みに失敗しました"))
567
+ image.src = src
568
+ })
569
+ }
570
+
571
+ /** Draw a heading band (title in foreground, summary muted) above the exported plot and return the new PNG. */
572
+ async function pngWithHeader(
573
+ dataUrl: string,
574
+ header: ChartExportHeader,
575
+ look: { background: string; color: string; mutedColor: string; fontFamily: string },
576
+ pixelRatio: number,
577
+ ): Promise<string> {
578
+ const image = await loadImage(dataUrl)
579
+ const width = image.width / pixelRatio
580
+ const plotHeight = image.height / pixelRatio
581
+ const canvas = document.createElement("canvas")
582
+ const ctx = canvas.getContext("2d")
583
+ if (!ctx) return dataUrl
584
+ const font = (kind: "title" | "summary") => `${kind === "title" ? 500 : 400} ${HEADER_FONT_SIZE}px ${look.fontFamily}`
585
+ ctx.font = font("title")
586
+ const layout = chartExportHeaderLayout(header, width, (text, kind) => {
587
+ ctx.font = font(kind)
588
+ return ctx.measureText(text).width
589
+ })
590
+ if (layout.height === 0) return dataUrl
591
+ canvas.width = Math.round(image.width)
592
+ canvas.height = Math.round((layout.height + plotHeight) * pixelRatio)
593
+ ctx.scale(pixelRatio, pixelRatio)
594
+ ctx.fillStyle = look.background
595
+ ctx.fillRect(0, 0, width, layout.height + plotHeight)
596
+ ctx.textBaseline = "middle"
597
+ let y = HEADER_PAD_TOP
598
+ let previous: "title" | "summary" | null = null
599
+ for (const line of layout.lines) {
600
+ if (previous === "title" && line.kind === "summary") y += HEADER_GAP
601
+ ctx.font = font(line.kind)
602
+ ctx.fillStyle = line.kind === "title" ? look.color : look.mutedColor
603
+ ctx.fillText(line.text, HEADER_PAD_X, y + HEADER_LINE_HEIGHT / 2)
604
+ y += HEADER_LINE_HEIGHT
605
+ previous = line.kind
606
+ }
607
+ ctx.drawImage(image, 0, layout.height, width, plotHeight)
608
+ return canvas.toDataURL("image/png")
609
+ }
610
+
611
+ /**
612
+ * Canvas-safe versions of theme colours, kept current across light/dark.
613
+ * Takes `"chart-1"`〜`"chart-5"`, `"var(--token)"` or any CSS colour; one that
614
+ * can't be resolved comes back as `""` (ECharts then uses the palette).
615
+ * Use it whenever a colour goes into a raw `EChart` option by hand.
616
+ *
617
+ * @example
618
+ * const [line, band] = useResolvedChartColors(["chart-1", "var(--muted-foreground)"])
619
+ */
620
+ export function useResolvedChartColors(colors: readonly string[]): string[] {
621
+ const tokens = useChartTokens()
622
+ const key = colors.join("\u0000")
623
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `key` stands for the array's contents
624
+ return useMemo(() => resolveChartColors(colors, tokens), [key, tokens])
316
625
  }
317
626
 
318
627
  type EChartsEventHandler = (params: unknown) => void
319
628
 
629
+ /**
630
+ * ECharts component main types. Listed in `replaceMerge`, a type keeps only the
631
+ * components whose `id` matches (merged, so they can transition) and drops the
632
+ * rest — including every one of them when the new option no longer has the type.
633
+ */
634
+ const COMPONENT_MAIN_TYPES = new Set([
635
+ "title", "legend", "grid", "xAxis", "yAxis", "polar", "radiusAxis", "angleAxis", "radar",
636
+ "dataZoom", "visualMap", "tooltip", "axisPointer", "toolbox", "brush", "geo", "parallel",
637
+ "parallelAxis", "singleAxis", "graphic", "calendar", "dataset", "series", "matrix", "thumbnail",
638
+ ])
639
+
640
+ export interface EChartSetOptionOpts {
641
+ notMerge: boolean
642
+ replaceMerge?: string[]
643
+ lazyUpdate: boolean
644
+ }
645
+
646
+ /**
647
+ * How to apply `next` after `prev`: the first option and timeline / media
648
+ * options replace everything; otherwise merge, replacing every component type
649
+ * either option uses so nothing stale survives.
650
+ */
651
+ export function echartSetOptionOpts(prev: EChartsOption | null, next: EChartsOption): EChartSetOptionOpts {
652
+ const special = (o: EChartsOption) => "timeline" in o || "baseOption" in o || "options" in o || "media" in o
653
+ if (!prev || special(prev) || special(next)) return { notMerge: true, lazyUpdate: true }
654
+ const types = new Set<string>()
655
+ for (const key of [...Object.keys(prev), ...Object.keys(next)]) if (COMPONENT_MAIN_TYPES.has(key)) types.add(key)
656
+ return { notMerge: false, replaceMerge: [...types], lazyUpdate: true }
657
+ }
658
+
659
+ /** `option` as applied: reduced motion forces `animation: false`; otherwise animation stays explicit so it can flip back. */
660
+ export function withMotionPreference(option: EChartsOption, reducedMotion: boolean): EChartsOption {
661
+ return { ...option, animation: reducedMotion ? false : (option.animation ?? true) }
662
+ }
663
+
664
+ const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"
665
+
666
+ /** `prefers-reduced-motion: reduce`, kept current. `false` on the server. */
667
+ export function usePrefersReducedMotion(): boolean {
668
+ const [reduced, setReduced] = useState(
669
+ () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia(REDUCED_MOTION_QUERY).matches,
670
+ )
671
+ useEffect(() => {
672
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return
673
+ const media = window.matchMedia(REDUCED_MOTION_QUERY)
674
+ const sync = () => setReduced(media.matches)
675
+ sync()
676
+ media.addEventListener("change", sync)
677
+ return () => media.removeEventListener("change", sync)
678
+ }, [])
679
+ return reduced
680
+ }
681
+
320
682
  export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
321
683
  {
322
684
  option,
@@ -328,6 +690,7 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
328
690
  className,
329
691
  actions = false,
330
692
  fileName = "chart.png",
693
+ exportHeader,
331
694
  },
332
695
  ref,
333
696
  ) {
@@ -336,17 +699,23 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
336
699
  const [copied, setCopied] = useState(false)
337
700
  // 初回はレンダー前に読めるので、マウント直後の作り直しが起きない
338
701
  const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
702
+ const reducedMotion = usePrefersReducedMotion()
703
+ // What the current instance last received, to pick the merge mode of the next update.
704
+ const appliedRef = useRef<EChartsOption | null>(null)
339
705
 
340
706
  // 明暗の切り替えとスタイルシートの差し替え(dev で styles.css を書き換えたときなど)を
341
707
  // 拾ってトークンを読み直す ― 何を見張るかは `watchChartTokens` 側が持つ。値が変わら
342
- // ないときは同じオブジェクトを返して再 init を防ぐ。
708
+ // ないときは state を更新せず、再 init を防ぐ。比較は updater 関数の中ではなく ref で行う:
709
+ // DOM を読む updater は React に何度も再生され、そのたびに新しいオブジェクトを返して
710
+ // 再描画が止まらなくなる(変数を後から当てた直後に Maximum update depth exceeded)。
711
+ const tokensRef = useRef(tokens)
343
712
  useEffect(() => {
344
713
  if (theme !== undefined) return
345
714
  const sync = () => {
346
- setTokens((prev) => {
347
- const next = readChartTokens()
348
- return sameChartTokens(prev, next) ? prev : next
349
- })
715
+ const next = readChartTokens()
716
+ if (sameChartTokens(tokensRef.current, next)) return
717
+ tokensRef.current = next
718
+ setTokens(next)
350
719
  }
351
720
  sync()
352
721
  return watchChartTokens(sync)
@@ -360,7 +729,8 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
360
729
  useEffect(() => {
361
730
  const container = containerRef.current
362
731
  if (!container) return
363
- const inst = echarts.init(container, resolvedTheme, { renderer: "canvas" })
732
+ const inst = init(container, resolvedTheme, { renderer: "canvas" })
733
+ appliedRef.current = null
364
734
  // `data-rendering` stays on until the (animated) paint is done, so anything
365
735
  // capturing the page — printing a document — can wait for real pixels.
366
736
  container.dataset.rendering = ""
@@ -369,11 +739,19 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
369
739
  })
370
740
  setInstance(inst)
371
741
 
372
- const observer = new ResizeObserver(() => inst.resize())
742
+ // At most one resize per frame (the sidebar animates the width).
743
+ let frame = 0
744
+ const observer = new ResizeObserver(() => {
745
+ cancelAnimationFrame(frame)
746
+ frame = requestAnimationFrame(() => {
747
+ if (!inst.isDisposed()) inst.resize()
748
+ })
749
+ })
373
750
  observer.observe(container)
374
751
 
375
752
  return () => {
376
753
  observer.disconnect()
754
+ cancelAnimationFrame(frame)
377
755
  inst.dispose()
378
756
  }
379
757
  }, [resolvedTheme])
@@ -381,8 +759,10 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
381
759
  useEffect(() => {
382
760
  if (!instance) return
383
761
  if (containerRef.current) containerRef.current.dataset.rendering = ""
384
- instance.setOption(option, { notMerge: true, lazyUpdate: true })
385
- }, [instance, option])
762
+ const next = withMotionPreference(option, reducedMotion)
763
+ instance.setOption(next, echartSetOptionOpts(appliedRef.current, next))
764
+ appliedRef.current = next
765
+ }, [instance, option, reducedMotion])
386
766
 
387
767
  useEffect(() => {
388
768
  if (!instance) return
@@ -404,20 +784,38 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
404
784
  }
405
785
  }, [instance, onEvents])
406
786
 
407
- const getPngDataUrl = useCallback(() => {
787
+ const includeTitle = typeof actions === "object" ? (actions.includeTitle ?? true) : true
788
+ const getPngDataUrl = useCallback(async (): Promise<string | null> => {
408
789
  if (!instance) return null
409
- // Export over the page background so the PNG isn't transparent. Computed
410
- // styles resolve to rgb(a), so this stays canvas-safe whatever the token.
411
- const pageBg = getComputedStyle(document.documentElement).backgroundColor
412
- return instance.getDataURL({
413
- type: "png",
414
- pixelRatio: 2,
415
- backgroundColor: pageBg && pageBg !== "rgba(0, 0, 0, 0)" ? pageBg : "#ffffff",
416
- })
417
- }, [instance])
790
+ // Export over the nearest opaque background (card, then page) so the PNG
791
+ // matches what is on screen in dark too. Computed styles resolve to rgb(a),
792
+ // so this stays canvas-safe whatever the token.
793
+ let backgroundColor = "#ffffff"
794
+ for (let el: HTMLElement | null = containerRef.current; el; el = el.parentElement) {
795
+ const bg = getComputedStyle(el).backgroundColor
796
+ if (bg && bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent") {
797
+ backgroundColor = bg
798
+ break
799
+ }
800
+ }
801
+ const pixelRatio = 2
802
+ const dataUrl = instance.getDataURL({ type: "png", pixelRatio, backgroundColor })
803
+ if (!includeTitle || !exportHeader || !(exportHeader.title || exportHeader.summary) || !containerRef.current) return dataUrl
804
+ try {
805
+ const style = getComputedStyle(containerRef.current)
806
+ return await pngWithHeader(
807
+ dataUrl,
808
+ exportHeader,
809
+ { background: backgroundColor, color: style.color, mutedColor: tokens?.mutedForeground ?? style.color, fontFamily: style.fontFamily },
810
+ pixelRatio,
811
+ )
812
+ } catch {
813
+ return dataUrl
814
+ }
815
+ }, [instance, includeTitle, exportHeader, tokens])
418
816
 
419
817
  const handleCopy = useCallback(async () => {
420
- const dataUrl = getPngDataUrl()
818
+ const dataUrl = await getPngDataUrl()
421
819
  if (!dataUrl) return
422
820
  try {
423
821
  const blob = await (await fetch(dataUrl)).blob()
@@ -429,8 +827,8 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
429
827
  }
430
828
  }, [getPngDataUrl])
431
829
 
432
- const handleDownload = useCallback(() => {
433
- const dataUrl = getPngDataUrl()
830
+ const handleDownload = useCallback(async () => {
831
+ const dataUrl = await getPngDataUrl()
434
832
  if (!dataUrl) return
435
833
  const link = document.createElement("a")
436
834
  link.href = dataUrl
@@ -440,6 +838,7 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
440
838
 
441
839
  // 高さの解決: height prop を渡した場合はインライン style が勝つ。未指定なら
442
840
  // h-[400px] をデフォルトで当て、className の高さクラスに後勝ちさせる。
841
+ const inFrame = useContext(ChartFrameContext)
443
842
  const styleHeight =
444
843
  height == null ? undefined : typeof height === "number" ? `${height}px` : height
445
844
 
@@ -449,14 +848,19 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
449
848
  data-slot="echart"
450
849
  className={cn("group relative w-full", height == null && "h-[400px]", className)}
451
850
  style={styleHeight != null ? { height: styleHeight } : undefined}
452
- role="img"
453
- aria-label={ariaLabel ?? "チャート"}
454
851
  >
455
- <div ref={containerRef} data-slot="echart-canvas" className="h-full w-full" />
852
+ {/* `role="img"` wraps the canvas only, so the action buttons stay reachable. Inside a ChartFrame the plot area is the one image. */}
853
+ <div
854
+ ref={containerRef}
855
+ data-slot="echart-canvas"
856
+ className="h-full w-full"
857
+ role={inFrame ? undefined : "img"}
858
+ aria-label={inFrame ? undefined : (ariaLabel ?? "チャート")}
859
+ />
456
860
  {actions && (
457
861
  <div
458
862
  data-slot="echart-actions"
459
- className="absolute top-2 right-2 z-10 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
863
+ className="absolute top-2 right-2 z-10 flex gap-1 pointer-events-none opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100"
460
864
  >
461
865
  <Button
462
866
  type="button"
@@ -1,5 +1,5 @@
1
1
  // Copied by `vantage add ui field`. This file is yours to edit.
2
- // Generated from @squadbase/vantage src/ui/field.tsx by `npm run registry` (in Vantage, edit src/, not registry/).
2
+ // Generated from @squadbase/squadkit registry/ui/field.tsx by `npm run registry` (in Vantage, edit packages/squadkit, not registry/).
3
3
  "use client"
4
4
 
5
5
  import { useMemo } from "react"