diff --git a/MANIFEST.in b/MANIFEST.in index 7e69755c..38bd6261 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1 @@ -include examples/example_graph.svg +include examples/example_graph.png diff --git a/README.md b/README.md index ff059467..35e6011f 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Alternatively, you can export the output to a file and view it in a web browser. The package wraps the [Neo4j Visualization JavaScript library (NVL)](https://neo4j.com/docs/nvl/current/). -![Example Graph](examples/example_graph.svg) +![Example Graph](examples/example_graph.png) ## Some notable features diff --git a/docs/antora/modules/ROOT/images/getting-started-graph-colored.svg b/docs/antora/modules/ROOT/images/getting-started-graph-colored.svg deleted file mode 100644 index 06349b2f..00000000 --- a/docs/antora/modules/ROOT/images/getting-started-graph-colored.svg +++ /dev/null @@ -1 +0,0 @@ -BUYSBUYSBUYSPersonProductProductPersonProduct \ No newline at end of file diff --git a/docs/antora/modules/ROOT/images/getting-started-graph.svg b/docs/antora/modules/ROOT/images/getting-started-graph.svg deleted file mode 100644 index a9a4ec97..00000000 --- a/docs/antora/modules/ROOT/images/getting-started-graph.svg +++ /dev/null @@ -1 +0,0 @@ -BUYSBUYSBUYSPersonProductProductPersonProduct \ No newline at end of file diff --git a/docs/antora/modules/ROOT/images/getting-started-widget-colored.png b/docs/antora/modules/ROOT/images/getting-started-widget-colored.png new file mode 100644 index 00000000..d1222773 Binary files /dev/null and b/docs/antora/modules/ROOT/images/getting-started-widget-colored.png differ diff --git a/docs/antora/modules/ROOT/images/getting-started-widget.png b/docs/antora/modules/ROOT/images/getting-started-widget.png new file mode 100644 index 00000000..0b90b40d Binary files /dev/null and b/docs/antora/modules/ROOT/images/getting-started-widget.png differ diff --git a/docs/antora/modules/ROOT/pages/getting-started.adoc b/docs/antora/modules/ROOT/pages/getting-started.adoc index eab70ccc..ac0fd2b8 100644 --- a/docs/antora/modules/ROOT/pages/getting-started.adoc +++ b/docs/antora/modules/ROOT/pages/getting-started.adoc @@ -48,15 +48,15 @@ VG.render(initial_zoom=2) [TIP] ==== -See the link:{tutorials-docs-uri}/getting-started/[Jupyter notebook] to interact with the graph. +See the link:{tutorials-docs-uri}/getting-started/[Getting started tutorial] to interact with the graph. ==== -image::getting-started-graph.svg["Graph"] +image::getting-started-widget.png["The graph rendered as an interactive widget"] As we can see in the graph above, the radius of one of the nodes is larger than the others. This is because we set the “size” field of the node to 20, while the others are set to 10. -At this time all nodes have the same color. +At this time the nodes are colored using the default colors. If we want to distinguish between the different types of nodes, we can color them differently with the `color_nodes` method. We can pass the field we want to use to color the nodes as an argument. In this case, we will use the "caption" field. @@ -72,10 +72,10 @@ VG.render(initial_zoom=2) [TIP] ==== -See the link:{tutorials-docs-uri}/getting-started/[Jupyter notebook] to interact with the graph. +See the link:{tutorials-docs-uri}/getting-started/[Getting started tutorial] to interact with the graph. ==== -image::getting-started-graph-colored.svg["Graph with colors"] +image::getting-started-widget-colored.png["The graph with nodes colored by caption"] We are now easily able to distinguish between the different types of nodes in the graph. diff --git a/docs/antora/modules/ROOT/pages/installation.adoc b/docs/antora/modules/ROOT/pages/installation.adoc index d574ee60..e1eb2c4a 100644 --- a/docs/antora/modules/ROOT/pages/installation.adoc +++ b/docs/antora/modules/ROOT/pages/installation.adoc @@ -18,7 +18,7 @@ Each integration page includes installation instructions for the needed dependen === Notebook tutorials -You need to install some additional dependencies to run the link:{tutorials-docs-uri}[Jupyter notebook tutorials]: +You need to install some additional dependencies to run the link:{tutorials-docs-uri}[tutorials]: [source, console] ---- diff --git a/docs/source/tutorials/gds-example.nblink b/docs/source/tutorials/gds-example.nblink index 27f52967..b4e7691c 100644 --- a/docs/source/tutorials/gds-example.nblink +++ b/docs/source/tutorials/gds-example.nblink @@ -1,3 +1,6 @@ { - "path": "../../../examples/gds-example.ipynb" + "path": "../../../examples/gds-example.ipynb", + "extra-media": [ + "../../../examples/gds-example-graph.png" + ] } diff --git a/docs/source/tutorials/getting-started.nblink b/docs/source/tutorials/getting-started.nblink index 7cbbf076..af3261f3 100644 --- a/docs/source/tutorials/getting-started.nblink +++ b/docs/source/tutorials/getting-started.nblink @@ -1,3 +1,7 @@ { - "path": "../../../examples/getting-started.ipynb" + "path": "../../../examples/getting-started.ipynb", + "extra-media": [ + "../../../examples/getting-started-widget.png", + "../../../examples/getting-started-widget-colored.png" + ] } diff --git a/docs/source/tutorials/index.rst b/docs/source/tutorials/index.rst index bfc22864..d928dcf5 100644 --- a/docs/source/tutorials/index.rst +++ b/docs/source/tutorials/index.rst @@ -3,10 +3,10 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -Jupyter notebooks +Tutorials ===================================================== -This chapter contains Jupyter notebook tutorials for the `neo4j-viz` package. +This chapter contains tutorials for the `neo4j-viz` package. .. toctree:: diff --git a/docs/source/tutorials/neo4j-example.nblink b/docs/source/tutorials/neo4j-example.nblink index 014929c0..b38cc33f 100644 --- a/docs/source/tutorials/neo4j-example.nblink +++ b/docs/source/tutorials/neo4j-example.nblink @@ -1,3 +1,6 @@ { - "path": "../../../examples/neo4j-example.ipynb" + "path": "../../../examples/neo4j-example.ipynb", + "extra-media": [ + "../../../examples/example_graph.png" + ] } diff --git a/examples/creds.env.example b/examples/creds.env.example new file mode 100644 index 00000000..3b6145b3 --- /dev/null +++ b/examples/creds.env.example @@ -0,0 +1,22 @@ +# Copy this file to `creds.env` in this directory and fill in your own values. +# The notebooks load `creds.env` automatically, so you do not have to edit them. + +# Neo4j database connection, used by neo4j-example.ipynb and gds-example.ipynb +NEO4J_URI=neo4j://localhost:7687 +NEO4J_USERNAME=neo4j +NEO4J_PASSWORD=password +NEO4J_DB=neo4j + +# Aura Graph Analytics, optionally used by gds-example.ipynb to run GDS in Aura +# instead of a local Neo4j instance with the GDS plugin. +AURA_INSTANCEID= +AURA_API_CLIENT_ID= +AURA_API_CLIENT_SECRET= +AURA_API_PROJECT_ID= + +# Snowflake connection, used by snowflake-example.ipynb +SNOWFLAKE_ACCOUNT= +SNOWFLAKE_USER= +SNOWFLAKE_PASSWORD= +SNOWFLAKE_ROLE= +SNOWFLAKE_WAREHOUSE= diff --git a/examples/example_graph.png b/examples/example_graph.png new file mode 100644 index 00000000..234ab9cd Binary files /dev/null and b/examples/example_graph.png differ diff --git a/examples/example_graph.svg b/examples/example_graph.svg deleted file mode 100644 index 3c2bce79..00000000 --- a/examples/example_graph.svg +++ /dev/null @@ -1 +0,0 @@ -BUYSBUYSBUYSBUYSBUYSBUYSBUYSBUYSBUYSBUYSBUYSBUYSBUYSBUYSBUYSBUYSBUYSBUYSPersonPersonPersonPersonPersonPersonProductProductProductProductProductProductProduct \ No newline at end of file diff --git a/examples/example_instruments_1.png b/examples/example_instruments_1.png index 32fcc537..d0de87ea 100644 Binary files a/examples/example_instruments_1.png and b/examples/example_instruments_1.png differ diff --git a/examples/example_instruments_2.png b/examples/example_instruments_2.png index 2d1b9a69..939bce39 100644 Binary files a/examples/example_instruments_2.png and b/examples/example_instruments_2.png differ diff --git a/examples/gds-example-graph.png b/examples/gds-example-graph.png new file mode 100644 index 00000000..0c70d490 Binary files /dev/null and b/examples/gds-example-graph.png differ diff --git a/examples/gds-example.ipynb b/examples/gds-example.ipynb index c4baedad..cefccf30 100644 --- a/examples/gds-example.ipynb +++ b/examples/gds-example.ipynb @@ -5,7 +5,9 @@ "id": "133e0a86", "metadata": {}, "source": [ - "# Visualizing Neo4j Graph Data Science (GDS) Graphs" + "# Visualizing Neo4j Graph Data Science (GDS) Graphs\n", + "\n", + "[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/neo4j/python-graph-visualization/blob/main/examples/gds-example.ipynb)\n" ] }, { @@ -20,6 +22,19 @@ "%pip install python-dotenv" ] }, + { + "cell_type": "markdown", + "id": "7f948daf", + "metadata": {}, + "source": [ + "### Credentials\n", + "\n", + "The connection details are read from an environment file named `creds.env`, next to this notebook.\n", + "Copy `creds.env.example` to `creds.env` and fill in the URI, username, password, and database of your Neo4j instance, and optionally your Aura Graph Analytics credentials.\n", + "\n", + "On Colab, upload a `creds.env` file with the same contents to the session, or set the same variables as [secrets](https://colab.research.google.com/notebooks/snippets/secrets.ipynb).\n" + ] + }, { "cell_type": "code", "execution_count": null, @@ -29,7 +44,8 @@ "source": [ "import dotenv\n", "\n", - "dotenv.load_dotenv()" + "# Connection details are read from `creds.env` (see above).\n", + "dotenv.load_dotenv(\"creds.env\")" ] }, { @@ -157,7 +173,7 @@ "metadata": {}, "outputs": [], "source": [ - "VG.render()" + "VG.render_widget()" ] }, { @@ -189,7 +205,7 @@ "metadata": {}, "outputs": [], "source": [ - "VG.render()" + "VG.render_widget()" ] }, { @@ -210,7 +226,7 @@ "outputs": [], "source": [ "VG.resize_nodes(property=\"pagerank\")\n", - "VG.render()" + "VG.render_widget()" ] }, { @@ -242,7 +258,7 @@ "outputs": [], "source": [ "VG.color_nodes(property=\"subject\")\n", - "VG.render()" + "VG.render_widget()" ] }, { @@ -265,7 +281,7 @@ "from neo4j_viz.colors import ColorSpace\n", "\n", "VG.color_nodes(field=\"size\", color_space=ColorSpace.CONTINUOUS, override=True)\n", - "VG.render()" + "VG.render_widget()" ] }, { @@ -315,7 +331,15 @@ "metadata": {}, "outputs": [], "source": [ - "VG.render()" + "VG.render_widget()" + ] + }, + { + "cell_type": "markdown", + "id": "a2fb5780", + "metadata": {}, + "source": [ + "![The Cora graph, sized by PageRank and colored by community](gds-example-graph.png)" ] }, { @@ -343,7 +367,7 @@ "source": [ "from neo4j_viz import Layout\n", "\n", - "VG.render(layout=Layout.CIRCULAR, initial_zoom=0.5)" + "VG.render_widget(layout=Layout.CIRCULAR, initial_zoom=0.5)" ] }, { diff --git a/examples/getting-started-widget-colored.png b/examples/getting-started-widget-colored.png new file mode 100644 index 00000000..d1222773 Binary files /dev/null and b/examples/getting-started-widget-colored.png differ diff --git a/examples/getting-started-widget.png b/examples/getting-started-widget.png new file mode 100644 index 00000000..0b90b40d Binary files /dev/null and b/examples/getting-started-widget.png differ diff --git a/examples/getting-started.ipynb b/examples/getting-started.ipynb index b52fcee7..d2429e10 100644 --- a/examples/getting-started.ipynb +++ b/examples/getting-started.ipynb @@ -5,7 +5,9 @@ "id": "1006bb2e58af56e2", "metadata": {}, "source": [ - "# Getting started" + "# Getting started\n", + "\n", + "[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/neo4j/python-graph-visualization/blob/main/examples/getting-started.ipynb)\n" ] }, { @@ -59,13 +61,18 @@ "\n", "VG = VisualizationGraph(nodes=nodes, relationships=relationships)\n", "\n", - "VG.color_nodes(field=\"size\")\n", - "VG.set_node_captions(field=\"size\")\n", - "\n", "widget = VG.render_widget(initial_zoom=2)\n", "widget" ] }, + { + "cell_type": "markdown", + "id": "69be3030", + "metadata": {}, + "source": [ + "![The graph rendered as an interactive widget](getting-started-widget.png)" + ] + }, { "cell_type": "markdown", "id": "38947ca89d48f139", @@ -74,7 +81,7 @@ "As we can see in the graph above, the radius of one of the nodes is larger than the others.\n", "This is because we set the \"size\" field of the node to 20, while the others are set to 10.\n", "\n", - "At this time all nodes have the same color.\n", + "At this time the nodes are colored using the default colors.\n", "If we want to distinguish between the different types of nodes, we can color them differently with the `color_nodes` method.\n", "We can pass the field we want to use to color the nodes as an argument.\n", "In this case, we will use the \"caption\" field.\n", @@ -82,6 +89,24 @@ "We will use the default colorscheme, which is the Neo4j colorscheme.\n" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "8a552cdc", + "metadata": {}, + "outputs": [], + "source": [ + "widget.color_nodes(field=\"caption\")" + ] + }, + { + "cell_type": "markdown", + "id": "c61b6779", + "metadata": {}, + "source": [ + "![The same graph, with the nodes colored by caption](getting-started-widget-colored.png)" + ] + }, { "cell_type": "markdown", "id": "28e00bdb7c99a5db", diff --git a/examples/neo4j-example.ipynb b/examples/neo4j-example.ipynb index c55498e0..3b4789fd 100644 --- a/examples/neo4j-example.ipynb +++ b/examples/neo4j-example.ipynb @@ -2,24 +2,30 @@ "cells": [ { "cell_type": "markdown", - "id": "0d3ffc27", + "id": "168b38b2", "metadata": {}, "source": [ - "# Visualizing Neo4j Graphs" + "# Visualizing Neo4j Graphs\n", + "\n", + "[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/neo4j/python-graph-visualization/blob/main/examples/neo4j-example.ipynb)\n", + "\n", + "This tutorial shows how to import a graph from a Neo4j database and visualize it as an interactive widget with the `neo4j-viz` library.\n" ] }, { "cell_type": "markdown", - "id": "6b83277d", + "id": "ab107b04", "metadata": {}, "source": [ - "Below is a basic connection to a Neo4j database. We use the `Result.graph` result transformer to map the result to a graph object and store the result in `result`." + "## Setup\n", + "\n", + "Install the dependencies used in this tutorial:" ] }, { "cell_type": "code", "execution_count": null, - "id": "39e8a71b", + "id": "b9c127db", "metadata": {}, "outputs": [], "source": [ @@ -30,16 +36,21 @@ }, { "cell_type": "markdown", - "id": "d8e503a3cdf26557", + "id": "d425ab6d", "metadata": {}, "source": [ - "Let us start by setting up a connection to our database." + "### Credentials\n", + "\n", + "The connection details are read from an environment file named `creds.env`, next to this notebook.\n", + "Copy `creds.env.example` to `creds.env` and fill in the URI, username, password, and database of your Neo4j instance.\n", + "\n", + "On Colab, upload a `creds.env` file with the same contents to the session, or set the same variables as [secrets](https://colab.research.google.com/notebooks/snippets/secrets.ipynb).\n" ] }, { "cell_type": "code", "execution_count": null, - "id": "801d0bed", + "id": "1bee0b2a", "metadata": {}, "outputs": [], "source": [ @@ -47,31 +58,32 @@ "\n", "import dotenv\n", "\n", - "dotenv.load_dotenv()\n", - "\n", + "# Connection details are read from `creds.env` (see above).\n", + "dotenv.load_dotenv(\"creds.env\")\n", "\n", - "URI = os.environ.get(\"NEO4J_URI\", \"bolt://localhost:7687\")\n", - "auth = (os.environ.get(\"NEO4J_USERNAME\"), os.environ.get(\"NEO4J_PASSWORD\"))" + "URI = os.environ.get(\"NEO4J_URI\", \"neo4j://localhost:7687\")\n", + "AUTH = (os.environ.get(\"NEO4J_USERNAME\", \"neo4j\"), os.environ.get(\"NEO4J_PASSWORD\", \"password\"))\n", + "DATABASE = os.environ.get(\"NEO4J_DB\", \"neo4j\")" ] }, { "cell_type": "markdown", - "id": "f15b2bee0f5c8e0b", + "id": "74a6dbd9", "metadata": {}, "source": [ - "To illustrate the library, we will create a small example graph representing web pages." + "To illustrate the library, we will create a small example graph of people and the products they buy." ] }, { "cell_type": "code", "execution_count": null, - "id": "fb95d30d7e932224", + "id": "d47aa983", "metadata": {}, "outputs": [], "source": [ "from neo4j import GraphDatabase\n", "\n", - "with GraphDatabase.driver(URI, auth=auth) as driver:\n", + "with GraphDatabase.driver(URI, auth=AUTH) as driver:\n", " driver.verify_connectivity()\n", " driver.execute_query(\n", " \"\"\"\n", @@ -114,33 +126,34 @@ "\n", " (elsa)-[:BUYS {amount: 3}]->(chocolate),\n", " (elsa)-[:BUYS {amount: 3}]->(milk)\n", - " \"\"\"\n", + " \"\"\",\n", + " database_=DATABASE,\n", " )" ] }, { "cell_type": "markdown", - "id": "af09ad6ec575aca4", + "id": "e1f6896e", "metadata": {}, "source": [ - "We can now fetch data from our database, to later include in our visualization." + "We can now fetch the graph from the database. The `Result.graph` result transformer maps the query result to a graph object." ] }, { "cell_type": "code", "execution_count": null, - "id": "b2ed863e8986fd27", + "id": "765f110f", "metadata": {}, "outputs": [], "source": [ "from neo4j import Result, RoutingControl\n", "\n", - "with GraphDatabase.driver(URI, auth=auth) as driver:\n", + "with GraphDatabase.driver(URI, auth=AUTH) as driver:\n", " driver.verify_connectivity()\n", "\n", " result = driver.execute_query(\n", " \"MATCH (n)-[r]->(m) RETURN n,r,m\",\n", - " database_=\"neo4j\",\n", + " database_=DATABASE,\n", " routing_=RoutingControl.READ,\n", " result_transformer_=Result.graph,\n", " )\n", @@ -150,3323 +163,64 @@ }, { "cell_type": "markdown", - "id": "365a1c31", - "metadata": {}, - "source": [ - "Below we map the graph object's nodes and relationships to the correct format for NVL. The Node and Relationship type documentation can be found in NVL's docs: https://neo4j.com/docs/nvl/current/base-library/#_nodes" - ] - }, - { - "cell_type": "markdown", - "id": "a28bd5aa", + "id": "eb823ae9", "metadata": {}, "source": [ - "Now, we can render our result with NVL the following way:\n" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "2322065c", - "metadata": { - "tags": [ - "preserve-output" - ] - }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - "\n", - " \n", - " \n", - " \n", - " neo4j-viz\n", - " \n", - " \n", - " \n", - " \n", - " \n", - "\n", - " \n", - "
\n", - " \n", - "\n" - ], - "text/plain": [ - "" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "from neo4j_viz.neo4j import from_neo4j\n", + "## Visualizing the graph\n", "\n", - "VG = from_neo4j(result)\n", + "The [`from_neo4j`](https://neo4j.com/docs/python-graph-visualization/current/reference/api-reference/from_neo4j/) function converts the query result into a [`VisualizationGraph`](https://neo4j.com/docs/python-graph-visualization/current/reference/api-reference/visualization-graph/), setting node captions from their labels and coloring the nodes by label.\n", "\n", - "VG.render()" + "Calling [`render_widget`](https://neo4j.com/docs/python-graph-visualization/current/reference/api-reference/visualization-graph/#neo4j_viz.VisualizationGraph.render_widget) displays the graph as an interactive widget, with two-way sync between Python and the browser.\n" ] }, { "cell_type": "code", "execution_count": null, - "id": "46aaa12317299ad0", + "id": "3e7f06cf", "metadata": {}, "outputs": [], "source": [ - "VG.color_nodes(field=\"caption\")" + "from neo4j_viz.neo4j import from_neo4j\n", + "\n", + "VG = from_neo4j(result)\n", + "\n", + "widget = VG.render_widget()\n", + "widget" ] }, { - "cell_type": "code", - "execution_count": 7, - "id": "e8b0f4c6", - "metadata": { - "tags": [ - "preserve-output" - ] - }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - "\n", - " \n", - " \n", - " \n", - " neo4j-viz\n", - " \n", - " \n", - " \n", - " \n", - " \n", - "\n", - " \n", - "
\n", - " \n", - "\n" - ], - "text/plain": [ - "" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], + "cell_type": "markdown", + "id": "3b912fb6", + "metadata": {}, "source": [ - "VG.render()" + "![The example graph rendered as an interactive widget](example_graph.png)" ] }, { "cell_type": "markdown", - "id": "deb635ed", + "id": "29016dab", "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." + "Instead of loading the whole graph up front, we can start small and let the user grow it interactively.\n", + "`render_widget()` returns a `GraphWidget` that emits interaction events via its synced `last_event` attribute (a typed `InteractionEvent` with `type` and `id`).\n", + "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`.\n", + "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.\n" ] }, { "cell_type": "code", "execution_count": null, - "id": "c1d2d585", + "id": "8f14c805", "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", + "with GraphDatabase.driver(URI, auth=AUTH) as driver:\n", " people = driver.execute_query(\n", " \"MATCH (n:Person) RETURN n\",\n", - " database_=\"neo4j\",\n", + " database_=DATABASE,\n", " routing_=RoutingControl.READ,\n", " result_transformer_=Result.graph,\n", " )\n", @@ -3475,7 +229,7 @@ "\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", + "driver = GraphDatabase.driver(URI, auth=AUTH)\n", "\n", "\n", "def expand_neighborhood(node):\n", @@ -3487,7 +241,7 @@ " 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", + " database_=DATABASE,\n", " routing_=RoutingControl.READ,\n", " result_transformer_=Result.graph,\n", " )\n", @@ -3512,30 +266,33 @@ }, { "cell_type": "markdown", - "id": "12664b80cf6051a1", + "id": "756b7e05", "metadata": {}, "source": [ - "Lastly we clean up our database by removing our toy graph." + "Lastly, we clean up the database by removing the example graph." ] }, { "cell_type": "code", "execution_count": null, - "id": "fe49b6a242e8fff0", + "id": "1c54ef49", "metadata": {}, "outputs": [], "source": [ - "with GraphDatabase.driver(URI, auth=auth) as driver:\n", - " result = driver.execute_query(\"MATCH (n:Person|Product) DETACH DELETE n RETURN count(n) \")\n", + "with GraphDatabase.driver(URI, auth=AUTH) as driver:\n", + " result = driver.execute_query(\n", + " \"MATCH (n:Person|Product) DETACH DELETE n RETURN count(n)\",\n", + " database_=DATABASE,\n", + " )\n", " print(result.summary.counters)" ] }, { "cell_type": "markdown", - "id": "daf2f685c2e13fb9", + "id": "dc32cec9", "metadata": {}, "source": [ - "**NOTE:** Since in this example we didn't already have a Neo4j DB populated with data, it would actually have been more convenient to use the serverless `from_gql_create` importer method to create our `VisualizationGraph`." + "**NOTE:** Since this example creates its own data, we could also have built the `VisualizationGraph` directly from a GQL `CREATE` query with the [`from_gql_create`](https://neo4j.com/docs/python-graph-visualization/current/reference/api-reference/from_gql_create/) importer, without a database.\n" ] } ], diff --git a/examples/nodes_2025_demo.ipynb b/examples/nodes_2025_demo.ipynb index 6ad6f351..0122b34f 100644 --- a/examples/nodes_2025_demo.ipynb +++ b/examples/nodes_2025_demo.ipynb @@ -38,7 +38,7 @@ "from dotenv import load_dotenv\n", "\n", "# Load credentials for the neo4j database\n", - "load_dotenv(\"db_creds.env\")" + "load_dotenv(\"creds.env\")" ] }, { diff --git a/examples/snowflake-example.ipynb b/examples/snowflake-example.ipynb index c85af1c7..9346cccb 100644 --- a/examples/snowflake-example.ipynb +++ b/examples/snowflake-example.ipynb @@ -5,7 +5,9 @@ "id": "0d3ffc27", "metadata": {}, "source": [ - "# Visualizing Snowflake Tables as a Graph" + "# Visualizing Snowflake Tables as a Graph\n", + "\n", + "[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/neo4j/python-graph-visualization/blob/main/examples/snowflake-example.ipynb)\n" ] }, { @@ -34,7 +36,8 @@ "metadata": {}, "outputs": [], "source": [ - "%pip install neo4j-viz[snowflake]" + "%pip install neo4j-viz[snowflake]\n", + "%pip install python-dotenv" ] }, { @@ -43,7 +46,11 @@ "metadata": {}, "source": [ "We can now proceed to set up our connection to Snowflake by initializing a new session.\n", - "Please note that you may need more or fewer connection parameters depending on your Snowflake configuration." + "Please note that you may need more or fewer connection parameters depending on your Snowflake configuration.\n", + "\n", + "The connection details are read from an environment file named `creds.env`, next to this notebook.\n", + "Copy `creds.env.example` to `creds.env` and fill in your Snowflake connection parameters.\n", + "On Colab, upload a `creds.env` file with the same contents to the session.\n" ] }, { @@ -55,8 +62,12 @@ "source": [ "import os\n", "\n", + "import dotenv\n", "from snowflake.snowpark import Session\n", "\n", + "# Connection details are read from `creds.env` (see above).\n", + "dotenv.load_dotenv(\"creds.env\")\n", + "\n", "# Configure according to your own setup\n", "connection_parameters = {\n", " \"account\": os.environ.get(\"SNOWFLAKE_ACCOUNT\"),\n", @@ -237,11 +248,7 @@ "cell_type": "code", "execution_count": null, "id": "e8b0f4c6", - "metadata": { - "tags": [ - "preserve-output" - ] - }, + "metadata": {}, "outputs": [], "source": [ "widget = VG.render_widget()\n", diff --git a/justfile b/justfile index f5b7a017..3e7075f2 100644 --- a/justfile +++ b/justfile @@ -148,12 +148,14 @@ api-docs: render-docs: ./scripts/render_docs.sh -# Regenerate the documentation images (README + getting-started guide) from the -# example graphs via GraphWidget.save() in a headless browser. All images are -# built locally without a database. Optionally restrict to named images: -# `just generate-docs-images getting-started-graph` +# Regenerate the documentation images (README, getting-started guide and +# tutorial notebooks) as widget screenshots in a headless browser. Most images +# are built locally without a database; the GDS image needs a running Graph Data +# Science instance (see `just local-neo4j-setup`) and is skipped unless +# NEO4J_URI is set. Optionally restrict to named images: +# `just generate-docs-images getting-started-widget` generate-docs-images *names: #!/usr/bin/env bash set -e - cd {{py_dir}} && uv sync --group dev --group notebook - cd {{py_dir}} && uv run --group dev --group notebook python {{root_dir}}/scripts/regenerate_docs_images.py {{names}} + cd {{py_dir}} && uv sync --group dev --group notebook --extra gds + cd {{py_dir}} && uv run --group dev --group notebook --extra gds python {{root_dir}}/scripts/regenerate_docs_images.py {{names}} diff --git a/python-wrapper/README.md b/python-wrapper/README.md index c3f6527a..f164c036 100644 --- a/python-wrapper/README.md +++ b/python-wrapper/README.md @@ -16,7 +16,7 @@ Alternatively, you can export the output to a file and view it in a web browser. The package wraps the [Neo4j Visualization JavaScript library (NVL)](https://neo4j.com/docs/nvl/current/). -![Example Graph](https://raw.githubusercontent.com/neo4j/python-graph-visualization/main/examples/example_graph.svg) +![Example Graph](https://raw.githubusercontent.com/neo4j/python-graph-visualization/main/examples/example_graph.png) ## Some notable features diff --git a/scripts/regenerate_docs_images.py b/scripts/regenerate_docs_images.py index b4c97b48..c909a158 100755 --- a/scripts/regenerate_docs_images.py +++ b/scripts/regenerate_docs_images.py @@ -3,18 +3,22 @@ For each image, a disposable JupyterLab server is booted, a distilled cell sequence reproducing the corresponding example's data and styling is run in -headless Chrome, and the rendered graph is saved via ``GraphWidget.save()``. -The images are copied back to their repository locations. The output format -(PNG or SVG) is derived from each target's file ending. - -All graphs are built locally without a database, via the toy graph from the -getting-started guide and the neo4j-example's CREATE query through the -``from_gql_create`` GQL integration. Run from anywhere inside the repo. +headless Chrome, and the whole widget element (graph canvas plus its UI chrome: +toolbar, search, legend) is screenshotted. The images are copied back to their +repository locations. + +Most graphs are built locally without a database, via the toy graph from the +getting-started guide and the examples' CREATE queries through the +``from_gql_create`` GQL integration. The GDS image needs a running Graph Data +Science instance: set ``NEO4J_URI`` (see ``just local-neo4j-setup``) to include +it, otherwise that spec is skipped. Run from anywhere inside the repo. """ from __future__ import annotations import argparse +import functools +import os import shutil import struct import sys @@ -22,18 +26,32 @@ from dataclasses import dataclass from pathlib import Path +from playwright.sync_api import Page + GIT_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(GIT_ROOT / "python-wrapper")) from tests.browser_harness import ( DONE_MARKER, - ERROR_MARKER, BrowserUnavailableError, jupyter_server, run_notebook_in_browser, ) _ANTORA_IMAGES = GIT_ROOT / "docs" / "antora" / "modules" / "ROOT" / "images" +_EXAMPLES = GIT_ROOT / "examples" + +# The widget wrapper element: the graph canvas plus its UI chrome (toolbar, +# search, legend). Screenshotting it captures the widget as a user sees it. +_WIDGET_SELECTOR = '[data-neo4j-viz-ndl="true"]' +# The canvas mounts before the force-directed layout settles, so give the +# widget a moment to paint before capturing it. +_WIDGET_SETTLE_MS = 3000 +# JupyterLab chrome that would otherwise overlap the widget in the capture. +_CHROME_HIDE_CSS = ( + ".jp-NotebookPanel-toolbar, .jp-Notebook-toolbar, .jp-Notification, " + ".jp-Notification-Stack, .jp-Dialog, .jp-StatusBar { display: none !important; }" +) _TOY_GRAPH_CELLS = """\ from neo4j_viz import Node, Relationship, VisualizationGraph @@ -104,6 +122,73 @@ ) """ +# The graph from examples/snowflake-example.ipynb, built without Snowflake via +# the GQL CREATE integration. The labels stand in for the Snowflake table names, +# so the captions and colors match what from_snowflake produces. +_SNOWFLAKE_CELLS = """\ +from neo4j_viz.gql_create import from_gql_create + +VG = from_gql_create( + \"\"\" + CREATE + (alice:PERSONS {SNOWFLAKEID: 'Alice'}), + (bob:PERSONS {SNOWFLAKEID: 'Bob'}), + (carol:PERSONS {SNOWFLAKEID: 'Carol'}), + (dave:PERSONS {SNOWFLAKEID: 'Dave'}), + (eve:PERSONS {SNOWFLAKEID: 'Eve'}), + + (guitar:INSTRUMENTS {SNOWFLAKEID: 'Guitar'}), + (synthesizer:INSTRUMENTS {SNOWFLAKEID: 'Synthesizer'}), + (bongos:INSTRUMENTS {SNOWFLAKEID: 'Bongos'}), + (trumpet:INSTRUMENTS {SNOWFLAKEID: 'Trumpet'}), + + (alice)-[:LIKES]->(guitar), + (alice)-[:LIKES]->(synthesizer), + (alice)-[:LIKES]->(bongos), + (bob)-[:LIKES]->(guitar), + (bob)-[:LIKES]->(synthesizer), + (carol)-[:LIKES]->(bongos), + (dave)-[:LIKES]->(guitar), + (dave)-[:LIKES]->(trumpet), + (dave)-[:LIKES]->(bongos) + \"\"\" +) +""" + +# The Cora graph from examples/gds-example.ipynb. Unlike the other specs this +# needs a running GDS instance (see the module docstring). +_GDS_CELLS = ( + """\ +import os + +from graphdatascience import GraphDataScience + +gds = GraphDataScience( + os.environ["NEO4J_URI"], + auth=(os.environ.get("NEO4J_USERNAME", "neo4j"), os.environ.get("NEO4J_PASSWORD", "password")), +) + +G = gds.graph.datasets.load_cora(graph_name="cora") +gds.node_similarity.mutate(G, mutate_relationship_type="SIMILAR", mutate_property="similarity") +gds.page_rank.mutate(G, mutate_property="pagerank") +gds.louvain.mutate(G, mutate_property="componentId") +""", + """\ +import matplotlib.colors as mcolors +from palettable.colorbrewer.qualitative import Dark2_7 + +from neo4j_viz.gds import from_gds + +VG = from_gds(gds, G, max_node_count=100) +VG.resize_nodes(property="pagerank") + +number_of_components = len({n.properties["componentId"] for n in VG.nodes}) +color_map = Dark2_7.mpl_colormap.resampled(number_of_components) +colors = [mcolors.rgb2hex(color_map(i)) for i in range(number_of_components)] +VG.color_nodes(property="componentId", colors=colors, override=True) +""", +) + @dataclass(frozen=True) class ImageSpec: @@ -111,18 +196,18 @@ class ImageSpec: name: str target: Path - # Widget canvas size in pixels; for PNG output this is also the expected - # image size. SVG output covers the entire graph, so the widget size only - # needs to be large enough to mount the widget. + # Widget canvas size in pixels; the screenshot is the same size (the widget + # element's bounding box). size: tuple[int, int] # Cells up to and including the one that displays the widget. The harness - # waits for the widget's canvas before running the generated save cell. + # waits for the widget's canvas before running the generated marker cell. cells: tuple[str, ...] = () - - @property - def fmt(self) -> str: - """The output format, derived from the target's file ending.""" - return self.target.suffix.lstrip(".") + # Extra locations to copy the produced image to (e.g. the examples/ copy + # that a notebook references alongside the Antora page copy). + also_copy: tuple[Path, ...] = () + # When set, the spec only runs if this environment variable is present. Used + # for images that need an external service (e.g. GDS via NEO4J_URI). + requires_env: str | None = None @property def filename(self) -> str: @@ -136,30 +221,22 @@ def _render_cell(size: tuple[int, int]) -> str: ) -def _save_cell(spec: ImageSpec) -> str: - filename = spec.filename - if spec.fmt == "svg": - # SVG export covers the entire graph and defaults to a transparent - # background, so it blends with the docs page. - save_call = f"await widget.save({filename!r})" - output_check = ( - f"assert Path({filename!r}).read_text().lstrip().startswith(' str: + """A final cell that signals completion.""" + return f"from pathlib import Path\nPath('{DONE_MARKER}').write_text('ok')\n" + + +def _screenshot_widget(page: Page, target: Path, size: tuple[int, int]) -> None: + """Capture the whole widget element, UI chrome included.""" + # Give the widget room so it is fully on screen (the notebook otherwise + # scrolls it partly out of view), then hide JupyterLab's own chrome so it + # does not bleed into the capture. + page.set_viewport_size({"width": size[0] + 600, "height": size[1] + 350}) + page.add_style_tag(content=_CHROME_HIDE_CSS) + widget = page.locator(_WIDGET_SELECTOR) + widget.scroll_into_view_if_needed() + page.wait_for_timeout(_WIDGET_SETTLE_MS) + widget.screenshot(path=str(target)) def _png_size(path: Path) -> tuple[int, int]: @@ -172,43 +249,64 @@ def _png_size(path: Path) -> tuple[int, int]: def _validate_produced(spec: ImageSpec, produced: Path) -> str | None: """Return an error message when the produced image does not look right.""" - if spec.fmt == "svg": - text = produced.read_text(encoding="utf-8", errors="replace").lstrip() - if not text.startswith(" 4 or abs(actual_size[1] - spec.size[1]) > 4: + return f"expected roughly a {spec.size[0]}x{spec.size[1]} image, got {actual_size[0]}x{actual_size[1]}" return None def _specs() -> list[ImageSpec]: - guide_size = (1588, 1200) - readme_size = (1392, 1200) + widget_size = (1200, 900) return [ ImageSpec( - name="getting-started-graph", - target=_ANTORA_IMAGES / "getting-started-graph.svg", - size=guide_size, - cells=(_TOY_GRAPH_CELLS, _render_cell(guide_size)), + name="example_graph", + target=_EXAMPLES / "example_graph.png", + size=widget_size, + cells=(_EXAMPLE_CREATE_CELLS, _render_cell(widget_size)), + ), + ImageSpec( + name="getting-started-widget", + target=_ANTORA_IMAGES / "getting-started-widget.png", + size=widget_size, + cells=(_TOY_GRAPH_CELLS, _render_cell(widget_size)), + also_copy=(_EXAMPLES / "getting-started-widget.png",), ), ImageSpec( - name="getting-started-graph-colored", - target=_ANTORA_IMAGES / "getting-started-graph-colored.svg", - size=guide_size, + name="getting-started-widget-colored", + target=_ANTORA_IMAGES / "getting-started-widget-colored.png", + size=widget_size, cells=( _TOY_GRAPH_CELLS, "VG.color_nodes(field='caption')\n", - _render_cell(guide_size), + _render_cell(widget_size), ), + also_copy=(_EXAMPLES / "getting-started-widget-colored.png",), ), ImageSpec( - name="example_graph", - target=GIT_ROOT / "examples" / "example_graph.svg", - size=readme_size, - cells=(_EXAMPLE_CREATE_CELLS, _render_cell(readme_size)), + name="gds-example-graph", + target=_EXAMPLES / "gds-example-graph.png", + size=widget_size, + cells=(*_GDS_CELLS, _render_cell(widget_size)), + requires_env="NEO4J_URI", + ), + ImageSpec( + name="snowflake-example-graph", + target=_EXAMPLES / "example_instruments_1.png", + size=widget_size, + cells=(_SNOWFLAKE_CELLS, _render_cell(widget_size)), + ), + ImageSpec( + name="snowflake-example-graph-colored", + target=_EXAMPLES / "example_instruments_2.png", + size=widget_size, + cells=( + _SNOWFLAKE_CELLS, + _render_cell(widget_size), + "widget.color_nodes(property='SNOWFLAKEID', override=True)\n", + ), ), ] @@ -234,21 +332,29 @@ def main() -> int: ) specs = [spec for spec in specs if spec.name in set(args.names)] - unsupported = [spec.name for spec in specs if spec.fmt not in ("png", "svg")] - if unsupported: - parser.error( - f"unsupported image file ending for: {unsupported} (expected .png or .svg)" - ) - failures: list[str] = [] with ( tempfile.TemporaryDirectory(prefix="neo4j-viz-docs-images-") as tmp, jupyter_server(Path(tmp)) as server, ): for spec in specs: + if spec.requires_env and not os.environ.get(spec.requires_env): + print( + f"Skipping {spec.name} (set {spec.requires_env} to regenerate)", + flush=True, + ) + continue + print(f"Regenerating {spec.target.relative_to(GIT_ROOT)} ...", flush=True) try: - run_notebook_in_browser(server, [*spec.cells, _save_cell(spec)]) + produced = server.root / spec.filename + run_notebook_in_browser( + server, + [*spec.cells, _marker_cell()], + check=functools.partial( + _screenshot_widget, target=produced, size=spec.size + ), + ) except BrowserUnavailableError as exc: print(f"ERROR: {exc}", flush=True) return 1 @@ -259,17 +365,16 @@ def main() -> int: failures.append(spec.name) continue - filename = spec.filename - produced = server.root / filename error = _validate_produced(spec, produced) if error: print(f"ERROR {spec.name}: {error}", flush=True) failures.append(spec.name) continue - spec.target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy(produced, spec.target) - print(f" -> {spec.target.relative_to(GIT_ROOT)}", flush=True) + for destination in (spec.target, *spec.also_copy): + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(produced, destination) + print(f" -> {destination.relative_to(GIT_ROOT)}", flush=True) if failures: print(f"Failed to regenerate: {', '.join(failures)}", flush=True)