Skip to content
Draft
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,6 +4,10 @@

## New features

* Allow reacting to DoubleClickEvents on nodes or relationships using `widget.on_node_double_click`.
* Handle duplicates for `widget.add_data` and allow different strategies (`ignore`, `replace` or `none`)


## Bug fixes

## Improvements
Expand Down
62 changes: 48 additions & 14 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": "ef711de91aae4e4994fefd733268edda",
"model_id": "24015dd5718c4f4591a7caa717bce5df",
"version_major": 2,
"version_minor": 1
},
"text/plain": [
"<neo4j_viz.widget.GraphWidget object at 0x10add3da0>"
"<neo4j_viz.widget.GraphWidget object at 0x10acef950>"
]
},
"execution_count": 1,
Expand Down Expand Up @@ -183,7 +183,7 @@
"\n",
"The widget exposes the current selection via its `selected` attribute, a typed `GraphSelection` with `nodeIds` and `relationshipIds`. To react to selection changes interactively, register a callback with `widget.on_selection_change(...)` - a convenience wrapper around `widget.observe(...)` whose callback receives the new `GraphSelection` directly and runs every time you select nodes or relationships in the widget above.\n",
"\n",
"For example, the callback below connects each node you select to another node in the graph. Run the cell once to register it, then click a node in the widget above and watch a new `KNOWS` relationship appear automatically:"
"The callback below highlights the selected nodes by changing their color. Run the cell once to register it, then click nodes in the widget above and watch them turn orange (and revert to their original color when deselected):"
]
},
{
Expand All @@ -193,25 +193,59 @@
"metadata": {},
"outputs": [],
"source": [
"# Run this cell once to register the callback, then select a node in the widget above.\n",
"def on_selection_change(selection: GraphSelection) -> None:\n",
"# Run this cell once to register the callback, then select nodes in the widget above.\n",
"original_colors = {\n",
" node.id: node.color for node in widget.nodes if node.color is not None\n",
"}\n",
"\n",
"\n",
"def highlight_selection(selection: GraphSelection) -> None:\n",
" # Selection IDs are strings, so match them against str(node.id) to recover the nodes.\n",
" selected_ids = set(selection.nodeIds)\n",
" selected_nodes = [n for n in widget.nodes if str(n.id) in selected_ids]\n",
" if not selected_nodes:\n",
" for node in widget.nodes:\n",
" if str(node.id) in selected_ids:\n",
" node.color = \"#FF5733\" # highlight the selected nodes\n",
" if str(node.id) not in selected_ids and node.id in original_colors:\n",
" node.color = original_colors[\n",
" node.id\n",
" ] # restore the original color for unselected nodes\n",
" widget.sync_nodes() # push the in-place color changes to the widget\n",
"\n",
"\n",
"widget.on_selection_change(highlight_selection)"
]
},
{
"cell_type": "markdown",
"id": "71711b0b",
"metadata": {},
"source": [
"### Expanding on double-click\n",
"\n",
"Double-clicking a node fires the callback registered with `widget.on_node_double_click(...)`, which receives the double-clicked `Node` (or `None` if it is no longer in the graph). This is handy for growing the graph on demand - for example fetching a node's neighbors from a database. Here we keep it self-contained and attach a new product to whichever node you double-click. Run the cell once to register it, then double-click a node in the widget above:"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "c6654c4a",
"metadata": {},
"outputs": [],
"source": [
"# Run this cell once to register the callback, then double-click a node in the widget above.\n",
"def expand_on_double_click(node: Node) -> None:\n",
" if node is None:\n",
" return\n",
"\n",
" source_node = selected_nodes[0]\n",
" other_nodes = [n for n in widget.nodes if n.id != source_node.id]\n",
" target_node = random.choice(other_nodes)\n",
" # Attach a freshly \"bought\" product to whichever node was double-clicked.\n",
" new_id = max(int(n.id) for n in widget.nodes) + 1\n",
" widget.add_data(\n",
" relationships=Relationship(\n",
" source=source_node.id, target=target_node.id, caption=\"KNOWS\"\n",
" ),\n",
" nodes=Node(id=new_id, size=10, caption=\"Product\"),\n",
" relationships=Relationship(source=node.id, target=new_id, caption=\"BUYS\"),\n",
" )\n",
"\n",
"\n",
"widget.on_selection_change(on_selection_change)"
"widget.on_node_double_click(expand_on_double_click)"
]
}
],
Expand Down
69 changes: 69 additions & 0 deletions examples/neo4j-example.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -3445,6 +3445,75 @@
"VG.render()"
]
},
{
"cell_type": "markdown",
"id": "deb635ed",
"metadata": {},
"source": [
"## Expanding the graph on double-click\n",
"\n",
"Instead of loading the whole graph up front, we can start small and let the user grow it interactively. Rendering with `render_widget()` (rather than `render()`) returns an interactive `GraphWidget` whose `on_node_double_click` hook fires with the double-clicked `Node`. In the callback we query Neo4j for that node's neighborhood and `add_data(...)` the new nodes and relationships, so double-clicking progressively expands the graph."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "c1d2d585",
"metadata": {},
"outputs": [],
"source": [
"from neo4j import GraphDatabase, Result, RoutingControl\n",
"\n",
"from neo4j_viz.neo4j import from_neo4j\n",
"\n",
"# Start from just the `Person` nodes -- we'll fetch each person's purchases on demand.\n",
"with GraphDatabase.driver(URI, auth=auth) as driver:\n",
" people = driver.execute_query(\n",
" \"MATCH (n:Person) RETURN n\",\n",
" database_=\"neo4j\",\n",
" routing_=RoutingControl.READ,\n",
" result_transformer_=Result.graph,\n",
" )\n",
"\n",
"widget = from_neo4j(people).render_widget()\n",
"\n",
"# Keep a driver open so the double-click callback can query the database on demand.\n",
"# Call `driver.close()` when you're done exploring.\n",
"driver = GraphDatabase.driver(URI, auth=auth)\n",
"\n",
"\n",
"def expand_neighborhood(node):\n",
" \"\"\"Fetch the double-clicked node's relationships and neighbors and add them to the graph.\"\"\"\n",
" if node is None:\n",
" return\n",
"\n",
" neighborhood = from_neo4j(\n",
" driver.execute_query(\n",
" \"MATCH (n)-[r]-(m) WHERE elementId(n) = $id RETURN n, r, m\",\n",
" parameters_={\"id\": node.id},\n",
" database_=\"neo4j\",\n",
" routing_=RoutingControl.READ,\n",
" result_transformer_=Result.graph,\n",
" )\n",
" )\n",
"\n",
" # `on_duplicate=\"ignore\"` drops entities already shown, so repeated expansions don't duplicate.\n",
" widget.add_data(\n",
" nodes=neighborhood.nodes,\n",
" relationships=neighborhood.relationships,\n",
" on_duplicate=\"ignore\",\n",
" )\n",
" print(\n",
" f\"Expanded {node.caption} ({node.id}): now {len(widget.nodes)} nodes, {len(widget.relationships)} relationships\"\n",
" )\n",
"\n",
"\n",
"# Double-click any node in the widget below to pull in its neighborhood.\n",
"widget.on_node_double_click(expand_neighborhood)\n",
"\n",
"widget"
]
},
{
"cell_type": "markdown",
"id": "12664b80cf6051a1",
Expand Down
14 changes: 14 additions & 0 deletions js-applet/src/graph-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export type GraphOptions = {
selectionMode?: Gesture;
};

export type DoubleClickEvent = {
kind: "node" | "relationship";
id: string;
};

export type WidgetData = {
nodes: SerializedNode[];
relationships: SerializedRelationship[];
Expand All @@ -40,6 +45,7 @@ export type WidgetData = {
theme: Theme;
selected: GraphSelection;
legend: LegendData;
last_double_click: DoubleClickEvent | null;
};

const EMPTY_SELECTION: GraphSelection = { nodeIds: [], relationshipIds: [] };
Expand Down Expand Up @@ -182,6 +188,8 @@ function GraphWidget() {
const [theme] = useModelState<WidgetData["theme"]>("theme");
const [selected, setSelected] =
useModelState<WidgetData["selected"]>("selected");
const [, setLastDoubleClick] =
useModelState<WidgetData["last_double_click"]>("last_double_click");
const [legend] = useModelState<WidgetData["legend"]>("legend");
const { layout, nvlOptions, zoom, pan, layoutOptions, showLayoutButton, selectionMode } =
options ?? {};
Expand Down Expand Up @@ -255,6 +263,12 @@ function GraphWidget() {
setGesture={setGesture}
selected={selected ?? EMPTY_SELECTION}
setSelected={setSelected}
mouseEventCallbacks={{
onNodeDoubleClick: (node) =>
setLastDoubleClick({ kind: "node", id: String(node.id) }),
onRelationshipDoubleClick: (rel) =>
setLastDoubleClick({ kind: "relationship", id: String(rel.id) }),
}}
layout={layout}
setLayout={setLayout}
nvlOptions={nvlOptionsWithoutWorkers}
Expand Down
2 changes: 1 addition & 1 deletion js-applet/src/streamlit-entrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ type Listener = (...args: unknown[]) => void;

// Traits the frontend is allowed to write back to Python. Mirror the two-way traits
// on GraphWidget (`selected` and `options`) and `_RECEIVE_KEYS` in streamlit.py.
const WRITABLE_KEYS: (keyof WidgetData)[] = ["selected", "options"];
const WRITABLE_KEYS: (keyof WidgetData)[] = ["selected", "options", "last_double_click"];

class StreamlitModel {
private state: Partial<WidgetData> = {};
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 @@ -5,6 +5,7 @@
ContinuousLegendSection,
Direction,
DiscreteLegendSection,
DoubleClickEvent,
ForceDirectedLayoutOptions,
GraphSelection,
HierarchicalLayoutOptions,
Expand All @@ -31,6 +32,7 @@
"NvlOptions",
"PanPosition",
"GraphSelection",
"DoubleClickEvent",
"Legend",
"LegendEntry",
"LegendSection",
Expand Down
58 changes: 57 additions & 1 deletion python-wrapper/src/neo4j_viz/_validation.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,73 @@
from __future__ import annotations

import warnings
from typing import Literal
from typing import Literal, TypeVar

from .node import Node
from .relationship import Relationship

OnDangling = Literal["error", "warn", "none"]

OnDuplicate = Literal["replace", "ignore", "none"]

_Entity = TypeVar("_Entity", Node, Relationship)

# Number of offending relationships to name in the message before truncating.
_MAX_REPORTED = 5


def merge_on_duplicate(
existing: list[_Entity],
incoming: list[_Entity],
on_duplicate: OnDuplicate,
) -> list[_Entity]:
"""Merge ``incoming`` entities into ``existing``, resolving id collisions per ``on_duplicate``.

Duplicates are detected by id, compared as strings to match how ids are serialized for the
frontend (so e.g. ``Node(id=1)`` and ``Node(id="1")`` collide). The check also de-duplicates
ids *within* ``incoming``.

Parameters
----------
existing:
The entities already in the graph.
incoming:
The entities being added.
on_duplicate:
How to resolve an incoming entity whose id already exists: ``"replace"`` swaps the existing
entity for the incoming one (keeping the existing position); ``"ignore"`` keeps the existing
entity and drops the incoming duplicate; ``"none"`` skips the check and appends everything,
which may leave duplicate ids in the graph.
"""
if on_duplicate == "none":
return existing + incoming

if on_duplicate not in ("replace", "ignore"):
raise ValueError(f"Invalid `on_duplicate` value {on_duplicate!r}. Expected 'replace', 'ignore', or 'none'.")

existing_ids = {str(e.id) for e in existing}

if on_duplicate == "replace":
# Last occurrence wins within `incoming`; replace matching existing entries in place.
incoming_by_id = {str(item.id): item for item in incoming}
result = [incoming_by_id.get(str(e.id), e) for e in existing]
# Append genuinely new ids, in first-seen order, using their last-wins value.
for key in dict.fromkeys(str(item.id) for item in incoming):
if key not in existing_ids:
result.append(incoming_by_id[key])
return result

# on_duplicate == "ignore": keep existing (and the first incoming for brand-new ids).
result = list(existing)
seen = set(existing_ids)
for item in incoming:
key = str(item.id)
if key not in seen:
result.append(item)
seen.add(key)
return result


def check_dangling_relationships(
nodes: list[Node],
relationships: list[Relationship],
Expand Down
21 changes: 21 additions & 0 deletions python-wrapper/src/neo4j_viz/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,27 @@ def to_json(self) -> dict[str, Any]:
return self.model_dump(mode="json")


DoubleClickKind = Literal["node", "relationship"]


# Mirrors the DoubleClickEvent type in js-applet/src/graph-widget.tsx. Field names match the
# frontend wire format verbatim.
class DoubleClickEvent(BaseModel):
"""A double-click on a node or relationship in the ``GraphWidget`` UI.

Held by ``GraphWidget.last_double_click``, which is ``None`` until the first double-click. The
``id`` is a string, so match it against ``str(node.id)`` / ``str(relationship.id)`` to recover
the entity.
"""

kind: DoubleClickKind
id: str

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


# Mirrors the LegendEntry/LegendSection/LegendData types in js-applet/src/legend.tsx
class LegendEntry(
BaseModel,
Expand Down
Loading
Loading