@squadbase/vantage 0.6.2-dev1.42c4e48 → 0.6.2-dev1.9365dcd
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{build-MFWRPYKW.js → build-X5NVCQKT.js} +4 -4
- package/dist/{build-MFWRPYKW.js.map → build-X5NVCQKT.js.map} +1 -1
- package/dist/{chunk-IBGOCWJB.js → chunk-2ICNHUG6.js} +186 -82
- package/dist/chunk-2ICNHUG6.js.map +1 -0
- package/dist/{chunk-5ZOP4SQR.js → chunk-2N72FKLB.js} +12 -7
- package/dist/chunk-2N72FKLB.js.map +1 -0
- package/dist/chunk-HKSVYMYJ.js +7919 -0
- package/dist/chunk-HKSVYMYJ.js.map +1 -0
- package/dist/{chunk-PXRX5PEC.js → chunk-PDOBHMUQ.js} +3 -3
- package/dist/chunk-PDOBHMUQ.js.map +1 -0
- package/dist/{chunk-A2UUGASH.js → chunk-U736PVHY.js} +2 -2
- package/dist/chunk-U736PVHY.js.map +1 -0
- package/dist/{chunk-QEWCYW3I.js → chunk-WAMZWZJB.js} +13 -3
- package/dist/chunk-WAMZWZJB.js.map +1 -0
- package/dist/{chunk-OB6UBJXF.js → chunk-XPQSTZUU.js} +125 -35
- package/dist/chunk-XPQSTZUU.js.map +1 -0
- package/dist/cli.js +3 -3
- package/dist/components/index.d.ts +1832 -100
- package/dist/components/index.js +4 -4
- package/dist/{dev-DCTOHQ6W.js → dev-4XEDTELV.js} +4 -4
- package/dist/{dev-DCTOHQ6W.js.map → dev-4XEDTELV.js.map} +1 -1
- package/dist/doc/index.js +2 -2
- package/dist/doc/index.js.map +1 -1
- package/dist/markdown/index.js +1 -1
- package/dist/markdown/index.js.map +1 -1
- package/dist/slide/index.js +2 -2
- package/dist/slide/index.js.map +1 -1
- package/dist/table/index.js +6 -6
- package/dist/table/index.js.map +1 -1
- package/dist/ui/index.d.ts +4 -1
- package/dist/ui/index.js +21 -111
- package/dist/ui/index.js.map +1 -1
- package/dist/{upgrade-DBBE6GIR.js → upgrade-JHXZBGQS.js} +3 -3
- package/dist/{upgrade-DBBE6GIR.js.map → upgrade-JHXZBGQS.js.map} +1 -1
- package/dist/vite/index.js +2 -2
- package/docs/en/components.md +29 -15
- package/docs/en/parts/data-table.md +22 -0
- package/docs/en/parts/delta.md +50 -0
- package/docs/en/parts/format.md +43 -0
- package/docs/en/parts/kpi-card.md +57 -0
- package/docs/en/parts/metric-value.md +3 -2
- package/docs/en/parts/sparkline.md +1 -1
- package/docs/en/parts/trend-indicator.md +17 -1
- package/docs/en/ui-and-theming.md +1 -1
- package/docs/index.json +92 -32
- package/docs/ja/components.md +27 -15
- package/docs/ja/parts/data-table.md +22 -0
- package/docs/ja/parts/delta.md +50 -0
- package/docs/ja/parts/format.md +44 -0
- package/docs/ja/parts/kpi-card.md +55 -0
- package/docs/ja/parts/metric-value.md +3 -2
- package/docs/ja/parts/sparkline.md +1 -1
- package/docs/ja/parts/trend-indicator.md +17 -1
- package/docs/ja/ui-and-theming.md +2 -2
- package/package.json +26 -24
- package/registry/ui/accordion.tsx +4 -2
- package/registry/ui/alert-dialog.tsx +3 -3
- package/registry/ui/alert.tsx +3 -1
- package/registry/ui/app-shell.tsx +7 -1
- package/registry/ui/area-chart.tsx +2817 -157
- package/registry/ui/attention-list.tsx +303 -0
- package/registry/ui/avatar.tsx +1 -1
- package/registry/ui/badge.tsx +3 -1
- package/registry/ui/bar-chart.tsx +2816 -157
- package/registry/ui/bar-list.tsx +3507 -93
- package/registry/ui/box-plot-chart.tsx +3880 -0
- package/registry/ui/breadcrumb.tsx +3 -1
- package/registry/ui/button.tsx +3 -1
- package/registry/ui/calendar.tsx +3 -1
- package/registry/ui/card.tsx +4 -2
- package/registry/ui/category-bar.tsx +998 -16
- package/registry/ui/chart-frame.tsx +1366 -0
- package/registry/ui/checkbox.tsx +3 -1
- package/registry/ui/collapsible.tsx +3 -1
- package/registry/ui/combobox.tsx +2 -2
- package/registry/ui/command.tsx +3 -1
- package/registry/ui/dashboard-card.tsx +71 -28
- package/registry/ui/data-table.tsx +678 -56
- package/registry/ui/date-range-picker.tsx +245 -64
- package/registry/ui/delta.tsx +188 -0
- package/registry/ui/dialog.tsx +5 -3
- package/registry/ui/donut-chart.tsx +2893 -173
- package/registry/ui/drawer.tsx +1 -1
- package/registry/ui/dropdown-menu.tsx +5 -3
- package/registry/ui/echart.tsx +483 -46
- package/registry/ui/field.tsx +1 -1
- package/registry/ui/filter-bar.tsx +33 -6
- package/registry/ui/funnel-steps.tsx +3344 -49
- package/registry/ui/heatmap.tsx +1760 -0
- package/registry/ui/histogram-chart.tsx +3960 -0
- package/registry/ui/hover-card.tsx +2 -2
- package/registry/ui/input-group.tsx +3 -1
- package/registry/ui/input.tsx +3 -1
- package/registry/ui/kbd.tsx +3 -1
- package/registry/ui/kpi-card.tsx +699 -0
- package/registry/ui/label.tsx +3 -1
- package/registry/ui/legend.tsx +3536 -0
- package/registry/ui/line-chart.tsx +2817 -151
- package/registry/ui/metric-value.tsx +9 -1
- package/registry/ui/multi-select.tsx +11 -5
- package/registry/ui/page-shell.tsx +106 -51
- package/registry/ui/pagination.tsx +3 -1
- package/registry/ui/paired-chart.tsx +3801 -0
- package/registry/ui/placeholder.tsx +8 -2
- package/registry/ui/popover.tsx +4 -2
- package/registry/ui/progress-circle.tsx +607 -12
- package/registry/ui/progress.tsx +3 -1
- package/registry/ui/radio-group.tsx +1 -1
- package/registry/ui/sample-data.tsx +86 -0
- package/registry/ui/scatter-chart.tsx +4186 -0
- package/registry/ui/scroll-area.tsx +3 -2
- package/registry/ui/searchable-select.tsx +8 -3
- package/registry/ui/section-header.tsx +5 -3
- package/registry/ui/segmented-control.tsx +7 -2
- package/registry/ui/select.tsx +4 -2
- package/registry/ui/separator.tsx +3 -1
- package/registry/ui/sheet.tsx +4 -2
- package/registry/ui/sidebar.tsx +4 -2
- package/registry/ui/skeleton.tsx +4 -2
- package/registry/ui/slider.tsx +3 -1
- package/registry/ui/sparkline.tsx +84 -9
- package/registry/ui/spinner.tsx +3 -1
- package/registry/ui/states.tsx +34 -5
- package/registry/ui/status-badge.tsx +81 -39
- package/registry/ui/switch.tsx +3 -1
- package/registry/ui/table.tsx +4 -2
- package/registry/ui/tabs.tsx +5 -3
- package/registry/ui/textarea.tsx +3 -1
- package/registry/ui/toast.tsx +2 -2
- package/registry/ui/toggle-group.tsx +3 -1
- package/registry/ui/toggle.tsx +3 -1
- package/registry/ui/tooltip.tsx +4 -2
- package/registry/ui/tracker.tsx +624 -17
- package/registry/ui/trend-indicator.tsx +13 -1
- package/registry/ui/updated-at.tsx +122 -0
- package/registry/ui/waterfall-chart.tsx +4222 -0
- package/skills/vantage-design/SKILL.md +10 -2
- package/templates/AGENTS.md +13 -1
- package/templates/agents/page.md +10 -0
- package/theme.css +241 -147
- package/dist/chunk-5ZOP4SQR.js.map +0 -1
- package/dist/chunk-A2UUGASH.js.map +0 -1
- package/dist/chunk-AG6RZ2RR.js +0 -3208
- package/dist/chunk-AG6RZ2RR.js.map +0 -1
- package/dist/chunk-IBGOCWJB.js.map +0 -1
- package/dist/chunk-OB6UBJXF.js.map +0 -1
- package/dist/chunk-PXRX5PEC.js.map +0 -1
- package/dist/chunk-QEWCYW3I.js.map +0 -1
package/registry/ui/echart.tsx
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Copied by `vantage add ui echart`. This file is yours to edit.
|
|
2
|
-
// Generated from @squadbase/
|
|
2
|
+
// Generated from @squadbase/squadkit registry/charts/echart.tsx by `pnpm 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
|
|
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
|
|
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
|
|
@@ -28,6 +85,8 @@ const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--ch
|
|
|
28
85
|
interface ChartTokens {
|
|
29
86
|
/** Resolved `--chart-1..5`, in order. */
|
|
30
87
|
series: string[]
|
|
88
|
+
/** Computed `font-family` of the container, so canvas text matches the DOM. Optional. */
|
|
89
|
+
fontFamily?: string
|
|
31
90
|
/** Body text: titles and the default `textStyle`. */
|
|
32
91
|
foreground: string | null
|
|
33
92
|
/** Secondary text: axis labels, legend, subtitles. */
|
|
@@ -97,6 +156,7 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
|
|
|
97
156
|
}
|
|
98
157
|
return {
|
|
99
158
|
series,
|
|
159
|
+
...(styles.fontFamily ? { fontFamily: styles.fontFamily } : {}),
|
|
100
160
|
foreground: read("--foreground"),
|
|
101
161
|
mutedForeground: read("--muted-foreground"),
|
|
102
162
|
border: read("--border"),
|
|
@@ -112,6 +172,7 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
|
|
|
112
172
|
return (
|
|
113
173
|
a.series.length === b.series.length &&
|
|
114
174
|
a.series.every((color, i) => color === b.series[i]) &&
|
|
175
|
+
a.fontFamily === b.fontFamily &&
|
|
115
176
|
a.foreground === b.foreground &&
|
|
116
177
|
a.mutedForeground === b.mutedForeground &&
|
|
117
178
|
a.border === b.border &&
|
|
@@ -185,10 +246,11 @@ function watchChartTokens(onChange: () => void): () => void {
|
|
|
185
246
|
function axisTheme(tokens: ChartTokens, splitLine: boolean) {
|
|
186
247
|
const line = tokens.border ?? undefined
|
|
187
248
|
return {
|
|
188
|
-
axisLine: { show:
|
|
189
|
-
axisTick: { show:
|
|
190
|
-
axisLabel: { show: true, color: tokens.mutedForeground ?? undefined },
|
|
191
|
-
|
|
249
|
+
axisLine: { show: false },
|
|
250
|
+
axisTick: { show: false },
|
|
251
|
+
axisLabel: { show: true, color: tokens.mutedForeground ?? undefined, fontSize: 12 },
|
|
252
|
+
// Faint horizontal rules only: the border colour at low opacity.
|
|
253
|
+
splitLine: { show: splitLine, lineStyle: { color: line ? [line] : undefined, opacity: 0.5, width: 1 } },
|
|
192
254
|
splitArea: { show: false },
|
|
193
255
|
}
|
|
194
256
|
}
|
|
@@ -205,7 +267,7 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
|
|
|
205
267
|
// Let the page background show through: charts sit on cards, not on a canvas.
|
|
206
268
|
backgroundColor: "transparent",
|
|
207
269
|
...(tokens.series.length > 0 ? { color: tokens.series } : {}),
|
|
208
|
-
textStyle: { color: text },
|
|
270
|
+
textStyle: { color: text, ...(tokens.fontFamily ? { fontFamily: tokens.fontFamily } : {}) },
|
|
209
271
|
title: { textStyle: { color: text }, subtextStyle: { color: muted } },
|
|
210
272
|
legend: { textStyle: { color: muted } },
|
|
211
273
|
categoryAxis: axisTheme(tokens, false),
|
|
@@ -213,10 +275,19 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
|
|
|
213
275
|
logAxis: axisTheme(tokens, true),
|
|
214
276
|
timeAxis: axisTheme(tokens, true),
|
|
215
277
|
tooltip: {
|
|
278
|
+
// ECharts' default 0.4s transform lags the tooltip ~300ms behind the cursor.
|
|
279
|
+
transitionDuration: 0,
|
|
216
280
|
backgroundColor: tokens.popover ?? undefined,
|
|
217
281
|
borderColor: line,
|
|
218
282
|
borderWidth: 1,
|
|
219
|
-
|
|
283
|
+
padding: [8, 12],
|
|
284
|
+
// Rounded card with a soft shadow; in dark mode the shadow barely shows, so the border carries the edge.
|
|
285
|
+
extraCssText: "border-radius:8px;box-shadow:var(--shadow-card, 0 4px 12px rgb(0 0 0 / 0.08));",
|
|
286
|
+
textStyle: {
|
|
287
|
+
color: tokens.popoverForeground ?? undefined,
|
|
288
|
+
fontSize: 12,
|
|
289
|
+
...(tokens.fontFamily ? { fontFamily: tokens.fontFamily } : {}),
|
|
290
|
+
},
|
|
220
291
|
axisPointer: { lineStyle: { color: line }, crossStyle: { color: line } },
|
|
221
292
|
},
|
|
222
293
|
}
|
|
@@ -230,12 +301,16 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
|
|
|
230
301
|
*/
|
|
231
302
|
function useChartTokens(): ChartTokens | null {
|
|
232
303
|
const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
|
|
304
|
+
// Compared here, not inside a `setTokens` updater: an updater that reads the DOM is
|
|
305
|
+
// replayed by React on every rebase and returns a fresh object each time, so the state
|
|
306
|
+
// never settles ("Maximum update depth exceeded" once the tokens change after mount).
|
|
307
|
+
const tokensRef = useRef(tokens)
|
|
233
308
|
useEffect(() => {
|
|
234
309
|
const sync = () => {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
310
|
+
const next = readChartTokens()
|
|
311
|
+
if (sameChartTokens(tokensRef.current, next)) return
|
|
312
|
+
tokensRef.current = next
|
|
313
|
+
setTokens(next)
|
|
239
314
|
}
|
|
240
315
|
sync()
|
|
241
316
|
return watchChartTokens(sync)
|
|
@@ -244,11 +319,14 @@ function useChartTokens(): ChartTokens | null {
|
|
|
244
319
|
}
|
|
245
320
|
|
|
246
321
|
const SERIES_TOKEN_NAME_RE = /^chart-([1-5])$/
|
|
322
|
+
const TONE_NAME_RE = /^(?:positive|warning|negative|neutral|info)(?:-fill)?$/
|
|
247
323
|
const CSS_VAR_RE = /^var\(\s*(--[\w-]+)\s*(?:,\s*(.+))?\)$/
|
|
324
|
+
const EMBEDDED_VAR_RE = /var\(\s*(--[\w-]+)\s*\)/g
|
|
248
325
|
|
|
249
326
|
/**
|
|
250
327
|
* Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
|
|
251
|
-
* the resolved series tokens; `
|
|
328
|
+
* 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
|
|
252
330
|
* other CSS colour is normalized like the tokens are. A colour that can't be
|
|
253
331
|
* resolved comes back as `""`, which ECharts treats as "use the palette".
|
|
254
332
|
*/
|
|
@@ -256,19 +334,120 @@ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | nul
|
|
|
256
334
|
return colors.map((color) => {
|
|
257
335
|
const token = SERIES_TOKEN_NAME_RE.exec(color.trim())
|
|
258
336
|
if (token) return tokens?.series[Number(token[1]) - 1] ?? ""
|
|
337
|
+
// Meaning colours are the theme tokens of the same name: same path as `var(--positive)`.
|
|
338
|
+
if (TONE_NAME_RE.test(color.trim())) {
|
|
339
|
+
if (typeof document === "undefined") return ""
|
|
340
|
+
return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue(`--${color.trim()}`)) ?? ""
|
|
341
|
+
}
|
|
259
342
|
if (typeof document === "undefined") return color
|
|
260
343
|
let value = color.trim()
|
|
261
344
|
const cssVar = CSS_VAR_RE.exec(value)
|
|
345
|
+
const styles = getComputedStyle(document.documentElement)
|
|
262
346
|
if (cssVar) {
|
|
263
|
-
const styles = getComputedStyle(document.documentElement)
|
|
264
347
|
value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
|
|
348
|
+
} else if (value.includes("var(")) {
|
|
349
|
+
// A colour function over tokens (`color-mix(… var(--muted-foreground) …)`):
|
|
350
|
+
// substitute the tokens, then let the canvas resolve the rest.
|
|
351
|
+
value = value.replace(EMBEDDED_VAR_RE, (_, name: string) => styles.getPropertyValue(name).trim())
|
|
265
352
|
}
|
|
266
353
|
return toCanvasColor(value) ?? ""
|
|
267
354
|
})
|
|
268
355
|
}
|
|
269
356
|
|
|
357
|
+
/** Every meaning colour resolved for the canvas (`""` when it can't be resolved). */
|
|
358
|
+
function resolveToneColors(tokens: ChartTokens | null): Record<ChartTone, string> {
|
|
359
|
+
const [positive = "", warning = "", negative = "", neutral = ""] = resolveChartColors(
|
|
360
|
+
["positive", "warning", "negative", "neutral"],
|
|
361
|
+
tokens,
|
|
362
|
+
)
|
|
363
|
+
return { positive, warning, negative, neutral }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** The area (`--<tone>-fill`) colours of every meaning tone, resolved for the canvas (`""` when it can't be resolved). */
|
|
367
|
+
function resolveFillColors(tokens: ChartTokens | null): Record<ChartTone, string> {
|
|
368
|
+
const [positive = "", warning = "", negative = "", neutral = ""] = resolveChartColors(
|
|
369
|
+
["positive-fill", "warning-fill", "negative-fill", "neutral-fill"],
|
|
370
|
+
tokens,
|
|
371
|
+
)
|
|
372
|
+
return { positive, warning, negative, neutral }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const TRANSPARENT_RE = /^(?:transparent|rgba\([^)]*,\s*0(?:\.0+)?\))$/i
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The colour the chart is painted on: the first opaque `background-color` up
|
|
379
|
+
* from `el` (a card, the page), else `--background`. Canvas-safe, or `null`
|
|
380
|
+
* outside the browser.
|
|
381
|
+
*/
|
|
382
|
+
function readChartSurface(el: Element | null): string | null {
|
|
383
|
+
if (typeof document === "undefined") return null
|
|
384
|
+
for (let node: Element | null = el; node; node = node.parentElement) {
|
|
385
|
+
const bg = getComputedStyle(node).backgroundColor.trim()
|
|
386
|
+
if (bg && !TRANSPARENT_RE.test(bg)) {
|
|
387
|
+
const color = toCanvasColor(bg)
|
|
388
|
+
if (color) return color
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return toCanvasColor(getComputedStyle(document.documentElement).getPropertyValue("--background"))
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Widths are bucketed so a resize only rebuilds the option when the label budget can change. */
|
|
395
|
+
const WIDTH_BUCKET = 40
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The surface colour behind a chart (see `readChartSurface`), re-read when the
|
|
399
|
+
* tokens move (light/dark), and the plot's width (rounded down to 40px;
|
|
400
|
+
* `undefined` until measured). Returns a ref for the plot that also feeds the
|
|
401
|
+
* component's forwarded ref.
|
|
402
|
+
*/
|
|
403
|
+
function useChartSurface<E extends Element>(
|
|
404
|
+
forwarded: ForwardedRef<E>,
|
|
405
|
+
tokens: ChartTokens | null,
|
|
406
|
+
): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined } {
|
|
407
|
+
const [node, setNode] = useState<E | null>(null)
|
|
408
|
+
const [surface, setSurface] = useState<string | undefined>(undefined)
|
|
409
|
+
const [width, setWidth] = useState<number | undefined>(undefined)
|
|
410
|
+
const ref = useCallback(
|
|
411
|
+
(el: E | null) => {
|
|
412
|
+
setNode(el)
|
|
413
|
+
if (typeof forwarded === "function") forwarded(el)
|
|
414
|
+
else if (forwarded) forwarded.current = el
|
|
415
|
+
},
|
|
416
|
+
[forwarded],
|
|
417
|
+
)
|
|
418
|
+
useEffect(() => {
|
|
419
|
+
if (!node) return
|
|
420
|
+
setSurface(readChartSurface(node) ?? undefined)
|
|
421
|
+
}, [node, tokens])
|
|
422
|
+
useEffect(() => {
|
|
423
|
+
if (!node || typeof ResizeObserver === "undefined") return
|
|
424
|
+
const update = (w: number) => setWidth(Math.floor(w / WIDTH_BUCKET) * WIDTH_BUCKET)
|
|
425
|
+
update(node.getBoundingClientRect().width)
|
|
426
|
+
const observer = new ResizeObserver((entries) => update(entries[0]?.contentRect.width ?? 0))
|
|
427
|
+
observer.observe(node)
|
|
428
|
+
return () => observer.disconnect()
|
|
429
|
+
}, [node])
|
|
430
|
+
return { ref, surface, width }
|
|
431
|
+
}
|
|
432
|
+
|
|
270
433
|
/* ---------- end of inlined code ---------- */
|
|
271
434
|
|
|
435
|
+
// What the typed charts (Bar / Line / Area / Donut) emit. Other series and
|
|
436
|
+
// components are the consumer's to `use()` — see the note at the top.
|
|
437
|
+
use([
|
|
438
|
+
BarChart,
|
|
439
|
+
CustomChart,
|
|
440
|
+
LineChart,
|
|
441
|
+
PieChart,
|
|
442
|
+
GridComponent,
|
|
443
|
+
LegendComponent,
|
|
444
|
+
MarkAreaComponent,
|
|
445
|
+
MarkLineComponent,
|
|
446
|
+
MarkPointComponent,
|
|
447
|
+
TooltipComponent,
|
|
448
|
+
CanvasRenderer,
|
|
449
|
+
])
|
|
450
|
+
|
|
272
451
|
export interface EChartProps {
|
|
273
452
|
/**
|
|
274
453
|
* ECharts のオプション。
|
|
@@ -278,7 +457,7 @@ export interface EChartProps {
|
|
|
278
457
|
* literal が `string` に拡張され、ビルドが失敗する。
|
|
279
458
|
*
|
|
280
459
|
* @example
|
|
281
|
-
* import type { EChartsOption } from "
|
|
460
|
+
* import type { EChartsOption } from "@/components/echart"
|
|
282
461
|
*
|
|
283
462
|
* const option: EChartsOption = {
|
|
284
463
|
* xAxis: { type: "category", data: ["A", "B"] },
|
|
@@ -309,14 +488,217 @@ export interface EChartProps {
|
|
|
309
488
|
onEvents?: Record<string, (params: unknown) => void>
|
|
310
489
|
ariaLabel?: string
|
|
311
490
|
className?: string
|
|
312
|
-
/**
|
|
313
|
-
|
|
491
|
+
/**
|
|
492
|
+
* true にするとチャート右上に PNG コピー / ダウンロードボタンを表示する。
|
|
493
|
+
* `exportHeader` があれば PNG の上にその見出しを含める(`{ includeTitle: false }` で含めない)。
|
|
494
|
+
*/
|
|
495
|
+
actions?: boolean | ChartActionsOptions
|
|
314
496
|
/** ダウンロード時のファイル名(拡張子含む)。省略時は "chart.png" */
|
|
315
497
|
fileName?: string
|
|
498
|
+
/**
|
|
499
|
+
* PNG の上に含める見出しと要約。型付きグラフは自分の `title` / `summary` を渡す。
|
|
500
|
+
* 自分で `ChartFrame` を組むときも同じものを渡すと、保存した画像だけで何のグラフか分かる。
|
|
501
|
+
*/
|
|
502
|
+
exportHeader?: ChartExportHeader
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** The heading a saved PNG carries above the plot. */
|
|
506
|
+
export interface ChartExportHeader {
|
|
507
|
+
title?: string
|
|
508
|
+
summary?: string
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const HEADER_PAD_X = 16
|
|
512
|
+
const HEADER_PAD_TOP = 16
|
|
513
|
+
const HEADER_PAD_BOTTOM = 12
|
|
514
|
+
const HEADER_LINE_HEIGHT = 20
|
|
515
|
+
const HEADER_GAP = 4
|
|
516
|
+
const HEADER_FONT_SIZE = 14
|
|
517
|
+
|
|
518
|
+
export interface ChartExportHeaderLine {
|
|
519
|
+
text: string
|
|
520
|
+
kind: "title" | "summary"
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Break `text` into lines no wider than `maxWidth` (px, via `measure`), character
|
|
525
|
+
* by character so Japanese wraps without spaces. Always at least one line for
|
|
526
|
+
* non-empty text; a single over-wide character still gets its own line.
|
|
527
|
+
*/
|
|
528
|
+
export function wrapChartText(text: string, maxWidth: number, measure: (text: string) => number): string[] {
|
|
529
|
+
const lines: string[] = []
|
|
530
|
+
let line = ""
|
|
531
|
+
for (const ch of text) {
|
|
532
|
+
if (ch === "\n") {
|
|
533
|
+
lines.push(line)
|
|
534
|
+
line = ""
|
|
535
|
+
continue
|
|
536
|
+
}
|
|
537
|
+
if (line !== "" && measure(line + ch) > maxWidth) {
|
|
538
|
+
lines.push(line)
|
|
539
|
+
line = ch.trim() === "" ? "" : ch
|
|
540
|
+
} else {
|
|
541
|
+
line += ch
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (line !== "" || lines.length === 0) lines.push(line)
|
|
545
|
+
return lines.filter((l, i) => l !== "" || i < lines.length - 1)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* The lines and total height (CSS px) of the PNG's heading band for a plot
|
|
550
|
+
* `width` px wide. `measure` takes (text, kind) and returns its width in px.
|
|
551
|
+
* No title and no summary → no band (`height` 0).
|
|
552
|
+
*/
|
|
553
|
+
export function chartExportHeaderLayout(
|
|
554
|
+
header: ChartExportHeader | undefined,
|
|
555
|
+
width: number,
|
|
556
|
+
measure: (text: string, kind: "title" | "summary") => number,
|
|
557
|
+
): { lines: ChartExportHeaderLine[]; height: number } {
|
|
558
|
+
const title = header?.title?.trim()
|
|
559
|
+
const summary = header?.summary?.trim()
|
|
560
|
+
if (!title && !summary) return { lines: [], height: 0 }
|
|
561
|
+
const room = Math.max(40, width - HEADER_PAD_X * 2)
|
|
562
|
+
const lines: ChartExportHeaderLine[] = [
|
|
563
|
+
...(title ? wrapChartText(title, room, (t) => measure(t, "title")).map((text) => ({ text, kind: "title" as const })) : []),
|
|
564
|
+
...(summary ? wrapChartText(summary, room, (t) => measure(t, "summary")).map((text) => ({ text, kind: "summary" as const })) : []),
|
|
565
|
+
]
|
|
566
|
+
const gap = title && summary ? HEADER_GAP : 0
|
|
567
|
+
return { lines, height: HEADER_PAD_TOP + lines.length * HEADER_LINE_HEIGHT + gap + HEADER_PAD_BOTTOM }
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function loadImage(src: string): Promise<HTMLImageElement> {
|
|
571
|
+
return new Promise((resolve, reject) => {
|
|
572
|
+
const image = new Image()
|
|
573
|
+
image.onload = () => resolve(image)
|
|
574
|
+
image.onerror = () => reject(new Error("PNG の読み込みに失敗しました"))
|
|
575
|
+
image.src = src
|
|
576
|
+
})
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Draw a heading band (title in foreground, summary muted) above the exported plot and return the new PNG. */
|
|
580
|
+
async function pngWithHeader(
|
|
581
|
+
dataUrl: string,
|
|
582
|
+
header: ChartExportHeader,
|
|
583
|
+
look: { background: string; color: string; mutedColor: string; fontFamily: string },
|
|
584
|
+
pixelRatio: number,
|
|
585
|
+
): Promise<string> {
|
|
586
|
+
const image = await loadImage(dataUrl)
|
|
587
|
+
const width = image.width / pixelRatio
|
|
588
|
+
const plotHeight = image.height / pixelRatio
|
|
589
|
+
const canvas = document.createElement("canvas")
|
|
590
|
+
const ctx = canvas.getContext("2d")
|
|
591
|
+
if (!ctx) return dataUrl
|
|
592
|
+
const font = (kind: "title" | "summary") => `${kind === "title" ? 500 : 400} ${HEADER_FONT_SIZE}px ${look.fontFamily}`
|
|
593
|
+
ctx.font = font("title")
|
|
594
|
+
const layout = chartExportHeaderLayout(header, width, (text, kind) => {
|
|
595
|
+
ctx.font = font(kind)
|
|
596
|
+
return ctx.measureText(text).width
|
|
597
|
+
})
|
|
598
|
+
if (layout.height === 0) return dataUrl
|
|
599
|
+
canvas.width = Math.round(image.width)
|
|
600
|
+
canvas.height = Math.round((layout.height + plotHeight) * pixelRatio)
|
|
601
|
+
ctx.scale(pixelRatio, pixelRatio)
|
|
602
|
+
ctx.fillStyle = look.background
|
|
603
|
+
ctx.fillRect(0, 0, width, layout.height + plotHeight)
|
|
604
|
+
ctx.textBaseline = "middle"
|
|
605
|
+
let y = HEADER_PAD_TOP
|
|
606
|
+
let previous: "title" | "summary" | null = null
|
|
607
|
+
for (const line of layout.lines) {
|
|
608
|
+
if (previous === "title" && line.kind === "summary") y += HEADER_GAP
|
|
609
|
+
ctx.font = font(line.kind)
|
|
610
|
+
ctx.fillStyle = line.kind === "title" ? look.color : look.mutedColor
|
|
611
|
+
ctx.fillText(line.text, HEADER_PAD_X, y + HEADER_LINE_HEIGHT / 2)
|
|
612
|
+
y += HEADER_LINE_HEIGHT
|
|
613
|
+
previous = line.kind
|
|
614
|
+
}
|
|
615
|
+
ctx.drawImage(image, 0, layout.height, width, plotHeight)
|
|
616
|
+
return canvas.toDataURL("image/png")
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Canvas-safe versions of theme colours, kept current across light/dark.
|
|
621
|
+
* Takes `"chart-1"`〜`"chart-5"`, `"var(--token)"` or any CSS colour; one that
|
|
622
|
+
* can't be resolved comes back as `""` (ECharts then uses the palette).
|
|
623
|
+
* Use it whenever a colour goes into a raw `EChart` option by hand.
|
|
624
|
+
*
|
|
625
|
+
* @example
|
|
626
|
+
* const [line, band] = useResolvedChartColors(["chart-1", "var(--muted-foreground)"])
|
|
627
|
+
*/
|
|
628
|
+
export function useResolvedChartColors(colors: readonly string[]): string[] {
|
|
629
|
+
const tokens = useChartTokens()
|
|
630
|
+
const key = colors.join("\u0000")
|
|
631
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- `key` stands for the array's contents
|
|
632
|
+
return useMemo(() => resolveChartColors(colors, tokens), [key, tokens])
|
|
316
633
|
}
|
|
317
634
|
|
|
318
635
|
type EChartsEventHandler = (params: unknown) => void
|
|
319
636
|
|
|
637
|
+
/**
|
|
638
|
+
* ECharts component main types. Listed in `replaceMerge`, a type keeps only the
|
|
639
|
+
* components whose `id` matches (merged, so they can transition) and drops the
|
|
640
|
+
* rest — including every one of them when the new option no longer has the type.
|
|
641
|
+
*/
|
|
642
|
+
const COMPONENT_MAIN_TYPES = new Set([
|
|
643
|
+
"title", "legend", "grid", "xAxis", "yAxis", "polar", "radiusAxis", "angleAxis", "radar",
|
|
644
|
+
"dataZoom", "visualMap", "tooltip", "axisPointer", "toolbox", "brush", "geo", "parallel",
|
|
645
|
+
"parallelAxis", "singleAxis", "graphic", "calendar", "dataset", "series", "matrix", "thumbnail",
|
|
646
|
+
])
|
|
647
|
+
|
|
648
|
+
export interface EChartSetOptionOpts {
|
|
649
|
+
notMerge: boolean
|
|
650
|
+
replaceMerge?: string[]
|
|
651
|
+
lazyUpdate: boolean
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* How to apply `next` after `prev`: the first option and timeline / media
|
|
656
|
+
* options replace everything; otherwise merge, replacing every component type
|
|
657
|
+
* either option uses so nothing stale survives.
|
|
658
|
+
*/
|
|
659
|
+
export function echartSetOptionOpts(prev: EChartsOption | null, next: EChartsOption): EChartSetOptionOpts {
|
|
660
|
+
const special = (o: EChartsOption) => "timeline" in o || "baseOption" in o || "options" in o || "media" in o
|
|
661
|
+
if (!prev || special(prev) || special(next)) return { notMerge: true, lazyUpdate: true }
|
|
662
|
+
const types = new Set<string>()
|
|
663
|
+
for (const key of [...Object.keys(prev), ...Object.keys(next)]) if (COMPONENT_MAIN_TYPES.has(key)) types.add(key)
|
|
664
|
+
return { notMerge: false, replaceMerge: [...types], lazyUpdate: true }
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** `graphic` text doesn't inherit the global `textStyle`, so give it the inherited font unless it sets its own. */
|
|
668
|
+
function withGraphicFont(option: EChartsOption, fontFamily: string | undefined): EChartsOption {
|
|
669
|
+
const graphic = (option as { graphic?: unknown }).graphic
|
|
670
|
+
if (!fontFamily || !Array.isArray(graphic)) return option
|
|
671
|
+
return {
|
|
672
|
+
...option,
|
|
673
|
+
graphic: graphic.map((g: { type?: string; style?: Record<string, unknown> }) =>
|
|
674
|
+
g && g.type === "text" && g.style && !g.style.fontFamily ? { ...g, style: { ...g.style, fontFamily } } : g,
|
|
675
|
+
),
|
|
676
|
+
} as EChartsOption
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** `option` as applied: reduced motion forces `animation: false`; otherwise animation stays explicit so it can flip back. */
|
|
680
|
+
export function withMotionPreference(option: EChartsOption, reducedMotion: boolean): EChartsOption {
|
|
681
|
+
return { ...option, animation: reducedMotion ? false : (option.animation ?? true) }
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"
|
|
685
|
+
|
|
686
|
+
/** `prefers-reduced-motion: reduce`, kept current. `false` on the server. */
|
|
687
|
+
export function usePrefersReducedMotion(): boolean {
|
|
688
|
+
const [reduced, setReduced] = useState(
|
|
689
|
+
() => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia(REDUCED_MOTION_QUERY).matches,
|
|
690
|
+
)
|
|
691
|
+
useEffect(() => {
|
|
692
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return
|
|
693
|
+
const media = window.matchMedia(REDUCED_MOTION_QUERY)
|
|
694
|
+
const sync = () => setReduced(media.matches)
|
|
695
|
+
sync()
|
|
696
|
+
media.addEventListener("change", sync)
|
|
697
|
+
return () => media.removeEventListener("change", sync)
|
|
698
|
+
}, [])
|
|
699
|
+
return reduced
|
|
700
|
+
}
|
|
701
|
+
|
|
320
702
|
export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
|
|
321
703
|
{
|
|
322
704
|
option,
|
|
@@ -328,6 +710,7 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
|
|
|
328
710
|
className,
|
|
329
711
|
actions = false,
|
|
330
712
|
fileName = "chart.png",
|
|
713
|
+
exportHeader,
|
|
331
714
|
},
|
|
332
715
|
ref,
|
|
333
716
|
) {
|
|
@@ -336,20 +719,39 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
|
|
|
336
719
|
const [copied, setCopied] = useState(false)
|
|
337
720
|
// 初回はレンダー前に読めるので、マウント直後の作り直しが起きない
|
|
338
721
|
const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
|
|
722
|
+
const reducedMotion = usePrefersReducedMotion()
|
|
723
|
+
// What the current instance last received, to pick the merge mode of the next update.
|
|
724
|
+
const appliedRef = useRef<EChartsOption | null>(null)
|
|
339
725
|
|
|
340
726
|
// 明暗の切り替えとスタイルシートの差し替え(dev で styles.css を書き換えたときなど)を
|
|
341
727
|
// 拾ってトークンを読み直す ― 何を見張るかは `watchChartTokens` 側が持つ。値が変わら
|
|
342
|
-
//
|
|
728
|
+
// ないときは state を更新せず、再 init を防ぐ。比較は updater 関数の中ではなく ref で行う:
|
|
729
|
+
// DOM を読む updater は React に何度も再生され、そのたびに新しいオブジェクトを返して
|
|
730
|
+
// 再描画が止まらなくなる(変数を後から当てた直後に Maximum update depth exceeded)。
|
|
731
|
+
const tokensRef = useRef(tokens)
|
|
343
732
|
useEffect(() => {
|
|
344
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.
|
|
345
736
|
const sync = () => {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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)
|
|
350
743
|
}
|
|
351
744
|
sync()
|
|
352
|
-
|
|
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
|
+
}
|
|
353
755
|
}, [theme])
|
|
354
756
|
|
|
355
757
|
const autoTheme = useMemo(() => (tokens ? buildChartTheme(tokens) : undefined), [tokens])
|
|
@@ -360,7 +762,8 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
|
|
|
360
762
|
useEffect(() => {
|
|
361
763
|
const container = containerRef.current
|
|
362
764
|
if (!container) return
|
|
363
|
-
const inst =
|
|
765
|
+
const inst = init(container, resolvedTheme, { renderer: "canvas" })
|
|
766
|
+
appliedRef.current = null
|
|
364
767
|
// `data-rendering` stays on until the (animated) paint is done, so anything
|
|
365
768
|
// capturing the page — printing a document — can wait for real pixels.
|
|
366
769
|
container.dataset.rendering = ""
|
|
@@ -369,11 +772,19 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
|
|
|
369
772
|
})
|
|
370
773
|
setInstance(inst)
|
|
371
774
|
|
|
372
|
-
|
|
775
|
+
// At most one resize per frame (the sidebar animates the width).
|
|
776
|
+
let frame = 0
|
|
777
|
+
const observer = new ResizeObserver(() => {
|
|
778
|
+
cancelAnimationFrame(frame)
|
|
779
|
+
frame = requestAnimationFrame(() => {
|
|
780
|
+
if (!inst.isDisposed()) inst.resize()
|
|
781
|
+
})
|
|
782
|
+
})
|
|
373
783
|
observer.observe(container)
|
|
374
784
|
|
|
375
785
|
return () => {
|
|
376
786
|
observer.disconnect()
|
|
787
|
+
cancelAnimationFrame(frame)
|
|
377
788
|
inst.dispose()
|
|
378
789
|
}
|
|
379
790
|
}, [resolvedTheme])
|
|
@@ -381,8 +792,10 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
|
|
|
381
792
|
useEffect(() => {
|
|
382
793
|
if (!instance) return
|
|
383
794
|
if (containerRef.current) containerRef.current.dataset.rendering = ""
|
|
384
|
-
|
|
385
|
-
|
|
795
|
+
const next = withGraphicFont(withMotionPreference(option, reducedMotion), theme === undefined ? tokens?.fontFamily : undefined)
|
|
796
|
+
instance.setOption(next, echartSetOptionOpts(appliedRef.current, next))
|
|
797
|
+
appliedRef.current = next
|
|
798
|
+
}, [instance, option, reducedMotion, theme, tokens?.fontFamily])
|
|
386
799
|
|
|
387
800
|
useEffect(() => {
|
|
388
801
|
if (!instance) return
|
|
@@ -404,20 +817,38 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
|
|
|
404
817
|
}
|
|
405
818
|
}, [instance, onEvents])
|
|
406
819
|
|
|
407
|
-
const
|
|
820
|
+
const includeTitle = typeof actions === "object" ? (actions.includeTitle ?? true) : true
|
|
821
|
+
const getPngDataUrl = useCallback(async (): Promise<string | null> => {
|
|
408
822
|
if (!instance) return null
|
|
409
|
-
// Export over the
|
|
410
|
-
// styles resolve to rgb(a),
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
823
|
+
// Export over the nearest opaque background (card, then page) so the PNG
|
|
824
|
+
// matches what is on screen in dark too. Computed styles resolve to rgb(a),
|
|
825
|
+
// so this stays canvas-safe whatever the token.
|
|
826
|
+
let backgroundColor = "#ffffff"
|
|
827
|
+
for (let el: HTMLElement | null = containerRef.current; el; el = el.parentElement) {
|
|
828
|
+
const bg = getComputedStyle(el).backgroundColor
|
|
829
|
+
if (bg && bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent") {
|
|
830
|
+
backgroundColor = bg
|
|
831
|
+
break
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
const pixelRatio = 2
|
|
835
|
+
const dataUrl = instance.getDataURL({ type: "png", pixelRatio, backgroundColor })
|
|
836
|
+
if (!includeTitle || !exportHeader || !(exportHeader.title || exportHeader.summary) || !containerRef.current) return dataUrl
|
|
837
|
+
try {
|
|
838
|
+
const style = getComputedStyle(containerRef.current)
|
|
839
|
+
return await pngWithHeader(
|
|
840
|
+
dataUrl,
|
|
841
|
+
exportHeader,
|
|
842
|
+
{ background: backgroundColor, color: style.color, mutedColor: tokens?.mutedForeground ?? style.color, fontFamily: style.fontFamily },
|
|
843
|
+
pixelRatio,
|
|
844
|
+
)
|
|
845
|
+
} catch {
|
|
846
|
+
return dataUrl
|
|
847
|
+
}
|
|
848
|
+
}, [instance, includeTitle, exportHeader, tokens])
|
|
418
849
|
|
|
419
850
|
const handleCopy = useCallback(async () => {
|
|
420
|
-
const dataUrl = getPngDataUrl()
|
|
851
|
+
const dataUrl = await getPngDataUrl()
|
|
421
852
|
if (!dataUrl) return
|
|
422
853
|
try {
|
|
423
854
|
const blob = await (await fetch(dataUrl)).blob()
|
|
@@ -429,8 +860,8 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
|
|
|
429
860
|
}
|
|
430
861
|
}, [getPngDataUrl])
|
|
431
862
|
|
|
432
|
-
const handleDownload = useCallback(() => {
|
|
433
|
-
const dataUrl = getPngDataUrl()
|
|
863
|
+
const handleDownload = useCallback(async () => {
|
|
864
|
+
const dataUrl = await getPngDataUrl()
|
|
434
865
|
if (!dataUrl) return
|
|
435
866
|
const link = document.createElement("a")
|
|
436
867
|
link.href = dataUrl
|
|
@@ -440,6 +871,7 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
|
|
|
440
871
|
|
|
441
872
|
// 高さの解決: height prop を渡した場合はインライン style が勝つ。未指定なら
|
|
442
873
|
// h-[400px] をデフォルトで当て、className の高さクラスに後勝ちさせる。
|
|
874
|
+
const inFrame = useContext(ChartFrameContext)
|
|
443
875
|
const styleHeight =
|
|
444
876
|
height == null ? undefined : typeof height === "number" ? `${height}px` : height
|
|
445
877
|
|
|
@@ -449,14 +881,19 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
|
|
|
449
881
|
data-slot="echart"
|
|
450
882
|
className={cn("group relative w-full", height == null && "h-[400px]", className)}
|
|
451
883
|
style={styleHeight != null ? { height: styleHeight } : undefined}
|
|
452
|
-
role="img"
|
|
453
|
-
aria-label={ariaLabel ?? "チャート"}
|
|
454
884
|
>
|
|
455
|
-
|
|
885
|
+
{/* `role="img"` wraps the canvas only, so the action buttons stay reachable. Inside a ChartFrame the plot area is the one image. */}
|
|
886
|
+
<div
|
|
887
|
+
ref={containerRef}
|
|
888
|
+
data-slot="echart-canvas"
|
|
889
|
+
className="h-full w-full"
|
|
890
|
+
role={inFrame ? undefined : "img"}
|
|
891
|
+
aria-label={inFrame ? undefined : (ariaLabel ?? "チャート")}
|
|
892
|
+
/>
|
|
456
893
|
{actions && (
|
|
457
894
|
<div
|
|
458
895
|
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"
|
|
896
|
+
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
897
|
>
|
|
461
898
|
<Button
|
|
462
899
|
type="button"
|