From 0277136f9dff02528a868fcc30ec2056dd0610d3 Mon Sep 17 00:00:00 2001 From: Adam Schill Collberg Date: Fri, 4 Apr 2025 16:24:15 +0200 Subject: [PATCH 1/4] Add functionality for continuous color mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Florentin Dörre --- python-wrapper/src/neo4j_viz/colors.py | 268 +++++++++++++++++- .../src/neo4j_viz/visualization_graph.py | 85 ++++-- python-wrapper/tests/test_colors.py | 56 +++- scripts/generate_linear_colormap.py | 15 + 4 files changed, 395 insertions(+), 29 deletions(-) create mode 100644 scripts/generate_linear_colormap.py diff --git a/python-wrapper/src/neo4j_viz/colors.py b/python-wrapper/src/neo4j_viz/colors.py index 4eabdc1..bbea387 100644 --- a/python-wrapper/src/neo4j_viz/colors.py +++ b/python-wrapper/src/neo4j_viz/colors.py @@ -1,12 +1,19 @@ from collections.abc import Iterable +from enum import Enum from typing import Any, Union from pydantic_extra_types.color import ColorType ColorsType = Union[dict[Any, ColorType], Iterable[ColorType]] + +class PropertyType(Enum): + DISCRETE = "discrete" + CONTINUOUS = "continuous" + + # Comes from https://neo4j.design/40a8cff71/p/5639c0-color/t/page-5639c0-79109681-33 -neo4j_colors = [ +NEO4J_COLORS_DISCRETE = [ "#FFDF81", "#C990C0", "#F79767", @@ -20,3 +27,262 @@ "#DA7294", "#579380", ] +_NEO4J_COLORS_CONTINUOUS_BASE = ["#FFDF81", "#C990C0"] +NEO4J_COLORS_CONTINUOUS = [ + (255, 223, 129), + (255, 223, 129), + (255, 222, 129), + (254, 222, 130), + (254, 222, 130), + (254, 221, 130), + (254, 221, 130), + (254, 221, 131), + (253, 221, 131), + (253, 220, 131), + (253, 220, 131), + (253, 220, 132), + (252, 219, 132), + (252, 219, 132), + (252, 219, 132), + (252, 218, 133), + (252, 218, 133), + (251, 218, 133), + (251, 217, 133), + (251, 217, 134), + (251, 217, 134), + (251, 216, 134), + (250, 216, 134), + (250, 216, 135), + (250, 216, 135), + (250, 215, 135), + (249, 215, 135), + (249, 215, 136), + (249, 214, 136), + (249, 214, 136), + (249, 214, 136), + (248, 213, 137), + (248, 213, 137), + (248, 213, 137), + (248, 212, 137), + (248, 212, 138), + (247, 212, 138), + (247, 212, 138), + (247, 211, 138), + (247, 211, 139), + (247, 211, 139), + (246, 210, 139), + (246, 210, 139), + (246, 210, 140), + (246, 209, 140), + (245, 209, 140), + (245, 209, 140), + (245, 208, 141), + (245, 208, 141), + (245, 208, 141), + (244, 208, 141), + (244, 207, 142), + (244, 207, 142), + (244, 207, 142), + (244, 206, 142), + (243, 206, 143), + (243, 206, 143), + (243, 205, 143), + (243, 205, 143), + (243, 205, 144), + (242, 204, 144), + (242, 204, 144), + (242, 204, 144), + (242, 203, 145), + (241, 203, 145), + (241, 203, 145), + (241, 203, 145), + (241, 202, 146), + (241, 202, 146), + (240, 202, 146), + (240, 201, 146), + (240, 201, 147), + (240, 201, 147), + (240, 200, 147), + (239, 200, 147), + (239, 200, 148), + (239, 199, 148), + (239, 199, 148), + (238, 199, 148), + (238, 199, 149), + (238, 198, 149), + (238, 198, 149), + (238, 198, 149), + (237, 197, 150), + (237, 197, 150), + (237, 197, 150), + (237, 196, 150), + (237, 196, 150), + (236, 196, 151), + (236, 195, 151), + (236, 195, 151), + (236, 195, 151), + (236, 194, 152), + (235, 194, 152), + (235, 194, 152), + (235, 194, 152), + (235, 193, 153), + (234, 193, 153), + (234, 193, 153), + (234, 192, 153), + (234, 192, 154), + (234, 192, 154), + (233, 191, 154), + (233, 191, 154), + (233, 191, 155), + (233, 190, 155), + (233, 190, 155), + (232, 190, 155), + (232, 190, 156), + (232, 189, 156), + (232, 189, 156), + (231, 189, 156), + (231, 188, 157), + (231, 188, 157), + (231, 188, 157), + (231, 187, 157), + (230, 187, 158), + (230, 187, 158), + (230, 186, 158), + (230, 186, 158), + (230, 186, 159), + (229, 186, 159), + (229, 185, 159), + (229, 185, 159), + (229, 185, 160), + (229, 184, 160), + (228, 184, 160), + (228, 184, 160), + (228, 183, 161), + (228, 183, 161), + (227, 183, 161), + (227, 182, 161), + (227, 182, 162), + (227, 182, 162), + (227, 181, 162), + (226, 181, 162), + (226, 181, 163), + (226, 181, 163), + (226, 180, 163), + (226, 180, 163), + (225, 180, 164), + (225, 179, 164), + (225, 179, 164), + (225, 179, 164), + (225, 178, 165), + (224, 178, 165), + (224, 178, 165), + (224, 177, 165), + (224, 177, 166), + (223, 177, 166), + (223, 177, 166), + (223, 176, 166), + (223, 176, 167), + (223, 176, 167), + (222, 175, 167), + (222, 175, 167), + (222, 175, 168), + (222, 174, 168), + (222, 174, 168), + (221, 174, 168), + (221, 173, 169), + (221, 173, 169), + (221, 173, 169), + (220, 173, 169), + (220, 172, 170), + (220, 172, 170), + (220, 172, 170), + (220, 171, 170), + (219, 171, 171), + (219, 171, 171), + (219, 170, 171), + (219, 170, 171), + (219, 170, 171), + (218, 169, 172), + (218, 169, 172), + (218, 169, 172), + (218, 168, 172), + (218, 168, 173), + (217, 168, 173), + (217, 168, 173), + (217, 167, 173), + (217, 167, 174), + (216, 167, 174), + (216, 166, 174), + (216, 166, 174), + (216, 166, 175), + (216, 165, 175), + (215, 165, 175), + (215, 165, 175), + (215, 164, 176), + (215, 164, 176), + (215, 164, 176), + (214, 164, 176), + (214, 163, 177), + (214, 163, 177), + (214, 163, 177), + (213, 162, 177), + (213, 162, 178), + (213, 162, 178), + (213, 161, 178), + (213, 161, 178), + (212, 161, 179), + (212, 160, 179), + (212, 160, 179), + (212, 160, 179), + (212, 159, 180), + (211, 159, 180), + (211, 159, 180), + (211, 159, 180), + (211, 158, 181), + (211, 158, 181), + (210, 158, 181), + (210, 157, 181), + (210, 157, 182), + (210, 157, 182), + (209, 156, 182), + (209, 156, 182), + (209, 156, 183), + (209, 155, 183), + (209, 155, 183), + (208, 155, 183), + (208, 155, 184), + (208, 154, 184), + (208, 154, 184), + (208, 154, 184), + (207, 153, 185), + (207, 153, 185), + (207, 153, 185), + (207, 152, 185), + (207, 152, 186), + (206, 152, 186), + (206, 151, 186), + (206, 151, 186), + (206, 151, 187), + (205, 151, 187), + (205, 150, 187), + (205, 150, 187), + (205, 150, 188), + (205, 149, 188), + (204, 149, 188), + (204, 149, 188), + (204, 148, 189), + (204, 148, 189), + (204, 148, 189), + (203, 147, 189), + (203, 147, 190), + (203, 147, 190), + (203, 146, 190), + (202, 146, 190), + (202, 146, 191), + (202, 146, 191), + (202, 145, 191), + (202, 145, 191), + (201, 145, 192), + (201, 144, 192), + (201, 144, 192), +] diff --git a/python-wrapper/src/neo4j_viz/visualization_graph.py b/python-wrapper/src/neo4j_viz/visualization_graph.py index 4576c69..a8fcefa 100644 --- a/python-wrapper/src/neo4j_viz/visualization_graph.py +++ b/python-wrapper/src/neo4j_viz/visualization_graph.py @@ -7,7 +7,7 @@ from IPython.display import HTML from pydantic_extra_types.color import Color, ColorType -from .colors import ColorsType, neo4j_colors +from .colors import NEO4J_COLORS_CONTINUOUS, NEO4J_COLORS_DISCRETE, ColorsType, PropertyType from .node import Node, NodeIdType from .node_size import RealNumber, verify_radii from .nvl import NVL @@ -168,21 +168,7 @@ def resize_nodes( if node_radius_min_max is not None: verify_radii(node_radius_min_max) - unscaled_min_size = min(all_sizes.values()) - unscaled_max_size = max(all_sizes.values()) - unscaled_size_range = float(unscaled_max_size - unscaled_min_size) - - new_min_size, new_max_size = node_radius_min_max - new_size_range = new_max_size - new_min_size - - if abs(unscaled_size_range) < 1e-6: - default_node_size = new_min_size + new_size_range / 2.0 - final_sizes = {id: default_node_size for id in all_sizes} - else: - final_sizes = { - id: new_min_size + new_size_range * ((nz - unscaled_min_size) / unscaled_size_range) - for id, nz in all_sizes.items() - } + final_sizes = self._normalize_values(all_sizes, node_radius_min_max) else: final_sizes = all_sizes @@ -194,25 +180,80 @@ def resize_nodes( node.size = size - def color_nodes(self, property: str, colors: Optional[ColorsType] = None, override: bool = False) -> None: + @staticmethod + def _normalize_values( + node_map: dict[NodeIdType, RealNumber], min_max: tuple[float, float] = (0, 1) + ) -> dict[NodeIdType, RealNumber]: + unscaled_min_size = min(node_map.values()) + unscaled_max_size = max(node_map.values()) + unscaled_size_range = float(unscaled_max_size - unscaled_min_size) + + new_min_size, new_max_size = min_max + new_size_range = new_max_size - new_min_size + + if abs(unscaled_size_range) < 1e-6: + default_node_size = new_min_size + new_size_range / 2.0 + new_map = {id: default_node_size for id in node_map} + else: + new_map = { + id: new_min_size + new_size_range * ((nz - unscaled_min_size) / unscaled_size_range) + for id, nz in node_map.items() + } + + return new_map + + def color_nodes( + self, + property: str, + colors: Optional[ColorsType] = None, + property_type: PropertyType = PropertyType.DISCRETE, + override: bool = False, + ) -> None: """ Color the nodes in the graph based on a property. + It's possible to color the nodes based on a discrete or continuous property. In the discrete case, a new color + from the `colors` provided is assigned to each unique value of the node property. + In the continuous case, the `colors` should be a list of colors representing a range that are used to + create a gradient of colors based on the values of the node property. + Parameters ---------- property: - The property of the nodes to use for coloring. The type of this property must be hashable, or be a + The property of the nodes to base the coloring on. The type of this property must be hashable, or be a list, set or dict containing only hashable types. colors: - The colors to use for the nodes. If a dictionary is given, it should map from property to color. - If an iterable is given, the colors are used in order. + The colors to use for the nodes. + If `property_type` is `PropertyType.DISCRETE`, the colors can be a dictionary mapping from property value + to color, or an iterable of colors in which case the colors are used in order. + If `property_type` is `PropertyType.CONTINUOUS`, the colors must be a list of colors representing a range. Allowed color values are for example “#FF0000”, “red” or (255, 0, 0) (full list: https://docs.pydantic.dev/2.0/usage/types/extra_types/color_types/). The default colors are the Neo4j graph colors. + property_type: + The type of the property, either `PropertyType.DISCRETE` or `PropertyType.CONTINUOUS`. It determines whether + colors are assigned based on unique property values or a gradient of the values of the property. override: Whether to override existing colors of the nodes, if they have any. """ - if colors is None: - colors = neo4j_colors + if property_type == PropertyType.DISCRETE: + if colors is None: + colors = NEO4J_COLORS_DISCRETE + else: + node_map = {node.id: getattr(node, property) for node in self.nodes if getattr(node, property) is not None} + normalized_map = self._normalize_values(node_map) + + if colors is None: + colors = NEO4J_COLORS_CONTINUOUS + + if not isinstance(colors, list): + raise ValueError("For continuous properties, `colors` must be a list of colors representing a range") + + num_colors = len(colors) + colors = { + getattr(node, property): colors[round(normalized_map[node.id] * (num_colors - 1))] + for node in self.nodes + if getattr(node, property) is not None + } if isinstance(colors, dict): self._color_nodes_dict(property, colors, override) diff --git a/python-wrapper/tests/test_colors.py b/python-wrapper/tests/test_colors.py index 950d150..a32546a 100644 --- a/python-wrapper/tests/test_colors.py +++ b/python-wrapper/tests/test_colors.py @@ -2,7 +2,7 @@ from pydantic_extra_types.color import Color from neo4j_viz import Node, VisualizationGraph -from neo4j_viz.colors import neo4j_colors +from neo4j_viz.colors import NEO4J_COLORS_CONTINUOUS, NEO4J_COLORS_DISCRETE, PropertyType @pytest.mark.parametrize("override", [True, False]) @@ -99,10 +99,55 @@ def test_color_nodes_default() -> None: VG.color_nodes("caption") - assert VG.nodes[0].color == Color(neo4j_colors[0]) - assert VG.nodes[1].color == Color(neo4j_colors[1]) - assert VG.nodes[2].color == Color(neo4j_colors[1]) - assert VG.nodes[3].color == Color(neo4j_colors[2]) + assert VG.nodes[0].color == Color(NEO4J_COLORS_DISCRETE[0]) + assert VG.nodes[1].color == Color(NEO4J_COLORS_DISCRETE[1]) + assert VG.nodes[2].color == Color(NEO4J_COLORS_DISCRETE[1]) + assert VG.nodes[3].color == Color(NEO4J_COLORS_DISCRETE[2]) + + +def test_color_nodes_continuous_default() -> None: + nodes = [ + Node(id="4:d09f48a4-5fca-421d-921d-a30a896c604d:0", rank=10), + Node(id="4:d09f48a4-5fca-421d-921d-a30a896c604d:6", rank=20), + Node(id="4:d09f48a4-5fca-421d-921d-a30a896c604d:11", rank=30), + ] + VG = VisualizationGraph(nodes=nodes, relationships=[]) + + VG.color_nodes("rank", property_type=PropertyType.CONTINUOUS) + + assert VG.nodes[0].color == Color(NEO4J_COLORS_CONTINUOUS[0]) + assert VG.nodes[1].color == Color(NEO4J_COLORS_CONTINUOUS[128]) + assert VG.nodes[2].color == Color(NEO4J_COLORS_CONTINUOUS[255]) + + +def test_color_nodes_continuous_custom() -> None: + nodes = [ + Node(id="4:d09f48a4-5fca-421d-921d-a30a896c604d:0", rank=10), + Node(id="4:d09f48a4-5fca-421d-921d-a30a896c604d:6", rank=18), + Node(id="4:d09f48a4-5fca-421d-921d-a30a896c604d:11", rank=30), + ] + VG = VisualizationGraph(nodes=nodes, relationships=[]) + + colors = [(0, 0, 0), (85, 85, 85), (170, 170, 170), (255, 255, 255)] + VG.color_nodes("rank", colors=colors, property_type=PropertyType.CONTINUOUS) + + assert VG.nodes[0].color == Color("black") + assert VG.nodes[1].color == Color((85, 85, 85)) + assert VG.nodes[2].color == Color("white") + + +def test_color_nodes_continuous_forbidden() -> None: + nodes = [ + Node(id="4:d09f48a4-5fca-421d-921d-a30a896c604d:0", rank=10), + Node(id="4:d09f48a4-5fca-421d-921d-a30a896c604d:11", rank=30), + ] + + VG = VisualizationGraph(nodes=nodes, relationships=[]) + + with pytest.raises( + ValueError, match="For continuous properties, `colors` must be a list of colors representing a range" + ): + VG.color_nodes("rank", {10: "#000000", 30: "#00FF00"}, property_type=PropertyType.CONTINUOUS) # type: ignore[arg-type] def test_color_nodes_lists() -> None: @@ -114,7 +159,6 @@ def test_color_nodes_lists() -> None: Node(id="4:d09f48a4-5fca-421d-921d-a30a896c604d:2", caption="Both again", labels=["Person", "Product"]), Node(id="4:d09f48a4-5fca-421d-921d-a30a896c604d:3", caption="Both reorder", labels=["Product", "Person"]), ] - VG = VisualizationGraph(nodes=nodes, relationships=[]) VG.color_nodes("labels", ["#000000", "#00FF00", "#FF0000", "#0000FF"]) diff --git a/scripts/generate_linear_colormap.py b/scripts/generate_linear_colormap.py new file mode 100644 index 0000000..f864c16 --- /dev/null +++ b/scripts/generate_linear_colormap.py @@ -0,0 +1,15 @@ +from matplotlib.colors import LinearSegmentedColormap as lsc +import numpy as np + +from neo4j_viz.colors import _NEO4J_COLORS_CONTINUOUS_BASE + +color_map = lsc.from_list("neo4j", _NEO4J_COLORS_CONTINUOUS_BASE, N=256) + +colors_array = np.linspace(0, 1, 256) +# print(colors_array) + +color_tuples = [ + (round(a[0] * 255), round(a[1] * 255), round(a[2] * 255)) + for a in color_map(colors_array) +] +print(color_tuples) From 77d7d9f6a49f921973f88036945f9be509db0240 Mon Sep 17 00:00:00 2001 From: Adam Schill Collberg Date: Fri, 11 Apr 2025 13:49:13 +0200 Subject: [PATCH 2/4] Add range coloring example to GDS notebook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Florentin Dörre --- examples/gds-example.ipynb | 122 ++++++++++++++++++++++++++----------- 1 file changed, 88 insertions(+), 34 deletions(-) diff --git a/examples/gds-example.ipynb b/examples/gds-example.ipynb index d1f4497..cb8d44e 100644 --- a/examples/gds-example.ipynb +++ b/examples/gds-example.ipynb @@ -118,7 +118,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 7, "metadata": { "tags": [ "preserve-output" @@ -129,14 +129,14 @@ "data": { "text/html": [ "\n", - "
\n", + "
\n", " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "VG.color_nodes(\"subject\")\n", + "VG.render()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now, let us color by our continuous node property \"size\" that we computed above with PageRank, again using the default colors.\n", + "We set `override=True` so as to replace the previous coloring completely.\n", + "Note how the nodes are colored from yellow to purple, and how that also corresponds to the nodes' sizes." ] }, { @@ -253,14 +305,14 @@ "data": { "text/html": [ "\n", - "
\n", + "
\n", "