@squadbase/vantage 0.6.2-dev1.9d91dda → 0.6.2-dev1.ac99e02
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/add-4GHF2TX2.js +6 -0
- package/dist/add-4GHF2TX2.js.map +1 -0
- package/dist/{build-TVKFIVFQ.js → build-MFWRPYKW.js} +7 -7
- package/dist/{build-TVKFIVFQ.js.map → build-MFWRPYKW.js.map} +1 -1
- package/dist/{check-3KHZXZMQ.js → check-ZBIH422T.js} +5 -5
- package/dist/{check-3KHZXZMQ.js.map → check-ZBIH422T.js.map} +1 -1
- package/dist/{chunk-P7W4LR3Z.js → chunk-7DZGJQ3P.js} +3 -3
- package/dist/{chunk-P7W4LR3Z.js.map → chunk-7DZGJQ3P.js.map} +1 -1
- package/dist/{chunk-U2M6HGKQ.js → chunk-AABMKHEU.js} +4 -4
- package/dist/{chunk-U2M6HGKQ.js.map → chunk-AABMKHEU.js.map} +1 -1
- package/dist/{chunk-NKFBFDRZ.js → chunk-AG6RZ2RR.js} +981 -14
- package/dist/chunk-AG6RZ2RR.js.map +1 -0
- package/dist/{chunk-QI3XQKHS.js → chunk-GNXCX4WE.js} +10 -3
- package/dist/chunk-GNXCX4WE.js.map +1 -0
- package/dist/chunk-IBGOCWJB.js +1716 -0
- package/dist/chunk-IBGOCWJB.js.map +1 -0
- package/dist/{chunk-RPZAALCX.js → chunk-OB6UBJXF.js} +153 -153
- package/dist/{chunk-RPZAALCX.js.map → chunk-OB6UBJXF.js.map} +1 -1
- package/dist/{chunk-LMUACBMA.js → chunk-QEWCYW3I.js} +43 -8
- package/dist/chunk-QEWCYW3I.js.map +1 -0
- package/dist/{chunk-ZVANIULZ.js → chunk-RNTDBX6S.js} +4 -4
- package/dist/{chunk-ZVANIULZ.js.map → chunk-RNTDBX6S.js.map} +1 -1
- package/dist/{chunk-PVTHHV7X.js → chunk-V3LF7BE7.js} +27 -5
- package/dist/chunk-V3LF7BE7.js.map +1 -0
- package/dist/{chunk-DTDVSFRY.js → chunk-XQUSGIDG.js} +17 -3
- package/dist/chunk-XQUSGIDG.js.map +1 -0
- package/dist/cli.js +12 -11
- package/dist/cli.js.map +1 -1
- package/dist/client/index.d.ts +32 -3
- package/dist/client/index.js +205 -12
- package/dist/client/index.js.map +1 -1
- package/dist/components/index.d.ts +216 -1
- package/dist/components/index.js +2 -2
- package/dist/{dev-GDKATCC7.js → dev-DCTOHQ6W.js} +8 -8
- package/dist/dev-DCTOHQ6W.js.map +1 -0
- package/dist/{docs-EJJ3BECT.js → docs-JVP4RPKT.js} +3 -3
- package/dist/{docs-EJJ3BECT.js.map → docs-JVP4RPKT.js.map} +1 -1
- package/dist/{doctor-WQXPXP6K.js → doctor-5E2VEZPK.js} +5 -5
- package/dist/{doctor-WQXPXP6K.js.map → doctor-5E2VEZPK.js.map} +1 -1
- package/dist/{preview-OGMCNQUO.js → preview-T6CLSQVX.js} +3 -3
- package/dist/{preview-OGMCNQUO.js.map → preview-T6CLSQVX.js.map} +1 -1
- package/dist/query/index.d.ts +0 -11
- package/dist/query/index.js +2 -2
- package/dist/{routes-MWBWWOJ2.js → routes-3SGAFDMC.js} +4 -4
- package/dist/{routes-MWBWWOJ2.js.map → routes-3SGAFDMC.js.map} +1 -1
- package/dist/{search-6Y422SV6.js → search-5LC4XPB5.js} +3 -3
- package/dist/{search-6Y422SV6.js.map → search-5LC4XPB5.js.map} +1 -1
- package/dist/table/index.js +7 -6
- package/dist/table/index.js.map +1 -1
- package/dist/ui/index.d.ts +140 -10
- package/dist/ui/index.js +1405 -63
- package/dist/ui/index.js.map +1 -1
- package/dist/{upgrade-HCANX4GQ.js → upgrade-DBBE6GIR.js} +6 -6
- package/dist/{upgrade-HCANX4GQ.js.map → upgrade-DBBE6GIR.js.map} +1 -1
- package/dist/vite/index.d.ts +10 -0
- package/dist/vite/index.js +3 -3
- package/docs/index.json +291 -61
- package/docs/ja/changelog.md +26 -0
- package/docs/ja/components.md +33 -89
- package/docs/ja/page-types/csv.md +83 -0
- package/docs/ja/page-types/doc.md +83 -0
- package/docs/ja/page-types/excel.md +82 -0
- package/docs/ja/page-types/slide.md +132 -0
- package/docs/ja/page-types.md +47 -0
- package/docs/ja/parts/area-chart.md +61 -0
- package/docs/ja/parts/bar-chart.md +69 -0
- package/docs/ja/parts/bar-list.md +35 -0
- package/docs/ja/parts/category-bar.md +36 -0
- package/docs/ja/parts/donut-chart.md +47 -0
- package/docs/ja/parts/echart.md +3 -0
- package/docs/ja/parts/line-chart.md +58 -0
- package/docs/ja/parts/progress-circle.md +34 -0
- package/docs/ja/parts/sparkline.md +1 -1
- package/docs/ja/parts/tracker.md +32 -0
- package/docs/ja/routing.md +8 -86
- package/docs/ja/ui/alert-dialog.md +41 -0
- package/docs/ja/ui/avatar.md +31 -0
- package/docs/ja/ui/combobox.md +74 -0
- package/docs/ja/ui/drawer.md +41 -0
- package/docs/ja/ui/field.md +42 -0
- package/docs/ja/ui/hover-card.md +21 -0
- package/docs/ja/ui/kbd.md +16 -0
- package/docs/ja/ui/pagination.md +36 -0
- package/docs/ja/ui/radio-group.md +21 -0
- package/docs/ja/ui/toast.md +46 -0
- package/docs/ja/ui-and-theming.md +14 -7
- package/package.json +21 -13
- package/registry/ui/accordion.tsx +74 -0
- package/registry/ui/alert-dialog.tsx +189 -0
- package/registry/ui/alert.tsx +78 -0
- package/registry/ui/app-shell.tsx +281 -0
- package/registry/ui/area-chart.tsx +1015 -0
- package/registry/ui/avatar.tsx +110 -0
- package/registry/ui/badge.tsx +54 -0
- package/registry/ui/bar-chart.tsx +1013 -0
- package/registry/ui/bar-list.tsx +302 -0
- package/registry/ui/breadcrumb.tsx +126 -0
- package/registry/ui/button.tsx +60 -0
- package/registry/ui/calendar.tsx +221 -0
- package/registry/ui/card.tsx +105 -0
- package/registry/ui/category-bar.tsx +281 -0
- package/registry/ui/checkbox.tsx +28 -0
- package/registry/ui/collapsible.tsx +21 -0
- package/registry/ui/combobox.tsx +310 -0
- package/registry/ui/command.tsx +196 -0
- package/registry/ui/dashboard-card.tsx +174 -0
- package/registry/ui/data-table.tsx +16 -91
- package/registry/ui/date-range-picker.tsx +209 -0
- package/registry/ui/dialog.tsx +159 -0
- package/registry/ui/donut-chart.tsx +1004 -0
- package/registry/ui/drawer.tsx +229 -0
- package/registry/ui/dropdown-menu.tsx +272 -0
- package/registry/ui/echart.tsx +486 -0
- package/registry/ui/field.tsx +240 -0
- package/registry/ui/filter-bar.tsx +365 -0
- package/registry/ui/funnel-steps.tsx +274 -0
- package/registry/ui/hover-card.tsx +52 -0
- package/registry/ui/input-group.tsx +158 -0
- package/registry/ui/input.tsx +22 -0
- package/registry/ui/kbd.tsx +28 -0
- package/registry/ui/label.tsx +20 -0
- package/registry/ui/line-chart.tsx +1007 -0
- package/registry/ui/metric-value.tsx +54 -0
- package/registry/ui/multi-select.tsx +226 -0
- package/registry/ui/page-shell.tsx +227 -0
- package/registry/ui/pagination.tsx +138 -0
- package/registry/ui/placeholder.tsx +30 -0
- package/registry/ui/popover.tsx +90 -0
- package/registry/ui/progress-circle.tsx +272 -0
- package/registry/ui/progress.tsx +83 -0
- package/registry/ui/radio-group.tsx +39 -0
- package/registry/ui/scroll-area.tsx +55 -0
- package/registry/ui/searchable-select.tsx +209 -0
- package/registry/ui/section-header.tsx +41 -0
- package/registry/ui/segmented-control.tsx +71 -0
- package/registry/ui/select.tsx +253 -0
- package/registry/ui/separator.tsx +25 -0
- package/registry/ui/sheet.tsx +137 -0
- package/registry/ui/sidebar.tsx +723 -0
- package/registry/ui/skeleton.tsx +15 -0
- package/registry/ui/slider.tsx +54 -0
- package/registry/ui/sparkline.tsx +155 -0
- package/registry/ui/spinner.tsx +12 -0
- package/registry/ui/states.tsx +61 -0
- package/registry/ui/status-badge.tsx +79 -0
- package/registry/ui/switch.tsx +32 -0
- package/registry/ui/table.tsx +116 -0
- package/registry/ui/tabs.tsx +82 -0
- package/registry/ui/textarea.tsx +20 -0
- package/registry/ui/toast.tsx +250 -0
- package/registry/ui/toggle-group.tsx +89 -0
- package/registry/ui/toggle.tsx +45 -0
- package/registry/ui/tooltip.tsx +66 -0
- package/registry/ui/tracker.tsx +236 -0
- package/registry/ui/trend-indicator.tsx +62 -0
- package/skills/vantage-add-feature/SKILL.md +9 -7
- package/skills/vantage-pitfalls/SKILL.md +5 -1
- package/templates/AGENTS.md +10 -1
- package/templates/agents/csv.md +2 -1
- package/templates/agents/doc.md +4 -3
- package/templates/agents/page.md +22 -8
- package/templates/agents/xlsx.md +2 -1
- package/dist/add-DY4AXANK.js +0 -6
- package/dist/add-DY4AXANK.js.map +0 -1
- package/dist/chunk-DTDVSFRY.js.map +0 -1
- package/dist/chunk-LMUACBMA.js.map +0 -1
- package/dist/chunk-NKFBFDRZ.js.map +0 -1
- package/dist/chunk-PVTHHV7X.js.map +0 -1
- package/dist/chunk-QI3XQKHS.js.map +0 -1
- package/dist/chunk-VA776YE5.js +0 -316
- package/dist/chunk-VA776YE5.js.map +0 -1
- package/dist/dev-GDKATCC7.js.map +0 -1
|
@@ -0,0 +1,1015 @@
|
|
|
1
|
+
// Copied by `vantage add ui area-chart`. This file is yours to edit.
|
|
2
|
+
// Generated from @squadbase/vantage src/components/area-chart.tsx by `npm run registry` (in Vantage, edit src/, not registry/).
|
|
3
|
+
/**
|
|
4
|
+
* AreaChart — a typed area chart over `EChart`: pass rows, the x-axis key
|
|
5
|
+
* (`index`) and the series keys (`categories`) instead of an ECharts option.
|
|
6
|
+
* Colours default to the `--chart-1..5` tokens and follow light/dark. Reach
|
|
7
|
+
* for `EChart` itself only when the option needs something this API lacks.
|
|
8
|
+
*/
|
|
9
|
+
import { forwardRef, useMemo } from "react"
|
|
10
|
+
|
|
11
|
+
import { cn } from "@squadbase/vantage/ui"
|
|
12
|
+
import { EChart } from "@squadbase/vantage/components"
|
|
13
|
+
import { useEffect, useState } from "react"
|
|
14
|
+
import type { ChartColor, ValueFormatter } from "@squadbase/vantage/components"
|
|
15
|
+
import { useRef } from "react"
|
|
16
|
+
import type { EChartsOption } from "@squadbase/vantage/components"
|
|
17
|
+
import type { ChartEventPayload } from "@squadbase/vantage/components"
|
|
18
|
+
|
|
19
|
+
/* ---------- inlined from src/components/chart-theme.ts (internal to @squadbase/vantage) ---------- */
|
|
20
|
+
|
|
21
|
+
/** Series palette, in order. Missing tokens are skipped, not defaulted. */
|
|
22
|
+
const SERIES_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"] as const
|
|
23
|
+
|
|
24
|
+
interface ChartTokens {
|
|
25
|
+
/** Resolved `--chart-1..5`, in order. */
|
|
26
|
+
series: string[]
|
|
27
|
+
/** Body text: titles and the default `textStyle`. */
|
|
28
|
+
foreground: string | null
|
|
29
|
+
/** Secondary text: axis labels, legend, subtitles. */
|
|
30
|
+
mutedForeground: string | null
|
|
31
|
+
/** Axis lines, ticks, split lines, tooltip border. */
|
|
32
|
+
border: string | null
|
|
33
|
+
/** Tooltip surface. */
|
|
34
|
+
popover: string | null
|
|
35
|
+
/** Tooltip text. */
|
|
36
|
+
popoverForeground: string | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let normalizeContext: CanvasRenderingContext2D | null | undefined
|
|
40
|
+
|
|
41
|
+
/** The forms `fillStyle` serializes legacy colours to, and zrender can parse. */
|
|
42
|
+
const ZRENDER_COLOR_RE = /^(?:#[0-9a-f]{6}|rgba?\()/i
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Serialize a CSS colour into a canvas-safe form, or `null` if the browser
|
|
46
|
+
* rejects it. Assigning an invalid value to `fillStyle` is a no-op, so the same
|
|
47
|
+
* input is written over two different known colours: agreeing results mean the
|
|
48
|
+
* value parsed, differing ones mean it was dropped.
|
|
49
|
+
*/
|
|
50
|
+
function toCanvasColor(value: string): string | null {
|
|
51
|
+
const raw = value.trim()
|
|
52
|
+
if (!raw) return null
|
|
53
|
+
if (normalizeContext === undefined) {
|
|
54
|
+
const canvas = document.createElement("canvas")
|
|
55
|
+
canvas.width = 1
|
|
56
|
+
canvas.height = 1
|
|
57
|
+
normalizeContext = canvas.getContext("2d", { willReadFrequently: true })
|
|
58
|
+
}
|
|
59
|
+
const ctx = normalizeContext
|
|
60
|
+
if (!ctx) return raw
|
|
61
|
+
ctx.fillStyle = "#000000"
|
|
62
|
+
ctx.fillStyle = raw
|
|
63
|
+
const onBlack = ctx.fillStyle
|
|
64
|
+
ctx.fillStyle = "#ffffff"
|
|
65
|
+
ctx.fillStyle = raw
|
|
66
|
+
if (onBlack !== ctx.fillStyle || typeof onBlack !== "string") return null
|
|
67
|
+
if (ZRENDER_COLOR_RE.test(onBlack)) return onBlack
|
|
68
|
+
// Still `oklch()` / `lab()` / `color(...)`: paint it and read the pixel back.
|
|
69
|
+
// Wide-gamut colours clip to sRGB, which is all zrender can express anyway.
|
|
70
|
+
ctx.clearRect(0, 0, 1, 1)
|
|
71
|
+
ctx.fillRect(0, 0, 1, 1)
|
|
72
|
+
const [r = 0, g = 0, b = 0, a = 0] = ctx.getImageData(0, 0, 1, 1).data
|
|
73
|
+
return a === 255
|
|
74
|
+
? `rgb(${r}, ${g}, ${b})`
|
|
75
|
+
: `rgba(${r}, ${g}, ${b}, ${Math.round((a / 255) * 1000) / 1000})`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read the chart tokens off `el` (the document root by default — tokens live on
|
|
80
|
+
* `:root` and inherit). Returns `null` outside the browser so callers fall back
|
|
81
|
+
* to plain ECharts defaults.
|
|
82
|
+
*/
|
|
83
|
+
function readChartTokens(el?: Element | null): ChartTokens | null {
|
|
84
|
+
if (typeof document === "undefined") return null
|
|
85
|
+
const target = el ?? document.documentElement
|
|
86
|
+
if (!target) return null
|
|
87
|
+
const styles = getComputedStyle(target)
|
|
88
|
+
const read = (name: string) => toCanvasColor(styles.getPropertyValue(name))
|
|
89
|
+
const series: string[] = []
|
|
90
|
+
for (const token of SERIES_TOKENS) {
|
|
91
|
+
const color = read(token)
|
|
92
|
+
if (color) series.push(color)
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
series,
|
|
96
|
+
foreground: read("--foreground"),
|
|
97
|
+
mutedForeground: read("--muted-foreground"),
|
|
98
|
+
border: read("--border"),
|
|
99
|
+
popover: read("--popover"),
|
|
100
|
+
popoverForeground: read("--popover-foreground"),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Structural equality, so a theme toggle that changes nothing doesn't re-init. */
|
|
105
|
+
function sameChartTokens(a: ChartTokens | null, b: ChartTokens | null): boolean {
|
|
106
|
+
if (a === b) return true
|
|
107
|
+
if (!a || !b) return false
|
|
108
|
+
return (
|
|
109
|
+
a.series.length === b.series.length &&
|
|
110
|
+
a.series.every((color, i) => color === b.series[i]) &&
|
|
111
|
+
a.foreground === b.foreground &&
|
|
112
|
+
a.mutedForeground === b.mutedForeground &&
|
|
113
|
+
a.border === b.border &&
|
|
114
|
+
a.popover === b.popover &&
|
|
115
|
+
a.popoverForeground === b.popoverForeground
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Watch everything that can change the resolved tokens and call `onChange` when
|
|
121
|
+
* something might have. It only ever says "re-read" — the caller compares with
|
|
122
|
+
* `sameChartTokens` and decides whether anything actually moved. Returns a
|
|
123
|
+
* disposer.
|
|
124
|
+
*
|
|
125
|
+
* Two sources, not one:
|
|
126
|
+
* - light/dark flips, which land as `class` / `data-theme` (or an inline
|
|
127
|
+
* `style`) on `<html>`/`<body>`, or as an OS preference change
|
|
128
|
+
* - stylesheet changes. In dev, editing `styles.css` rewrites the injected
|
|
129
|
+
* `<style>` in `<head>` and touches nothing else, so watching the theme
|
|
130
|
+
* attributes alone leaves the chart painted in the previous palette until a
|
|
131
|
+
* reload — the tokens in the DOM are new, the canvas is not.
|
|
132
|
+
*/
|
|
133
|
+
function watchChartTokens(onChange: () => void): () => void {
|
|
134
|
+
if (typeof document === "undefined") return () => {}
|
|
135
|
+
// Style edits arrive as bursts of mutations; coalesce them into one read, and
|
|
136
|
+
// take it after the next frame so a swapped stylesheet has been applied.
|
|
137
|
+
let frame = 0
|
|
138
|
+
const schedule = () => {
|
|
139
|
+
if (frame) return
|
|
140
|
+
frame = requestAnimationFrame(() => {
|
|
141
|
+
frame = 0
|
|
142
|
+
onChange()
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const themeObserver = new MutationObserver(schedule)
|
|
147
|
+
const attributeFilter = ["class", "style", "data-theme"]
|
|
148
|
+
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter })
|
|
149
|
+
if (document.body) themeObserver.observe(document.body, { attributes: true, attributeFilter })
|
|
150
|
+
|
|
151
|
+
const styleObserver = new MutationObserver(schedule)
|
|
152
|
+
if (document.head) {
|
|
153
|
+
styleObserver.observe(document.head, {
|
|
154
|
+
childList: true,
|
|
155
|
+
subtree: true,
|
|
156
|
+
characterData: true,
|
|
157
|
+
attributes: true,
|
|
158
|
+
attributeFilter: ["href", "media", "disabled"],
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// A swapped `<link>` only applies once the sheet has loaded, after its mutation.
|
|
163
|
+
const onLoad = (event: Event) => {
|
|
164
|
+
if (event.target instanceof HTMLLinkElement) schedule()
|
|
165
|
+
}
|
|
166
|
+
document.addEventListener("load", onLoad, true)
|
|
167
|
+
|
|
168
|
+
const media = window.matchMedia("(prefers-color-scheme: dark)")
|
|
169
|
+
media.addEventListener("change", schedule)
|
|
170
|
+
|
|
171
|
+
return () => {
|
|
172
|
+
if (frame) cancelAnimationFrame(frame)
|
|
173
|
+
themeObserver.disconnect()
|
|
174
|
+
styleObserver.disconnect()
|
|
175
|
+
document.removeEventListener("load", onLoad, true)
|
|
176
|
+
media.removeEventListener("change", schedule)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Shared shape for the four axis types ECharts themes address separately. */
|
|
181
|
+
function axisTheme(tokens: ChartTokens, splitLine: boolean) {
|
|
182
|
+
const line = tokens.border ?? undefined
|
|
183
|
+
return {
|
|
184
|
+
axisLine: { show: true, lineStyle: { color: line } },
|
|
185
|
+
axisTick: { show: true, lineStyle: { color: line } },
|
|
186
|
+
axisLabel: { show: true, color: tokens.mutedForeground ?? undefined },
|
|
187
|
+
splitLine: { show: splitLine, lineStyle: { color: line ? [line] : undefined } },
|
|
188
|
+
splitArea: { show: false },
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Build the ECharts theme. `option.color` still wins over `theme.color`, so an
|
|
194
|
+
* app that sets its own palette keeps it.
|
|
195
|
+
*/
|
|
196
|
+
function buildChartTheme(tokens: ChartTokens): Record<string, unknown> {
|
|
197
|
+
const text = tokens.foreground ?? undefined
|
|
198
|
+
const muted = tokens.mutedForeground ?? undefined
|
|
199
|
+
const line = tokens.border ?? undefined
|
|
200
|
+
return {
|
|
201
|
+
// Let the page background show through: charts sit on cards, not on a canvas.
|
|
202
|
+
backgroundColor: "transparent",
|
|
203
|
+
...(tokens.series.length > 0 ? { color: tokens.series } : {}),
|
|
204
|
+
textStyle: { color: text },
|
|
205
|
+
title: { textStyle: { color: text }, subtextStyle: { color: muted } },
|
|
206
|
+
legend: { textStyle: { color: muted } },
|
|
207
|
+
categoryAxis: axisTheme(tokens, false),
|
|
208
|
+
valueAxis: axisTheme(tokens, true),
|
|
209
|
+
logAxis: axisTheme(tokens, true),
|
|
210
|
+
timeAxis: axisTheme(tokens, true),
|
|
211
|
+
tooltip: {
|
|
212
|
+
backgroundColor: tokens.popover ?? undefined,
|
|
213
|
+
borderColor: line,
|
|
214
|
+
borderWidth: 1,
|
|
215
|
+
textStyle: { color: tokens.popoverForeground ?? undefined },
|
|
216
|
+
axisPointer: { lineStyle: { color: line }, crossStyle: { color: line } },
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The resolved tokens, kept current: re-read whenever `watchChartTokens` says
|
|
223
|
+
* they may have moved, and only replaced when a value actually changed. For
|
|
224
|
+
* components that build an ECharts option from token colours themselves (the
|
|
225
|
+
* typed charts), so their palette follows light/dark like `EChart`'s theme.
|
|
226
|
+
*/
|
|
227
|
+
function useChartTokens(): ChartTokens | null {
|
|
228
|
+
const [tokens, setTokens] = useState<ChartTokens | null>(() => readChartTokens())
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
const sync = () => {
|
|
231
|
+
setTokens((prev) => {
|
|
232
|
+
const next = readChartTokens()
|
|
233
|
+
return sameChartTokens(prev, next) ? prev : next
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
sync()
|
|
237
|
+
return watchChartTokens(sync)
|
|
238
|
+
}, [])
|
|
239
|
+
return tokens
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const SERIES_TOKEN_NAME_RE = /^chart-([1-5])$/
|
|
243
|
+
const CSS_VAR_RE = /^var\(\s*(--[\w-]+)\s*(?:,\s*(.+))?\)$/
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Turn chart colours into canvas-safe strings. `"chart-1"`〜`"chart-5"` come from
|
|
247
|
+
* the resolved series tokens; `var(--token)` is read off the document root; any
|
|
248
|
+
* other CSS colour is normalized like the tokens are. A colour that can't be
|
|
249
|
+
* resolved comes back as `""`, which ECharts treats as "use the palette".
|
|
250
|
+
*/
|
|
251
|
+
function resolveChartColors(colors: readonly string[], tokens: ChartTokens | null): string[] {
|
|
252
|
+
return colors.map((color) => {
|
|
253
|
+
const token = SERIES_TOKEN_NAME_RE.exec(color.trim())
|
|
254
|
+
if (token) return tokens?.series[Number(token[1]) - 1] ?? ""
|
|
255
|
+
if (typeof document === "undefined") return color
|
|
256
|
+
let value = color.trim()
|
|
257
|
+
const cssVar = CSS_VAR_RE.exec(value)
|
|
258
|
+
if (cssVar) {
|
|
259
|
+
const styles = getComputedStyle(document.documentElement)
|
|
260
|
+
value = styles.getPropertyValue(cssVar[1]!).trim() || (cssVar[2] ?? "").trim()
|
|
261
|
+
}
|
|
262
|
+
return toCanvasColor(value) ?? ""
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/* ---------- inlined from src/components/chart-utils.ts (internal to @squadbase/vantage) ---------- */
|
|
267
|
+
|
|
268
|
+
/** The series tokens, in palette order — the default `colors` of every typed chart. */
|
|
269
|
+
const DEFAULT_CHART_COLORS: readonly ChartColor[] = [
|
|
270
|
+
"chart-1",
|
|
271
|
+
"chart-2",
|
|
272
|
+
"chart-3",
|
|
273
|
+
"chart-4",
|
|
274
|
+
"chart-5",
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
const TOKEN_NAME_RE = /^chart-[1-5]$/
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* A chart colour as a CSS value: `"chart-2"` → `var(--chart-2)` (so it follows
|
|
281
|
+
* light/dark on its own), anything else passes through unchanged.
|
|
282
|
+
*/
|
|
283
|
+
function chartColorToCss(color: ChartColor): string {
|
|
284
|
+
const value = color.trim()
|
|
285
|
+
return TOKEN_NAME_RE.test(value) ? `var(--${value})` : value
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The `i`-th colour, cycling through `colors` (the token palette when empty). */
|
|
289
|
+
function chartColorAt(colors: readonly ChartColor[] | undefined, i: number): ChartColor {
|
|
290
|
+
const palette = colors && colors.length > 0 ? colors : DEFAULT_CHART_COLORS
|
|
291
|
+
return palette[i % palette.length]!
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** `colors` extended (by cycling) to exactly `count` entries. */
|
|
295
|
+
function chartColorsFor(colors: readonly ChartColor[] | undefined, count: number): ChartColor[] {
|
|
296
|
+
return Array.from({ length: count }, (_, i) => chartColorAt(colors, i))
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** A translucent version of a CSS colour (works for `var(--…)` too). */
|
|
300
|
+
function tintCss(cssColor: string, percent: number): string {
|
|
301
|
+
return `color-mix(in oklab, ${cssColor} ${percent}%, transparent)`
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const numberFormat = new Intl.NumberFormat("ja-JP", { maximumFractionDigits: 2 })
|
|
305
|
+
|
|
306
|
+
/** Default `valueFormatter`: grouped digits, up to two decimals (`1,234.5`). */
|
|
307
|
+
const defaultValueFormatter: ValueFormatter = (value) => numberFormat.format(value)
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* A data cell as a number, or `null` for a gap. Numeric strings count — SQL
|
|
311
|
+
* `DECIMAL` / `BIGINT` columns usually arrive as strings.
|
|
312
|
+
*/
|
|
313
|
+
function toChartNumber(value: unknown): number | null {
|
|
314
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null
|
|
315
|
+
if (typeof value === "bigint") return Number(value)
|
|
316
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
317
|
+
const n = Number(value)
|
|
318
|
+
return Number.isFinite(n) ? n : null
|
|
319
|
+
}
|
|
320
|
+
return null
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Clamp `value` into `[min, max]`. */
|
|
324
|
+
function clamp(value: number, min: number, max: number): number {
|
|
325
|
+
return Math.min(max, Math.max(min, value))
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/* ---------------------------------------------------------------- BarList */
|
|
329
|
+
|
|
330
|
+
type BarListSortOrder = "ascending" | "descending" | "none"
|
|
331
|
+
|
|
332
|
+
/** Sort a copy of the rows by value; `"none"` keeps the given order. */
|
|
333
|
+
function sortBarListData<T extends { value: number }>(data: readonly T[], sortOrder: BarListSortOrder): T[] {
|
|
334
|
+
const rows = [...data]
|
|
335
|
+
if (sortOrder === "ascending") rows.sort((a, b) => a.value - b.value)
|
|
336
|
+
else if (sortOrder === "descending") rows.sort((a, b) => b.value - a.value)
|
|
337
|
+
return rows
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Bar widths in percent of the largest value (at least a sliver for non-zero values). */
|
|
341
|
+
function barListWidths(values: readonly number[]): number[] {
|
|
342
|
+
const max = Math.max(0, ...values)
|
|
343
|
+
if (max === 0) return values.map(() => 0)
|
|
344
|
+
return values.map((v) => (v <= 0 ? 0 : Math.max(1, (v / max) * 100)))
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/* ------------------------------------------------------------ CategoryBar */
|
|
348
|
+
|
|
349
|
+
interface CategoryBarSegment {
|
|
350
|
+
/** Width in percent of the whole bar. */
|
|
351
|
+
width: number
|
|
352
|
+
/** Where the segment starts, in percent. */
|
|
353
|
+
start: number
|
|
354
|
+
/** The running total at the segment's end — what its right-hand label shows. */
|
|
355
|
+
end: number
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Lay the values side by side; negative values count as zero. */
|
|
359
|
+
function categoryBarSegments(values: readonly number[]): CategoryBarSegment[] {
|
|
360
|
+
const clean = values.map((v) => (Number.isFinite(v) && v > 0 ? v : 0))
|
|
361
|
+
const total = clean.reduce((sum, v) => sum + v, 0)
|
|
362
|
+
let running = 0
|
|
363
|
+
return clean.map((v) => {
|
|
364
|
+
const start = total === 0 ? 0 : (running / total) * 100
|
|
365
|
+
running += v
|
|
366
|
+
return { width: total === 0 ? 0 : (v / total) * 100, start, end: running }
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
interface CategoryBarLabel {
|
|
371
|
+
/** Position in percent of the bar. */
|
|
372
|
+
position: number
|
|
373
|
+
/** The running total printed there. */
|
|
374
|
+
value: number
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Tick labels under the bar: `0` at the start, then the running total at each
|
|
379
|
+
* segment's end. A label closer than `minGap` percent to the one before is
|
|
380
|
+
* dropped so narrow segments don't print on top of each other — except the
|
|
381
|
+
* last one (the total), which replaces its neighbour instead.
|
|
382
|
+
*/
|
|
383
|
+
function categoryBarLabels(values: readonly number[], minGap = 8): CategoryBarLabel[] {
|
|
384
|
+
const segments = categoryBarSegments(values).filter((s) => s.width > 0)
|
|
385
|
+
if (segments.length === 0) return []
|
|
386
|
+
const labels: CategoryBarLabel[] = [{ position: 0, value: 0 }]
|
|
387
|
+
segments.forEach((s, i) => {
|
|
388
|
+
const label = { position: s.start + s.width, value: s.end }
|
|
389
|
+
const prev = labels[labels.length - 1]!
|
|
390
|
+
if (label.position - prev.position >= minGap) labels.push(label)
|
|
391
|
+
else if (i === segments.length - 1 && labels.length > 1) labels[labels.length - 1] = label
|
|
392
|
+
})
|
|
393
|
+
return labels
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Marker position in percent of the bar, clamped to its ends. */
|
|
397
|
+
function categoryBarMarkerPosition(value: number, values: readonly number[]): number {
|
|
398
|
+
const total = values.reduce((sum, v) => sum + (Number.isFinite(v) && v > 0 ? v : 0), 0)
|
|
399
|
+
if (total === 0) return 0
|
|
400
|
+
return clamp((value / total) * 100, 0, 100)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Index of the segment the marker sits on (the last one for a value past the end). */
|
|
404
|
+
function categoryBarMarkerSegment(value: number, values: readonly number[]): number {
|
|
405
|
+
const segments = categoryBarSegments(values)
|
|
406
|
+
const index = segments.findIndex((s) => s.width > 0 && value <= s.end)
|
|
407
|
+
if (index !== -1) return index
|
|
408
|
+
for (let i = segments.length - 1; i >= 0; i--) if (segments[i]!.width > 0) return i
|
|
409
|
+
return 0
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/* --------------------------------------------------------- ProgressCircle */
|
|
413
|
+
|
|
414
|
+
interface ProgressCircleGeometry {
|
|
415
|
+
/** SVG width/height. */
|
|
416
|
+
size: number
|
|
417
|
+
/** Radius of the stroke's centre line. */
|
|
418
|
+
normalizedRadius: number
|
|
419
|
+
circumference: number
|
|
420
|
+
/** `stroke-dashoffset` for the value arc. */
|
|
421
|
+
offset: number
|
|
422
|
+
/** The value as a fraction of `max`, clamped to `[0, 1]`. */
|
|
423
|
+
ratio: number
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function progressCircleGeometry(
|
|
427
|
+
value: number,
|
|
428
|
+
max: number,
|
|
429
|
+
radius: number,
|
|
430
|
+
strokeWidth: number,
|
|
431
|
+
): ProgressCircleGeometry {
|
|
432
|
+
const ratio = max > 0 && Number.isFinite(value) ? clamp(value / max, 0, 1) : 0
|
|
433
|
+
const normalizedRadius = Math.max(0, radius - strokeWidth / 2)
|
|
434
|
+
const circumference = 2 * Math.PI * normalizedRadius
|
|
435
|
+
return { size: radius * 2, normalizedRadius, circumference, offset: circumference * (1 - ratio), ratio }
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/* ---------- inlined from src/components/chart-options.ts (internal to @squadbase/vantage) ---------- */
|
|
439
|
+
|
|
440
|
+
type CartesianChartKind = "area" | "bar" | "line"
|
|
441
|
+
type CartesianStackType = "default" | "stacked" | "percent"
|
|
442
|
+
type CartesianCurveType = "linear" | "smooth" | "step"
|
|
443
|
+
type AreaFill = "gradient" | "solid" | "none"
|
|
444
|
+
type BarLayout = "horizontal" | "vertical"
|
|
445
|
+
|
|
446
|
+
/** Props every cartesian typed chart (Area / Bar / Line) shares. */
|
|
447
|
+
interface CartesianChartBaseProps {
|
|
448
|
+
/** 行の配列。1 行 = x 軸の 1 点。数値の列は number か数値文字列(SQL の DECIMAL など)。 */
|
|
449
|
+
data: Record<string, unknown>[]
|
|
450
|
+
/** x 軸(カテゴリ軸)に使う列名。 */
|
|
451
|
+
index: string
|
|
452
|
+
/** 系列として描く列名。並び順が凡例と配色の順になる。 */
|
|
453
|
+
categories: string[]
|
|
454
|
+
/** 系列色。`"chart-1"`〜`"chart-5"` か CSS カラー。省略時はテーマのトークン順。足りなければ繰り返す。 */
|
|
455
|
+
colors?: ChartColor[]
|
|
456
|
+
/** 軸目盛りとツールチップの数値の書式。 */
|
|
457
|
+
valueFormatter?: ValueFormatter
|
|
458
|
+
/** 凡例を出す。既定 `true`。 */
|
|
459
|
+
showLegend?: boolean
|
|
460
|
+
/** ホバー時のツールチップを出す。既定 `true`。 */
|
|
461
|
+
showTooltip?: boolean
|
|
462
|
+
/** 値軸の目盛り線を出す。既定 `true`。 */
|
|
463
|
+
showGridLines?: boolean
|
|
464
|
+
showXAxis?: boolean
|
|
465
|
+
showYAxis?: boolean
|
|
466
|
+
/** x 軸のラベルを最初と最後だけにする。 */
|
|
467
|
+
startEndOnly?: boolean
|
|
468
|
+
/** y 軸ラベルの幅(px)。並べた複数グラフのプロット領域を揃えたいときに固定する。 */
|
|
469
|
+
yAxisWidth?: number
|
|
470
|
+
minValue?: number
|
|
471
|
+
maxValue?: number
|
|
472
|
+
/** 値軸を 0 からではなくデータの範囲に合わせる。 */
|
|
473
|
+
autoMinValue?: boolean
|
|
474
|
+
/** `false` で値軸の目盛りを整数に限る。 */
|
|
475
|
+
allowDecimals?: boolean
|
|
476
|
+
xAxisLabel?: string
|
|
477
|
+
yAxisLabel?: string
|
|
478
|
+
/**
|
|
479
|
+
* 点(棒)のクリックで呼ばれる。クリックした行に `eventType` / `categoryClicked` を
|
|
480
|
+
* 足して渡し、同じ点をもう一度クリックすると `null`。渡すと選択以外が薄くなる。
|
|
481
|
+
*/
|
|
482
|
+
onValueChange?: (value: ChartEventPayload) => void
|
|
483
|
+
/** 高さ。数値は px。省略時は `h-80`(320px)で、`className` の高さクラスが勝つ。 */
|
|
484
|
+
height?: string | number
|
|
485
|
+
loading?: boolean
|
|
486
|
+
ariaLabel?: string
|
|
487
|
+
className?: string
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** The point a user clicked, used to dim everything else. */
|
|
491
|
+
interface ChartActivePoint {
|
|
492
|
+
category: string
|
|
493
|
+
index: number
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
interface CartesianChartConfig {
|
|
497
|
+
kind: CartesianChartKind
|
|
498
|
+
data: readonly Record<string, unknown>[]
|
|
499
|
+
index: string
|
|
500
|
+
categories: readonly string[]
|
|
501
|
+
/** Resolved series colours, by category position. `""`/missing → theme palette. */
|
|
502
|
+
palette: readonly string[]
|
|
503
|
+
valueFormatter: (value: number) => string
|
|
504
|
+
type?: CartesianStackType
|
|
505
|
+
/** Bar only. `"vertical"` lays the categories down the y axis (horizontal bars). */
|
|
506
|
+
layout?: BarLayout
|
|
507
|
+
curveType?: CartesianCurveType
|
|
508
|
+
fill?: AreaFill
|
|
509
|
+
connectNulls?: boolean
|
|
510
|
+
showLegend?: boolean
|
|
511
|
+
showTooltip?: boolean
|
|
512
|
+
showGridLines?: boolean
|
|
513
|
+
showXAxis?: boolean
|
|
514
|
+
showYAxis?: boolean
|
|
515
|
+
startEndOnly?: boolean
|
|
516
|
+
yAxisWidth?: number
|
|
517
|
+
minValue?: number
|
|
518
|
+
maxValue?: number
|
|
519
|
+
autoMinValue?: boolean
|
|
520
|
+
allowDecimals?: boolean
|
|
521
|
+
xAxisLabel?: string
|
|
522
|
+
yAxisLabel?: string
|
|
523
|
+
barCategoryGap?: string | number
|
|
524
|
+
/** Always draw the line/area points (needed to click them). */
|
|
525
|
+
showDots?: boolean
|
|
526
|
+
active?: ChartActivePoint | null
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Same coercion as `toChartNumber` in chart-utils (kept local: inlined modules can't import each other). */
|
|
530
|
+
function cellNumber(value: unknown): number | null {
|
|
531
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null
|
|
532
|
+
if (typeof value === "bigint") return Number(value)
|
|
533
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
534
|
+
const n = Number(value)
|
|
535
|
+
return Number.isFinite(n) ? n : null
|
|
536
|
+
}
|
|
537
|
+
return null
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Series values per category. `"percent"` turns each row into shares of that
|
|
542
|
+
* row's total (0–100); a row whose total is 0 stays at 0.
|
|
543
|
+
*/
|
|
544
|
+
function cartesianSeriesValues(
|
|
545
|
+
data: readonly Record<string, unknown>[],
|
|
546
|
+
categories: readonly string[],
|
|
547
|
+
type: CartesianStackType = "default",
|
|
548
|
+
): (number | null)[][] {
|
|
549
|
+
const raw = categories.map((category) => data.map((row) => cellNumber(row[category])))
|
|
550
|
+
if (type !== "percent") return raw
|
|
551
|
+
return raw.map((values) =>
|
|
552
|
+
values.map((value, i) => {
|
|
553
|
+
if (value === null) return null
|
|
554
|
+
const total = categories.reduce((sum, _, k) => sum + Math.abs(raw[k]![i] ?? 0), 0)
|
|
555
|
+
return total === 0 ? 0 : (Math.abs(value) / total) * 100
|
|
556
|
+
}),
|
|
557
|
+
)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** A colour with alpha, for gradient stops. Understands `#rgb`, `#rrggbb`, `rgb()`/`rgba()`. */
|
|
561
|
+
function withAlpha(color: string, alpha: number): string {
|
|
562
|
+
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim())
|
|
563
|
+
if (hex) {
|
|
564
|
+
const h = hex[1]!
|
|
565
|
+
const full = h.length === 3 ? h.replace(/./g, (c) => c + c) : h
|
|
566
|
+
const n = Number.parseInt(full, 16)
|
|
567
|
+
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`
|
|
568
|
+
}
|
|
569
|
+
const rgb = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(color.trim())
|
|
570
|
+
if (rgb) return `rgba(${rgb[1]}, ${rgb[2]}, ${rgb[3]}, ${alpha})`
|
|
571
|
+
return color
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const HTML_ESCAPES: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }
|
|
575
|
+
|
|
576
|
+
function escapeHtml(value: string): string {
|
|
577
|
+
return value.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]!)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
interface TooltipRow {
|
|
581
|
+
name: string
|
|
582
|
+
color: string
|
|
583
|
+
value: number | null
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** The tooltip body: a title, then one `● name value` row per series. */
|
|
587
|
+
function tooltipHtml(title: string, rows: readonly TooltipRow[], valueFormatter: (value: number) => string): string {
|
|
588
|
+
const lines = rows.map(
|
|
589
|
+
(row) =>
|
|
590
|
+
`<div style="display:flex;align-items:center;gap:8px;line-height:20px">` +
|
|
591
|
+
`<span style="display:inline-block;width:8px;height:8px;border-radius:9999px;background:${escapeHtml(row.color)}"></span>` +
|
|
592
|
+
`<span style="flex:1;opacity:.8">${escapeHtml(row.name)}</span>` +
|
|
593
|
+
`<span style="font-weight:500;font-variant-numeric:tabular-nums;margin-left:12px">${
|
|
594
|
+
row.value === null ? "–" : escapeHtml(valueFormatter(row.value))
|
|
595
|
+
}</span></div>`,
|
|
596
|
+
)
|
|
597
|
+
return `<div style="font-weight:500;margin-bottom:4px">${escapeHtml(title)}</div>${lines.join("")}`
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
interface AxisTooltipParam {
|
|
601
|
+
seriesName?: string
|
|
602
|
+
dataIndex?: number
|
|
603
|
+
color?: unknown
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const DIMMED = 0.3
|
|
607
|
+
|
|
608
|
+
function buildCartesianOption(cfg: CartesianChartConfig): EChartsOption {
|
|
609
|
+
const {
|
|
610
|
+
kind,
|
|
611
|
+
data,
|
|
612
|
+
index,
|
|
613
|
+
categories,
|
|
614
|
+
palette,
|
|
615
|
+
valueFormatter,
|
|
616
|
+
type = "default",
|
|
617
|
+
layout = "horizontal",
|
|
618
|
+
curveType = "linear",
|
|
619
|
+
fill = "gradient",
|
|
620
|
+
connectNulls = false,
|
|
621
|
+
showLegend = true,
|
|
622
|
+
showTooltip = true,
|
|
623
|
+
showGridLines = true,
|
|
624
|
+
showXAxis = true,
|
|
625
|
+
showYAxis = true,
|
|
626
|
+
startEndOnly = false,
|
|
627
|
+
yAxisWidth,
|
|
628
|
+
minValue,
|
|
629
|
+
maxValue,
|
|
630
|
+
autoMinValue = false,
|
|
631
|
+
allowDecimals = true,
|
|
632
|
+
xAxisLabel,
|
|
633
|
+
yAxisLabel,
|
|
634
|
+
barCategoryGap,
|
|
635
|
+
showDots = false,
|
|
636
|
+
active = null,
|
|
637
|
+
} = cfg
|
|
638
|
+
|
|
639
|
+
const labels = data.map((row) => String(row[index] ?? ""))
|
|
640
|
+
const values = cartesianSeriesValues(data, categories, kind === "line" ? "default" : type)
|
|
641
|
+
const stacked = kind !== "line" && type !== "default"
|
|
642
|
+
const percent = kind !== "line" && type === "percent"
|
|
643
|
+
const horizontalBars = kind === "bar" && layout === "vertical"
|
|
644
|
+
const last = labels.length - 1
|
|
645
|
+
|
|
646
|
+
const series = categories.map((category, c) => {
|
|
647
|
+
const color = palette[c] || undefined
|
|
648
|
+
const dimSeries = active !== null && active.category !== category
|
|
649
|
+
if (kind === "bar") {
|
|
650
|
+
const radius = stacked ? 0 : horizontalBars ? [0, 3, 3, 0] : [3, 3, 0, 0]
|
|
651
|
+
return {
|
|
652
|
+
type: "bar" as const,
|
|
653
|
+
name: category,
|
|
654
|
+
stack: stacked ? "total" : undefined,
|
|
655
|
+
barCategoryGap,
|
|
656
|
+
itemStyle: { color, borderRadius: radius },
|
|
657
|
+
emphasis: { focus: "series" as const },
|
|
658
|
+
data: values[c]!.map((value, i) =>
|
|
659
|
+
active !== null && !(active.category === category && active.index === i)
|
|
660
|
+
? { value, itemStyle: { opacity: DIMMED } }
|
|
661
|
+
: value,
|
|
662
|
+
),
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
const areaStyle =
|
|
666
|
+
kind !== "area" || fill === "none"
|
|
667
|
+
? undefined
|
|
668
|
+
: fill === "solid" || !color
|
|
669
|
+
? { opacity: fill === "solid" ? 0.3 : 0.2 }
|
|
670
|
+
: {
|
|
671
|
+
color: {
|
|
672
|
+
type: "linear" as const,
|
|
673
|
+
x: 0,
|
|
674
|
+
y: 0,
|
|
675
|
+
x2: 0,
|
|
676
|
+
y2: 1,
|
|
677
|
+
colorStops: [
|
|
678
|
+
{ offset: 0, color: withAlpha(color, 0.35) },
|
|
679
|
+
{ offset: 1, color: withAlpha(color, 0) },
|
|
680
|
+
],
|
|
681
|
+
},
|
|
682
|
+
}
|
|
683
|
+
return {
|
|
684
|
+
type: "line" as const,
|
|
685
|
+
name: category,
|
|
686
|
+
stack: stacked ? "total" : undefined,
|
|
687
|
+
smooth: curveType === "smooth",
|
|
688
|
+
step: curveType === "step" ? ("middle" as const) : undefined,
|
|
689
|
+
connectNulls,
|
|
690
|
+
symbol: "circle",
|
|
691
|
+
symbolSize: 6,
|
|
692
|
+
showSymbol: showDots || labels.length === 1,
|
|
693
|
+
lineStyle: { width: 2, color, opacity: dimSeries ? DIMMED : 1 },
|
|
694
|
+
itemStyle: { color, opacity: dimSeries ? DIMMED : 1 },
|
|
695
|
+
areaStyle: areaStyle && dimSeries ? { ...areaStyle, opacity: DIMMED * 0.3 } : areaStyle,
|
|
696
|
+
emphasis: { focus: "series" as const },
|
|
697
|
+
data: values[c]!,
|
|
698
|
+
}
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
const categoryAxis = {
|
|
702
|
+
type: "category" as const,
|
|
703
|
+
data: labels,
|
|
704
|
+
show: horizontalBars ? showYAxis : showXAxis,
|
|
705
|
+
boundaryGap: kind === "bar",
|
|
706
|
+
inverse: horizontalBars,
|
|
707
|
+
axisTick: { show: false },
|
|
708
|
+
axisLabel: {
|
|
709
|
+
hideOverlap: true,
|
|
710
|
+
interval: startEndOnly ? (i: number) => i === 0 || i === last : ("auto" as const),
|
|
711
|
+
...(horizontalBars && yAxisWidth != null ? { width: yAxisWidth, overflow: "truncate" as const } : {}),
|
|
712
|
+
},
|
|
713
|
+
name: horizontalBars ? yAxisLabel : xAxisLabel,
|
|
714
|
+
nameLocation: "middle" as const,
|
|
715
|
+
nameGap: horizontalBars ? (yAxisWidth ?? 48) + 8 : 28,
|
|
716
|
+
}
|
|
717
|
+
const valueAxis = {
|
|
718
|
+
type: "value" as const,
|
|
719
|
+
show: horizontalBars ? showXAxis : showYAxis,
|
|
720
|
+
min: percent ? 0 : minValue,
|
|
721
|
+
max: percent ? 100 : maxValue,
|
|
722
|
+
scale: autoMinValue && minValue == null,
|
|
723
|
+
minInterval: allowDecimals ? undefined : 1,
|
|
724
|
+
axisLine: { show: false },
|
|
725
|
+
axisTick: { show: false },
|
|
726
|
+
splitLine: { show: showGridLines },
|
|
727
|
+
axisLabel: {
|
|
728
|
+
formatter: percent ? (v: number) => `${v}%` : (v: number) => valueFormatter(v),
|
|
729
|
+
...(!horizontalBars && yAxisWidth != null ? { width: yAxisWidth, overflow: "truncate" as const } : {}),
|
|
730
|
+
},
|
|
731
|
+
name: horizontalBars ? xAxisLabel : yAxisLabel,
|
|
732
|
+
nameLocation: "middle" as const,
|
|
733
|
+
nameGap: horizontalBars ? 28 : (yAxisWidth ?? 48) + 8,
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
return {
|
|
737
|
+
animationDuration: 400,
|
|
738
|
+
legend: {
|
|
739
|
+
show: showLegend,
|
|
740
|
+
top: 0,
|
|
741
|
+
right: 0,
|
|
742
|
+
type: "scroll",
|
|
743
|
+
icon: "circle",
|
|
744
|
+
itemWidth: 8,
|
|
745
|
+
itemHeight: 8,
|
|
746
|
+
itemGap: 16,
|
|
747
|
+
},
|
|
748
|
+
grid: {
|
|
749
|
+
top: showLegend ? 36 : 12,
|
|
750
|
+
// Lines start and end on the outermost ticks, so the last label hangs past the plot.
|
|
751
|
+
right: kind === "bar" ? 12 : 24,
|
|
752
|
+
bottom: xAxisLabel ? 24 : 4,
|
|
753
|
+
left: yAxisLabel ? 24 : 4,
|
|
754
|
+
containLabel: true,
|
|
755
|
+
},
|
|
756
|
+
tooltip: {
|
|
757
|
+
show: showTooltip,
|
|
758
|
+
trigger: "axis",
|
|
759
|
+
axisPointer: { type: kind === "bar" ? "shadow" : "line" },
|
|
760
|
+
formatter: (params: unknown) => {
|
|
761
|
+
const list = (Array.isArray(params) ? params : [params]) as AxisTooltipParam[]
|
|
762
|
+
const i = list[0]?.dataIndex ?? 0
|
|
763
|
+
const row = data[i] ?? {}
|
|
764
|
+
return tooltipHtml(
|
|
765
|
+
String(row[index] ?? ""),
|
|
766
|
+
list.map((p) => ({
|
|
767
|
+
name: p.seriesName ?? "",
|
|
768
|
+
color: typeof p.color === "string" ? p.color : "currentColor",
|
|
769
|
+
value: cellNumber(row[p.seriesName ?? ""]),
|
|
770
|
+
})),
|
|
771
|
+
valueFormatter,
|
|
772
|
+
)
|
|
773
|
+
},
|
|
774
|
+
},
|
|
775
|
+
xAxis: horizontalBars ? valueAxis : categoryAxis,
|
|
776
|
+
yAxis: horizontalBars ? categoryAxis : valueAxis,
|
|
777
|
+
series,
|
|
778
|
+
} as EChartsOption
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/* ------------------------------------------------------------------ donut */
|
|
782
|
+
|
|
783
|
+
type DonutVariant = "donut" | "pie"
|
|
784
|
+
|
|
785
|
+
interface DonutChartConfig {
|
|
786
|
+
data: readonly Record<string, unknown>[]
|
|
787
|
+
category: string
|
|
788
|
+
value: string
|
|
789
|
+
/** Resolved slice colours, by row position. `""`/missing → theme palette. */
|
|
790
|
+
palette: readonly string[]
|
|
791
|
+
valueFormatter: (value: number) => string
|
|
792
|
+
variant?: DonutVariant
|
|
793
|
+
showTooltip?: boolean
|
|
794
|
+
active?: string | null
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** Sum of the `value` column, ignoring gaps and negative cells. */
|
|
798
|
+
function donutTotal(data: readonly Record<string, unknown>[], value: string): number {
|
|
799
|
+
return data.reduce((sum, row) => {
|
|
800
|
+
const n = cellNumber(row[value])
|
|
801
|
+
return n !== null && n > 0 ? sum + n : sum
|
|
802
|
+
}, 0)
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
interface ItemTooltipParam {
|
|
806
|
+
name?: string
|
|
807
|
+
value?: unknown
|
|
808
|
+
percent?: number
|
|
809
|
+
color?: unknown
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function buildDonutOption(cfg: DonutChartConfig): EChartsOption {
|
|
813
|
+
const { data, category, value, palette, valueFormatter, variant = "donut", showTooltip = true, active = null } = cfg
|
|
814
|
+
return {
|
|
815
|
+
animationDuration: 400,
|
|
816
|
+
tooltip: {
|
|
817
|
+
show: showTooltip,
|
|
818
|
+
trigger: "item",
|
|
819
|
+
formatter: (params: unknown) => {
|
|
820
|
+
const p = params as ItemTooltipParam
|
|
821
|
+
const n = cellNumber(p.value)
|
|
822
|
+
const share = typeof p.percent === "number" ? ` (${p.percent.toFixed(1)}%)` : ""
|
|
823
|
+
return tooltipHtml(
|
|
824
|
+
`${p.name ?? ""}${share}`,
|
|
825
|
+
[{ name: value, color: typeof p.color === "string" ? p.color : "currentColor", value: n }],
|
|
826
|
+
valueFormatter,
|
|
827
|
+
)
|
|
828
|
+
},
|
|
829
|
+
},
|
|
830
|
+
series: [
|
|
831
|
+
{
|
|
832
|
+
type: "pie",
|
|
833
|
+
radius: variant === "donut" ? ["72%", "100%"] : [0, "100%"],
|
|
834
|
+
center: ["50%", "50%"],
|
|
835
|
+
avoidLabelOverlap: true,
|
|
836
|
+
label: { show: false },
|
|
837
|
+
labelLine: { show: false },
|
|
838
|
+
// A hairline gap between slices instead of a border — no background colour to match.
|
|
839
|
+
padAngle: data.length > 1 ? 1 : 0,
|
|
840
|
+
itemStyle: { borderRadius: variant === "donut" ? 2 : 0 },
|
|
841
|
+
emphasis: { scale: true, scaleSize: 4 },
|
|
842
|
+
data: data.map((row, i) => {
|
|
843
|
+
const name = String(row[category] ?? "")
|
|
844
|
+
const color = palette[i] || undefined
|
|
845
|
+
const n = cellNumber(row[value])
|
|
846
|
+
return {
|
|
847
|
+
name,
|
|
848
|
+
value: n !== null && n > 0 ? n : 0,
|
|
849
|
+
itemStyle: { color, opacity: active !== null && active !== name ? DIMMED : 1 },
|
|
850
|
+
}
|
|
851
|
+
}),
|
|
852
|
+
},
|
|
853
|
+
],
|
|
854
|
+
} as EChartsOption
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/* -------------------------------------------------------------- selection */
|
|
858
|
+
|
|
859
|
+
/** Toggle: clicking the active point again clears the selection. */
|
|
860
|
+
function nextChartSelection(
|
|
861
|
+
current: ChartActivePoint | null,
|
|
862
|
+
category: string,
|
|
863
|
+
index: number,
|
|
864
|
+
): ChartActivePoint | null {
|
|
865
|
+
return current && current.category === category && current.index === index ? null : { category, index }
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/** What `onValueChange` receives: the clicked row plus what was clicked (ours win over row keys). */
|
|
869
|
+
function chartSelectionPayload(
|
|
870
|
+
selection: ChartActivePoint | null,
|
|
871
|
+
data: readonly Record<string, unknown>[],
|
|
872
|
+
eventType: NonNullable<ChartEventPayload>["eventType"],
|
|
873
|
+
): ChartEventPayload {
|
|
874
|
+
if (!selection) return null
|
|
875
|
+
return { ...data[selection.index], eventType, categoryClicked: selection.category }
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
interface ChartClickParams {
|
|
879
|
+
componentType?: string
|
|
880
|
+
seriesName?: string
|
|
881
|
+
name?: string
|
|
882
|
+
dataIndex?: number
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Click-to-select for the typed charts: the `onEvents` to hand `EChart`, and
|
|
887
|
+
* the active point to dim the rest with. Inert (no handler, nothing active)
|
|
888
|
+
* without `onValueChange`.
|
|
889
|
+
*/
|
|
890
|
+
function useChartSelection(
|
|
891
|
+
data: readonly Record<string, unknown>[],
|
|
892
|
+
onValueChange: ((value: ChartEventPayload) => void) | undefined,
|
|
893
|
+
eventType: NonNullable<ChartEventPayload>["eventType"],
|
|
894
|
+
): { active: ChartActivePoint | null; onEvents: Record<string, (params: unknown) => void> | undefined } {
|
|
895
|
+
const [active, setActive] = useState<ChartActivePoint | null>(null)
|
|
896
|
+
// Handlers read the latest props without re-subscribing on every render.
|
|
897
|
+
const latest = useRef({ data, onValueChange, active })
|
|
898
|
+
latest.current = { data, onValueChange, active }
|
|
899
|
+
const interactive = onValueChange !== undefined
|
|
900
|
+
const onEvents = useMemo(() => {
|
|
901
|
+
if (!interactive) return undefined
|
|
902
|
+
return {
|
|
903
|
+
click: (raw: unknown) => {
|
|
904
|
+
const p = raw as ChartClickParams
|
|
905
|
+
if (p.componentType !== "series" || typeof p.dataIndex !== "number") return
|
|
906
|
+
const category = eventType === "slice" ? (p.name ?? "") : (p.seriesName ?? "")
|
|
907
|
+
const next = nextChartSelection(latest.current.active, category, p.dataIndex)
|
|
908
|
+
latest.current.active = next
|
|
909
|
+
setActive(next)
|
|
910
|
+
latest.current.onValueChange?.(chartSelectionPayload(next, latest.current.data, eventType))
|
|
911
|
+
},
|
|
912
|
+
}
|
|
913
|
+
}, [interactive, eventType])
|
|
914
|
+
return { active: interactive ? active : null, onEvents }
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/* ---------- end of inlined code ---------- */
|
|
918
|
+
|
|
919
|
+
export interface AreaChartProps extends CartesianChartBaseProps {
|
|
920
|
+
/** `"stacked"` は積み上げ、`"percent"` は各点の合計を 100% にした積み上げ。既定 `"default"`。 */
|
|
921
|
+
type?: CartesianStackType
|
|
922
|
+
/** 線の形。既定 `"linear"`。 */
|
|
923
|
+
curveType?: CartesianCurveType
|
|
924
|
+
/** 面の塗り。既定 `"gradient"`。 */
|
|
925
|
+
fill?: AreaFill
|
|
926
|
+
/** 値の欠けた点(`null` など)をまたいで線をつなぐ。既定 `false`。 */
|
|
927
|
+
connectNulls?: boolean
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
export const AreaChart = forwardRef<HTMLDivElement, AreaChartProps>(function AreaChart(
|
|
931
|
+
{
|
|
932
|
+
data,
|
|
933
|
+
index,
|
|
934
|
+
categories,
|
|
935
|
+
colors,
|
|
936
|
+
valueFormatter = defaultValueFormatter,
|
|
937
|
+
type = "default",
|
|
938
|
+
curveType = "linear",
|
|
939
|
+
fill = "gradient",
|
|
940
|
+
connectNulls = false,
|
|
941
|
+
showLegend = true,
|
|
942
|
+
showTooltip = true,
|
|
943
|
+
showGridLines = true,
|
|
944
|
+
showXAxis = true,
|
|
945
|
+
showYAxis = true,
|
|
946
|
+
startEndOnly = false,
|
|
947
|
+
yAxisWidth,
|
|
948
|
+
minValue,
|
|
949
|
+
maxValue,
|
|
950
|
+
autoMinValue = false,
|
|
951
|
+
allowDecimals = true,
|
|
952
|
+
xAxisLabel,
|
|
953
|
+
yAxisLabel,
|
|
954
|
+
onValueChange,
|
|
955
|
+
height,
|
|
956
|
+
loading,
|
|
957
|
+
ariaLabel,
|
|
958
|
+
className,
|
|
959
|
+
},
|
|
960
|
+
ref,
|
|
961
|
+
) {
|
|
962
|
+
const tokens = useChartTokens()
|
|
963
|
+
const palette = useMemo(
|
|
964
|
+
() => resolveChartColors(chartColorsFor(colors, categories.length), tokens),
|
|
965
|
+
[colors, categories.length, tokens],
|
|
966
|
+
)
|
|
967
|
+
const { active, onEvents } = useChartSelection(data, onValueChange, "dot")
|
|
968
|
+
const option = useMemo(
|
|
969
|
+
() =>
|
|
970
|
+
buildCartesianOption({
|
|
971
|
+
kind: "area",
|
|
972
|
+
data,
|
|
973
|
+
index,
|
|
974
|
+
categories,
|
|
975
|
+
palette,
|
|
976
|
+
valueFormatter,
|
|
977
|
+
type,
|
|
978
|
+
curveType,
|
|
979
|
+
fill,
|
|
980
|
+
connectNulls,
|
|
981
|
+
showLegend,
|
|
982
|
+
showTooltip,
|
|
983
|
+
showGridLines,
|
|
984
|
+
showXAxis,
|
|
985
|
+
showYAxis,
|
|
986
|
+
startEndOnly,
|
|
987
|
+
yAxisWidth,
|
|
988
|
+
minValue,
|
|
989
|
+
maxValue,
|
|
990
|
+
autoMinValue,
|
|
991
|
+
allowDecimals,
|
|
992
|
+
xAxisLabel,
|
|
993
|
+
yAxisLabel,
|
|
994
|
+
showDots: onValueChange !== undefined,
|
|
995
|
+
active,
|
|
996
|
+
}),
|
|
997
|
+
[
|
|
998
|
+
data, index, categories, palette, valueFormatter, type, curveType, fill, connectNulls,
|
|
999
|
+
showLegend, showTooltip, showGridLines, showXAxis, showYAxis, startEndOnly, yAxisWidth,
|
|
1000
|
+
minValue, maxValue, autoMinValue, allowDecimals, xAxisLabel, yAxisLabel, onValueChange, active,
|
|
1001
|
+
],
|
|
1002
|
+
)
|
|
1003
|
+
return (
|
|
1004
|
+
<EChart
|
|
1005
|
+
ref={ref}
|
|
1006
|
+
option={option}
|
|
1007
|
+
onEvents={onEvents}
|
|
1008
|
+
height={height}
|
|
1009
|
+
loading={loading}
|
|
1010
|
+
ariaLabel={ariaLabel ?? `${categories.join("・")}の面グラフ`}
|
|
1011
|
+
className={cn("h-80", className)}
|
|
1012
|
+
/>
|
|
1013
|
+
)
|
|
1014
|
+
})
|
|
1015
|
+
AreaChart.displayName = "AreaChart"
|