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
3 changes: 1 addition & 2 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ updates:
interval: "weekly"
open-pull-requests-limit: 10
cooldown:
default-day: 3
enabled: true
default-days: 3
groups:
python-patch-and-minor:
update-types:
Expand Down
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 interaction events (click, double-click, and right-click on nodes, relationships, or the canvas) using `widget.on_node_event`, `widget.on_relationship_event`, and `widget.on_canvas_event`, and the synced `widget.last_event` trait.
* Handle duplicates for `widget.add_data` and allow different strategies (`ignore`, `replace` or `none`)


## Bug fixes

* Fixed clicks being misaligned while the built-in "Node details" side panel is open ([#417](https://github.com/neo4j/python-graph-visualization/issues/417)).
Expand Down
49 changes: 49 additions & 0 deletions docs/antora/modules/ROOT/pages/customizing.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,55 @@ 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.

== Reacting to interaction events

Beyond reading the selection, the widget also exposes interaction events whenever the user clicks, double-clicks,
or right-clicks a node, a relationship, or the canvas.
The most recent event is available on the widget's `last_event` attribute, a typed
link:{api-docs-uri}/widget[`InteractionEvent`] with a `type` and an `id`.

To react to a specific event, register a callback with one of `widget.on_node_event(...)`,
`widget.on_relationship_event(...)`, or `widget.on_canvas_event(...)`, depending on what was
interacted with.
Each takes a `MouseEvent` (one of `"click"`, `"double_click"`, `"right_click"` -- the category
node/relationship/canvas is implied by the method) and a callback.
The node and relationship callbacks receive the resolved link:{api-docs-uri}/node[Node] or
link:{api-docs-uri}/relationship[Relationship] directly (matched by id from the widget's current
`nodes`/`relationships`), or `None` if that id is no longer in the graph.
Canvas events target the empty graph background rather than an entity, so the canvas callback takes
no arguments.
An unknown `MouseEvent` string raises `ValueError` when the callback is registered, so typos are
caught early.

One use case is growing the graph on demand -- for example fetching a node's neighborhood from a database when it
is double-clicked:

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


def expand_neighborhood(node):
if node is None:
return
# ... fetch the double-clicked node's neighbors, then widget.add_data(...)


widget.on_node_event("double_click", expand_neighborhood)
----

These methods return the registered handler, which you can pass to `widget.unobserve(handler, names=["last_event"])`
to stop reacting.
You can also read `widget.last_event` directly at any point for the most recent event.

Note that firing the *same* event on the *same* entity twice in a row calls the callback only once, because the
underlying `last_event` value does not change.
For the raw event (`type` and `id`) on every occurrence, observe `last_event` directly instead.

In a Streamlit app, callbacks are not run live; instead read `widget.last_event` after `display_widget` returns on each
rerun, the same way you read `widget.selected` (see xref:rendering.adoc[Using with Streamlit]).

== 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
Expand Up @@ -4,3 +4,7 @@
.. autoclass:: neo4j_viz.GraphSelection
:members:
:exclude-members: model_config

.. autoclass:: neo4j_viz.InteractionEvent
:members:
:exclude-members: model_config
104 changes: 59 additions & 45 deletions examples/getting-started.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
"cells": [
{
"cell_type": "markdown",
"id": "0d3ffc27",
"id": "1006bb2e58af56e2",
"metadata": {},
"source": [
"# Getting started"
]
},
{
"cell_type": "markdown",
"id": "6b83277d",
"id": "623d682217b20b49",
"metadata": {},
"source": [
"In this section, we will cover the very basics of creating a visualization graph using the `neo4j-viz` library.\n",
Expand All @@ -25,30 +25,10 @@
},
{
"cell_type": "code",
"execution_count": 1,
"id": "2ec305c1",
"metadata": {
"tags": [
"preserve-output"
]
},
"outputs": [
{
"data": {
"application/vnd.jupyter.widget-view+json": {
"model_id": "ef711de91aae4e4994fefd733268edda",
"version_major": 2,
"version_minor": 1
},
"text/plain": [
"<neo4j_viz.widget.GraphWidget object at 0x10add3da0>"
]
},
"execution_count": 1,
"metadata": {},
"output_type": "execute_result"
}
],
"execution_count": null,
"id": "35e0abc7e4940b6c",
"metadata": {},
"outputs": [],
"source": [
"from neo4j_viz import GraphSelection, Node, Relationship, VisualizationGraph\n",
"\n",
Expand Down Expand Up @@ -88,7 +68,7 @@
},
{
"cell_type": "markdown",
"id": "365a1c31",
"id": "38947ca89d48f139",
"metadata": {},
"source": [
"As we can see in the graph above, the radius of one of the nodes is larger than the others.\n",
Expand All @@ -104,15 +84,15 @@
},
{
"cell_type": "markdown",
"id": "a28bd5aa",
"id": "28e00bdb7c99a5db",
"metadata": {},
"source": [
"We are now easily able to distinguish between the different types of nodes in the graph."
]
},
{
"cell_type": "markdown",
"id": "57plfflyo7m",
"id": "3a0b086819bee669",
"metadata": {},
"source": [
"## Interacting with the widget\n",
Expand All @@ -127,7 +107,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "652ybsznrab",
"id": "d59ea1ebdc6fc8e3",
"metadata": {},
"outputs": [],
"source": [
Expand All @@ -146,7 +126,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "686e0beb",
"id": "5af68a72d658d8a",
"metadata": {},
"outputs": [],
"source": [
Expand All @@ -156,7 +136,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "c68712f0",
"id": "b84d6f4e99dbc26",
"metadata": {},
"outputs": [],
"source": [
Expand All @@ -167,7 +147,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "f174b6ed00027bf5",
"id": "5004a3da24659553",
"metadata": {},
"outputs": [],
"source": [
Expand All @@ -176,40 +156,74 @@
},
{
"cell_type": "markdown",
"id": "0139ce46",
"id": "be014e42b6270335",
"metadata": {},
"source": [
"### Reacting to the selection\n",
"\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):"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "1fc58d6d",
"id": "9a5a70d23aca6ebb",
"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 = {node.id: node.color for node in widget.nodes if node.color is not None}\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[node.id] # 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": "564b0571cfe9e480",
"metadata": {},
"source": [
"### Reacting to interaction events\n",
"\n",
"The widget emits discrete interaction events (click, double-click, and right-click on a node, relationship, or the canvas) via its synced `last_event` attribute, a typed `InteractionEvent` with `type` and `id`. To react to a specific event, register a callback with `widget.on_node_event(...)`, `widget.on_relationship_event(...)`, or `widget.on_canvas_event(...)` - convenience wrappers around `widget.observe(...)`. The node and relationship callbacks receive the resolved `Node`/`Relationship` directly (or `None` if its id is no longer in the graph); the canvas callback takes no arguments.\n",
"\n",
"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": "1e50af034cf0f79b",
"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(source=source_node.id, target=target_node.id, caption=\"KNOWS\"),\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_event(\"double_click\", expand_on_double_click)"
]
}
],
Expand Down
68 changes: 68 additions & 0 deletions examples/neo4j-example.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -3442,6 +3442,74 @@
"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` that emits discrete interaction events via its synced `last_event` attribute (a typed `InteractionEvent` with `type` and `id`). Register a callback with `widget.on_node_event(...)` to react to a specific node mouse event - here `\"double_click\"` - whose callback receives 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",
"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_event(\"double_click\", expand_neighborhood)\n",
"\n",
"widget"
]
},
{
"cell_type": "markdown",
"id": "12664b80cf6051a1",
Expand Down
Loading