Skip to content

Commit 96c1ef3

Browse files
authored
Add dynamic resizing for Vega charts (#121)
* initial commit * dev version bump * fix vega versions mishap (new release brought dep issue hell) * fix test * update package-lock.json * update CHANGES.md * extract hook and add tests
1 parent 7f34d9e commit 96c1ef3

File tree

8 files changed

+1676
-934
lines changed

8 files changed

+1676
-934
lines changed

chartlets.js/CHANGES.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
## Version 0.1.5
1+
## Version 0.1.6 (in development)
2+
3+
* Implemented dynamic resizing for Vega Charts when the side panel's
4+
width changes.
5+
See https://github.com/xcube-dev/xcube/issues/1134
6+
7+
## Version 0.1.5 (from 2025/03/21)
28

39
* Add `multiple` property for `Select` component to enable the selection
410
of multiple elements. The `default` mode is supported at the moment.

chartlets.js/package-lock.json

Lines changed: 1506 additions & 920 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: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "chartlets-demo",
3-
"version": "0.1.5",
3+
"version": "0.1.6-dev.0",
44
"description": "Demonstrator for the Chartlets framework.",
55
"type": "module",
66
"files": [
@@ -34,7 +34,11 @@
3434
"chartlets": "file:../lib",
3535
"react": "^18.3.1",
3636
"react-dom": "^18.3.1",
37-
"react-vega": "^7.6.0",
37+
"react-vega": ">=7",
38+
"vega": "^5.33.0",
39+
"vega-embed": "^6.5.1",
40+
"vega-lite": "^5.23.0",
41+
"vega-themes": ">=2",
3842
"zustand": "^5.0.0"
3943
},
4044
"devDependencies": {

chartlets.js/packages/lib/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "chartlets",
3-
"version": "0.1.5",
3+
"version": "0.1.6-dev.0",
44
"description": "An experimental library for integrating interactive charts into existing JavaScript applications.",
55
"type": "module",
66
"files": [
@@ -66,6 +66,9 @@
6666
"react": "^18.3.1",
6767
"react-dom": "^18.3.1",
6868
"react-vega": ">=7",
69+
"vega": "^5.33.0",
70+
"vega-embed": "^6.5.1",
71+
"vega-lite": "^5.23.0",
6972
"vega-themes": ">=2"
7073
},
7174
"peerDependenciesMeta": {

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1-
import { describe, it, expect } from "vitest";
1+
import { describe, expect, it } from "vitest";
22
import { render } from "@testing-library/react";
33
// import { render, screen, fireEvent } from "@testing-library/react";
44
import type { TopLevelSpec } from "vega-lite";
55
import { createChangeHandler } from "@/plugins/mui/common.test";
66
import { VegaChart } from "./VegaChart";
77

8+
global.ResizeObserver = class {
9+
observe() {}
10+
unobserve() {}
11+
disconnect() {}
12+
};
13+
814
describe("VegaChart", () => {
915
it("should render if chart is not given", () => {
1016
render(

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

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { VegaLite } from "react-vega";
22
import type { TopLevelSpec } from "vega-lite";
33

4-
import type { ComponentState, ComponentProps } from "@/index";
4+
import type { ComponentProps, ComponentState } from "@/index";
55
import { useSignalListeners } from "./hooks/useSignalListeners";
66
import { useVegaTheme, type VegaTheme } from "./hooks/useVegaTheme";
7+
import { useResizeObserver } from "./hooks/useResizeObserver";
78

89
interface VegaChartState extends ComponentState {
910
theme?: VegaTheme | "default" | "system";
@@ -24,17 +25,21 @@ export function VegaChart({
2425
}: VegaChartProps) {
2526
const signalListeners = useSignalListeners(chart, type, id, onChange);
2627
const vegaTheme = useVegaTheme(theme);
28+
const { containerSizeKey, containerCallbackRef } = useResizeObserver();
2729
if (chart) {
2830
return (
29-
<VegaLite
30-
theme={vegaTheme}
31-
spec={chart}
32-
style={style}
33-
signalListeners={signalListeners}
34-
actions={false}
35-
/>
31+
<div id="chart-container" ref={containerCallbackRef} style={style}>
32+
<VegaLite
33+
key={containerSizeKey}
34+
theme={vegaTheme}
35+
spec={chart}
36+
style={style}
37+
signalListeners={signalListeners}
38+
actions={false}
39+
/>
40+
</div>
3641
);
3742
} else {
38-
return <div id={id} style={style} />;
43+
return <div id={id} />;
3944
}
4045
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { useResizeObserver } from "./useResizeObserver";
3+
import { act, render } from "@testing-library/react";
4+
import { useEffect } from "react";
5+
6+
global.ResizeObserver = class {
7+
observe() {}
8+
unobserve() {}
9+
disconnect() {}
10+
};
11+
12+
describe("useResizeObserver", () => {
13+
it("should observe a node", () => {
14+
let callbackRef: (node: Element | null) => void;
15+
16+
const TestComponent = () => {
17+
const { containerCallbackRef } = useResizeObserver();
18+
callbackRef = containerCallbackRef;
19+
return <div ref={containerCallbackRef} />;
20+
};
21+
22+
render(<TestComponent />);
23+
24+
const observeSpy = vi.spyOn(global.ResizeObserver.prototype, "observe");
25+
26+
const node = document.createElement("div");
27+
act(() => {
28+
if (callbackRef) {
29+
callbackRef(node);
30+
}
31+
});
32+
33+
expect(observeSpy).toHaveBeenCalledWith(node);
34+
});
35+
36+
it("should disconnect previous observer when node changes", () => {
37+
let callbackRef: (node: Element | null) => void;
38+
39+
const TestComponent = () => {
40+
const { containerCallbackRef } = useResizeObserver();
41+
callbackRef = containerCallbackRef;
42+
return null;
43+
};
44+
45+
render(<TestComponent />);
46+
47+
const disconnectSpy = vi.spyOn(
48+
global.ResizeObserver.prototype,
49+
"disconnect",
50+
);
51+
52+
const observeSpy = vi.spyOn(global.ResizeObserver.prototype, "observe");
53+
54+
const node1 = document.createElement("div");
55+
const node2 = document.createElement("div");
56+
57+
act(() => {
58+
if (callbackRef) {
59+
callbackRef(node1);
60+
callbackRef(node2);
61+
}
62+
});
63+
64+
expect(disconnectSpy).toHaveBeenCalled();
65+
expect(observeSpy).toHaveBeenCalledWith(node2);
66+
});
67+
68+
it("should update containerSizeKey on resize", () => {
69+
let observerCallback: ResizeObserverCallback | null = null;
70+
71+
global.ResizeObserver = class {
72+
constructor(cb: ResizeObserverCallback) {
73+
observerCallback = cb;
74+
}
75+
observe() {}
76+
unobserve() {}
77+
disconnect() {}
78+
};
79+
80+
let lastKey = 0;
81+
82+
const TestComponent = () => {
83+
const { containerCallbackRef, containerSizeKey } = useResizeObserver();
84+
85+
useEffect(() => {
86+
lastKey = containerSizeKey;
87+
}, [containerSizeKey]);
88+
89+
return <div ref={containerCallbackRef} />;
90+
};
91+
92+
render(<TestComponent />);
93+
const initialKey = lastKey;
94+
95+
act(() => {
96+
if (observerCallback) {
97+
observerCallback([], {} as ResizeObserver);
98+
}
99+
});
100+
101+
expect(lastKey).not.toBe(initialKey);
102+
});
103+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { useCallback, useRef, useState } from "react";
2+
3+
export interface ResizeObserverResult {
4+
containerSizeKey: number;
5+
containerCallbackRef: (node: Element | null) => void;
6+
}
7+
8+
export function useResizeObserver(): ResizeObserverResult {
9+
const containerRef = useRef<ResizeObserver | null>(null);
10+
const [containerSizeKey, setContainerSizeKey] = useState(0);
11+
const containerCallbackRef = useCallback((node: Element | null) => {
12+
if (containerRef.current) {
13+
containerRef.current.disconnect();
14+
containerRef.current = null;
15+
}
16+
if (node !== null) {
17+
const resizeObserver = new ResizeObserver((_entries) => {
18+
// We only need to know that a resize happened because it triggers a
19+
// re-render allowing vega to get the latest layout.
20+
setContainerSizeKey(Date.now());
21+
});
22+
23+
resizeObserver.observe(node);
24+
containerRef.current = resizeObserver;
25+
}
26+
}, []);
27+
28+
return { containerSizeKey, containerCallbackRef };
29+
}

0 commit comments

Comments
 (0)