Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion chartlets.js/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@

* Updated dependencies
- `glob: ^13.0.1`
- `react-vega: ^8.0.0`
- `vega-lite: ^6.4.1`
- `@vitest/coverage-istanbul: ^3.2.4`
- `vite: ^7.1.11`
- `vitest: ^3.2.4`

* Added icon support for `Button`, `IconButton` and `Tabs` components.
(#124).
(#124)

* Adjusted `VegaChart` component, due to `react-vega` upgrade
from v7 to v8. (#132)


## Version 0.1.7 (from 2025/12/03)

Expand Down
567 changes: 256 additions & 311 deletions chartlets.js/package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions chartlets.js/packages/demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@
"chartlets": "file:../lib",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-vega": "^7.7.1",
"react-vega": "^8.0.0",
"vega": "^6.2.0",
"vega-embed": "^7.1.0",
"vega-lite": "^6.4.1",
"vega-lite": "^6.4.2",
"vega-themes": ">=2",
"zustand": "^5.0.0"
},
Expand Down
4 changes: 2 additions & 2 deletions chartlets.js/packages/lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,10 @@
"@mui/x-data-grid": ">=7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-vega": "^7.7.1",
"react-vega": "^8.0.0",
"vega": "^6.2.0",
"vega-embed": "^7.1.0",
"vega-lite": "^6.4.1",
"vega-lite": "^6.4.2",
"vega-themes": ">=2"
},
"peerDependenciesMeta": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe("VegaChart", () => {
});

const chart: TopLevelSpec = {
$schema: "https://vega.github.io/schema/vega-lite/v5.20.1.json",
$schema: "https://vega.github.io/schema/vega-lite/v6.json",
config: { view: { continuousWidth: 300, continuousHeight: 300 } },
data: { name: "data-0" },
mark: { type: "bar" },
Expand Down
23 changes: 14 additions & 9 deletions chartlets.js/packages/lib/src/plugins/vega/VegaChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
* https://opensource.org/licenses/MIT.
*/

import { VegaLite } from "react-vega";
import { useRef } from "react";
import { VegaEmbed } from "react-vega";
import type { TopLevelSpec } from "vega-lite";

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

interface VegaChartState extends ComponentState {
theme?: VegaTheme | "default" | "system";
chart?:
| TopLevelSpec // This is the vega-lite specification type
| null;
chart?: TopLevelSpec | null;
}

interface VegaChartProps extends ComponentProps, VegaChartState {}
Expand All @@ -29,19 +28,25 @@ export function VegaChart({
chart,
onChange,
}: VegaChartProps) {
const signalListeners = useSignalListeners(chart, type, id, onChange);
const { onEmbed } = useSignalListeners(chart, type, id, onChange);
const vegaTheme = useVegaTheme(theme);
const { containerSizeKey, containerCallbackRef } = useResizeObserver();

const embedDivRef = useRef<HTMLDivElement | null>(null);

if (chart) {
return (
<div id="chart-container" ref={containerCallbackRef} style={style}>
<VegaLite
<VegaEmbed
key={containerSizeKey}
theme={vegaTheme}
ref={embedDivRef}
spec={chart}
onEmbed={onEmbed}
options={{
actions: false,
theme: vegaTheme,
}}
style={style}
signalListeners={signalListeners}
actions={false}
/>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@
* https://opensource.org/licenses/MIT.
*/

import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import type { TopLevelSpec } from "vega-lite";
import { useSignalListeners } from "./useSignalListeners";
import { createChangeHandler } from "@/plugins/mui/common.test";
import type { Result as VegaEmbedResult } from "vega-embed";

const chart: TopLevelSpec = {
$schema: "https://vega.github.io/schema/vega-lite/v5.20.1.json",
$schema: "https://vega.github.io/schema/vega-lite/v6.json",
config: { view: { continuousWidth: 300, continuousHeight: 300 } },
data: { name: "data-0" },
mark: { type: "bar" },
Expand Down Expand Up @@ -54,9 +55,9 @@ describe("useSignalListeners", () => {
const { result, rerender } = renderHook(() =>
useSignalListeners(chart, "VegaChart", "my_chart", () => {}),
);
const signalHandlers1 = result.current;
const signalHandlers1 = result.current.signalListenerMap;
rerender();
const signalHandlers2 = result.current;
const signalHandlers2 = result.current.signalListenerMap;
expect(signalHandlers1).toEqual({});
expect(signalHandlers2).toEqual({});
expect(signalHandlers1).toBe(signalHandlers1);
Expand All @@ -66,21 +67,21 @@ describe("useSignalListeners", () => {
const { result } = renderHook(() =>
useSignalListeners(chartWithSelect, "VegaChart", "my_chart", () => {}),
);
const signalHandlers = result.current;
const signalHandlers = result.current.signalListenerMap;
expect(signalHandlers).toBeDefined();
expect(signalHandlers["sel_point"]).toBeTypeOf("function");
expect(signalHandlers["sel_interval"]).toBeTypeOf("function");
expect(signalHandlers["sel_point_a"]).toBeTypeOf("function");
// "wheel" not supported
expect(signalHandlers["sel_point_b"]).toBeUndefined();
expect(signalHandlers["sel_interval_b"]).toBeUndefined();
});

it("should call onChange", () => {
const { recordedEvents, onChange } = createChangeHandler();
const { result } = renderHook(() =>
useSignalListeners(chartWithSelect, "VegaChart", "my_chart", onChange),
);
const signalHandlers = result.current;
const signalHandlers = result.current.signalListenerMap;
expect(signalHandlers).toBeDefined();
const signalHandler = signalHandlers["sel_point_a"];
expect(signalHandler).toBeTypeOf("function");
Expand All @@ -95,4 +96,99 @@ describe("useSignalListeners", () => {
value: [1, 2, 3],
});
});

it("should register signal listeners on embed", () => {
const { result } = renderHook(() =>
useSignalListeners(chartWithSelect, "VegaChart", "my_chart", () => {}),
);

const view = createMockView();

act(() => {
result.current.onEmbed({ view } as unknown as VegaEmbedResult);
});

// Supported signals: sel_point, sel_interval, sel_point_a
expect(view.addSignalListener).toHaveBeenCalledTimes(3);

const names = view.addSignalListener.mock.calls.map(([name]) => name);
expect(names).toEqual(
expect.arrayContaining(["sel_point", "sel_interval", "sel_point_a"]),
);

// Unsupported "wheel" should not be registered
expect(names).not.toContain("sel_interval_b");
});

it("should remove old listeners when embedding again", () => {
const { result } = renderHook(() =>
useSignalListeners(chartWithSelect, "VegaChart", "my_chart", () => {}),
);

const view1 = createMockView();
const view2 = createMockView();

act(() => {
result.current.onEmbed({ view: view1 } as unknown as VegaEmbedResult);
});

const attachedToView1 = view1.addSignalListener.mock.calls.map(
([name, fn]) => ({ name, fn }),
);

act(() => {
result.current.onEmbed({ view: view2 } as unknown as VegaEmbedResult);
});

expect(view1.removeSignalListener).toHaveBeenCalledTimes(
attachedToView1.length,
);

for (const { name, fn } of attachedToView1) {
expect(view1.removeSignalListener).toHaveBeenCalledWith(name, fn);
}

expect(view2.addSignalListener).toHaveBeenCalledTimes(3);
});

it("should cleanup listeners on unmount", () => {
const { result, unmount } = renderHook(() =>
useSignalListeners(chartWithSelect, "VegaChart", "my_chart", () => {}),
);

const view = createMockView();

act(() => {
result.current.onEmbed({ view } as unknown as VegaEmbedResult);
});

const attached = view.addSignalListener.mock.calls.map(([name, fn]) => ({
name,
fn,
}));

unmount();

expect(view.removeSignalListener).toHaveBeenCalledTimes(attached.length);
for (const { name, fn } of attached) {
expect(view.removeSignalListener).toHaveBeenCalledWith(name, fn);
}
});

it("should do nothing if embed result has no view", () => {
const { result } = renderHook(() =>
useSignalListeners(chartWithSelect, "VegaChart", "my_chart", () => {}),
);

act(() => {
result.current.onEmbed({} as unknown as VegaEmbedResult);
});
});
});

function createMockView() {
return {
addSignalListener: vi.fn(),
removeSignalListener: vi.fn(),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
* https://opensource.org/licenses/MIT.
*/

import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useEffect, useRef } from "react";
import type { Result as VegaEmbedResult } from "vega-embed";
import type { TopLevelSpec } from "vega-lite";

import { type ComponentChangeHandler } from "@/index";
Expand All @@ -24,6 +25,11 @@ type SelectionParameter = {
select: "point" | "interval" | { type: "point" | "interval"; on: string };
};

type UseSignalListenersReturn = {
onEmbed: (result: VegaEmbedResult) => void;
signalListenerMap: Record<string, SignalHandler>;
};

const isSelectionParameter = (param: unknown): param is SelectionParameter =>
isObject(param) &&
(param.select === "point" ||
Expand All @@ -37,7 +43,7 @@ export function useSignalListeners(
type: string,
id: string | undefined,
onChange: ComponentChangeHandler,
): Record<string, SignalHandler> {
): UseSignalListenersReturn {
/*
* Here, we create map of signals which will be then used to create the
* map of signal-listeners because not all params are event-listeners, and we
Expand Down Expand Up @@ -65,7 +71,7 @@ export function useSignalListeners(
}, signalNames);
}, [chart]);

const handleClickSignal = useCallback(
const handleSignal = useCallback(
(signalName: string, signalValue: unknown) => {
if (id) {
return onChange({
Expand All @@ -83,14 +89,14 @@ export function useSignalListeners(
* Creates the map of signal listeners based on
* the `signals` map computed above.
*/
return useMemo(() => {
const signalListenerMap = useMemo(() => {
/*
* Currently, we only have click events support, but if more are required,
* they can be implemented and added in the map below.
*/
const signalHandlers: Record<string, SignalHandler> = {
click: handleClickSignal,
drag: handleClickSignal,
click: handleSignal,
drag: handleSignal,
};

const signalListeners: Record<string, SignalHandler> = {};
Expand All @@ -104,5 +110,49 @@ export function useSignalListeners(
}
});
return signalListeners;
}, [signalNames, handleClickSignal]);
}, [signalNames, handleSignal]);

// Keep cleanup in a ref so it can run on re-embed and unmount.
const cleanupRef = useRef<null | (() => void)>(null);

const onEmbed = useCallback(
Comment thread
clarasb marked this conversation as resolved.
Outdated
(result: VegaEmbedResult) => {
cleanupRef.current?.();
cleanupRef.current = null;

const view = result?.view;
if (!view) return;

/*
* Keep track of the exact listener functions registered on the Vega view.
* Vega requires the same function reference for removal, so we store them
* here in order to properly clean them up on re-embed or unmount.
*/
const attachedListeners: Array<{
name: string;
fn: (name: string, value: unknown) => void;
}> = [];

for (const [signalName, handler] of Object.entries(signalListenerMap)) {
const fn = (name: string, value: unknown) => handler(name, value);
view.addSignalListener(signalName, fn);
attachedListeners.push({ name: signalName, fn });
}

cleanupRef.current = () => {
for (const { name, fn } of attachedListeners)
view.removeSignalListener(name, fn);
};
},
[signalListenerMap],
);

useEffect(() => {
return () => {
cleanupRef.current?.();
cleanupRef.current = null;
};
}, []);

return { onEmbed, signalListenerMap };
}
1 change: 0 additions & 1 deletion chartlets.js/packages/lib/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,5 @@ export default defineConfig({
return false;
}
},
exclude: ["**/vega/index.test.ts", "**/vega/VegaChart.test.tsx"],
},
});
2 changes: 2 additions & 0 deletions chartlets.py/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

* Added `size` and removed `variant` property from `IconButton`
component to align with component in chartlets.js. (#124)

* Removed pinning of `altair` dependency. (#132)

## Version 0.1.7 (from 2025/12/03)

Expand Down
2 changes: 1 addition & 1 deletion chartlets.py/environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ dependencies:
# Library Dependencies
- python >=3.10,<3.14
# Optional Dependencies
- altair>=5.5.0,<6.0.0
- altair
Comment thread
clarasb marked this conversation as resolved.
Outdated
# Demo Dependencies
- pandas
- pyaml
Expand Down
Loading