@exegia/corpora-ui 2.0.0 → 3.0.0
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-lib/components/composed/chat/chart/chart-atom.d.ts +30 -0
- package/dist-lib/components/composed/chat/chart/chart-context.d.ts +2 -0
- package/dist-lib/components/composed/chat/chart/chart-legend.d.ts +4 -0
- package/dist-lib/components/composed/chat/chart/chart-plot.d.ts +3 -0
- package/dist-lib/components/composed/chat/chart/chart-root.d.ts +3 -0
- package/dist-lib/components/composed/chat/chart/chart-tooltip.d.ts +4 -0
- package/dist-lib/components/composed/chat/chart/constants.d.ts +3 -0
- package/dist-lib/components/composed/chat/chart/evilcharts/echarts-area-chart.d.ts +93 -0
- package/dist-lib/components/composed/chat/chart/evilcharts/echarts-bar-chart.d.ts +86 -0
- package/dist-lib/components/composed/chat/chart/evilcharts/echarts-brush.d.ts +65 -0
- package/dist-lib/components/composed/chat/chart/evilcharts/echarts-chart.d.ts +44 -0
- package/dist-lib/components/composed/chat/chart/evilcharts/echarts-dot.d.ts +16 -0
- package/dist-lib/components/composed/chat/chart/evilcharts/echarts-legend.d.ts +24 -0
- package/dist-lib/components/composed/chat/chart/evilcharts/echarts-line-chart.d.ts +90 -0
- package/dist-lib/components/composed/chat/chart/evilcharts/echarts-pie-chart.d.ts +73 -0
- package/dist-lib/components/composed/chat/chart/evilcharts/echarts-tooltip.d.ts +29 -0
- package/dist-lib/components/composed/chat/chart/index.d.ts +6 -0
- package/dist-lib/components/composed/chat/chart/type.d.ts +80 -0
- package/dist-lib/components/composed/chat/chart/use-chart-state.d.ts +3 -0
- package/dist-lib/components/composed/chat/chart/use-chart.d.ts +6 -0
- package/dist-lib/components/composed/chat/chart/utils.d.ts +5 -0
- package/dist-lib/components/composed/chat/chart.d.ts +1 -44
- package/dist-lib/components/composed/chat/index.d.ts +1 -1
- package/dist-lib/components/stories/chart.story.d.ts +39 -2
- package/dist-lib/components/stories/insight-cards.story.d.ts +30 -1
- package/dist-lib/index.js +10020 -5549
- package/dist-lib/index.js.map +1 -1
- package/package.json +2 -1
- package/src/components/composed/chat/__tests__/chart.test.tsx +255 -1
- package/src/components/composed/chat/chart/chart-atom.ts +55 -0
- package/src/components/composed/chat/chart/chart-context.ts +9 -0
- package/src/components/composed/chat/chart/chart-legend.tsx +75 -0
- package/src/components/composed/chat/chart/chart-plot.tsx +233 -0
- package/src/components/composed/chat/chart/chart-root.tsx +229 -0
- package/src/components/composed/chat/chart/chart-tooltip.tsx +74 -0
- package/src/components/composed/chat/chart/constants.ts +8 -0
- package/src/components/composed/chat/chart/evilcharts/LICENSE +21 -0
- package/src/components/composed/chat/chart/evilcharts/README.md +8 -0
- package/src/components/composed/chat/chart/evilcharts/echarts-area-chart.tsx +2358 -0
- package/src/components/composed/chat/chart/evilcharts/echarts-bar-chart.tsx +2261 -0
- package/src/components/composed/chat/chart/evilcharts/echarts-brush.tsx +233 -0
- package/src/components/composed/chat/chart/evilcharts/echarts-chart.tsx +214 -0
- package/src/components/composed/chat/chart/evilcharts/echarts-dot.tsx +111 -0
- package/src/components/composed/chat/chart/evilcharts/echarts-legend.tsx +131 -0
- package/src/components/composed/chat/chart/evilcharts/echarts-line-chart.tsx +2112 -0
- package/src/components/composed/chat/chart/evilcharts/echarts-pie-chart.tsx +1235 -0
- package/src/components/composed/chat/chart/evilcharts/echarts-tooltip.tsx +125 -0
- package/src/components/composed/chat/chart/index.ts +13 -0
- package/src/components/composed/chat/chart/type.ts +88 -0
- package/src/components/composed/chat/chart/use-chart-state.ts +22 -0
- package/src/components/composed/chat/chart/use-chart.ts +70 -0
- package/src/components/composed/chat/chart/utils.ts +47 -0
- package/src/components/composed/chat/chart.tsx +2 -400
- package/src/components/composed/chat/index.ts +1 -7
- package/src/components/stories/chart.story.tsx +16 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exegia/corpora-ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "shadcn-ready React UI library for the corpora manuscript-research apps.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
"clsx": "^2.1.1",
|
|
61
61
|
"cn": "^0.2.6",
|
|
62
62
|
"cuelume": "^0.2.2",
|
|
63
|
+
"echarts": "^6.1.0",
|
|
63
64
|
"frimousse": "^0.3.0",
|
|
64
65
|
"fumadocs-core": "^16.15.10",
|
|
65
66
|
"fumadocs-mdx": "^15.4.0",
|
|
@@ -1,7 +1,31 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from "bun:test"
|
|
2
2
|
import { fireEvent, render, screen } from "@testing-library/react"
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
Chart,
|
|
6
|
+
useChartActions,
|
|
7
|
+
useChartState,
|
|
8
|
+
removeChartInstance,
|
|
9
|
+
} from "../chart"
|
|
10
|
+
import { StrictMode } from "react"
|
|
11
|
+
import { Provider, createStore } from "jotai"
|
|
12
|
+
import { chartSelectedKeyAtom } from "../chart/chart-atom"
|
|
13
|
+
import { buildChartConfig } from "../chart/utils"
|
|
14
|
+
import { createChartTooltip } from "../chart/chart-tooltip"
|
|
15
|
+
|
|
16
|
+
function Controls({ id }: { id: string }) {
|
|
17
|
+
const actions = useChartActions(id)
|
|
18
|
+
const { selectedKey } = useChartState(id)
|
|
19
|
+
return (
|
|
20
|
+
<>
|
|
21
|
+
<output data-testid={`${id}-selection`}>{selectedKey ?? "none"}</output>
|
|
22
|
+
<button onClick={() => actions.select("v")}>Select {id}</button>
|
|
23
|
+
<button onClick={actions.clearSelection}>Clear {id}</button>
|
|
24
|
+
<button onClick={actions.reset}>Reset {id}</button>
|
|
25
|
+
<button onClick={() => actions.select("missing")}>Invalid {id}</button>
|
|
26
|
+
</>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
5
29
|
|
|
6
30
|
const data = [
|
|
7
31
|
{ label: "A", v: 1 },
|
|
@@ -60,3 +84,233 @@ describe("Chart", () => {
|
|
|
60
84
|
expect(onClick).toHaveBeenCalledTimes(1)
|
|
61
85
|
})
|
|
62
86
|
})
|
|
87
|
+
|
|
88
|
+
describe("Chart selection", () => {
|
|
89
|
+
test("named instances stay isolated and external actions update legend selection", () => {
|
|
90
|
+
const onChange = mock(() => {})
|
|
91
|
+
render(
|
|
92
|
+
<Provider>
|
|
93
|
+
<Chart
|
|
94
|
+
chartId="one"
|
|
95
|
+
type="bar"
|
|
96
|
+
data={data}
|
|
97
|
+
series={series}
|
|
98
|
+
interactive
|
|
99
|
+
onSelectionChange={onChange}
|
|
100
|
+
/>
|
|
101
|
+
<Chart
|
|
102
|
+
chartId="two"
|
|
103
|
+
type="line"
|
|
104
|
+
data={data}
|
|
105
|
+
series={series}
|
|
106
|
+
interactive
|
|
107
|
+
/>
|
|
108
|
+
<Controls id="one" />
|
|
109
|
+
<Controls id="two" />
|
|
110
|
+
</Provider>
|
|
111
|
+
)
|
|
112
|
+
fireEvent.click(screen.getByText("Select one"))
|
|
113
|
+
expect(screen.getByTestId("one-selection").textContent).toBe("v")
|
|
114
|
+
expect(screen.getByTestId("two-selection").textContent).toBe("none")
|
|
115
|
+
expect(
|
|
116
|
+
screen
|
|
117
|
+
.getAllByRole("button", { name: "Value" })[0]
|
|
118
|
+
.getAttribute("aria-pressed")
|
|
119
|
+
).toBe("true")
|
|
120
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
121
|
+
fireEvent.click(screen.getByText("Invalid one"))
|
|
122
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
123
|
+
fireEvent.click(screen.getByText("Clear one"))
|
|
124
|
+
expect(screen.getByTestId("one-selection").textContent).toBe("none")
|
|
125
|
+
})
|
|
126
|
+
test("controlled props remain authoritative until parent accepts selection", () => {
|
|
127
|
+
const onChange = mock(() => {})
|
|
128
|
+
const { rerender } = render(
|
|
129
|
+
<Chart
|
|
130
|
+
type="area"
|
|
131
|
+
data={data}
|
|
132
|
+
series={series}
|
|
133
|
+
interactive
|
|
134
|
+
selectedKey={null}
|
|
135
|
+
onSelectionChange={onChange}
|
|
136
|
+
/>
|
|
137
|
+
)
|
|
138
|
+
fireEvent.click(screen.getByRole("button", { name: "Value" }))
|
|
139
|
+
expect(onChange).toHaveBeenCalledWith("v")
|
|
140
|
+
expect(
|
|
141
|
+
screen.getByRole("button", { name: "Value" }).getAttribute("aria-pressed")
|
|
142
|
+
).toBe("false")
|
|
143
|
+
rerender(
|
|
144
|
+
<Chart
|
|
145
|
+
type="area"
|
|
146
|
+
data={data}
|
|
147
|
+
series={series}
|
|
148
|
+
interactive
|
|
149
|
+
selectedKey="v"
|
|
150
|
+
onSelectionChange={onChange}
|
|
151
|
+
/>
|
|
152
|
+
)
|
|
153
|
+
expect(
|
|
154
|
+
screen.getByRole("button", { name: "Value" }).getAttribute("aria-pressed")
|
|
155
|
+
).toBe("true")
|
|
156
|
+
})
|
|
157
|
+
test("reset restores the default and removed series clear selection", () => {
|
|
158
|
+
const store = createStore()
|
|
159
|
+
const { rerender } = render(
|
|
160
|
+
<Provider store={store}>
|
|
161
|
+
<Chart
|
|
162
|
+
chartId="reset"
|
|
163
|
+
type="bar"
|
|
164
|
+
data={data}
|
|
165
|
+
series={series}
|
|
166
|
+
defaultSelectedKey="v"
|
|
167
|
+
/>
|
|
168
|
+
<Controls id="reset" />
|
|
169
|
+
</Provider>
|
|
170
|
+
)
|
|
171
|
+
fireEvent.click(screen.getByText("Clear reset"))
|
|
172
|
+
fireEvent.click(screen.getByText("Reset reset"))
|
|
173
|
+
expect(store.get(chartSelectedKeyAtom("reset"))).toBe("v")
|
|
174
|
+
rerender(
|
|
175
|
+
<Provider store={store}>
|
|
176
|
+
<Chart
|
|
177
|
+
chartId="reset"
|
|
178
|
+
type="bar"
|
|
179
|
+
data={data}
|
|
180
|
+
series={[]}
|
|
181
|
+
defaultSelectedKey="v"
|
|
182
|
+
/>
|
|
183
|
+
</Provider>
|
|
184
|
+
)
|
|
185
|
+
expect(store.get(chartSelectedKeyAtom("reset"))).toBeNull()
|
|
186
|
+
})
|
|
187
|
+
test("generated identity survives StrictMode effect replay", async () => {
|
|
188
|
+
render(
|
|
189
|
+
<StrictMode>
|
|
190
|
+
<Chart type="line" data={data} series={series} interactive />
|
|
191
|
+
</StrictMode>
|
|
192
|
+
)
|
|
193
|
+
await Promise.resolve()
|
|
194
|
+
fireEvent.click(screen.getByRole("button", { name: "Value" }))
|
|
195
|
+
expect(
|
|
196
|
+
screen.getByRole("button", { name: "Value" }).getAttribute("aria-pressed")
|
|
197
|
+
).toBe("true")
|
|
198
|
+
})
|
|
199
|
+
test("pie selection uses category labels", () => {
|
|
200
|
+
const callback = mock(() => {})
|
|
201
|
+
render(
|
|
202
|
+
<Chart
|
|
203
|
+
type="pie"
|
|
204
|
+
data={data}
|
|
205
|
+
series={series}
|
|
206
|
+
interactive
|
|
207
|
+
onSelectionChange={callback}
|
|
208
|
+
/>
|
|
209
|
+
)
|
|
210
|
+
fireEvent.click(screen.getByRole("button", { name: "A 1" }))
|
|
211
|
+
expect(callback).toHaveBeenCalledWith("A")
|
|
212
|
+
})
|
|
213
|
+
test("instance removal releases the atom family", () => {
|
|
214
|
+
const original = chartSelectedKeyAtom("removed")
|
|
215
|
+
removeChartInstance("removed")
|
|
216
|
+
expect(chartSelectedKeyAtom("removed")).not.toBe(original)
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
describe("Chart adapters", () => {
|
|
220
|
+
test("config preserves CSS tokens and custom series colors", () => {
|
|
221
|
+
expect(
|
|
222
|
+
buildChartConfig("bar", data, [{ ...series[0], color: "var(--primary)" }])
|
|
223
|
+
.v.colors?.light
|
|
224
|
+
).toEqual(["var(--primary)"])
|
|
225
|
+
expect(Object.keys(buildChartConfig("pie", data, series))).toEqual([
|
|
226
|
+
"A",
|
|
227
|
+
"B",
|
|
228
|
+
])
|
|
229
|
+
})
|
|
230
|
+
test("tooltip escapes data and preserves React formatters", () => {
|
|
231
|
+
const tooltip = createChartTooltip("bar", data, [
|
|
232
|
+
{
|
|
233
|
+
key: "v",
|
|
234
|
+
label: "<script>",
|
|
235
|
+
format: (value) => <strong>{value}%</strong>,
|
|
236
|
+
},
|
|
237
|
+
])
|
|
238
|
+
const html = tooltip({ seriesIndex: 0, dataIndex: 1 })
|
|
239
|
+
expect(html).toContain("<script>")
|
|
240
|
+
expect(html).toContain("<strong>2%</strong>")
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test("tooltip defaults to frosted glass with one surface shadow", () => {
|
|
245
|
+
const params = { seriesIndex: 0, dataIndex: 1 }
|
|
246
|
+
const html = createChartTooltip("bar", data, series)(params)
|
|
247
|
+
expect(html).toContain("bg-background/50")
|
|
248
|
+
expect(html).toContain("backdrop-blur-md")
|
|
249
|
+
expect(html).toContain("box-shadow:0 4px 12px rgb(0 0 0 / 0.12)")
|
|
250
|
+
const solid = createChartTooltip("bar", data, series, { variant: "default" })(
|
|
251
|
+
params
|
|
252
|
+
)
|
|
253
|
+
expect(solid).not.toContain("backdrop-blur-md")
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
describe("Chart consumer layout", () => {
|
|
257
|
+
test("accepts CSS dimensions and replaces the framed root", () => {
|
|
258
|
+
const { container } = render(
|
|
259
|
+
<Chart
|
|
260
|
+
type="bar"
|
|
261
|
+
data={[]}
|
|
262
|
+
series={series}
|
|
263
|
+
width="100%"
|
|
264
|
+
height={360}
|
|
265
|
+
render={<section aria-label="Research chart" />}
|
|
266
|
+
/>
|
|
267
|
+
)
|
|
268
|
+
const root = screen.getByRole("region", { name: "Research chart" })
|
|
269
|
+
expect(root.getAttribute("data-slot")).toBe("chart")
|
|
270
|
+
expect(root.style.width).toBe("100%")
|
|
271
|
+
expect(root.style.height).toBe("360px")
|
|
272
|
+
expect(
|
|
273
|
+
(container.querySelector('[role="group"]') as HTMLElement).style.height
|
|
274
|
+
).toBe("100%")
|
|
275
|
+
})
|
|
276
|
+
test("explicit plot height and consumer styles take precedence", () => {
|
|
277
|
+
const { container } = render(
|
|
278
|
+
<Chart
|
|
279
|
+
type="bar"
|
|
280
|
+
data={[]}
|
|
281
|
+
series={series}
|
|
282
|
+
width={320}
|
|
283
|
+
height={360}
|
|
284
|
+
plotHeight="12rem"
|
|
285
|
+
style={{ width: "90%" }}
|
|
286
|
+
/>
|
|
287
|
+
)
|
|
288
|
+
expect(
|
|
289
|
+
(container.querySelector('[data-slot="chart"]') as HTMLElement).style
|
|
290
|
+
.width
|
|
291
|
+
).toBe("90%")
|
|
292
|
+
expect(
|
|
293
|
+
(container.querySelector('[role="group"]') as HTMLElement).style.height
|
|
294
|
+
).toBe("12rem")
|
|
295
|
+
})
|
|
296
|
+
test("custom components receive props and children in headerless mode", () => {
|
|
297
|
+
function Surface(props: React.ComponentProps<"section">) {
|
|
298
|
+
return <section {...props} data-surface="custom" />
|
|
299
|
+
}
|
|
300
|
+
render(
|
|
301
|
+
<Chart
|
|
302
|
+
type="pie"
|
|
303
|
+
data={[]}
|
|
304
|
+
series={series}
|
|
305
|
+
headerless
|
|
306
|
+
render={<Surface aria-label="Bare chart" />}
|
|
307
|
+
width={480}
|
|
308
|
+
height={240}
|
|
309
|
+
/>
|
|
310
|
+
)
|
|
311
|
+
const root = screen.getByRole("region", { name: "Bare chart" })
|
|
312
|
+
expect(root.getAttribute("data-surface")).toBe("custom")
|
|
313
|
+
expect(root.style.width).toBe("480px")
|
|
314
|
+
expect(screen.getByRole("status").textContent).toBe("No data")
|
|
315
|
+
})
|
|
316
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { atom } from "jotai"
|
|
2
|
+
import type { IChartState, TChartInstanceId } from "./type"
|
|
3
|
+
|
|
4
|
+
function createChartAtoms(id: TChartInstanceId) {
|
|
5
|
+
const selected = atom<string | null>(null)
|
|
6
|
+
selected.debugLabel = `chart/${id}/selectedKey`
|
|
7
|
+
const config = atom<{
|
|
8
|
+
controlled: boolean
|
|
9
|
+
initial: string | null
|
|
10
|
+
keys: readonly string[]
|
|
11
|
+
onChange?: (key: string | null) => void
|
|
12
|
+
}>({ controlled: false, initial: null, keys: [] })
|
|
13
|
+
const select = atom(null, (get, set, key: string | null) => {
|
|
14
|
+
const settings = get(config)
|
|
15
|
+
if (key !== null && !settings.keys.includes(key)) return
|
|
16
|
+
if (get(selected) === key) return
|
|
17
|
+
if (!settings.controlled) set(selected, key)
|
|
18
|
+
settings.onChange?.(key)
|
|
19
|
+
})
|
|
20
|
+
const toggle = atom(null, (get, set, key: string) =>
|
|
21
|
+
set(select, get(selected) === key ? null : key)
|
|
22
|
+
)
|
|
23
|
+
const reset = atom(null, (get, set) => {
|
|
24
|
+
const { initial, keys } = get(config)
|
|
25
|
+
set(select, initial !== null && keys.includes(initial) ? initial : null)
|
|
26
|
+
})
|
|
27
|
+
const state = atom<IChartState>((get) => ({ selectedKey: get(selected) }))
|
|
28
|
+
return { selected, config, select, toggle, reset, state }
|
|
29
|
+
}
|
|
30
|
+
const instances = new Map<
|
|
31
|
+
TChartInstanceId,
|
|
32
|
+
ReturnType<typeof createChartAtoms>
|
|
33
|
+
>()
|
|
34
|
+
function chartAtoms(id: TChartInstanceId) {
|
|
35
|
+
let atoms = instances.get(id)
|
|
36
|
+
if (!atoms) {
|
|
37
|
+
atoms = createChartAtoms(id)
|
|
38
|
+
instances.set(id, atoms)
|
|
39
|
+
}
|
|
40
|
+
return atoms
|
|
41
|
+
}
|
|
42
|
+
export const chartSelectedKeyAtom = (id: TChartInstanceId) =>
|
|
43
|
+
chartAtoms(id).selected
|
|
44
|
+
export const chartStateAtom = (id: TChartInstanceId) => chartAtoms(id).state
|
|
45
|
+
export const selectChartKeyAtom = (id: TChartInstanceId) =>
|
|
46
|
+
chartAtoms(id).select
|
|
47
|
+
export const toggleChartKeyAtom = (id: TChartInstanceId) =>
|
|
48
|
+
chartAtoms(id).toggle
|
|
49
|
+
export const resetChartAtom = (id: TChartInstanceId) => chartAtoms(id).reset
|
|
50
|
+
/** @internal Mounted root projects configuration and controlled props. */
|
|
51
|
+
export const chartConfigAtom = (id: TChartInstanceId) => chartAtoms(id).config
|
|
52
|
+
/** Call only after all consumers of a named instance have unmounted. */
|
|
53
|
+
export function removeChartInstance(id: TChartInstanceId) {
|
|
54
|
+
instances.delete(id)
|
|
55
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
import { createContext, useContext } from "react"
|
|
3
|
+
export const ChartContext = createContext<string | null>(null)
|
|
4
|
+
export function useChartContext() {
|
|
5
|
+
const id = useContext(ChartContext)
|
|
6
|
+
if (id === null)
|
|
7
|
+
throw new Error("useChartContext must be used within <Chart>")
|
|
8
|
+
return id
|
|
9
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
import type { CSSProperties } from "react"
|
|
3
|
+
import { LegendItem, type TDotTone } from "@/components/ui/chat"
|
|
4
|
+
import { cn } from "@/lib/utils"
|
|
5
|
+
import { chartSeriesColor, formatChartValue } from "./utils"
|
|
6
|
+
import type { IChartProps } from "./type"
|
|
7
|
+
export function ChartLegend({
|
|
8
|
+
type,
|
|
9
|
+
data,
|
|
10
|
+
series,
|
|
11
|
+
selectedKey,
|
|
12
|
+
interactive,
|
|
13
|
+
onSelect,
|
|
14
|
+
}: Pick<
|
|
15
|
+
IChartProps,
|
|
16
|
+
"type" | "data" | "series" | "selectedKey" | "interactive"
|
|
17
|
+
> & { onSelect: (key: string) => void }) {
|
|
18
|
+
const entries =
|
|
19
|
+
type === "pie"
|
|
20
|
+
? data.map((row, i) => ({
|
|
21
|
+
key: row.label,
|
|
22
|
+
label: row.label,
|
|
23
|
+
value: formatChartValue(series[0], row[series[0].key]),
|
|
24
|
+
color: `var(--chart-series-${(i % 5) + 1})`,
|
|
25
|
+
}))
|
|
26
|
+
: series.map((s, i) => ({
|
|
27
|
+
key: s.key,
|
|
28
|
+
label: s.label,
|
|
29
|
+
value: undefined,
|
|
30
|
+
color: chartSeriesColor(s, i),
|
|
31
|
+
}))
|
|
32
|
+
return (
|
|
33
|
+
<ul
|
|
34
|
+
data-slot="chart-legend"
|
|
35
|
+
className={cn(
|
|
36
|
+
"gap-3 flex flex-wrap",
|
|
37
|
+
type === "pie" && "min-w-24 flex-1 flex-col"
|
|
38
|
+
)}
|
|
39
|
+
>
|
|
40
|
+
{entries.map((entry, i) => (
|
|
41
|
+
<li
|
|
42
|
+
key={entry.key}
|
|
43
|
+
style={{ "--legend-color": entry.color } as CSSProperties}
|
|
44
|
+
className={cn(
|
|
45
|
+
"min-w-0 [&_[data-slot=dot]]:bg-(--legend-color)",
|
|
46
|
+
selectedKey && selectedKey !== entry.key && "opacity-40"
|
|
47
|
+
)}
|
|
48
|
+
>
|
|
49
|
+
{interactive ? (
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
aria-pressed={selectedKey === entry.key}
|
|
53
|
+
className="w-full rounded-sm text-left focus-visible:outline-2 focus-visible:outline-ring"
|
|
54
|
+
onClick={() => onSelect(entry.key)}
|
|
55
|
+
>
|
|
56
|
+
<LegendItem
|
|
57
|
+
tone={`series-${(i % 5) + 1}` as TDotTone}
|
|
58
|
+
value={entry.value}
|
|
59
|
+
>
|
|
60
|
+
{entry.label}
|
|
61
|
+
</LegendItem>
|
|
62
|
+
</button>
|
|
63
|
+
) : (
|
|
64
|
+
<LegendItem
|
|
65
|
+
tone={`series-${(i % 5) + 1}` as TDotTone}
|
|
66
|
+
value={entry.value}
|
|
67
|
+
>
|
|
68
|
+
{entry.label}
|
|
69
|
+
</LegendItem>
|
|
70
|
+
)}
|
|
71
|
+
</li>
|
|
72
|
+
))}
|
|
73
|
+
</ul>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
import { useMemo, useEffect, useRef, useState } from "react"
|
|
3
|
+
import { EChartsAreaChart as Area } from "./evilcharts/echarts-area-chart"
|
|
4
|
+
import { EChartsLineChart as Line } from "./evilcharts/echarts-line-chart"
|
|
5
|
+
import { EChartsBarChart as Bar } from "./evilcharts/echarts-bar-chart"
|
|
6
|
+
import { EChartsPieChart as Pie } from "./evilcharts/echarts-pie-chart"
|
|
7
|
+
import { resolveTooltipPosition } from "./evilcharts/echarts-tooltip"
|
|
8
|
+
import { buildChartConfig } from "./utils"
|
|
9
|
+
import { createChartTooltip } from "./chart-tooltip"
|
|
10
|
+
import type { IChartProps } from "./type"
|
|
11
|
+
|
|
12
|
+
/** Wait for layout before initializing ECharts (also supports hidden tabs and SSR). */
|
|
13
|
+
export function ChartPlot(props: IChartProps) {
|
|
14
|
+
const host = useRef<HTMLDivElement>(null)
|
|
15
|
+
const [ready, setReady] = useState(false)
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const node = host.current
|
|
18
|
+
if (!node) return
|
|
19
|
+
const observer = new ResizeObserver((entries) => {
|
|
20
|
+
if (
|
|
21
|
+
entries.some(
|
|
22
|
+
(entry) => entry.contentRect.width > 0 && entry.contentRect.height > 0
|
|
23
|
+
)
|
|
24
|
+
)
|
|
25
|
+
setReady(true)
|
|
26
|
+
})
|
|
27
|
+
observer.observe(node)
|
|
28
|
+
return () => observer.disconnect()
|
|
29
|
+
}, [])
|
|
30
|
+
return (
|
|
31
|
+
<div ref={host} className="h-full w-full">
|
|
32
|
+
{ready && <ChartRenderer {...props} />}
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ChartRenderer({
|
|
38
|
+
type,
|
|
39
|
+
data,
|
|
40
|
+
series,
|
|
41
|
+
variant = "default",
|
|
42
|
+
renderer = "canvas",
|
|
43
|
+
animation = true,
|
|
44
|
+
loading = false,
|
|
45
|
+
interactive = false,
|
|
46
|
+
selectedKey = null,
|
|
47
|
+
onSelectionChange,
|
|
48
|
+
curveType = "monotone",
|
|
49
|
+
grid = true,
|
|
50
|
+
xAxis = true,
|
|
51
|
+
yAxis = false,
|
|
52
|
+
tooltip = true,
|
|
53
|
+
brush = false,
|
|
54
|
+
pie,
|
|
55
|
+
chartOptions,
|
|
56
|
+
}: IChartProps) {
|
|
57
|
+
const config = useMemo(
|
|
58
|
+
() => buildChartConfig(type, data, series),
|
|
59
|
+
[type, data, series]
|
|
60
|
+
)
|
|
61
|
+
const options = useMemo(
|
|
62
|
+
() => ({
|
|
63
|
+
...(tooltip
|
|
64
|
+
? {
|
|
65
|
+
tooltip: {
|
|
66
|
+
show: !loading,
|
|
67
|
+
trigger: type === "pie" ? "item" : "axis",
|
|
68
|
+
confine: true,
|
|
69
|
+
padding: 0,
|
|
70
|
+
borderWidth: 0,
|
|
71
|
+
backgroundColor: "transparent",
|
|
72
|
+
// Only the inner tooltip owns a shadow; ECharts adds one otherwise.
|
|
73
|
+
extraCssText: "box-shadow:none;",
|
|
74
|
+
axisPointer: {
|
|
75
|
+
type:
|
|
76
|
+
typeof tooltip === "object" && tooltip.cursor === false
|
|
77
|
+
? "none"
|
|
78
|
+
: "line",
|
|
79
|
+
},
|
|
80
|
+
position: resolveTooltipPosition(
|
|
81
|
+
typeof tooltip === "object"
|
|
82
|
+
? (tooltip.position ?? "variable")
|
|
83
|
+
: "variable"
|
|
84
|
+
),
|
|
85
|
+
formatter: createChartTooltip(
|
|
86
|
+
type,
|
|
87
|
+
data,
|
|
88
|
+
series,
|
|
89
|
+
typeof tooltip === "object" ? tooltip : {}
|
|
90
|
+
),
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
: {}),
|
|
94
|
+
...chartOptions,
|
|
95
|
+
}),
|
|
96
|
+
[tooltip, loading, type, data, series, chartOptions]
|
|
97
|
+
)
|
|
98
|
+
const common = {
|
|
99
|
+
data,
|
|
100
|
+
config,
|
|
101
|
+
renderer,
|
|
102
|
+
animation,
|
|
103
|
+
isLoading: loading,
|
|
104
|
+
chartOptions: options,
|
|
105
|
+
className: "h-full w-full",
|
|
106
|
+
}
|
|
107
|
+
const selection = { selectedDataKey: selectedKey, onSelectionChange }
|
|
108
|
+
const tooltipProps = typeof tooltip === "object" ? tooltip : {}
|
|
109
|
+
const xProps = typeof xAxis === "object" ? xAxis : {}
|
|
110
|
+
const yProps = typeof yAxis === "object" ? yAxis : {}
|
|
111
|
+
const brushProps = typeof brush === "object" ? brush : {}
|
|
112
|
+
if (type === "pie")
|
|
113
|
+
return (
|
|
114
|
+
<Pie
|
|
115
|
+
{...common}
|
|
116
|
+
dataKey={series[0]?.key ?? "value"}
|
|
117
|
+
nameKey="label"
|
|
118
|
+
selectedSector={selectedKey}
|
|
119
|
+
onSelectionChange={(value) =>
|
|
120
|
+
onSelectionChange?.(value?.dataKey ?? null)
|
|
121
|
+
}
|
|
122
|
+
>
|
|
123
|
+
<Pie.Pie
|
|
124
|
+
innerRadius={variant === "donut" || variant === "default" ? "66%" : 0}
|
|
125
|
+
outerRadius="95%"
|
|
126
|
+
paddingAngle={2}
|
|
127
|
+
cornerRadius={8}
|
|
128
|
+
isClickable={interactive}
|
|
129
|
+
{...pie}
|
|
130
|
+
/>
|
|
131
|
+
{tooltip && <Pie.Tooltip {...tooltipProps} />}
|
|
132
|
+
</Pie>
|
|
133
|
+
)
|
|
134
|
+
if (type === "bar")
|
|
135
|
+
return (
|
|
136
|
+
<Bar
|
|
137
|
+
{...common}
|
|
138
|
+
{...selection}
|
|
139
|
+
xDataKey="label"
|
|
140
|
+
barRadius={8}
|
|
141
|
+
layout={variant === "horizontal" ? "horizontal" : "vertical"}
|
|
142
|
+
stackType={
|
|
143
|
+
variant === "percent"
|
|
144
|
+
? "percent"
|
|
145
|
+
: variant === "stacked"
|
|
146
|
+
? "stacked"
|
|
147
|
+
: "default"
|
|
148
|
+
}
|
|
149
|
+
>
|
|
150
|
+
{series.map((s) => (
|
|
151
|
+
<Bar.Bar
|
|
152
|
+
key={s.key}
|
|
153
|
+
dataKey={s.key}
|
|
154
|
+
isClickable={interactive}
|
|
155
|
+
{...s.bar}
|
|
156
|
+
/>
|
|
157
|
+
))}
|
|
158
|
+
{xAxis && <Bar.XAxis {...xProps} />}
|
|
159
|
+
{yAxis && (
|
|
160
|
+
<Bar.YAxis
|
|
161
|
+
{...yProps}
|
|
162
|
+
tickFormatter={
|
|
163
|
+
yProps.tickFormatter
|
|
164
|
+
? (value, index) => yProps.tickFormatter!(Number(value), index)
|
|
165
|
+
: undefined
|
|
166
|
+
}
|
|
167
|
+
/>
|
|
168
|
+
)}
|
|
169
|
+
{grid && <Bar.Grid />}
|
|
170
|
+
{tooltip && <Bar.Tooltip {...tooltipProps} />}
|
|
171
|
+
{brush && variant !== "horizontal" && <Bar.Brush {...brushProps} />}
|
|
172
|
+
</Bar>
|
|
173
|
+
)
|
|
174
|
+
if (type === "line")
|
|
175
|
+
return (
|
|
176
|
+
<Line
|
|
177
|
+
{...common}
|
|
178
|
+
{...selection}
|
|
179
|
+
xDataKey="label"
|
|
180
|
+
curveType={curveType}
|
|
181
|
+
enableHoverHighlight={interactive}
|
|
182
|
+
>
|
|
183
|
+
{series.map((s) => (
|
|
184
|
+
<Line.Line
|
|
185
|
+
key={s.key}
|
|
186
|
+
dataKey={s.key}
|
|
187
|
+
isClickable={interactive}
|
|
188
|
+
{...s.line}
|
|
189
|
+
>
|
|
190
|
+
<Line.Dot />
|
|
191
|
+
<Line.ActiveDot />
|
|
192
|
+
</Line.Line>
|
|
193
|
+
))}
|
|
194
|
+
{xAxis && <Line.XAxis {...xProps} />}
|
|
195
|
+
{yAxis && <Line.YAxis {...yProps} />}
|
|
196
|
+
{grid && <Line.Grid />}
|
|
197
|
+
{tooltip && <Line.Tooltip {...tooltipProps} />}
|
|
198
|
+
{brush && <Line.Brush {...brushProps} />}
|
|
199
|
+
</Line>
|
|
200
|
+
)
|
|
201
|
+
return (
|
|
202
|
+
<Area
|
|
203
|
+
{...common}
|
|
204
|
+
{...selection}
|
|
205
|
+
xDataKey="label"
|
|
206
|
+
curveType={curveType}
|
|
207
|
+
enableHoverHighlight={interactive}
|
|
208
|
+
stackType={
|
|
209
|
+
variant === "percent"
|
|
210
|
+
? "expanded"
|
|
211
|
+
: variant === "stacked"
|
|
212
|
+
? "stacked"
|
|
213
|
+
: "default"
|
|
214
|
+
}
|
|
215
|
+
>
|
|
216
|
+
{series.map((s) => (
|
|
217
|
+
<Area.Area
|
|
218
|
+
key={s.key}
|
|
219
|
+
dataKey={s.key}
|
|
220
|
+
isClickable={interactive}
|
|
221
|
+
{...s.area}
|
|
222
|
+
>
|
|
223
|
+
<Area.ActiveDot />
|
|
224
|
+
</Area.Area>
|
|
225
|
+
))}
|
|
226
|
+
{xAxis && <Area.XAxis {...xProps} />}
|
|
227
|
+
{yAxis && <Area.YAxis {...yProps} />}
|
|
228
|
+
{grid && <Area.Grid />}
|
|
229
|
+
{tooltip && <Area.Tooltip {...tooltipProps} />}
|
|
230
|
+
{brush && <Area.Brush {...brushProps} />}
|
|
231
|
+
</Area>
|
|
232
|
+
)
|
|
233
|
+
}
|