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
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
## New features

* Added the ability to set the selection mode (gesture) of the `GraphWidget` from Python, via the `selection_mode` render option or the `GraphWidget.set_selection_mode` method.
* Added the `GraphWidget.selected` trait to read back the IDs of the nodes and relationships selected in the widget UI. Use the `GraphWidget.on_selection_change` method (or `widget.observe`) to react to selection changes.

## Bug fixes

Expand Down
32 changes: 32 additions & 0 deletions docs/antora/modules/ROOT/pages/customizing.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,38 @@ In the following example, we pin the node with ID 1337 and unpin the node with I
VG.toggle_nodes_pinned(1337: True, 42: False)})
----

== Reading the selection

When a graph is rendered as an interactive widget via
link:{api-docs-uri}/visualization-graph/#neo4j_viz.VisualizationGraph.render_widget[`neo4j_viz.VisualizationGraph.render_widget()`],
you can read back which nodes and relationships the user has selected in the UI (using single, box, or lasso selection).

The widget exposes the `selected` attribute, a typed link:{api-docs-uri}/widget[`GraphSelection`] with `nodeIds` and
`relationshipIds` fields holding the IDs of the currently selected nodes and relationships.
Note that the IDs are strings, so match them against `str(node.id)` / `str(relationship.id)` to recover the
link:{api-docs-uri}/node[Node] and link:{api-docs-uri}/relationship[Relationship] objects.

To react to selection changes interactively, register a callback with `widget.on_selection_change(...)`. This is a
convenience wrapper around `widget.observe(..., names=["selected"])`: the callback runs every time the user selects
nodes or relationships in the widget, and receives the new `GraphSelection` directly.

[source, python]
----
# VG is a VisualizationGraph object
widget = VG.render_widget()


def on_selection_change(selection):
selected_nodes = [node for node in widget.nodes if str(node.id) in set(selection.nodeIds)]
# ... act on the selected nodes, e.g. add data or update other widgets


widget.on_selection_change(on_selection_change)
----

`on_selection_change` returns the registered handler, which you can pass to `widget.unobserve(handler, names=["selected"])`
to stop reacting. You can also read `widget.selected` directly at any point for the current selection.

== Direct modification of nodes and relationships

Nodes and relationships can also be modified directly by accessing the `nodes` and `relationships` fields of an
Expand Down
4 changes: 4 additions & 0 deletions docs/source/api-reference/widget.rst
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
.. autoclass:: neo4j_viz.GraphWidget
:members:

.. autoclass:: neo4j_viz.GraphSelection
:members:
:exclude-members: model_config
1,711 changes: 55 additions & 1,656 deletions examples/getting-started.ipynb

Large diffs are not rendered by default.

30 changes: 28 additions & 2 deletions js-applet/src/graph-widget.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type WidgetState = {
height: string;
width: string;
theme: "light" | "dark" | "auto";
selected: { nodeIds: string[]; relationshipIds: string[] };
};

class FakeModel {
Expand Down Expand Up @@ -65,6 +66,7 @@ class FakeModel {

type RenderedWidget = {
el: HTMLDivElement;
model: FakeModel;
teardown: void | (() => void | Promise<void>) | (() => Promise<void>);
};

Expand All @@ -90,6 +92,7 @@ async function renderWidget(
height: overrides.height ?? "400px",
width: overrides.width ?? "600px",
theme: overrides.theme ?? "light",
selected: overrides.selected ?? { nodeIds: [], relationshipIds: [] },
});

let teardown: RenderedWidget["teardown"] = undefined;
Expand All @@ -101,7 +104,7 @@ async function renderWidget(
});
});

return { el, teardown };
return { el, model, teardown };
}

async function renderWidgetInShadowRoot(
Expand All @@ -124,6 +127,7 @@ async function renderWidgetInShadowRoot(
height: "400px",
width: "600px",
theme: "light",
selected: { nodeIds: [], relationshipIds: [] },
});

