Skip to content
Merged
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
4 changes: 4 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@

## New features

* Added a color legend overlay to the visualization. It is captured automatically from `color_nodes`/`color_relationships`, can be set explicitly via `set_legend`, and toggled via `show_legend`.

## Bug fixes

* Fixed a bug where nodes and relationships could not be selected on using `VG.render()`.

## Improvements

## Other changes
23 changes: 23 additions & 0 deletions docs/antora/modules/ROOT/pages/customizing.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,29 @@ Since we only provided five colors in the range, the granularity of the gradient

`palettable` and `matplotlib` are great libraries to use to create custom color gradients.

=== The color legend

Whenever you call `color_nodes` or `color_relationships`, a color legend is captured automatically and shown as an overlay in the bottom-left corner of the visualization, so it always reflects the colors currently drawn.
For a discrete coloring it lists one color box per value; for a continuous coloring it shows the color gradient together with the minimum and maximum values.
The legend works in both `render()` and `render_widget()`.

You can set the legend explicitly, overriding whatever was captured, using `set_legend`.
It accepts a `{label: color}` mapping, an iterable of `(label, color)` pairs, or a `LegendSection`:

[source, python]
----
# VG is a VisualizationGraph object
VG.set_legend(nodes={"Movies": "blue", "Directors": "red"})
----

To hide or show the legend overlay, use `show_legend`:

[source, python]
----
VG.show_legend(False) # hide
VG.show_legend(True) # show
----

== Sizing nodes and relationships

Nodes can be given a size directly by providing them with a size field, upon creation.
Expand Down
19 changes: 19 additions & 0 deletions docs/source/api-reference/legend.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
.. autoclass:: neo4j_viz.Legend
:members:
:exclude-members: model_config

.. autoclass:: neo4j_viz.LegendSection
:members:
:exclude-members: model_config

.. autoclass:: neo4j_viz.DiscreteLegendSection
:members:
:exclude-members: model_config

.. autoclass:: neo4j_viz.ContinuousLegendSection
:members:
:exclude-members: model_config

.. autoclass:: neo4j_viz.LegendEntry
:members:
:exclude-members: model_config
4 changes: 2 additions & 2 deletions examples/getting-started.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,12 @@
{
"data": {
"application/vnd.jupyter.widget-view+json": {
"model_id": "363e80d7a7c44e2c93e26c6696a50ea1",
"model_id": "50baeb1722b94ae58cd6604cb9f4257d",
"version_major": 2,
"version_minor": 1
},
"text/plain": [
"<neo4j_viz.widget.GraphWidget object at 0x108172030>"
"<neo4j_viz.widget.GraphWidget object at 0x10c4b0ce0>"
]
},
"execution_count": 1,
Expand Down
112 changes: 112 additions & 0 deletions js-applet/src/graph-widget.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ type WidgetState = {
width: string;
theme: "light" | "dark" | "auto";
selected: { nodeIds: string[]; relationshipIds: string[] };
legend: {
nodes?: { colorSpace?: string; title?: string; entries?: Array<{ label: string; color: string }> } | null;
relationships?: { colorSpace?: string; title?: string; entries?: Array<{ label: string; color: string }> } | null;
visible?: boolean;
};
};

// The static HTML render path uses the real `createLocalModel` shim, so tests
Expand Down Expand Up @@ -72,6 +77,7 @@ async function renderWidget(
width: overrides.width ?? "600px",
theme: overrides.theme ?? "light",
selected: overrides.selected ?? { nodeIds: [], relationshipIds: [] },
legend: overrides.legend ?? { nodes: null, relationships: null, visible: true },
});

let teardown: RenderedWidget["teardown"] = undefined;
Expand Down Expand Up @@ -107,6 +113,7 @@ async function renderWidgetInShadowRoot(
width: "600px",
theme: "light",
selected: { nodeIds: [], relationshipIds: [] },
legend: { nodes: null, relationships: null, visible: true },
});

let teardown: RenderedWidget["teardown"] = undefined;
Expand Down Expand Up @@ -188,6 +195,111 @@ describe("graph-widget button testing", () => {
}
});

it("renders a non-empty legend sourced from the model", async () => {
const { el, teardown } = await renderWidget({
legend: {
nodes: {
colorSpace: "discrete",
title: "label",
entries: [{ label: "Movies", color: "#569480" }],
},
relationships: null,
visible: true,
},
});

try {
await waitFor(() => {
expect(within(el).getByText("Movies")).toBeTruthy();
});
} finally {
if (typeof teardown === "function") {
await teardown();
}
}
});

it("toggles the legend overlay via its island button", async () => {
const { el, teardown } = await renderWidget({
legend: {
nodes: {
colorSpace: "discrete",
entries: [{ label: "Movies", color: "#569480" }],
},
relationships: null,
visible: true,
},
});

try {
// Auto-shown when a legend is available.
await waitFor(() => {
expect(within(el).getByText("Movies")).toBeTruthy();
});

const toggle = within(el).getByRole("button", { name: "Toggle legend" });
await act(async () => {
fireEvent.click(toggle);
});
expect(within(el).queryByText("Movies")).toBeNull();

await act(async () => {
fireEvent.click(toggle);
});
expect(within(el).getByText("Movies")).toBeTruthy();
} finally {
if (typeof teardown === "function") {
await teardown();
}
}
});

