Skip to content

Commit 9b3e945

Browse files
authored
Merge pull request #141 from bcdev/clarasb-132-update_VegaChart
Update react-vega to v8 and adapt VegaChart accordingly
2 parents e295ef8 + ad8def6 commit 9b3e945

File tree

15 files changed

+505
-343
lines changed

15 files changed

+505
-343
lines changed

chartlets.js/CHANGES.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
* Updated dependencies
44
- `glob: ^13.0.1`
5+
- `react-vega: ^8.0.0`
6+
- `vega-lite: ^6.4.1`
57
- `@vitest/coverage-istanbul: ^3.2.4`
68
- `vite: ^7.1.11`
79
- `vitest: ^3.2.4`
@@ -10,6 +12,10 @@
1012
(#124).
1113

1214
* Added (MUI) component `Accordion`. (#41, #134)
15+
(#124)
16+
17+
* Adjusted `VegaChart` component, due to `react-vega` upgrade
18+
from v7 to v8. (#132)
1319

1420
* Fixed handling of `style` prop in `Tabs` component and added prop
1521
`iconPosition`. (#135, #136)

chartlets.js/package-lock.json

Lines changed: 256 additions & 311 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

chartlets.js/packages/demo/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@
3434
"chartlets": "file:../lib",
3535
"react": "^18.3.1",
3636
"react-dom": "^18.3.1",
37-
"react-vega": "^7.7.1",
37+
"react-vega": "^8.0.0",
3838
"vega": "^6.2.0",
3939
"vega-embed": "^7.1.0",
40-
"vega-lite": "^6.4.1",
40+
"vega-lite": "^6.4.2",
4141
"vega-themes": ">=2",
4242
"zustand": "^5.0.0"
4343
},

chartlets.js/packages/lib/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,10 @@
6565
"@mui/x-data-grid": ">=7",
6666
"react": "^18.3.1",
6767
"react-dom": "^18.3.1",
68-
"react-vega": "^7.7.1",
68+
"react-vega": "^8.0.0",
6969
"vega": "^6.2.0",
7070
"vega-embed": "^7.1.0",
71-
"vega-lite": "^6.4.1",
71+
"vega-lite": "^6.4.2",
7272
"vega-themes": ">=2"
7373
},
7474
"peerDependenciesMeta": {

chartlets.js/packages/lib/src/plugins/vega/VegaChart.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ describe("VegaChart", () => {
5050
});
5151

5252
const chart: TopLevelSpec = {
53-
$schema: "https://vega.github.io/schema/vega-lite/v5.20.1.json",
53+
$schema: "https://vega.github.io/schema/vega-lite/v6.json",
5454
config: { view: { continuousWidth: 300, continuousHeight: 300 } },
5555
data: { name: "data-0" },
5656
mark: { type: "bar" },

chartlets.js/packages/lib/src/plugins/vega/VegaChart.tsx

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
* https://opensource.org/licenses/MIT.
55
*/
66

7-
import { VegaLite } from "react-vega";
7+
import { useRef } from "react";
8+
import { VegaEmbed } from "react-vega";
89
import type { TopLevelSpec } from "vega-lite";
910

1011
import type { ComponentProps, ComponentState } from "@/index";
@@ -14,9 +15,7 @@ import { useResizeObserver } from "./hooks/useResizeObserver";
1415

1516
interface VegaChartState extends ComponentState {
1617
theme?: VegaTheme | "default" | "system";
17-
chart?:
18-
| TopLevelSpec // This is the vega-lite specification type
19-
| null;
18+
chart?: TopLevelSpec | null;
2019
}
2120

2221
interface VegaChartProps extends ComponentProps, VegaChartState {}
@@ -29,19 +28,25 @@ export function VegaChart({
2928
chart,
3029
onChange,
3130
}: VegaChartProps) {
32-
const signalListeners = useSignalListeners(chart, type, id, onChange);
31+
const { onEmbed } = useSignalListeners(chart, type, id, onChange);
3332
const vegaTheme = useVegaTheme(theme);
3433
const { containerSizeKey, containerCallbackRef } = useResizeObserver();
34+
35+
const embedDivRef = useRef<HTMLDivElement | null>(null);
36+
3537
if (chart) {
3638
return (
3739
<div id="chart-container" ref={containerCallbackRef} style={style}>
38-
<VegaLite
40+
<VegaEmbed
3941
key={containerSizeKey}
40-
theme={vegaTheme}
42+
ref={embedDivRef}
4143
spec={chart}
44+
onEmbed={onEmbed}
45+
options={{
46+
actions: false,
47+
theme: vegaTheme,
48+
}}
4249
style={style}
43-
signalListeners={signalListeners}
44-
actions={false}
4550
/>
4651
</div>
4752
);

chartlets.js/packages/lib/src/plugins/vega/hooks/useSignalListeners.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { useSignalListeners } from "./useSignalListeners";
1111
import { createChangeHandler } from "@/plugins/mui/common.test";
1212

1313
const chart: TopLevelSpec = {
14-
$schema: "https://vega.github.io/schema/vega-lite/v5.20.1.json",
14+
$schema: "https://vega.github.io/schema/vega-lite/v6.json",
1515
config: { view: { continuousWidth: 300, continuousHeight: 300 } },
1616
data: { name: "data-0" },
1717
mark: { type: "bar" },
@@ -54,9 +54,9 @@ describe("useSignalListeners", () => {
5454
const { result, rerender } = renderHook(() =>
5555
useSignalListeners(chart, "VegaChart", "my_chart", () => {}),
5656
);
57-
const signalHandlers1 = result.current;
57+
const signalHandlers1 = result.current.signalListenerMap;
5858
rerender();
59-
const signalHandlers2 = result.current;
59+
const signalHandlers2 = result.current.signalListenerMap;
6060
expect(signalHandlers1).toEqual({});
6161
expect(signalHandlers2).toEqual({});
6262
expect(signalHandlers1).toBe(signalHandlers1);
@@ -66,21 +66,21 @@ describe("useSignalListeners", () => {
6666
const { result } = renderHook(() =>
6767
useSignalListeners(chartWithSelect, "VegaChart", "my_chart", () => {}),
6868
);
69-
const signalHandlers = result.current;
69+
const signalHandlers = result.current.signalListenerMap;
7070
expect(signalHandlers).toBeDefined();
7171
expect(signalHandlers["sel_point"]).toBeTypeOf("function");
7272
expect(signalHandlers["sel_interval"]).toBeTypeOf("function");
7373
expect(signalHandlers["sel_point_a"]).toBeTypeOf("function");
7474
// "wheel" not supported
75-
expect(signalHandlers["sel_point_b"]).toBeUndefined();
75+
expect(signalHandlers["sel_interval_b"]).toBeUndefined();
7676
});
7777

7878
it("should call onChange", () => {
7979
const { recordedEvents, onChange } = createChangeHandler();
8080
const { result } = renderHook(() =>
8181
useSignalListeners(chartWithSelect, "VegaChart", "my_chart", onChange),
8282
);
83-
const signalHandlers = result.current;
83+
const signalHandlers = result.current.signalListenerMap;
8484
expect(signalHandlers).toBeDefined();
8585
const signalHandler = signalHandlers["sel_point_a"];
8686
expect(signalHandler).toBeTypeOf("function");

chartlets.js/packages/lib/src/plugins/vega/hooks/useSignalListeners.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
*/
66

77
import { useCallback, useMemo } from "react";
8+
import type { Result as VegaEmbedResult } from "vega-embed";
89
import type { TopLevelSpec } from "vega-lite";
910

1011
import { type ComponentChangeHandler } from "@/index";
1112
import { isString } from "@/utils/isString";
1213
import { isObject } from "@/utils/isObject";
14+
import { useVegaSignalEmbed } from "./useVegaSignalEmbed";
1315

1416
type SignalHandler = (signalName: string, signalValue: unknown) => void;
1517

@@ -24,6 +26,11 @@ type SelectionParameter = {
2426
select: "point" | "interval" | { type: "point" | "interval"; on: string };
2527
};
2628

29+
type UseSignalListenersReturn = {
30+
onEmbed: (result: VegaEmbedResult) => void;
31+
signalListenerMap: Record<string, SignalHandler>;
32+
};
33+
2734
const isSelectionParameter = (param: unknown): param is SelectionParameter =>
2835
isObject(param) &&
2936
(param.select === "point" ||
@@ -37,7 +44,7 @@ export function useSignalListeners(
3744
type: string,
3845
id: string | undefined,
3946
onChange: ComponentChangeHandler,
40-
): Record<string, SignalHandler> {
47+
): UseSignalListenersReturn {
4148
/*
4249
* Here, we create map of signals which will be then used to create the
4350
* map of signal-listeners because not all params are event-listeners, and we
@@ -65,7 +72,7 @@ export function useSignalListeners(
6572
}, signalNames);
6673
}, [chart]);
6774

68-
const handleClickSignal = useCallback(
75+
const handleSignal = useCallback(
6976
(signalName: string, signalValue: unknown) => {
7077
if (id) {
7178
return onChange({
@@ -83,14 +90,14 @@ export function useSignalListeners(
8390
* Creates the map of signal listeners based on
8491
* the `signals` map computed above.
8592
*/
86-
return useMemo(() => {
93+
const signalListenerMap = useMemo(() => {
8794
/*
8895
* Currently, we only have click events support, but if more are required,
8996
* they can be implemented and added in the map below.
9097
*/
9198
const signalHandlers: Record<string, SignalHandler> = {
92-
click: handleClickSignal,
93-
drag: handleClickSignal,
99+
click: handleSignal,
100+
drag: handleSignal,
94101
};
95102

96103
const signalListeners: Record<string, SignalHandler> = {};
@@ -104,5 +111,9 @@ export function useSignalListeners(
104111
}
105112
});
106113
return signalListeners;
107-
}, [signalNames, handleClickSignal]);
114+
}, [signalNames, handleSignal]);
115+
116+
const onEmbed = useVegaSignalEmbed(signalListenerMap);
117+
118+
return { onEmbed, signalListenerMap };
108119
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/*
2+
* Copyright (c) 2019-2026 by Brockmann Consult Development team
3+
* Permissions are hereby granted under the terms of the MIT License:
4+
* https://opensource.org/licenses/MIT.
5+
*/
6+
7+
import { describe, it, expect, vi } from "vitest";
8+
import { renderHook, act } from "@testing-library/react";
9+
import type { Result as VegaEmbedResult } from "vega-embed";
10+
11+
import { useVegaSignalEmbed } from "./useVegaSignalEmbed";
12+
13+
type SignalHandler = (signalName: string, signalValue: unknown) => void;
14+
15+
describe("useVegaSignalEmbed", () => {
16+
it("should register signal listeners on embed", () => {
17+
const signalListenerMap: Record<string, SignalHandler> = {
18+
sel_point: vi.fn(),
19+
sel_interval: vi.fn(),
20+
sel_point_a: vi.fn(),
21+
};
22+
23+
const { result } = renderHook(() => useVegaSignalEmbed(signalListenerMap));
24+
25+
const view = createMockView();
26+
27+
act(() => {
28+
result.current({ view } as unknown as VegaEmbedResult);
29+
});
30+
31+
expect(view.addSignalListener).toHaveBeenCalledTimes(3);
32+
33+
const names = view.addSignalListener.mock.calls.map(([name]) => name);
34+
expect(names).toEqual(
35+
expect.arrayContaining(["sel_point", "sel_interval", "sel_point_a"]),
36+
);
37+
});
38+
39+
it("should remove old listeners when embedding again", () => {
40+
const signalListenerMap: Record<string, SignalHandler> = {
41+
sel_point: vi.fn(),
42+
sel_interval: vi.fn(),
43+
};
44+
45+
const { result } = renderHook(() => useVegaSignalEmbed(signalListenerMap));
46+
47+
const view1 = createMockView();
48+
const view2 = createMockView();
49+
50+
act(() => {
51+
result.current({ view: view1 } as unknown as VegaEmbedResult);
52+
});
53+
54+
const attachedToView1 = view1.addSignalListener.mock.calls.map(
55+
([name, fn]) => ({ name, fn }),
56+
);
57+
58+
act(() => {
59+
result.current({ view: view2 } as unknown as VegaEmbedResult);
60+
});
61+
62+
expect(view1.removeSignalListener).toHaveBeenCalledTimes(
63+
attachedToView1.length,
64+
);
65+
66+
for (const { name, fn } of attachedToView1) {
67+
expect(view1.removeSignalListener).toHaveBeenCalledWith(name, fn);
68+
}
69+
70+
expect(view2.addSignalListener).toHaveBeenCalledTimes(2);
71+
});
72+
73+
it("should cleanup listeners on unmount", () => {
74+
const signalListenerMap: Record<string, SignalHandler> = {
75+
sel_point: vi.fn(),
76+
sel_interval: vi.fn(),
77+
};
78+
79+
const { result, unmount } = renderHook(() =>
80+
useVegaSignalEmbed(signalListenerMap),
81+
);
82+
83+
const view = createMockView();
84+
85+
act(() => {
86+
result.current({ view } as unknown as VegaEmbedResult);
87+
});
88+
89+
const attached = view.addSignalListener.mock.calls.map(([name, fn]) => ({
90+
name,
91+
fn,
92+
}));
93+
94+
unmount();
95+
96+
expect(view.removeSignalListener).toHaveBeenCalledTimes(attached.length);
97+
98+
for (const { name, fn } of attached) {
99+
expect(view.removeSignalListener).toHaveBeenCalledWith(name, fn);
100+
}
101+
});
102+
103+
it("should do nothing if embed result has no view", () => {
104+
const signalListenerMap: Record<string, SignalHandler> = {
105+
sel_point: vi.fn(),
106+
};
107+
108+
const { result } = renderHook(() => useVegaSignalEmbed(signalListenerMap));
109+
110+
act(() => {
111+
result.current({} as VegaEmbedResult);
112+
});
113+
});
114+
115+
it("should register no listeners if the signal listener map is empty", () => {
116+
const { result } = renderHook(() => useVegaSignalEmbed({}));
117+
118+
const view = createMockView();
119+
120+
act(() => {
121+
result.current({ view } as unknown as VegaEmbedResult);
122+
});
123+
124+
expect(view.addSignalListener).not.toHaveBeenCalled();
125+
expect(view.removeSignalListener).not.toHaveBeenCalled();
126+
});
127+
});
128+
129+
function createMockView() {
130+
return {
131+
addSignalListener: vi.fn(),
132+
removeSignalListener: vi.fn(),
133+
};
134+
}

0 commit comments

Comments
 (0)