let teardown: RenderedWidget["teardown"] = undefined;
Expand All @@ -135,7 +139,7 @@ async function renderWidgetInShadowRoot(
});
});

return { el, host, shadowRoot, teardown };
return { el, host, shadowRoot, model, teardown };
}

afterEach(() => {
Expand Down Expand Up @@ -183,6 +187,28 @@ describe("graph-widget button testing", () => {
}
});

it("renders with an initial selection sourced from the model", async () => {
const { el, model, teardown } = await renderWidget({
selected: { nodeIds: ["n1"], relationshipIds: [] },
});

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

// The selection is controlled by the model and left untouched on initial render.
expect(model.get("selected")).toEqual({
nodeIds: ["n1"],
relationshipIds: [],
});
} 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
9 changes: 8 additions & 1 deletion js-applet/src/graph-widget.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createRender, useModelState } from "@anywidget/react";
import ndlCssText from "@neo4j-ndl/base/lib/neo4j-ds-styles.css?inline";
import { Gesture, GraphVisualization } from "@neo4j-ndl/react-graph";
import { Gesture, GraphSelection, GraphVisualization } from "@neo4j-ndl/react-graph";
import type { Layout, NvlOptions } from "@neo4j-nvl/base";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Expand Down Expand Up @@ -35,8 +35,11 @@ export type WidgetData = {
height: string;
width: string;
theme: Theme;
selected: GraphSelection;
};

const EMPTY_SELECTION: GraphSelection = { nodeIds: [], relationshipIds: [] };

function detectTheme(): "light" | "dark" {
if (document.body.classList.contains("vscode-light") || document.body.classList.contains("light-theme")) {
return "light";
Expand Down Expand Up @@ -168,6 +171,8 @@ function GraphWidget() {
const [height] = useModelState<WidgetData["height"]>("height");
const [width] = useModelState<WidgetData["width"]>("width");
const [theme] = useModelState<WidgetData["theme"]>("theme");
const [selected, setSelected] =
useModelState<WidgetData["selected"]>("selected");
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 @@ -222,6 +227,8 @@ function GraphWidget() {
rels={neoRelationships}
gesture={gesture}
setGesture={setGesture}
selected={selected ?? EMPTY_SELECTION}
setSelected={setSelected}
layout={layout}
setLayout={setLayout}
nvlOptions={nvlOptionsWithoutWorkers}
Expand Down
2 changes: 2 additions & 0 deletions python-wrapper/src/neo4j_viz/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
CaptionAlignment,
Direction,
ForceDirectedLayoutOptions,
GraphSelection,
HierarchicalLayoutOptions,
Layout,
NvlOptions,
Expand All @@ -22,6 +23,7 @@
"WidgetOptions",
"NvlOptions",
"PanPosition",
"GraphSelection",
"Node",
"Relationship",
"CaptionAlignment",
Expand Down
13 changes: 13 additions & 0 deletions python-wrapper/src/neo4j_viz/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,19 @@ class PanPosition(BaseModel):
y: float


# Mirrors the GraphSelection type in js-applet/src/graph-widget.tsx. Field names match the
# frontend wire format (`nodeIds`, `relationshipIds`) verbatim.
class GraphSelection(BaseModel):
"""The IDs of the nodes and relationships currently selected in the ``GraphWidget`` UI."""

nodeIds: list[str] = Field(default_factory=list)
relationshipIds: list[str] = Field(default_factory=list)

def to_json(self) -> dict[str, Any]:
"""Serialize to the dict the frontend consumes."""
return self.model_dump(mode="json")


# Fields are snake_case in Python; pydantic serializes them to the camelCase keys the
# frontend's Partial<NvlOptions> expects (and accepts either casing on input). The frontend
# has many more fields, so extra="allow" lets other keys round-trip unchanged.
Expand Down
74 changes: 37 additions & 37 deletions python-wrapper/src/neo4j_viz/resources/nvl_entrypoint/index.html

Large diffs are not rendered by default.

Loading
Loading