Skip to content
Open
Show file tree
Hide file tree
Changes from all 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(
(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
# Demo Dependencies
- pandas
- pyaml
Expand Down
Loading