@squadbase/vantage 0.6.2-dev1.42c4e48 → 0.6.2-dev1.51e401a
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-U7GO26HR.js} +7 -7
- package/dist/{build-MFWRPYKW.js.map → build-U7GO26HR.js.map} +1 -1
- package/dist/{check-ZBIH422T.js → check-MRVAIHG5.js} +5 -5
- package/dist/{check-ZBIH422T.js.map → check-MRVAIHG5.js.map} +1 -1
- package/dist/chunk-2IAPIOPS.js +152 -0
- package/dist/chunk-2IAPIOPS.js.map +1 -0
- package/dist/chunk-3RJEOP3J.js +174 -0
- package/dist/chunk-3RJEOP3J.js.map +1 -0
- package/dist/chunk-77CISBMG.js +7581 -0
- package/dist/chunk-77CISBMG.js.map +1 -0
- package/dist/{chunk-V3LF7BE7.js → chunk-ANZLVSEP.js} +4 -5
- package/dist/chunk-ANZLVSEP.js.map +1 -0
- package/dist/{chunk-XQUSGIDG.js → chunk-CKWPK4RO.js} +10 -3
- package/dist/chunk-CKWPK4RO.js.map +1 -0
- package/dist/chunk-EG4PX726.js +678 -0
- package/dist/chunk-EG4PX726.js.map +1 -0
- package/dist/chunk-NZ5QIIG4.js +74 -0
- package/dist/chunk-NZ5QIIG4.js.map +1 -0
- package/dist/{chunk-7DZGJQ3P.js → chunk-PJ4SPXHS.js} +23 -6
- package/dist/chunk-PJ4SPXHS.js.map +1 -0
- package/dist/{chunk-OB6UBJXF.js → chunk-QNOX7WLZ.js} +127 -36
- package/dist/chunk-QNOX7WLZ.js.map +1 -0
- package/dist/{chunk-PXRX5PEC.js → chunk-ROHNXP7J.js} +73 -4
- package/dist/chunk-ROHNXP7J.js.map +1 -0
- package/dist/{chunk-CEXAQQ5W.js → chunk-S5MDTHH7.js} +180 -18
- package/dist/chunk-S5MDTHH7.js.map +1 -0
- package/dist/{chunk-IBGOCWJB.js → chunk-SDJTTMLK.js} +247 -86
- package/dist/chunk-SDJTTMLK.js.map +1 -0
- package/dist/{chunk-QEWCYW3I.js → chunk-SHZM3EXB.js} +29 -8
- package/dist/chunk-SHZM3EXB.js.map +1 -0
- package/dist/chunk-TRMHNMMZ.js +61 -0
- package/dist/chunk-TRMHNMMZ.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-JKLLFVEU.js → chunk-UWSQUHX6.js} +4 -99
- package/dist/chunk-UWSQUHX6.js.map +1 -0
- package/dist/cli.js +6 -6
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +8 -2
- package/dist/client/index.js.map +1 -1
- package/dist/components/index.d.ts +1617 -101
- package/dist/components/index.js +7 -4
- package/dist/delta-text-CbrOaW83.d.ts +17 -0
- package/dist/{dev-DCTOHQ6W.js → dev-O42JFQ64.js} +7 -7
- package/dist/{dev-DCTOHQ6W.js.map → dev-O42JFQ64.js.map} +1 -1
- package/dist/doc/index.d.ts +215 -2
- package/dist/doc/index.js +423 -5
- package/dist/doc/index.js.map +1 -1
- package/dist/{doctor-5E2VEZPK.js → doctor-YD3XAAOT.js} +5 -5
- package/dist/{doctor-5E2VEZPK.js.map → doctor-YD3XAAOT.js.map} +1 -1
- package/dist/format-DihQkL1N.d.ts +249 -0
- package/dist/markdown/index.js +1 -1
- package/dist/markdown/index.js.map +1 -1
- package/dist/query/index.js +2 -2
- package/dist/{routes-3SGAFDMC.js → routes-PVKNT777.js} +4 -4
- package/dist/{routes-3SGAFDMC.js.map → routes-PVKNT777.js.map} +1 -1
- package/dist/slide/index.d.ts +272 -1
- package/dist/slide/index.js +1025 -4
- package/dist/slide/index.js.map +1 -1
- package/dist/table/index.js +11 -8
- package/dist/table/index.js.map +1 -1
- package/dist/ui/index.d.ts +8 -2
- package/dist/ui/index.js +22 -112
- package/dist/ui/index.js.map +1 -1
- package/dist/{upgrade-DBBE6GIR.js → upgrade-6DT7ADRP.js} +4 -4
- package/dist/{upgrade-DBBE6GIR.js.map → upgrade-6DT7ADRP.js.map} +1 -1
- package/dist/vite/index.js +3 -3
- package/docs/en/agent-skills.md +1 -1
- package/docs/en/changelog.md +2 -0
- package/docs/en/components.md +42 -17
- package/docs/en/doc/delta-text.md +40 -0
- package/docs/en/doc/doc-action-items.md +49 -0
- package/docs/en/doc/doc-callout.md +38 -0
- package/docs/en/doc/doc-data-table.md +60 -0
- package/docs/en/doc/doc-executive-summary.md +64 -0
- package/docs/en/doc/doc-figure.md +56 -0
- package/docs/en/doc/doc-key-findings.md +61 -0
- package/docs/en/doc/doc-kpi-scorecard.md +80 -0
- package/docs/en/doc/doc-notes-and-sources.md +43 -0
- package/docs/en/doc/doc-report-header.md +46 -0
- package/docs/en/pages-and-metadata.md +15 -0
- package/docs/en/parts/area-chart.md +99 -0
- package/docs/en/parts/bar-chart.md +91 -0
- package/docs/en/parts/bar-list.md +50 -0
- package/docs/en/parts/category-bar.md +37 -0
- package/docs/en/parts/chart-frame.md +39 -0
- package/docs/en/parts/data-table.md +22 -0
- package/docs/en/parts/delta.md +51 -0
- package/docs/en/parts/donut-chart.md +72 -0
- package/docs/en/parts/format.md +43 -0
- package/docs/en/parts/funnel-steps.md +2 -1
- package/docs/en/parts/heatmap.md +53 -0
- package/docs/en/parts/kpi-card.md +58 -0
- package/docs/en/parts/legend.md +35 -0
- package/docs/en/parts/line-chart.md +94 -0
- package/docs/en/parts/metric-value.md +3 -2
- package/docs/en/parts/progress-circle.md +33 -0
- package/docs/en/parts/sample-data.md +39 -0
- package/docs/en/parts/scatter-chart.md +56 -0
- package/docs/en/parts/sparkline.md +1 -1
- package/docs/en/parts/tracker.md +36 -0
- package/docs/en/parts/trend-indicator.md +17 -1
- package/docs/en/parts/updated-at.md +30 -0
- package/docs/en/parts/waterfall-chart.md +56 -0
- package/docs/en/slide/slide-chart-full.md +48 -0
- package/docs/en/slide/slide-chart-insight.md +52 -0
- package/docs/en/slide/slide-columns.md +46 -0
- package/docs/en/slide/slide-comparison.md +68 -0
- package/docs/en/slide/slide-cover.md +39 -0
- package/docs/en/slide/slide-exec-summary.md +47 -0
- package/docs/en/slide/slide-heading.md +38 -0
- package/docs/en/slide/slide-kpi-grid.md +55 -0
- package/docs/en/slide/slide-kpi-hero.md +58 -0
- package/docs/en/slide/slide-next-actions.md +49 -0
- package/docs/en/slide/slide-scorecard-table.md +54 -0
- package/docs/en/slide/slide-section.md +44 -0
- package/docs/en/slide/slide-source-line.md +34 -0
- package/docs/en/ui/alert-dialog.md +42 -0
- package/docs/en/ui/avatar.md +31 -0
- package/docs/en/ui/combobox.md +74 -0
- package/docs/en/ui/drawer.md +41 -0
- package/docs/en/ui/empty.md +1 -1
- package/docs/en/ui/error-state.md +4 -3
- package/docs/en/ui/field.md +42 -0
- package/docs/en/ui/hover-card.md +21 -0
- package/docs/en/ui/kbd.md +17 -0
- package/docs/en/ui/loading.md +1 -1
- package/docs/en/ui/pagination.md +36 -0
- package/docs/en/ui/radio-group.md +21 -0
- package/docs/en/ui/toast.md +44 -0
- package/docs/en/ui/toggle-group.md +4 -3
- package/docs/en/ui-and-theming.md +1 -1
- package/docs/index.json +917 -77
- package/docs/ja/agent-skills.md +1 -1
- package/docs/ja/changelog.md +2 -0
- package/docs/ja/components.md +39 -16
- package/docs/ja/doc/delta-text.md +40 -0
- package/docs/ja/doc/doc-action-items.md +49 -0
- package/docs/ja/doc/doc-callout.md +38 -0
- package/docs/ja/doc/doc-data-table.md +60 -0
- package/docs/ja/doc/doc-executive-summary.md +64 -0
- package/docs/ja/doc/doc-figure.md +56 -0
- package/docs/ja/doc/doc-key-findings.md +61 -0
- package/docs/ja/doc/doc-kpi-scorecard.md +80 -0
- package/docs/ja/doc/doc-notes-and-sources.md +43 -0
- package/docs/ja/doc/doc-report-header.md +46 -0
- package/docs/ja/page-types/slide.md +1 -0
- package/docs/ja/pages-and-metadata.md +15 -0
- package/docs/ja/parts/area-chart.md +49 -25
- package/docs/ja/parts/bar-chart.md +50 -24
- package/docs/ja/parts/bar-list.md +31 -8
- package/docs/ja/parts/chart-frame.md +36 -0
- package/docs/ja/parts/data-table.md +22 -0
- package/docs/ja/parts/delta.md +51 -0
- package/docs/ja/parts/donut-chart.md +45 -26
- package/docs/ja/parts/format.md +44 -0
- package/docs/ja/parts/funnel-steps.md +27 -8
- package/docs/ja/parts/heatmap.md +45 -0
- package/docs/ja/parts/kpi-card.md +56 -0
- package/docs/ja/parts/legend.md +36 -0
- package/docs/ja/parts/line-chart.md +48 -22
- package/docs/ja/parts/metric-value.md +3 -2
- package/docs/ja/parts/sample-data.md +61 -0
- package/docs/ja/parts/scatter-chart.md +69 -0
- package/docs/ja/parts/sparkline.md +1 -1
- package/docs/ja/parts/trend-indicator.md +17 -1
- package/docs/ja/parts/updated-at.md +27 -0
- package/docs/ja/parts/waterfall-chart.md +61 -0
- package/docs/ja/slide/slide-chart-full.md +48 -0
- package/docs/ja/slide/slide-chart-insight.md +52 -0
- package/docs/ja/slide/slide-columns.md +46 -0
- package/docs/ja/slide/slide-comparison.md +68 -0
- package/docs/ja/slide/slide-cover.md +39 -0
- package/docs/ja/slide/slide-exec-summary.md +47 -0
- package/docs/ja/slide/slide-heading.md +38 -0
- package/docs/ja/slide/slide-kpi-grid.md +55 -0
- package/docs/ja/slide/slide-kpi-hero.md +58 -0
- package/docs/ja/slide/slide-next-actions.md +49 -0
- package/docs/ja/slide/slide-scorecard-table.md +54 -0
- package/docs/ja/slide/slide-section.md +44 -0
- package/docs/ja/slide/slide-source-line.md +34 -0
- package/docs/ja/ui/empty.md +1 -1
- package/docs/ja/ui/error-state.md +4 -3
- package/docs/ja/ui/loading.md +1 -1
- package/docs/ja/ui/toggle-group.md +4 -3
- 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 +145 -2
- package/registry/ui/area-chart.tsx +3111 -166
- package/registry/ui/attention-list.tsx +465 -0
- package/registry/ui/avatar.tsx +1 -1
- package/registry/ui/badge.tsx +3 -1
- package/registry/ui/bar-chart.tsx +3105 -164
- package/registry/ui/bar-list.tsx +3877 -181
- package/registry/ui/box-plot-chart.tsx +4214 -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 +1233 -16
- package/registry/ui/chart-frame.tsx +1604 -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 +882 -68
- package/registry/ui/date-range-picker.tsx +400 -65
- package/registry/ui/delta.tsx +350 -0
- package/registry/ui/dialog.tsx +5 -3
- package/registry/ui/donut-chart.tsx +3184 -182
- package/registry/ui/drawer.tsx +1 -1
- package/registry/ui/dropdown-menu.tsx +5 -3
- package/registry/ui/echart.tsx +761 -69
- package/registry/ui/field.tsx +1 -1
- package/registry/ui/filter-bar.tsx +212 -26
- package/registry/ui/funnel-steps.tsx +3622 -53
- package/registry/ui/heatmap.tsx +2027 -0
- package/registry/ui/histogram-chart.tsx +4284 -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 +905 -0
- package/registry/ui/label.tsx +3 -1
- package/registry/ui/legend.tsx +3817 -0
- package/registry/ui/line-chart.tsx +3109 -158
- package/registry/ui/metric-value.tsx +9 -1
- package/registry/ui/multi-select.tsx +170 -14
- package/registry/ui/page-shell.tsx +244 -51
- package/registry/ui/pagination.tsx +3 -1
- package/registry/ui/paired-chart.tsx +4086 -0
- package/registry/ui/placeholder.tsx +8 -2
- package/registry/ui/popover.tsx +4 -2
- package/registry/ui/progress-circle.tsx +773 -12
- package/registry/ui/progress.tsx +3 -1
- package/registry/ui/radio-group.tsx +1 -1
- package/registry/ui/sample-data.tsx +230 -0
- package/registry/ui/scatter-chart.tsx +4503 -0
- package/registry/ui/scroll-area.tsx +3 -2
- package/registry/ui/searchable-select.tsx +153 -10
- 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 +4 -2
- package/registry/ui/sparkline.tsx +84 -9
- package/registry/ui/spinner.tsx +3 -1
- package/registry/ui/states.tsx +194 -8
- package/registry/ui/status-badge.tsx +246 -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 +10 -4
- package/registry/ui/tracker.tsx +798 -19
- package/registry/ui/trend-indicator.tsx +164 -9
- package/registry/ui/updated-at.tsx +276 -0
- package/registry/ui/waterfall-chart.tsx +4561 -0
- package/skills/vantage-add-feature/SKILL.md +5 -0
- package/skills/vantage-design/SKILL.md +60 -52
- package/skills/vantage-design/references/extract.md +26 -27
- package/skills/vantage-design/references/format.md +12 -172
- package/skills/vantage-design/references/presets.md +50 -110
- package/skills/vantage-pitfalls/SKILL.md +5 -0
- package/skills/vantage-slide/SKILL.md +54 -36
- package/templates/AGENTS.md +45 -16
- package/templates/agents/api.md +7 -0
- package/templates/agents/doc.md +48 -0
- package/templates/agents/page.md +69 -2
- package/templates/agents/slide.md +77 -3
- package/theme.css +247 -149
- package/dist/chunk-5ZOP4SQR.js +0 -47
- package/dist/chunk-5ZOP4SQR.js.map +0 -1
- package/dist/chunk-7DZGJQ3P.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-CEXAQQ5W.js.map +0 -1
- package/dist/chunk-IBGOCWJB.js.map +0 -1
- package/dist/chunk-JKLLFVEU.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/dist/chunk-V3LF7BE7.js.map +0 -1
- package/dist/chunk-XQUSGIDG.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,203 @@
|
|
|
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 { useSyncExternalStore } from "react"
|
|
62
|
+
import { createContext, type ForwardedRef, type RefObject } from "react"
|
|
63
|
+
import type { ChartTone } from "@squadbase/vantage/components"
|
|
64
|
+
|
|
65
|
+
/* ---------- inlined from @squadbase/squadkit registry/lib/locale.ts (internal to @squadbase/vantage) ---------- */
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The display language of the parts and the defaults that follow it.
|
|
69
|
+
*
|
|
70
|
+
* The language comes from `<html lang>`: a tag starting with `ja` reads Japanese, anything else English.
|
|
71
|
+
* Without a usable `lang` (missing, invalid, or no `document` — SSR, Node) the locale is `FALLBACK_LOCALE`
|
|
72
|
+
* (`en-US`). A `locale` passed explicitly (a prop, an argument) always wins.
|
|
73
|
+
*
|
|
74
|
+
* Parts read the locale through `useLocale` (`hooks/use-locale`) and pass it down; the pure helpers here
|
|
75
|
+
* and in `format` / `comparison` / `chart-*` take it as an argument and fall back to `documentLocale()`.
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/** The language of the built-in wording: Japanese or English. */
|
|
79
|
+
type UiLanguage = "ja" | "en"
|
|
80
|
+
|
|
81
|
+
/** The locale used when `<html lang>` gives none (and on the server). */
|
|
82
|
+
const FALLBACK_LOCALE = "en-US"
|
|
83
|
+
|
|
84
|
+
/** `"ja"` when `locale` starts with `ja` (`ja`, `ja-JP`), else `"en"`. A missing locale is `"en"`. */
|
|
85
|
+
function uiLanguage(locale: string | null | undefined): UiLanguage {
|
|
86
|
+
return typeof locale === "string" && locale.trim().toLowerCase().startsWith("ja") ? "ja" : "en"
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A BCP 47 tag in its canonical form (`en-gb` → `en-GB`), or `FALLBACK_LOCALE` when it is empty or invalid. */
|
|
90
|
+
function normalizeLocale(tag: string | null | undefined): string {
|
|
91
|
+
const value = tag?.trim()
|
|
92
|
+
if (!value) return FALLBACK_LOCALE
|
|
93
|
+
try {
|
|
94
|
+
return Intl.getCanonicalLocales(value)[0] ?? FALLBACK_LOCALE
|
|
95
|
+
} catch {
|
|
96
|
+
return FALLBACK_LOCALE
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The locale of the page: `document.documentElement.lang`, canonicalized. `FALLBACK_LOCALE` when it is empty or
|
|
102
|
+
* invalid, or there is no `document`. Parts read it through `useLocale`, never while rendering.
|
|
103
|
+
*/
|
|
104
|
+
function documentLocale(): string {
|
|
105
|
+
if (typeof document === "undefined") return FALLBACK_LOCALE
|
|
106
|
+
return normalizeLocale(document.documentElement?.getAttribute("lang"))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `locale` when given, else the page's locale (`documentLocale()`). */
|
|
110
|
+
function resolveLocale(locale?: string | null): string {
|
|
111
|
+
return locale || documentLocale()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Picks the wording table of the locale's language. */
|
|
115
|
+
function pickLocale<T>(table: { ja: T; en: T }, locale?: string | null): T {
|
|
116
|
+
return table[uiLanguage(resolveLocale(locale))]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The currency `format="currency"` uses without a `currency`: JPY for Japanese, USD otherwise. */
|
|
120
|
+
function defaultCurrency(locale?: string | null): string {
|
|
121
|
+
return uiLanguage(resolveLocale(locale)) === "ja" ? "JPY" : "USD"
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The month a fiscal year starts in (1–12) when none is given: April for Japanese, January otherwise. */
|
|
125
|
+
function defaultFiscalYearStartMonth(locale?: string | null): number {
|
|
126
|
+
return uiLanguage(resolveLocale(locale)) === "ja" ? 4 : 1
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A day of the week, 0 = Sunday … 6 = Saturday (the `Date#getDay` / date-fns numbering). */
|
|
130
|
+
type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6
|
|
131
|
+
|
|
132
|
+
/** Locales whose week starts on Sunday when the runtime has no week data. */
|
|
133
|
+
const SUNDAY_FIRST = new Set(["en", "en-US", "en-CA"])
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The first day of the week: Monday for Japanese; otherwise the locale's convention from `Intl.Locale`
|
|
137
|
+
* (`en-US` Sunday, `en-GB` Monday). Without week data in the runtime: Sunday for `en` / `en-US` / `en-CA`,
|
|
138
|
+
* Monday for the rest.
|
|
139
|
+
*/
|
|
140
|
+
function weekStartsOn(locale?: string | null): WeekDay {
|
|
141
|
+
const tag = normalizeLocale(resolveLocale(locale))
|
|
142
|
+
if (uiLanguage(tag) === "ja") return 1
|
|
143
|
+
try {
|
|
144
|
+
const info = new Intl.Locale(tag) as Intl.Locale & {
|
|
145
|
+
getWeekInfo?: () => { firstDay: number }
|
|
146
|
+
weekInfo?: { firstDay: number }
|
|
147
|
+
}
|
|
148
|
+
const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay
|
|
149
|
+
if (typeof firstDay === "number" && firstDay >= 1 && firstDay <= 7) return (firstDay % 7) as WeekDay
|
|
150
|
+
} catch {
|
|
151
|
+
// Fall through to the table below.
|
|
152
|
+
}
|
|
153
|
+
return SUNDAY_FIRST.has(tag) ? 0 : 1
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* ---------- inlined from @squadbase/squadkit registry/hooks/use-locale.ts (internal to @squadbase/vantage) ---------- */
|
|
157
|
+
|
|
158
|
+
const listeners = new Set<() => void>()
|
|
159
|
+
let observer: MutationObserver | null = null
|
|
160
|
+
|
|
161
|
+
/** One observer on `<html lang>` for every subscribed part. */
|
|
162
|
+
function subscribe(onChange: () => void): () => void {
|
|
163
|
+
if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {}
|
|
164
|
+
listeners.add(onChange)
|
|
165
|
+
if (!observer) {
|
|
166
|
+
observer = new MutationObserver(() => {
|
|
167
|
+
for (const listener of [...listeners]) listener()
|
|
168
|
+
})
|
|
169
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] })
|
|
170
|
+
}
|
|
171
|
+
return () => {
|
|
172
|
+
listeners.delete(onChange)
|
|
173
|
+
if (listeners.size === 0 && observer) {
|
|
174
|
+
observer.disconnect()
|
|
175
|
+
observer = null
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const serverLocale = () => FALLBACK_LOCALE
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The page's locale (`<html lang>`), following changes made while the page runs. On the server and during
|
|
184
|
+
* hydration it is `FALLBACK_LOCALE` (`en-US`), so the markup matches; it switches right after.
|
|
185
|
+
*/
|
|
186
|
+
function useDocumentLocale(): string {
|
|
187
|
+
return useSyncExternalStore(subscribe, documentLocale, serverLocale)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The locale a part renders in: its `locale` prop when given, else the page's (`useDocumentLocale`). */
|
|
191
|
+
function useLocale(locale?: string | null): string {
|
|
192
|
+
const page = useDocumentLocale()
|
|
193
|
+
return locale || page
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/* ---------- inlined from @squadbase/squadkit registry/lib/chart-theme.ts (internal to @squadbase/vantage) ---------- */
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The colour of the folded「その他」series / slice / row. The ONLY place this
|
|
200
|
+
* grey is defined — every chart takes it from here. It is the most subdued
|
|
201
|
+
* colour in a chart, not the darkest: the theme's `--neutral` mixed halfway into
|
|
202
|
+
* `--background`, so it recedes in light and dark alike (and brand themes follow
|
|
203
|
+
* it). `--neutral-subtle` alone sits too close to the background to read as a
|
|
204
|
+
* slice. Canvas charts resolve it through `resolveChartColors`.
|
|
205
|
+
*/
|
|
206
|
+
const CHART_OTHER_COLOR = "color-mix(in oklab, var(--neutral) 50%, var(--background))"
|
|
22
207
|
|
|
23
|
-
|
|
208
|
+
/**
|
|
209
|
+
* True inside `ChartFrame`. The frame's plot area carries the chart's single
|
|
210
|
+
* `role="img"` and label, so `EChart` drops its own when it sees this.
|
|
211
|
+
*/
|
|
212
|
+
const ChartFrameContext = createContext(false)
|
|
24
213
|
|
|
25
214
|
/** Series palette, in order. Missing tokens are skipped, not defaulted. */
|
|
26
215
|
const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"] as const
|
|
@@ -28,6 +217,8 @@ const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--ch
|
|
|
28
217
|
interface ChartTokens {
|
|
29
218
|
/** Resolved `--chart-1..5`, in order. */
|
|
30
219
|
series: string[]
|
|
220
|
+
/** Computed `font-family` of the container, so canvas text matches the DOM. Optional. */
|
|
221
|
+
fontFamily?: string
|
|
31
222
|
/** Body text: titles and the default `textStyle`. */
|
|
32
223
|
foreground: string | null
|
|
33
224
|
/** Secondary text: axis labels, legend, subtitles. */
|
|
@@ -36,10 +227,23 @@ interface ChartTokens {
|
|
|
36
227
|
border: string | null
|
|
37
228
|
/** Tooltip surface. */
|
|
38
229
|
popover: string | null
|
|
230
|
+
/** Semantic and surface dependencies used by typed chart options. Optional for hand-built tokens. */
|
|
231
|
+
colors?: Record<string, string>
|
|
232
|
+
/** Actual inherited painting surface (may change without a token changing). */
|
|
233
|
+
surface?: string | null
|
|
39
234
|
/** Tooltip text. */
|
|
40
235
|
popoverForeground: string | null
|
|
41
236
|
}
|
|
42
237
|
|
|
238
|
+
// Keep scope out of the public token shape. A resolved snapshot remembers where arbitrary
|
|
239
|
+
// var(--token) expressions must be read, without retaining unmounted charts globally.
|
|
240
|
+
const tokenScopes = new WeakMap<ChartTokens, Element>()
|
|
241
|
+
const OPTION_COLOR_TOKENS = [
|
|
242
|
+
"--background", "--card", "--muted", "--primary", "--destructive",
|
|
243
|
+
...["positive", "negative", "warning", "neutral", "info"].flatMap((tone) =>
|
|
244
|
+
["", "-fill", "-subtle"].map((suffix) => `--${tone}${suffix}`)),
|
|
245
|
+
]
|
|
246
|
+
|
|
43
247
|
let normalizeContext: CanvasRenderingContext2D | null | undefined
|
|
44
248
|
|
|
45
249
|
/** The forms `fillStyle` serializes legacy colours to, and zrender can parse. */
|
|
@@ -95,14 +299,19 @@ function readChartTokens(el?: Element | null): ChartTokens | null {
|
|
|
95
299
|
const color = read(token)
|
|
96
300
|
if (color) series.push(color)
|
|
97
301
|
}
|
|
98
|
-
|
|
302
|
+
const tokens: ChartTokens = {
|
|
99
303
|
series,
|
|
304
|
+
colors: Object.fromEntries(OPTION_COLOR_TOKENS.map((name) => [name, styles.getPropertyValue(name).trim()])),
|
|
305
|
+
surface: readChartSurface(target),
|
|
306
|
+
...(styles.fontFamily ? { fontFamily: styles.fontFamily } : {}),
|
|
100
307
|
foreground: read("--foreground"),
|
|
101
308
|
mutedForeground: read("--muted-foreground"),
|
|
102
309
|
border: read("--border"),
|
|
103
310
|
popover: read("--popover"),
|
|
104
311
|
popoverForeground: read("--popover-foreground"),
|
|
105
312
|
}
|
|
313
|
+
tokenScopes.set(tokens, target)
|
|
314
|
+
return tokens
|
|
106
315
|
}
|
|
107
316
|
|
|
108
317
|
/** Structural equality, so a theme toggle that changes nothing doesn't re-init. */
|
|
@@ -110,8 +319,13 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
|
|
|
110
319
|
if (a === b) return true
|
|
111
320
|
if (!a || !b) return false
|
|
112
321
|
return (
|
|
322
|
+
tokenScopes.get(a) === tokenScopes.get(b) &&
|
|
323
|
+
a.surface === b.surface &&
|
|
324
|
+
Object.keys(a.colors ?? {}).length === Object.keys(b.colors ?? {}).length &&
|
|
325
|
+
Object.entries(a.colors ?? {}).every(([name, value]) => b.colors?.[name] === value) &&
|
|
113
326
|
a.series.length === b.series.length &&
|
|
114
327
|
a.series.every((color, i) => color === b.series[i]) &&
|
|
328
|
+
a.fontFamily === b.fontFamily &&
|
|
115
329
|
a.foreground === b.foreground &&
|
|
116
330
|
a.mutedForeground === b.mutedForeground &&
|
|
117
331
|
a.border === b.border &&
|
|
@@ -134,7 +348,7 @@ function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean
|
|
|
134
348
|
* attributes alone leaves the chart painted in the previous palette until a
|
|
135
349
|
* reload — the tokens in the DOM are new, the canvas is not.
|
|
136
350
|
*/
|
|
137
|
-
function watchChartTokens(onChange: () => void): () => void {
|
|
351
|
+
function watchChartTokens(onChange: () => void, scope?: Element | null): () => void {
|
|
138
352
|
if (typeof document === "undefined") return () => {}
|
|
139
353
|
// Style edits arrive as bursts of mutations; coalesce them into one read, and
|
|
140
354
|
// take it after the next frame so a swapped stylesheet has been applied.
|
|
@@ -149,8 +363,11 @@ function watchChartTokens(onChange: () => void): () => void {
|
|
|
149
363
|
|
|
150
364
|
const themeObserver = new MutationObserver(schedule)
|
|
151
365
|
const attributeFilter = ["class", "style", "data-theme"]
|
|
152
|
-
|
|
153
|
-
|
|
366
|
+
// Only the chart and its ancestors can change its inherited theme. Never observe
|
|
367
|
+
// the document subtree: canvas/tooltip/rendering mutations would trigger every chart.
|
|
368
|
+
for (let node: Element | null = scope ?? document.body ?? document.documentElement; node; node = node.parentElement) {
|
|
369
|
+
themeObserver.observe(node, { attributes: true, attributeFilter })
|
|
370
|
+
}
|
|
154
371
|
|
|
155
372
|
const styleObserver = new MutationObserver(schedule)
|
|
156
373
|
if (document.head) {
|
|
@@ -185,10 +402,11 @@ function watchChartTokens(onChange: () => void): () => void {
|
|
|
185
402
|
function axisTheme(tokens: ChartTokens, splitLine: boolean) {
|
|
186
403
|
const line = tokens.border ?? undefined
|
|
187
404
|
return {
|
|
188
|
-
axisLine: { show:
|
|
189
|
-
axisTick: { show:
|
|
190
|
-
axisLabel: { show: true, color: tokens.mutedForeground ?? undefined },
|
|
191
|
-
|
|
405
|
+
axisLine: { show: false },
|
|
406
|
+
axisTick: { show: false },
|
|
407
|
+
axisLabel: { show: true, color: tokens.mutedForeground ?? undefined, fontSize: 12 },
|
|
408
|
+
// Faint horizontal rules only: the border colour at low opacity.
|
|
409
|
+
splitLine: { show: splitLine, lineStyle: { color: line ? [line] : undefined, opacity: 0.5, width: 1 } },
|
|
192
410
|
splitArea: { show: false },
|
|
193
411
|
}
|
|
194
412
|
}
|
|
@@ -205,7 +423,7 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
|
|
|
205
423
|
// Let the page background show through: charts sit on cards, not on a canvas.
|
|
206
424
|
backgroundColor: "transparent",
|
|
207
425
|
...(tokens.series.length > 0 ? { color: tokens.series } : {}),
|
|
208
|
-
textStyle: { color: text },
|
|
426
|
+
textStyle: { color: text, ...(tokens.fontFamily ? { fontFamily: tokens.fontFamily } : {}) },
|
|
209
427
|
title: { textStyle: { color: text }, subtextStyle: { color: muted } },
|
|
210
428
|
legend: { textStyle: { color: muted } },
|
|
211
429
|
categoryAxis: axisTheme(tokens, false),
|
|
@@ -213,10 +431,19 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
|
|
|
213
431
|
logAxis: axisTheme(tokens, true),
|
|
214
432
|
timeAxis: axisTheme(tokens, true),
|
|
215
433
|
tooltip: {
|
|
434
|
+
// ECharts' default 0.4s transform lags the tooltip ~300ms behind the cursor.
|
|
435
|
+
transitionDuration: 0,
|
|
216
436
|
backgroundColor: tokens.popover ?? undefined,
|
|
217
437
|
borderColor: line,
|
|
218
438
|
borderWidth: 1,
|
|
219
|
-
|
|
439
|
+
padding: [8, 12],
|
|
440
|
+
// Rounded card with a soft shadow; in dark mode the shadow barely shows, so the border carries the edge.
|
|
441
|
+
extraCssText: "border-radius:8px;box-shadow:var(--shadow-card, 0 4px 12px rgb(0 0 0 / 0.08));",
|
|
442
|
+
textStyle: {
|
|
443
|
+
color: tokens.popoverForeground ?? undefined,
|
|
444
|
+
fontSize: 12,
|
|
445
|
+
...(tokens.fontFamily ? { fontFamily: tokens.fontFamily } : {}),
|
|
446
|
+
},
|
|
220
447
|
axisPointer: { lineStyle: { color: line }, crossStyle: { color: line } },
|
|
221
448
|
},
|
|
222
449
|
}
|
|
@@ -228,27 +455,35 @@ function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
|
|
|
228
455
|
* components that build an ECharts option from token colours themselves (the
|
|
229
456
|
* typed charts), so their palette follows light/dark like `EChart`'s theme.
|
|
230
457
|
*/
|
|
231
|
-
function useChartTokens(): ChartTokens | null {
|
|
458
|
+
function useChartTokens(scope?: Element | null | RefObject<Element | null>): ChartTokens | null {
|
|
232
459
|
const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
|
|
460
|
+
// Compared here, not inside a `setTokens` updater: an updater that reads the DOM is
|
|
461
|
+
// replayed by React on every rebase and returns a fresh object each time, so the state
|
|
462
|
+
// never settles ("Maximum update depth exceeded" once the tokens change after mount).
|
|
463
|
+
const tokensRef = useRef(tokens)
|
|
233
464
|
useEffect(() => {
|
|
465
|
+
const target = scope && "current" in scope ? scope.current : scope
|
|
234
466
|
const sync = () => {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
467
|
+
const next = readChartTokens(target)
|
|
468
|
+
if (sameChartTokens(tokensRef.current, next)) return
|
|
469
|
+
tokensRef.current = next
|
|
470
|
+
setTokens(next)
|
|
239
471
|
}
|
|
240
472
|
sync()
|
|
241
|
-
return watchChartTokens(sync)
|
|
242
|
-
}, [])
|
|
473
|
+
return watchChartTokens(sync, target)
|
|
474
|
+
}, [scope])
|
|
243
475
|
return tokens
|
|
244
476
|
}
|
|
245
477
|
|
|
246
478
|
const SERIES_TOKEN_NAME_RE = /^chart-([1-5])$/
|
|
479
|
+
const TONE_NAME_RE = /^(?:positive|warning|negative|neutral|info)(?:-fill)?$/
|
|
247
480
|
const CSS_VAR_RE = /^var\(\s*(--[\w-]+)\s*(?:,\s*(.+))?\)$/
|
|
481
|
+
const EMBEDDED_VAR_RE = /var\(\s*(--[\w-]+)\s*\)/g
|
|
248
482
|
|
|
249
483
|
/**
|
|
250
484
|
* Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
|
|
251
|
-
* the resolved series tokens; `
|
|
485
|
+
* the resolved series tokens; `"positive"` / `"warning"` / `"negative"` / `"neutral"` (and the area
|
|
486
|
+
* variants `"positive-fill"`, …) are read in the snapshot's inherited scope (`--positive`, …); `var(--token)` uses the same scope; any
|
|
252
487
|
* other CSS colour is normalized like the tokens are. A colour that can't be
|
|
253
488
|
* resolved comes back as `""`, which ECharts treats as "use the palette".
|
|
254
489
|
*/
|
|
@@ -256,19 +491,122 @@ function resolveChartColors(colors: readonly string[], tokens: ChartTokens | nul
|
|
|
256
491
|
return colors.map((color) => {
|
|
257
492
|
const token = SERIES_TOKEN_NAME_RE.exec(color.trim())
|
|
258
493
|
if (token) return tokens?.series[Number(token[1]) - 1] ?? ""
|
|
494
|
+
// Meaning colours are the theme tokens of the same name: same path as `var(--positive)`.
|
|
495
|
+
if (TONE_NAME_RE.test(color.trim())) {
|
|
496
|
+
if (typeof document === "undefined") return ""
|
|
497
|
+
const target = (tokens && tokenScopes.get(tokens)) ?? document.documentElement
|
|
498
|
+
return toCanvasColor(getComputedStyle(target).getPropertyValue(`--${color.trim()}`)) ?? ""
|
|
499
|
+
}
|
|
259
500
|
if (typeof document === "undefined") return color
|
|
260
501
|
let value = color.trim()
|
|
261
502
|
const cssVar = CSS_VAR_RE.exec(value)
|
|
503
|
+
const styles = getComputedStyle((tokens && tokenScopes.get(tokens)) ?? document.documentElement)
|
|
262
504
|
if (cssVar) {
|
|
263
|
-
const styles = getComputedStyle(document.documentElement)
|
|
264
505
|
value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
|
|
506
|
+
} else if (value.includes("var(")) {
|
|
507
|
+
// A colour function over tokens (`color-mix(… var(--muted-foreground) …)`):
|
|
508
|
+
// substitute the tokens, then let the canvas resolve the rest.
|
|
509
|
+
value = value.replace(EMBEDDED_VAR_RE, (_, name: string) => styles.getPropertyValue(name).trim())
|
|
265
510
|
}
|
|
266
511
|
return toCanvasColor(value) ?? ""
|
|
267
512
|
})
|
|
268
513
|
}
|
|
269
514
|
|
|
515
|
+
/** Every meaning colour resolved for the canvas (`""` when it can't be resolved). */
|
|
516
|
+
function resolveToneColors(tokens: ChartTokens | null): Record<ChartTone, string> {
|
|
517
|
+
const [positive = "", warning = "", negative = "", neutral = ""] = resolveChartColors(
|
|
518
|
+
["positive", "warning", "negative", "neutral"],
|
|
519
|
+
tokens,
|
|
520
|
+
)
|
|
521
|
+
return { positive, warning, negative, neutral }
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** The area (`--<tone>-fill`) colours of every meaning tone, resolved for the canvas (`""` when it can't be resolved). */
|
|
525
|
+
function resolveFillColors(tokens: ChartTokens | null): Record<ChartTone, string> {
|
|
526
|
+
const [positive = "", warning = "", negative = "", neutral = ""] = resolveChartColors(
|
|
527
|
+
["positive-fill", "warning-fill", "negative-fill", "neutral-fill"],
|
|
528
|
+
tokens,
|
|
529
|
+
)
|
|
530
|
+
return { positive, warning, negative, neutral }
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const TRANSPARENT_RE = /^(?:transparent|rgba\([^)]*,\s*0(?:\.0+)?\))$/i
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* The colour the chart is painted on: the first opaque `background-color` up
|
|
537
|
+
* from `el` (a card, the page), else `--background`. Canvas-safe, or `null`
|
|
538
|
+
* outside the browser.
|
|
539
|
+
*/
|
|
540
|
+
function readChartSurface(el: Element | null): string | null {
|
|
541
|
+
if (typeof document === "undefined") return null
|
|
542
|
+
for (let node: Element | null = el; node; node = node.parentElement) {
|
|
543
|
+
const bg = getComputedStyle(node).backgroundColor.trim()
|
|
544
|
+
if (bg && !TRANSPARENT_RE.test(bg)) {
|
|
545
|
+
const color = toCanvasColor(bg)
|
|
546
|
+
if (color) return color
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return toCanvasColor(getComputedStyle(el ?? document.documentElement).getPropertyValue("--background"))
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Widths are bucketed so a resize only rebuilds the option when the label budget can change. */
|
|
553
|
+
const WIDTH_BUCKET = 40
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* The surface colour behind a chart (see `readChartSurface`), re-read when the
|
|
557
|
+
* tokens move (light/dark), and the plot's width (rounded down to 40px;
|
|
558
|
+
* `undefined` until measured). Returns a ref for the plot that also feeds the
|
|
559
|
+
* component's forwarded ref.
|
|
560
|
+
*/
|
|
561
|
+
function useChartSurface<E extends Element>(
|
|
562
|
+
forwarded: ForwardedRef<E>,
|
|
563
|
+
tokens?: ChartTokens | null,
|
|
564
|
+
): { ref: (el: E | null) => void; surface: string | undefined; width: number | undefined; tokens: ChartTokens | null } {
|
|
565
|
+
const [node, setNode] = useState<E | null>(null)
|
|
566
|
+
const scopedTokens = useChartTokens(node)
|
|
567
|
+
const [surface, setSurface] = useState<string | undefined>(undefined)
|
|
568
|
+
const [width, setWidth] = useState<number | undefined>(undefined)
|
|
569
|
+
const ref = useCallback(
|
|
570
|
+
(el: E | null) => {
|
|
571
|
+
setNode(el)
|
|
572
|
+
if (typeof forwarded === "function") forwarded(el)
|
|
573
|
+
else if (forwarded) forwarded.current = el
|
|
574
|
+
},
|
|
575
|
+
[forwarded],
|
|
576
|
+
)
|
|
577
|
+
useEffect(() => {
|
|
578
|
+
if (!node) return
|
|
579
|
+
setSurface(readChartSurface(node) ?? undefined)
|
|
580
|
+
}, [node, tokens, scopedTokens])
|
|
581
|
+
useEffect(() => {
|
|
582
|
+
if (!node || typeof ResizeObserver === "undefined") return
|
|
583
|
+
const update = (w: number) => setWidth(Math.floor(w / WIDTH_BUCKET) * WIDTH_BUCKET)
|
|
584
|
+
update(node.getBoundingClientRect().width)
|
|
585
|
+
const observer = new ResizeObserver((entries) => update(entries[0]?.contentRect.width ?? 0))
|
|
586
|
+
observer.observe(node)
|
|
587
|
+
return () => observer.disconnect()
|
|
588
|
+
}, [node])
|
|
589
|
+
return { ref, surface, width, tokens: scopedTokens }
|
|
590
|
+
}
|
|
591
|
+
|
|
270
592
|
/* ---------- end of inlined code ---------- */
|
|
271
593
|
|
|
594
|
+
// What the typed charts (Bar / Line / Area / Donut) emit. Other series and
|
|
595
|
+
// components are the consumer's to `use()` — see the note at the top.
|
|
596
|
+
use([
|
|
597
|
+
BarChart,
|
|
598
|
+
CustomChart,
|
|
599
|
+
LineChart,
|
|
600
|
+
PieChart,
|
|
601
|
+
GridComponent,
|
|
602
|
+
LegendComponent,
|
|
603
|
+
MarkAreaComponent,
|
|
604
|
+
MarkLineComponent,
|
|
605
|
+
MarkPointComponent,
|
|
606
|
+
TooltipComponent,
|
|
607
|
+
CanvasRenderer,
|
|
608
|
+
])
|
|
609
|
+
|
|
272
610
|
export interface EChartProps {
|
|
273
611
|
/**
|
|
274
612
|
* ECharts のオプション。
|
|
@@ -278,7 +616,7 @@ export interface EChartProps {
|
|
|
278
616
|
* literal が `string` に拡張され、ビルドが失敗する。
|
|
279
617
|
*
|
|
280
618
|
* @example
|
|
281
|
-
* import type { EChartsOption } from "
|
|
619
|
+
* import type { EChartsOption } from "@/components/echart"
|
|
282
620
|
*
|
|
283
621
|
* const option: EChartsOption = {
|
|
284
622
|
* xAxis: { type: "category", data: ["A", "B"] },
|
|
@@ -309,14 +647,332 @@ export interface EChartProps {
|
|
|
309
647
|
onEvents?: Record<string, (params: unknown) => void>
|
|
310
648
|
ariaLabel?: string
|
|
311
649
|
className?: string
|
|
312
|
-
/**
|
|
313
|
-
|
|
650
|
+
/**
|
|
651
|
+
* true にするとチャート右上に PNG コピー / ダウンロードボタンを表示する。
|
|
652
|
+
* `exportHeader` があれば PNG の上にその見出しを含める(`{ includeTitle: false }` で含めない)。
|
|
653
|
+
*/
|
|
654
|
+
actions?: boolean | ChartActionsOptions
|
|
314
655
|
/** ダウンロード時のファイル名(拡張子含む)。省略時は "chart.png" */
|
|
315
656
|
fileName?: string
|
|
657
|
+
/**
|
|
658
|
+
* PNG の上に含める見出しと要約。型付きグラフは自分の `title` / `summary` を渡す。
|
|
659
|
+
* 自分で `ChartFrame` を組むときも同じものを渡すと、保存した画像だけで何のグラフか分かる。
|
|
660
|
+
*/
|
|
661
|
+
exportHeader?: ChartExportHeader
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** The heading a saved PNG carries above the plot. */
|
|
665
|
+
export interface ChartExportHeader {
|
|
666
|
+
title?: string
|
|
667
|
+
summary?: string
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const HEADER_PAD_X = 16
|
|
671
|
+
const HEADER_PAD_TOP = 16
|
|
672
|
+
const HEADER_PAD_BOTTOM = 12
|
|
673
|
+
const HEADER_LINE_HEIGHT = 20
|
|
674
|
+
const HEADER_GAP = 4
|
|
675
|
+
const HEADER_FONT_SIZE = 14
|
|
676
|
+
|
|
677
|
+
export interface ChartExportHeaderLine {
|
|
678
|
+
text: string
|
|
679
|
+
kind: "title" | "summary"
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Break `text` into lines no wider than `maxWidth` (px, via `measure`), character
|
|
684
|
+
* by character so Japanese wraps without spaces. Always at least one line for
|
|
685
|
+
* non-empty text; a single over-wide character still gets its own line.
|
|
686
|
+
*/
|
|
687
|
+
export function wrapChartText(text: string, maxWidth: number, measure: (text: string) => number): string[] {
|
|
688
|
+
const lines: string[] = []
|
|
689
|
+
let line = ""
|
|
690
|
+
for (const ch of text) {
|
|
691
|
+
if (ch === "\n") {
|
|
692
|
+
lines.push(line)
|
|
693
|
+
line = ""
|
|
694
|
+
continue
|
|
695
|
+
}
|
|
696
|
+
if (line !== "" && measure(line + ch) > maxWidth) {
|
|
697
|
+
lines.push(line)
|
|
698
|
+
line = ch.trim() === "" ? "" : ch
|
|
699
|
+
} else {
|
|
700
|
+
line += ch
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
if (line !== "" || lines.length === 0) lines.push(line)
|
|
704
|
+
return lines.filter((l, i) => l !== "" || i < lines.length - 1)
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* The lines and total height (CSS px) of the PNG's heading band for a plot
|
|
709
|
+
* `width` px wide. `measure` takes (text, kind) and returns its width in px.
|
|
710
|
+
* No title and no summary → no band (`height` 0).
|
|
711
|
+
*/
|
|
712
|
+
export function chartExportHeaderLayout(
|
|
713
|
+
header: ChartExportHeader | undefined,
|
|
714
|
+
width: number,
|
|
715
|
+
measure: (text: string, kind: "title" | "summary") => number,
|
|
716
|
+
): { lines: ChartExportHeaderLine[]; height: number } {
|
|
717
|
+
const title = header?.title?.trim()
|
|
718
|
+
const summary = header?.summary?.trim()
|
|
719
|
+
if (!title && !summary) return { lines: [], height: 0 }
|
|
720
|
+
const room = Math.max(40, width - HEADER_PAD_X * 2)
|
|
721
|
+
const lines: ChartExportHeaderLine[] = [
|
|
722
|
+
...(title ? wrapChartText(title, room, (t) => measure(t, "title")).map((text) => ({ text, kind: "title" as const })) : []),
|
|
723
|
+
...(summary ? wrapChartText(summary, room, (t) => measure(t, "summary")).map((text) => ({ text, kind: "summary" as const })) : []),
|
|
724
|
+
]
|
|
725
|
+
const gap = title && summary ? HEADER_GAP : 0
|
|
726
|
+
return { lines, height: HEADER_PAD_TOP + lines.length * HEADER_LINE_HEIGHT + gap + HEADER_PAD_BOTTOM }
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const TEXT = {
|
|
730
|
+
ja: { chart: "チャート", copied: "コピーしました", copy: "PNG をクリップボードにコピー", download: "PNG をダウンロード" },
|
|
731
|
+
en: { chart: "Chart", copied: "Copied", copy: "Copy PNG to clipboard", download: "Download PNG" },
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function loadImage(src: string): Promise<HTMLImageElement> {
|
|
735
|
+
return new Promise((resolve, reject) => {
|
|
736
|
+
const image = new Image()
|
|
737
|
+
image.onload = () => resolve(image)
|
|
738
|
+
image.onerror = () => reject(new Error("Couldn't load the PNG"))
|
|
739
|
+
image.src = src
|
|
740
|
+
})
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** Draw a heading band (title in foreground, summary muted) above the exported plot and return the new PNG. */
|
|
744
|
+
async function pngWithHeader(
|
|
745
|
+
dataUrl: string,
|
|
746
|
+
header: ChartExportHeader,
|
|
747
|
+
look: { background: string; color: string; mutedColor: string; fontFamily: string },
|
|
748
|
+
pixelRatio: number,
|
|
749
|
+
): Promise<string> {
|
|
750
|
+
const image = await loadImage(dataUrl)
|
|
751
|
+
const width = image.width / pixelRatio
|
|
752
|
+
const plotHeight = image.height / pixelRatio
|
|
753
|
+
const canvas = document.createElement("canvas")
|
|
754
|
+
const ctx = canvas.getContext("2d")
|
|
755
|
+
if (!ctx) return dataUrl
|
|
756
|
+
const font = (kind: "title" | "summary") => `${kind === "title" ? 500 : 400} ${HEADER_FONT_SIZE}px ${look.fontFamily}`
|
|
757
|
+
ctx.font = font("title")
|
|
758
|
+
const layout = chartExportHeaderLayout(header, width, (text, kind) => {
|
|
759
|
+
ctx.font = font(kind)
|
|
760
|
+
return ctx.measureText(text).width
|
|
761
|
+
})
|
|
762
|
+
if (layout.height === 0) return dataUrl
|
|
763
|
+
canvas.width = Math.round(image.width)
|
|
764
|
+
canvas.height = Math.round((layout.height + plotHeight) * pixelRatio)
|
|
765
|
+
ctx.scale(pixelRatio, pixelRatio)
|
|
766
|
+
ctx.fillStyle = look.background
|
|
767
|
+
ctx.fillRect(0, 0, width, layout.height + plotHeight)
|
|
768
|
+
ctx.textBaseline = "middle"
|
|
769
|
+
let y = HEADER_PAD_TOP
|
|
770
|
+
let previous: "title" | "summary" | null = null
|
|
771
|
+
for (const line of layout.lines) {
|
|
772
|
+
if (previous === "title" && line.kind === "summary") y += HEADER_GAP
|
|
773
|
+
ctx.font = font(line.kind)
|
|
774
|
+
ctx.fillStyle = line.kind === "title" ? look.color : look.mutedColor
|
|
775
|
+
ctx.fillText(line.text, HEADER_PAD_X, y + HEADER_LINE_HEIGHT / 2)
|
|
776
|
+
y += HEADER_LINE_HEIGHT
|
|
777
|
+
previous = line.kind
|
|
778
|
+
}
|
|
779
|
+
ctx.drawImage(image, 0, layout.height, width, plotHeight)
|
|
780
|
+
return canvas.toDataURL("image/png")
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Canvas-safe versions of theme colours, kept current across light/dark.
|
|
785
|
+
* Takes `"chart-1"`〜`"chart-5"`, `"var(--token)"` or any CSS colour; one that
|
|
786
|
+
* can't be resolved comes back as `""` (ECharts then uses the palette).
|
|
787
|
+
* Use it whenever a colour goes into a raw `EChart` option by hand.
|
|
788
|
+
*
|
|
789
|
+
* @example
|
|
790
|
+
* const [line, band] = useResolvedChartColors(["chart-1", "var(--muted-foreground)"])
|
|
791
|
+
*/
|
|
792
|
+
export function useResolvedChartColors(colors: readonly string[]): string[] {
|
|
793
|
+
const tokens = useChartTokens()
|
|
794
|
+
const key = colors.join("\u0000")
|
|
795
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- `key` stands for the array's contents
|
|
796
|
+
return useMemo(() => resolveChartColors(colors, tokens), [key, tokens])
|
|
316
797
|
}
|
|
317
798
|
|
|
318
799
|
type EChartsEventHandler = (params: unknown) => void
|
|
319
800
|
|
|
801
|
+
/**
|
|
802
|
+
* ECharts component main types. Listed in `replaceMerge`, a type keeps only the
|
|
803
|
+
* components whose `id` matches (merged, so they can transition) and drops the
|
|
804
|
+
* rest — including every one of them when the new option no longer has the type.
|
|
805
|
+
*/
|
|
806
|
+
const COMPONENT_MAIN_TYPES = new Set([
|
|
807
|
+
"title", "legend", "grid", "xAxis", "yAxis", "polar", "radiusAxis", "angleAxis", "radar",
|
|
808
|
+
"dataZoom", "visualMap", "tooltip", "axisPointer", "toolbox", "brush", "geo", "parallel",
|
|
809
|
+
"parallelAxis", "singleAxis", "graphic", "calendar", "dataset", "series", "matrix", "thumbnail",
|
|
810
|
+
])
|
|
811
|
+
|
|
812
|
+
export interface EChartSetOptionOpts {
|
|
813
|
+
notMerge: boolean
|
|
814
|
+
replaceMerge?: string[]
|
|
815
|
+
lazyUpdate: boolean
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* How to apply `next` after `prev`: the first option and timeline / media
|
|
820
|
+
* options replace everything; otherwise merge, replacing every component type
|
|
821
|
+
* either option uses so nothing stale survives.
|
|
822
|
+
*/
|
|
823
|
+
export function echartSetOptionOpts(prev: EChartsOption | null, next: EChartsOption): EChartSetOptionOpts {
|
|
824
|
+
const special = (o: EChartsOption) => "timeline" in o || "baseOption" in o || "options" in o || "media" in o
|
|
825
|
+
if (!prev || special(prev) || special(next)) return { notMerge: true, lazyUpdate: true }
|
|
826
|
+
const types = new Set<string>()
|
|
827
|
+
for (const key of [...Object.keys(prev), ...Object.keys(next)]) if (COMPONENT_MAIN_TYPES.has(key)) types.add(key)
|
|
828
|
+
return { notMerge: false, replaceMerge: [...types], lazyUpdate: true }
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Component types that get a generated `id` when the option has none, so replaceMerge can match them across updates.
|
|
833
|
+
* Not `dataset` / `graphic` / `tooltip`: they are rebuilt on every update (there is nothing to transition).
|
|
834
|
+
*/
|
|
835
|
+
const ID_TYPES = [
|
|
836
|
+
"title", "legend", "grid", "xAxis", "yAxis", "polar", "radiusAxis", "angleAxis", "radar",
|
|
837
|
+
"dataZoom", "visualMap", "geo", "singleAxis", "calendar",
|
|
838
|
+
] as const
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* What `withStableIds` remembers between updates: per generated id, the shape last applied and how many
|
|
842
|
+
* times it changed (the id's `~v<n>` suffix); per component type, the order of its ids.
|
|
843
|
+
*/
|
|
844
|
+
export interface StableIdState {
|
|
845
|
+
ids: Map<string, { shape: string; version: number }>
|
|
846
|
+
order: Map<string, string[]>
|
|
847
|
+
}
|
|
848
|
+
export const createStableIdState = (): StableIdState => ({ ids: new Map(), order: new Map() })
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Shape of a component: which keys it has, recursively (arrays only as "array", values not at all —
|
|
852
|
+
* except `type`). A merge keeps keys the new option dropped, so a changed shape must not be merged.
|
|
853
|
+
*/
|
|
854
|
+
function shapeOf(v: unknown): string {
|
|
855
|
+
if (Array.isArray(v)) return "[]"
|
|
856
|
+
if (!v || typeof v !== "object" || Object.getPrototypeOf(v) !== Object.prototype) return "."
|
|
857
|
+
const o = v as Record<string, unknown>
|
|
858
|
+
return `{${Object.keys(o)
|
|
859
|
+
.filter((k) => o[k] !== undefined)
|
|
860
|
+
.sort()
|
|
861
|
+
.map((k) => (k === "type" || k === "id" ? `${k}=${String(o[k])}` : `${k}:${shapeOf(o[k])}`))
|
|
862
|
+
.join(",")}}`
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Gives every series (and the common components) an `id` it does not have yet —
|
|
867
|
+
* series: `series:<name ?? index>`, others `<type>:<index>` — so an update that
|
|
868
|
+
* only changes values is matched to the existing component and transitions
|
|
869
|
+
* instead of being rebuilt. If the component's shape changed since `state` last
|
|
870
|
+
* saw it (a key was removed or added, the type changed), the id gets a new
|
|
871
|
+
* `~v<n>` suffix so replaceMerge builds it from scratch: the result then always
|
|
872
|
+
* equals applying the option with `notMerge`. Explicit ids are kept; the input is not mutated.
|
|
873
|
+
*/
|
|
874
|
+
export function withStableIds(option: EChartsOption, state: StableIdState = createStableIdState()): EChartsOption {
|
|
875
|
+
const out: Record<string, unknown> = { ...(option as Record<string, unknown>) }
|
|
876
|
+
const taken = new Set<string>()
|
|
877
|
+
const list = (v: unknown): Record<string, unknown>[] | null =>
|
|
878
|
+
Array.isArray(v) ? (v as Record<string, unknown>[]) : v && typeof v === "object" ? [v as Record<string, unknown>] : null
|
|
879
|
+
const give = (key: string, pick: (c: Record<string, unknown>, i: number) => string) => {
|
|
880
|
+
const items = list(out[key])
|
|
881
|
+
if (!items) return
|
|
882
|
+
const used = new Set<string>(items.flatMap((c) => (c && c.id != null ? [String(c.id)] : [])))
|
|
883
|
+
const bases = items.map((c, i) => {
|
|
884
|
+
if (!c || typeof c !== "object" || c.id != null) return null
|
|
885
|
+
let base = pick(c, i)
|
|
886
|
+
for (let n = 2; used.has(base) || taken.has(`${key}\u0000${base}`); n++) base = `${pick(c, i)}#${n}`
|
|
887
|
+
used.add(base)
|
|
888
|
+
taken.add(`${key}\u0000${base}`)
|
|
889
|
+
return base
|
|
890
|
+
})
|
|
891
|
+
// A merge fills the empty slots a removal left and keeps the slot order of what exists, so after any
|
|
892
|
+
// change in which components there are (or their order) it would not match a fresh render: rebuild them all.
|
|
893
|
+
const prevOrder = state.order.get(key) ?? []
|
|
894
|
+
const cur = bases.filter((b): b is string => b !== null)
|
|
895
|
+
const inOrder = cur.length === prevOrder.length && cur.every((b, i) => b === prevOrder[i])
|
|
896
|
+
state.order.set(key, cur)
|
|
897
|
+
out[key] = items.map((c, i) => {
|
|
898
|
+
const base = bases[i]
|
|
899
|
+
if (base == null) return c
|
|
900
|
+
const shape = shapeOf(c)
|
|
901
|
+
const prev = state.ids.get(base)
|
|
902
|
+
const version = !prev ? 0 : prev.shape === shape && inOrder ? prev.version : prev.version + 1
|
|
903
|
+
state.ids.set(base, { shape, version })
|
|
904
|
+
return { ...c, id: version ? `${base}~v${version}` : base }
|
|
905
|
+
})
|
|
906
|
+
}
|
|
907
|
+
give("series", (c, i) => `series:${typeof c.name === "string" && c.name ? c.name : i}`)
|
|
908
|
+
for (const key of ID_TYPES) give(key, (_c, i) => `${key}:${i}`)
|
|
909
|
+
return out as EChartsOption
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/** Copy of the plain objects / arrays of an option (functions, class instances, Dates stay shared), to compare against later in-place edits. */
|
|
913
|
+
export function snapshotOption<T>(v: T): T {
|
|
914
|
+
if (Array.isArray(v)) return v.map(snapshotOption) as T
|
|
915
|
+
if (v && typeof v === "object") {
|
|
916
|
+
const proto = Object.getPrototypeOf(v)
|
|
917
|
+
if (proto === Object.prototype || proto === null) {
|
|
918
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, snapshotOption(x)])) as T
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
return v
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/** Structural equality for option trees; functions and other non-plain objects compare by reference. */
|
|
925
|
+
export function optionEquals(a: unknown, b: unknown): boolean {
|
|
926
|
+
if (Object.is(a, b)) return true
|
|
927
|
+
if (typeof a !== "object" || typeof b !== "object" || !a || !b) return false
|
|
928
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false
|
|
929
|
+
if (Array.isArray(a)) {
|
|
930
|
+
const bb = b as unknown[]
|
|
931
|
+
return a.length === bb.length && a.every((v, i) => optionEquals(v, bb[i]))
|
|
932
|
+
}
|
|
933
|
+
if (a instanceof Date || b instanceof Date) return a instanceof Date && b instanceof Date && a.getTime() === b.getTime()
|
|
934
|
+
const pa = Object.getPrototypeOf(a)
|
|
935
|
+
if ((pa !== Object.prototype && pa !== null) || Object.getPrototypeOf(b) !== pa) return false
|
|
936
|
+
const ka = Object.keys(a)
|
|
937
|
+
const kb = Object.keys(b)
|
|
938
|
+
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]))
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/** `graphic` text doesn't inherit the global `textStyle`, so give it the inherited font unless it sets its own. */
|
|
942
|
+
function withGraphicFont(option: EChartsOption, fontFamily: string | undefined): EChartsOption {
|
|
943
|
+
const graphic = (option as { graphic?: unknown }).graphic
|
|
944
|
+
if (!fontFamily || !Array.isArray(graphic)) return option
|
|
945
|
+
return {
|
|
946
|
+
...option,
|
|
947
|
+
graphic: graphic.map((g: { type?: string; style?: Record<string, unknown> }) =>
|
|
948
|
+
g && g.type === "text" && g.style && !g.style.fontFamily ? { ...g, style: { ...g.style, fontFamily } } : g,
|
|
949
|
+
),
|
|
950
|
+
} as EChartsOption
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/** `option` as applied: reduced motion forces `animation: false`; otherwise animation stays explicit so it can flip back. */
|
|
954
|
+
export function withMotionPreference(option: EChartsOption, reducedMotion: boolean): EChartsOption {
|
|
955
|
+
return { ...option, animation: reducedMotion ? false : (option.animation ?? true) }
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"
|
|
959
|
+
|
|
960
|
+
/** `prefers-reduced-motion: reduce`, kept current. `false` on the server. */
|
|
961
|
+
export function usePrefersReducedMotion(): boolean {
|
|
962
|
+
const [reduced, setReduced] = useState(
|
|
963
|
+
() => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia(REDUCED_MOTION_QUERY).matches,
|
|
964
|
+
)
|
|
965
|
+
useEffect(() => {
|
|
966
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return
|
|
967
|
+
const media = window.matchMedia(REDUCED_MOTION_QUERY)
|
|
968
|
+
const sync = () => setReduced(media.matches)
|
|
969
|
+
sync()
|
|
970
|
+
media.addEventListener("change", sync)
|
|
971
|
+
return () => media.removeEventListener("change", sync)
|
|
972
|
+
}, [])
|
|
973
|
+
return reduced
|
|
974
|
+
}
|
|
975
|
+
|
|
320
976
|
export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
|
|
321
977
|
{
|
|
322
978
|
option,
|
|
@@ -328,29 +984,21 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
|
|
|
328
984
|
className,
|
|
329
985
|
actions = false,
|
|
330
986
|
fileName = "chart.png",
|
|
987
|
+
exportHeader,
|
|
331
988
|
},
|
|
332
989
|
ref,
|
|
333
990
|
) {
|
|
334
991
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
335
992
|
const [instance, setInstance] = useState<ECharts | null>(null)
|
|
336
993
|
const [copied, setCopied] = useState(false)
|
|
337
|
-
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
//
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const sync = () => {
|
|
346
|
-
setTokens((prev) => {
|
|
347
|
-
const next = readChartTokens()
|
|
348
|
-
return sameChartTokens(prev, next) ? prev : next
|
|
349
|
-
})
|
|
350
|
-
}
|
|
351
|
-
sync()
|
|
352
|
-
return watchChartTokens(sync)
|
|
353
|
-
}, [theme])
|
|
994
|
+
const t = TEXT[uiLanguage(useLocale())]
|
|
995
|
+
const tokens = useChartTokens(containerRef)
|
|
996
|
+
const reducedMotion = usePrefersReducedMotion()
|
|
997
|
+
// What the current instance last received, to pick the merge mode of the next update.
|
|
998
|
+
const appliedRef = useRef<EChartsOption | null>(null)
|
|
999
|
+
// The option (before ids) as it was when applied, deep-copied; and the shape history behind the generated ids.
|
|
1000
|
+
const snapshotRef = useRef<EChartsOption | null>(null)
|
|
1001
|
+
const idStateRef = useRef<StableIdState>(createStableIdState())
|
|
354
1002
|
|
|
355
1003
|
const autoTheme = useMemo(() => (tokens ? buildChartTheme(tokens) : undefined), [tokens])
|
|
356
1004
|
const resolvedTheme = theme ?? autoTheme
|
|
@@ -360,7 +1008,9 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
|
|
|
360
1008
|
useEffect(() => {
|
|
361
1009
|
const container = containerRef.current
|
|
362
1010
|
if (!container) return
|
|
363
|
-
const inst =
|
|
1011
|
+
const inst = init(container, resolvedTheme, { renderer: "canvas" })
|
|
1012
|
+
appliedRef.current = null
|
|
1013
|
+
snapshotRef.current = null
|
|
364
1014
|
// `data-rendering` stays on until the (animated) paint is done, so anything
|
|
365
1015
|
// capturing the page — printing a document — can wait for real pixels.
|
|
366
1016
|
container.dataset.rendering = ""
|
|
@@ -369,29 +1019,47 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
|
|
|
369
1019
|
})
|
|
370
1020
|
setInstance(inst)
|
|
371
1021
|
|
|
372
|
-
|
|
1022
|
+
// At most one resize per frame (the sidebar animates the width).
|
|
1023
|
+
let frame = 0
|
|
1024
|
+
const observer = new ResizeObserver(() => {
|
|
1025
|
+
cancelAnimationFrame(frame)
|
|
1026
|
+
frame = requestAnimationFrame(() => {
|
|
1027
|
+
if (!inst.isDisposed()) inst.resize()
|
|
1028
|
+
})
|
|
1029
|
+
})
|
|
373
1030
|
observer.observe(container)
|
|
374
1031
|
|
|
375
1032
|
return () => {
|
|
376
1033
|
observer.disconnect()
|
|
1034
|
+
cancelAnimationFrame(frame)
|
|
377
1035
|
inst.dispose()
|
|
378
1036
|
}
|
|
379
1037
|
}, [resolvedTheme])
|
|
380
1038
|
|
|
381
1039
|
useEffect(() => {
|
|
382
|
-
if (!instance) return
|
|
1040
|
+
if (!instance || instance.isDisposed()) return
|
|
1041
|
+
const prepared = withGraphicFont(
|
|
1042
|
+
withMotionPreference(option, reducedMotion),
|
|
1043
|
+
theme === undefined ? tokens?.fontFamily : undefined,
|
|
1044
|
+
)
|
|
1045
|
+
// Same content as last time (a re-render that built an equal option): nothing to redraw.
|
|
1046
|
+
// Compared with a snapshot, so an in-place edit of a nested array of the caller's option still counts as a change.
|
|
1047
|
+
if (snapshotRef.current && optionEquals(snapshotRef.current, prepared)) return
|
|
383
1048
|
if (containerRef.current) containerRef.current.dataset.rendering = ""
|
|
384
|
-
|
|
385
|
-
|
|
1049
|
+
const next = withStableIds(prepared, idStateRef.current)
|
|
1050
|
+
instance.setOption(next, echartSetOptionOpts(appliedRef.current, next))
|
|
1051
|
+
appliedRef.current = next
|
|
1052
|
+
snapshotRef.current = snapshotOption(prepared)
|
|
1053
|
+
}, [instance, option, reducedMotion, theme, tokens?.fontFamily])
|
|
386
1054
|
|
|
387
1055
|
useEffect(() => {
|
|
388
|
-
if (!instance) return
|
|
1056
|
+
if (!instance || instance.isDisposed()) return
|
|
389
1057
|
if (loading) instance.showLoading()
|
|
390
1058
|
else instance.hideLoading()
|
|
391
1059
|
}, [instance, loading])
|
|
392
1060
|
|
|
393
1061
|
useEffect(() => {
|
|
394
|
-
if (!instance || !onEvents) return
|
|
1062
|
+
if (!instance || instance.isDisposed() || !onEvents) return
|
|
395
1063
|
const entries = Object.entries(onEvents)
|
|
396
1064
|
for (const [event, handler] of entries) {
|
|
397
1065
|
instance.on(event, handler as EChartsEventHandler)
|
|
@@ -404,20 +1072,38 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
|
|
|
404
1072
|
}
|
|
405
1073
|
}, [instance, onEvents])
|
|
406
1074
|
|
|
407
|
-
const
|
|
1075
|
+
const includeTitle = typeof actions === "object" ? (actions.includeTitle ?? true) : true
|
|
1076
|
+
const getPngDataUrl = useCallback(async (): Promise<string | null> => {
|
|
408
1077
|
if (!instance) return null
|
|
409
|
-
// Export over the
|
|
410
|
-
// styles resolve to rgb(a),
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
1078
|
+
// Export over the nearest opaque background (card, then page) so the PNG
|
|
1079
|
+
// matches what is on screen in dark too. Computed styles resolve to rgb(a),
|
|
1080
|
+
// so this stays canvas-safe whatever the token.
|
|
1081
|
+
let backgroundColor = "#ffffff"
|
|
1082
|
+
for (let el: HTMLElement | null = containerRef.current; el; el = el.parentElement) {
|
|
1083
|
+
const bg = getComputedStyle(el).backgroundColor
|
|
1084
|
+
if (bg && bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent") {
|
|
1085
|
+
backgroundColor = bg
|
|
1086
|
+
break
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
const pixelRatio = 2
|
|
1090
|
+
const dataUrl = instance.getDataURL({ type: "png", pixelRatio, backgroundColor })
|
|
1091
|
+
if (!includeTitle || !exportHeader || !(exportHeader.title || exportHeader.summary) || !containerRef.current) return dataUrl
|
|
1092
|
+
try {
|
|
1093
|
+
const style = getComputedStyle(containerRef.current)
|
|
1094
|
+
return await pngWithHeader(
|
|
1095
|
+
dataUrl,
|
|
1096
|
+
exportHeader,
|
|
1097
|
+
{ background: backgroundColor, color: style.color, mutedColor: tokens?.mutedForeground ?? style.color, fontFamily: style.fontFamily },
|
|
1098
|
+
pixelRatio,
|
|
1099
|
+
)
|
|
1100
|
+
} catch {
|
|
1101
|
+
return dataUrl
|
|
1102
|
+
}
|
|
1103
|
+
}, [instance, includeTitle, exportHeader, tokens])
|
|
418
1104
|
|
|
419
1105
|
const handleCopy = useCallback(async () => {
|
|
420
|
-
const dataUrl = getPngDataUrl()
|
|
1106
|
+
const dataUrl = await getPngDataUrl()
|
|
421
1107
|
if (!dataUrl) return
|
|
422
1108
|
try {
|
|
423
1109
|
const blob = await (await fetch(dataUrl)).blob()
|
|
@@ -429,8 +1115,8 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
|
|
|
429
1115
|
}
|
|
430
1116
|
}, [getPngDataUrl])
|
|
431
1117
|
|
|
432
|
-
const handleDownload = useCallback(() => {
|
|
433
|
-
const dataUrl = getPngDataUrl()
|
|
1118
|
+
const handleDownload = useCallback(async () => {
|
|
1119
|
+
const dataUrl = await getPngDataUrl()
|
|
434
1120
|
if (!dataUrl) return
|
|
435
1121
|
const link = document.createElement("a")
|
|
436
1122
|
link.href = dataUrl
|
|
@@ -440,6 +1126,7 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
|
|
|
440
1126
|
|
|
441
1127
|
// 高さの解決: height prop を渡した場合はインライン style が勝つ。未指定なら
|
|
442
1128
|
// h-[400px] をデフォルトで当て、className の高さクラスに後勝ちさせる。
|
|
1129
|
+
const inFrame = useContext(ChartFrameContext)
|
|
443
1130
|
const styleHeight =
|
|
444
1131
|
height == null ? undefined : typeof height === "number" ? `${height}px` : height
|
|
445
1132
|
|
|
@@ -449,21 +1136,26 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
|
|
|
449
1136
|
data-slot="echart"
|
|
450
1137
|
className={cn("group relative w-full", height == null && "h-[400px]", className)}
|
|
451
1138
|
style={styleHeight != null ? { height: styleHeight } : undefined}
|
|
452
|
-
role="img"
|
|
453
|
-
aria-label={ariaLabel ?? "チャート"}
|
|
454
1139
|
>
|
|
455
|
-
|
|
1140
|
+
{/* `role="img"` wraps the canvas only, so the action buttons stay reachable. Inside a ChartFrame the plot area is the one image. */}
|
|
1141
|
+
<div
|
|
1142
|
+
ref={containerRef}
|
|
1143
|
+
data-slot="echart-canvas"
|
|
1144
|
+
className="h-full w-full"
|
|
1145
|
+
role={inFrame ? undefined : "img"}
|
|
1146
|
+
aria-label={inFrame ? undefined : (ariaLabel ?? t.chart)}
|
|
1147
|
+
/>
|
|
456
1148
|
{actions && (
|
|
457
1149
|
<div
|
|
458
1150
|
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"
|
|
1151
|
+
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
1152
|
>
|
|
461
1153
|
<Button
|
|
462
1154
|
type="button"
|
|
463
1155
|
variant="ghost"
|
|
464
1156
|
size="icon-sm"
|
|
465
1157
|
onClick={handleCopy}
|
|
466
|
-
aria-label={copied ?
|
|
1158
|
+
aria-label={copied ? t.copied : t.copy}
|
|
467
1159
|
>
|
|
468
1160
|
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
|
|
469
1161
|
</Button>
|
|
@@ -472,7 +1164,7 @@ export const EChart = forwardRef<HTMLDivElement, EChartProps>(function EChart(
|
|
|
472
1164
|
variant="ghost"
|
|
473
1165
|
size="icon-sm"
|
|
474
1166
|
onClick={handleDownload}
|
|
475
|
-
aria-label=
|
|
1167
|
+
aria-label={t.download}
|
|
476
1168
|
>
|
|
477
1169
|
<Download className="size-4" />
|
|
478
1170
|
</Button>
|