it("renders no legend panel when the legend is empty", async () => {
const { el, teardown } = await renderWidget();

try {
await waitFor(() => {
expect(within(el).getByRole("button", { name: /download/i })).toBeTruthy();
});

expect(el.querySelector(".nvl-legend")).toBeNull();
} finally {
if (typeof teardown === "function") {
await teardown();
}
}
});

it("re-renders the legend when the model's legend trait changes", async () => {
const { el, model, teardown } = await renderWidget();

try {
await waitFor(() => {
expect(within(el).getByRole("button", { name: /download/i })).toBeTruthy();
});
expect(el.querySelector(".nvl-legend")).toBeNull();

await act(async () => {
model.set("legend", {
nodes: {
colorSpace: "discrete",
entries: [{ label: "Directors", color: "#c990c0" }],
},
relationships: null,
visible: true,
});
});

await waitFor(() => {
expect(within(el).getByText("Directors")).toBeTruthy();
});
} finally {
if (typeof teardown === "function") {
await teardown();
}
}
});

it("bridges NDL styles to document.head when rendered inside a shadow root", async () => {
const { shadowRoot, teardown } = await renderWidgetInShadowRoot();

Expand Down
46 changes: 44 additions & 2 deletions js-applet/src/graph-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ import {
transformNodes,
transformRelationships,
} from "./data-transforms";
import { hasLegendContent, Legend, LegendData } from "./legend";
import { GraphErrorBoundary } from "./graph-error-boundary";
import {
Divider,
IconButton,
IconButtonArray,
NeedleThemeProvider,
} from "@neo4j-ndl/react";
import { SwatchIconOutline } from "@neo4j-ndl/react/icons";

export type Theme = "dark" | "light" | "auto";

Expand All @@ -36,9 +39,15 @@ export type WidgetData = {
width: string;
theme: Theme;
selected: GraphSelection;
legend: LegendData;
};

const EMPTY_SELECTION: GraphSelection = { nodeIds: [], relationshipIds: [] };
const EMPTY_LEGEND: LegendData = {
nodes: null,
relationships: null,
visible: true,
};

function detectTheme(): "light" | "dark" {
if (document.body.classList.contains("vscode-light") || document.body.classList.contains("light-theme")) {
Expand Down Expand Up @@ -173,6 +182,7 @@ function GraphWidget() {
const [theme] = useModelState<WidgetData["theme"]>("theme");
const [selected, setSelected] =
useModelState<WidgetData["selected"]>("selected");
const [legend] = useModelState<WidgetData["legend"]>("legend");
const { layout, nvlOptions, zoom, pan, layoutOptions, showLayoutButton, selectionMode } =
options ?? {};
// `gesture` is locally controlled so the GestureSelectButton stays interactive, but it is
Expand Down Expand Up @@ -213,14 +223,30 @@ function GraphWidget() {
const [isSidePanelOpen, setIsSidePanelOpen] = useState(false);
const [sidePanelWidth, setSidePanelWidth] = useState(300);

// The legend is a floating overlay toggled by its own island button, independent of the side
// panel (which holds the results overview / selection details). Show it automatically whenever a
// legend becomes available so it is discoverable without a click. Runs only when the `legend`
// trait changes, so it won't fight a user who has closed it.
const [isLegendOpen, setIsLegendOpen] = useState(false);
useEffect(() => {
if (hasLegendContent(legend ?? EMPTY_LEGEND)) {
setIsLegendOpen(true);
}
}, [legend]);
const legendAvailable = hasLegendContent(legend ?? EMPTY_LEGEND);

return (
<NeedleThemeProvider
theme={resolvedTheme}
wrapperProps={{ isWrappingChildren: false }}
>
<div
ref={wrapperRef}
style={{ height: height ?? "600px", width: width ?? "100%" }}
style={{
position: "relative",
height: height ?? "600px",
width: width ?? "100%",
}}
>
<GraphVisualization
nodes={neoNodes}
Expand All @@ -246,7 +272,22 @@ function GraphWidget() {
<GraphVisualization.DownloadButton tooltipPlacement="right" />
}
topRightIsland={
<GraphVisualization.ToggleSidePanelButton tooltipPlacement="left" />
<IconButtonArray size="small" orientation="horizontal">
{legendAvailable && (
<IconButton
size="small"
isFloating
isActive={isLegendOpen}
description={isLegendOpen ? "Hide legend" : "Show legend"}
onClick={() => setIsLegendOpen((open) => !open)}
htmlAttributes={{ "aria-label": "Toggle legend" }}
tooltipProps={{ root: { placement: "bottom", isPortaled: false } }}
>
<SwatchIconOutline />
</IconButton>
)}
<GraphVisualization.ToggleSidePanelButton tooltipPlacement="bottom" />
</IconButtonArray>
}
bottomRightIsland={
<IconButtonArray size="medium" orientation="horizontal">
Expand All @@ -270,6 +311,7 @@ function GraphWidget() {
</IconButtonArray>
}
/>
{isLegendOpen && <Legend legend={legend ?? EMPTY_LEGEND} />}
</div>
</NeedleThemeProvider>
);
Expand Down
Loading
Loading