From 17bcd3fb3832d9b345da8b955c105a41db55c4e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Mon, 8 Jun 2026 13:28:32 +0200 Subject: [PATCH 1/6] Add utiltiy methods to GraphWidget similar API to VisualizationGraph going forward. this will avoid rerendering for every change to take effect --- .../src/neo4j_viz/_graph_entity_operations.py | 592 +++++++++++++++++ .../src/neo4j_viz/visualization_graph.py | 599 +++--------------- python-wrapper/src/neo4j_viz/widget.py | 88 +++ python-wrapper/tests/test_widget.py | 81 +++ 4 files changed, 832 insertions(+), 528 deletions(-) create mode 100644 python-wrapper/src/neo4j_viz/_graph_entity_operations.py diff --git a/python-wrapper/src/neo4j_viz/_graph_entity_operations.py b/python-wrapper/src/neo4j_viz/_graph_entity_operations.py new file mode 100644 index 0000000..fd20abe --- /dev/null +++ b/python-wrapper/src/neo4j_viz/_graph_entity_operations.py @@ -0,0 +1,592 @@ +from __future__ import annotations + +import warnings +from collections.abc import Hashable, Iterable +from typing import Any, Callable, Protocol, TypeVar + +from pydantic.alias_generators import to_snake +from pydantic_extra_types.color import Color, ColorType + +from .colors import NEO4J_COLORS_CONTINUOUS, NEO4J_COLORS_DISCRETE, ColorSpace, ColorsType +from .node import Node, NodeIdType +from .node_size import RealNumber, verify_radii +from .relationship import Relationship + +F = TypeVar("F", bound=Callable[..., Any]) + + +def delegate_doc(target: Callable[..., Any]) -> Callable[[F], F]: + """Copy the docstring of `target` onto the decorated function. + + Lets the thin delegating methods on the host classes reuse the canonical docstrings + defined on `GraphEntityOperations` without duplicating the text. + """ + + def decorator(fn: F) -> F: + fn.__doc__ = target.__doc__ + return fn + + return decorator + + +class EntityHost(Protocol): + """The interface a host must expose to be driven by `GraphEntityOperations`.""" + + nodes: list[Node] + relationships: list[Relationship] + + def _sync_entities(self, *, nodes: bool = ..., relationships: bool = ...) -> None: ... + + +class GraphEntityOperations: + """Recolor, resize, caption and pin operations over a host's graph entities. + + This is a composable component: it does not own the data, but reads the `nodes` and + `relationships` from its `host` and mutates the entities in place. After each mutation + it calls the host's `_sync_entities` hook so the host can react (e.g. the widget pushes + the changes to its frontend). + """ + + def __init__(self, host: EntityHost) -> None: + self._host = host + + @property + def nodes(self) -> list[Node]: + return self._host.nodes + + @property + def relationships(self) -> list[Relationship]: + return self._host.relationships + + def toggle_nodes_pinned(self, pinned: dict[NodeIdType, bool]) -> None: + """ + Toggle whether nodes should be pinned or not. + + Parameters + ---------- + pinned: + A dictionary mapping from node ID to whether the node should be pinned or not. + """ + for node in self.nodes: + node_pinned = pinned.get(node.id) + + if node_pinned is None: + continue + + node.pinned = node_pinned + + self._host._sync_entities(nodes=True) + + def set_node_captions( + self, + *, + field: str | None = None, + property: str | None = None, + override: bool = True, + ) -> None: + """ + Set the caption for nodes in the graph based on either a node field or a node property. + + Parameters + ---------- + field: + The field of the nodes to use as the caption. Must be None if `property` is provided. + property: + The property of the nodes to use as the caption. Must be None if `field` is provided. + override: + Whether to override existing captions of the nodes, if they have any. + + Examples + -------- + Given a VisualizationGraph `VG`: + + >>> nodes = [ + ... Node(id="0", properties={"name": "Alice", "age": 30}), + ... Node(id="1", properties={"name": "Bob", "age": 25}), + ... ] + >>> VG = VisualizationGraph(nodes=nodes) + + Set node captions from a property: + + >>> VG.set_node_captions(property="name") + + Set node captions from a field, only if not already set: + + >>> VG.set_node_captions(field="id", override=False) + + Set captions from multiple properties with fallback: + + >>> for node in VG.nodes: + ... caption = node.properties.get("name") or node.properties.get("title") or node.id + ... if override or node.caption is None: + ... node.caption = str(caption) + """ + if not ((field is None) ^ (property is None)): + raise ValueError( + f"Exactly one of the arguments `field` (received '{field}') and `property` (received '{property}') must be provided" + ) + + if property: + # Use property + for node in self.nodes: + if not override and node.caption is not None: + continue + + value = node.properties.get(property, "") + node.caption = str(value) + else: + # Use field + assert field is not None + attribute = to_snake(field) + + for node in self.nodes: + if not override and node.caption is not None: + continue + + value = getattr(node, attribute, "") + node.caption = str(value) + + self._host._sync_entities(nodes=True) + + def resize_nodes( + self, + sizes: dict[NodeIdType, RealNumber] | None = None, + node_radius_min_max: tuple[RealNumber, RealNumber] | None = (3, 60), + property: str | None = None, + ) -> None: + """ + Resize the nodes in the graph. + + Parameters + ---------- + sizes: + A dictionary mapping from node ID to the new size of the node. + If a node ID is not in the dictionary, the size of the node is not changed. + Must be None if `property` is provided. + node_radius_min_max: + Minimum and maximum node size radius as a tuple. To avoid tiny or huge nodes in the visualization, the + node sizes are scaled to fit in the given range. If None, the sizes are used as is. + property: + The property of the nodes to use for sizing. Must be None if `sizes` is provided. + """ + if sizes is not None and property is not None: + raise ValueError("At most one of the arguments `sizes` and `property` can be provided") + + if sizes is None and property is None and node_radius_min_max is None: + raise ValueError("At least one of `sizes`, `property` or `node_radius_min_max` must be given") + + # Gather node sizes + all_sizes = {} + if sizes is not None: + for node in self.nodes: + size = sizes.get(node.id, node.size) + if size is not None: + all_sizes[node.id] = size + elif property is not None: + for node in self.nodes: + size = node.properties.get(property, node.size) + if size is not None: + all_sizes[node.id] = size + else: + for node in self.nodes: + if node.size is not None: + all_sizes[node.id] = node.size + + # Validate node sizes + for id, size in all_sizes.items(): + if size is None: + continue + + if not isinstance(size, (int, float)): + raise ValueError(f"Size for node '{id}' must be a real number, but was {size}") + + if size < 0: + raise ValueError(f"Size for node '{id}' must be non-negative, but was {size}") + + if node_radius_min_max is not None: + verify_radii(node_radius_min_max) + + final_sizes = self._normalize_values(all_sizes, node_radius_min_max) + else: + final_sizes = all_sizes + + # Apply the final sizes to the nodes + for node in self.nodes: + size = final_sizes.get(node.id) + + if size is None: + continue + + node.size = size + + self._host._sync_entities(nodes=True) + + def resize_relationships( + self, + widths: dict[str | int, RealNumber] | None = None, + property: str | None = None, + ) -> None: + """ + Resize the width of relationships in the graph. + + Parameters + ---------- + widths: + A dictionary mapping from relationship ID to the new width of the relationship. + If a relationship ID is not in the dictionary, the width of the relationship is not changed. + Must be None if `property` is provided. + property: + The property of the relationships to use for sizing. Must be None if `widths` is provided. + """ + if widths is not None and property is not None: + raise ValueError("At most one of the arguments `widths` and `property` can be provided") + + if widths is None and property is None: + raise ValueError("At least one of `widths` or `property` must be given") + + # Gather relationship widths + all_widths = {} + if widths is not None: + for rel in self.relationships: + width = widths.get(rel.id, rel.width) + if width is not None: + all_widths[rel.id] = width + elif property is not None: + for rel in self.relationships: + width = rel.properties.get(property, rel.width) + if width is not None: + all_widths[rel.id] = width + + # Validate and apply relationship widths + for rel in self.relationships: + width = all_widths.get(rel.id) + + if width is None: + continue + + if not isinstance(width, (int, float)): + raise ValueError(f"Width for relationship '{rel.id}' must be a real number, but was {width}") + + if width <= 0: + raise ValueError(f"Width for relationship '{rel.id}' must be positive, but was {width}") + + rel.width = width + + self._host._sync_entities(relationships=True) + + @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, + *, + field: str | None = None, + property: str | None = None, + colors: ColorsType | None = None, + color_space: ColorSpace = ColorSpace.DISCRETE, + override: bool = True, + ) -> None: + """ + Color the nodes in the graph based on either a node field, or a node property. + + It's possible to color the nodes based on a discrete or continuous color space. In the discrete case, a new + color from the `colors` provided is assigned to each unique value of the node field/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 field/property. + + Parameters + ---------- + field: + The field of the nodes to base the coloring on. The type of this field must be hashable, or be a + list, set or dict containing only hashable types. Must be None if `property` is provided. + property: + 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. Must be None if `field` is provided. + colors: + The colors to use for the nodes. + If `color_space` is `ColorSpace.DISCRETE`, the colors can be a dictionary mapping from field/property value + to color, or an iterable of colors in which case the colors are used in order. + If `color_space` is `ColorSpace.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. + color_space: + The type of space of the provided `colors`. Either `ColorSpace.DISCRETE` or `ColorSpace.CONTINUOUS`. It determines whether + colors are assigned based on unique field/property values or a gradient of the values of the field/property. + override: + Whether to override existing colors of the nodes, if they have any. + + Examples + -------- + + Given a VisualizationGraph `VG`: + + >>> nodes = [ + ... Node(id="0", properties={"label": "Person", "score": 10}), + ... Node(id="1", properties={"label": "Person", "score": 20}), + ... ] + >>> VG = VisualizationGraph(nodes=nodes) + + Color nodes based on a discrete field such as "label": + + >>> VG.color_nodes(field="label", color_space=ColorSpace.DISCRETE) + + Color nodes based on a continuous field such as "score": + + >>> VG.color_nodes(field="score", color_space=ColorSpace.CONTINUOUS) + + Color nodes based on a custom colors such as from palettable: + + >>> from palettable.wesanderson import Moonrise1_5 # type: ignore[import-untyped] + >>> VG.color_nodes(field="label", colors=Moonrise1_5.colors) + """ + if not ((field is None) ^ (property is None)): + raise ValueError( + f"Exactly one of the arguments `field` (received '{field}') and `property` (received '{property}') must be provided" + ) + + if field is None: + assert property is not None + attribute = property + + def node_to_attr(node: Node) -> Any: + return node.properties.get(attribute) + + else: + assert field is not None + attribute = to_snake(field) + + def node_to_attr(node: Node) -> Any: + return getattr(node, attribute) + + if color_space == ColorSpace.DISCRETE: + if colors is None: + colors = NEO4J_COLORS_DISCRETE + else: + node_map = {node.id: node_to_attr(node) for node in self.nodes if node_to_attr(node) 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 = { + node_to_attr(node): colors[round(normalized_map[node.id] * (num_colors - 1))] + for node in self.nodes + if node_to_attr(node) is not None + } + + if isinstance(colors, dict): + self._color_items_dict(self.nodes, colors, override, node_to_attr) + else: + self._color_items_iter(self.nodes, attribute, colors, override, node_to_attr) + + self._host._sync_entities(nodes=True) + + def color_relationships( + self, + *, + field: str | None = None, + property: str | None = None, + colors: ColorsType | None = None, + color_space: ColorSpace = ColorSpace.DISCRETE, + override: bool = True, + ) -> None: + """ + Color the relationships in the graph based on either a relationship field, or a relationship property. + + It's possible to color the relationships based on a discrete or continuous color space. In the discrete case, + a new color from the `colors` provided is assigned to each unique value of the relationship field/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 relationship field/property. + + Parameters + ---------- + field: + The field of the relationships to base the coloring on. The type of this field must be hashable, or be a + list, set or dict containing only hashable types. Must be None if `property` is provided. + property: + The property of the relationships to base the coloring on. The type of this property must be hashable, or be a + list, set or dict containing only hashable types. Must be None if `field` is provided. + colors: + The colors to use for the relationships. + If `color_space` is `ColorSpace.DISCRETE`, the colors can be a dictionary mapping from field/property value + to color, or an iterable of colors in which case the colors are used in order. + If `color_space` is `ColorSpace.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. + color_space: + The type of space of the provided `colors`. Either `ColorSpace.DISCRETE` or `ColorSpace.CONTINUOUS`. It determines whether + colors are assigned based on unique field/property values or a gradient of the values of the field/property. + override: + Whether to override existing colors of the relationships, if they have any. + + Examples + -------- + + Given a VisualizationGraph `VG`: + + >>> nodes = [Node(id="0"), Node(id="1")] + >>> relationships = [ + ... Relationship(source="0", target="1", caption="ACTED_IN", properties={"score": 10}), + ... Relationship(source="1", target="0", caption="DIRECTED", properties={"score": 20}), + ... ] + >>> VG = VisualizationGraph(nodes=nodes, relationships=relationships) + + Color relationships based on a discrete field such as "caption": + + >>> VG.color_relationships(field="caption", color_space=ColorSpace.DISCRETE) + + Color relationships based on a continuous field such as "score": + + >>> VG.color_relationships(property="score", color_space=ColorSpace.CONTINUOUS) + """ + if not ((field is None) ^ (property is None)): + raise ValueError( + f"Exactly one of the arguments `field` (received '{field}') and `property` (received '{property}') must be provided" + ) + + if field is None: + assert property is not None + attribute = property + + def rel_to_attr(rel: Relationship) -> Any: + return rel.properties.get(attribute) + + else: + assert field is not None + attribute = to_snake(field) + + def rel_to_attr(rel: Relationship) -> Any: + return getattr(rel, attribute) + + if color_space == ColorSpace.DISCRETE: + if colors is None: + colors = NEO4J_COLORS_DISCRETE + else: + rel_map = {rel.id: rel_to_attr(rel) for rel in self.relationships if rel_to_attr(rel) is not None} + normalized_map = self._normalize_values(rel_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 = { + rel_to_attr(rel): colors[round(normalized_map[rel.id] * (num_colors - 1))] + for rel in self.relationships + if rel_to_attr(rel) is not None + } + + if isinstance(colors, dict): + self._color_items_dict(self.relationships, colors, override, rel_to_attr) + else: + self._color_items_iter(self.relationships, attribute, colors, override, rel_to_attr) + + self._host._sync_entities(relationships=True) + + def _color_items_dict( + self, + items: list[Node] | list[Relationship], + colors: dict[Hashable, ColorType], + override: bool, + item_to_attr: Callable[[Any], Any], + ) -> None: + for item in items: + color = colors.get(item_to_attr(item)) + + if color is None: + continue + + if item.color is not None and not override: + continue + + if not isinstance(color, Color): + item.color = Color(color) + else: + item.color = color + + def _color_items_iter( + self, + items: list[Node] | list[Relationship], + attribute: str, + colors: Iterable[ColorType], + override: bool, + item_to_attr: Callable[[Any], Any], + ) -> None: + exhausted_colors = False + prop_to_color = {} + colors_iter = iter(colors) + for item in items: + raw_prop = item_to_attr(item) + try: + prop = self._make_hashable(raw_prop) + except ValueError: + item_type = "nodes" if isinstance(item, Node) else "relationships" + raise ValueError(f"Unable to color {item_type} by unhashable property type '{type(raw_prop)}'") + + if prop not in prop_to_color: + next_color = next(colors_iter, None) + if next_color is None: + exhausted_colors = True + colors_iter = iter(colors) + next_color = next(colors_iter) + prop_to_color[prop] = next_color + + color = prop_to_color[prop] + + if item.color is not None and not override: + continue + + if not isinstance(color, Color): + item.color = Color(color) + else: + item.color = color + + if exhausted_colors: + warnings.warn( + f"Ran out of colors for property '{attribute}'. {len(prop_to_color)} colors were needed, but only " + f"{len(set(prop_to_color.values()))} were given, so reused colors" + ) + + @staticmethod + def _make_hashable(raw_prop: Any) -> Hashable: + prop = raw_prop + if isinstance(raw_prop, list): + prop = tuple(raw_prop) + elif isinstance(raw_prop, set): + prop = frozenset(raw_prop) + elif isinstance(raw_prop, dict): + prop = tuple(sorted(raw_prop.items())) + + try: + hash(prop) + except TypeError: + raise ValueError(f"Unable to convert '{raw_prop}' of type {type(raw_prop)} to a hashable type") + + assert isinstance(prop, Hashable) + + return prop diff --git a/python-wrapper/src/neo4j_viz/visualization_graph.py b/python-wrapper/src/neo4j_viz/visualization_graph.py index c25b860..67bff0b 100644 --- a/python-wrapper/src/neo4j_viz/visualization_graph.py +++ b/python-wrapper/src/neo4j_viz/visualization_graph.py @@ -1,16 +1,14 @@ from __future__ import annotations -import warnings -from collections.abc import Hashable, Iterable -from typing import Any, Callable, Literal +from functools import cached_property +from typing import Any, Literal from IPython.display import HTML -from pydantic.alias_generators import to_snake -from pydantic_extra_types.color import Color, ColorType -from .colors import NEO4J_COLORS_CONTINUOUS, NEO4J_COLORS_DISCRETE, ColorSpace, ColorsType +from ._graph_entity_operations import GraphEntityOperations, delegate_doc +from .colors import ColorSpace, ColorsType from .node import Node, NodeIdType -from .node_size import RealNumber, verify_radii +from .node_size import RealNumber from .nvl import NVL from .options import ( Layout, @@ -87,6 +85,72 @@ def __init__(self, nodes: list[Node], relationships: list[Relationship]) -> None def __str__(self) -> str: return f"VisualizationGraph(nodes={len(self.nodes)}, relationships={len(self.relationships)})" + @cached_property + def _entity_ops(self) -> GraphEntityOperations: + return GraphEntityOperations(self) + + def _sync_entities(self, *, nodes: bool = False, relationships: bool = False) -> None: + """Hook invoked after entities are mutated in place. A no-op for a plain graph.""" + + @delegate_doc(GraphEntityOperations.toggle_nodes_pinned) + def toggle_nodes_pinned(self, pinned: dict[NodeIdType, bool]) -> None: + self._entity_ops.toggle_nodes_pinned(pinned) + + @delegate_doc(GraphEntityOperations.set_node_captions) + def set_node_captions( + self, + *, + field: str | None = None, + property: str | None = None, + override: bool = True, + ) -> None: + self._entity_ops.set_node_captions(field=field, property=property, override=override) + + @delegate_doc(GraphEntityOperations.resize_nodes) + def resize_nodes( + self, + sizes: dict[NodeIdType, RealNumber] | None = None, + node_radius_min_max: tuple[RealNumber, RealNumber] | None = (3, 60), + property: str | None = None, + ) -> None: + self._entity_ops.resize_nodes(sizes=sizes, node_radius_min_max=node_radius_min_max, property=property) + + @delegate_doc(GraphEntityOperations.resize_relationships) + def resize_relationships( + self, + widths: dict[str | int, RealNumber] | None = None, + property: str | None = None, + ) -> None: + self._entity_ops.resize_relationships(widths=widths, property=property) + + @delegate_doc(GraphEntityOperations.color_nodes) + def color_nodes( + self, + *, + field: str | None = None, + property: str | None = None, + colors: ColorsType | None = None, + color_space: ColorSpace = ColorSpace.DISCRETE, + override: bool = True, + ) -> None: + self._entity_ops.color_nodes( + field=field, property=property, colors=colors, color_space=color_space, override=override + ) + + @delegate_doc(GraphEntityOperations.color_relationships) + def color_relationships( + self, + *, + field: str | None = None, + property: str | None = None, + colors: ColorsType | None = None, + color_space: ColorSpace = ColorSpace.DISCRETE, + override: bool = True, + ) -> None: + self._entity_ops.color_relationships( + field=field, property=property, colors=colors, color_space=color_space, override=override + ) + def _build_render_options( self, layout: Layout | str | None, @@ -283,524 +347,3 @@ def render_widget( options=render_options, theme=theme, ) - - def toggle_nodes_pinned(self, pinned: dict[NodeIdType, bool]) -> None: - """ - Toggle whether nodes should be pinned or not. - - Parameters - ---------- - pinned: - A dictionary mapping from node ID to whether the node should be pinned or not. - """ - for node in self.nodes: - node_pinned = pinned.get(node.id) - - if node_pinned is None: - continue - - node.pinned = node_pinned - - def set_node_captions( - self, - *, - field: str | None = None, - property: str | None = None, - override: bool = True, - ) -> None: - """ - Set the caption for nodes in the graph based on either a node field or a node property. - - Parameters - ---------- - field: - The field of the nodes to use as the caption. Must be None if `property` is provided. - property: - The property of the nodes to use as the caption. Must be None if `field` is provided. - override: - Whether to override existing captions of the nodes, if they have any. - - Examples - -------- - Given a VisualizationGraph `VG`: - - >>> nodes = [ - ... Node(id="0", properties={"name": "Alice", "age": 30}), - ... Node(id="1", properties={"name": "Bob", "age": 25}), - ... ] - >>> VG = VisualizationGraph(nodes=nodes) - - Set node captions from a property: - - >>> VG.set_node_captions(property="name") - - Set node captions from a field, only if not already set: - - >>> VG.set_node_captions(field="id", override=False) - - Set captions from multiple properties with fallback: - - >>> for node in VG.nodes: - ... caption = node.properties.get("name") or node.properties.get("title") or node.id - ... if override or node.caption is None: - ... node.caption = str(caption) - """ - if not ((field is None) ^ (property is None)): - raise ValueError( - f"Exactly one of the arguments `field` (received '{field}') and `property` (received '{property}') must be provided" - ) - - if property: - # Use property - for node in self.nodes: - if not override and node.caption is not None: - continue - - value = node.properties.get(property, "") - node.caption = str(value) - else: - # Use field - assert field is not None - attribute = to_snake(field) - - for node in self.nodes: - if not override and node.caption is not None: - continue - - value = getattr(node, attribute, "") - node.caption = str(value) - - def resize_nodes( - self, - sizes: dict[NodeIdType, RealNumber] | None = None, - node_radius_min_max: tuple[RealNumber, RealNumber] | None = (3, 60), - property: str | None = None, - ) -> None: - """ - Resize the nodes in the graph. - - Parameters - ---------- - sizes: - A dictionary mapping from node ID to the new size of the node. - If a node ID is not in the dictionary, the size of the node is not changed. - Must be None if `property` is provided. - node_radius_min_max: - Minimum and maximum node size radius as a tuple. To avoid tiny or huge nodes in the visualization, the - node sizes are scaled to fit in the given range. If None, the sizes are used as is. - property: - The property of the nodes to use for sizing. Must be None if `sizes` is provided. - """ - if sizes is not None and property is not None: - raise ValueError("At most one of the arguments `sizes` and `property` can be provided") - - if sizes is None and property is None and node_radius_min_max is None: - raise ValueError("At least one of `sizes`, `property` or `node_radius_min_max` must be given") - - # Gather node sizes - all_sizes = {} - if sizes is not None: - for node in self.nodes: - size = sizes.get(node.id, node.size) - if size is not None: - all_sizes[node.id] = size - elif property is not None: - for node in self.nodes: - size = node.properties.get(property, node.size) - if size is not None: - all_sizes[node.id] = size - else: - for node in self.nodes: - if node.size is not None: - all_sizes[node.id] = node.size - - # Validate node sizes - for id, size in all_sizes.items(): - if size is None: - continue - - if not isinstance(size, (int, float)): - raise ValueError(f"Size for node '{id}' must be a real number, but was {size}") - - if size < 0: - raise ValueError(f"Size for node '{id}' must be non-negative, but was {size}") - - if node_radius_min_max is not None: - verify_radii(node_radius_min_max) - - final_sizes = self._normalize_values(all_sizes, node_radius_min_max) - else: - final_sizes = all_sizes - - # Apply the final sizes to the nodes - for node in self.nodes: - size = final_sizes.get(node.id) - - if size is None: - continue - - node.size = size - - def resize_relationships( - self, - widths: dict[str | int, RealNumber] | None = None, - property: str | None = None, - ) -> None: - """ - Resize the width of relationships in the graph. - - Parameters - ---------- - widths: - A dictionary mapping from relationship ID to the new width of the relationship. - If a relationship ID is not in the dictionary, the width of the relationship is not changed. - Must be None if `property` is provided. - property: - The property of the relationships to use for sizing. Must be None if `widths` is provided. - """ - if widths is not None and property is not None: - raise ValueError("At most one of the arguments `widths` and `property` can be provided") - - if widths is None and property is None: - raise ValueError("At least one of `widths` or `property` must be given") - - # Gather relationship widths - all_widths = {} - if widths is not None: - for rel in self.relationships: - width = widths.get(rel.id, rel.width) - if width is not None: - all_widths[rel.id] = width - elif property is not None: - for rel in self.relationships: - width = rel.properties.get(property, rel.width) - if width is not None: - all_widths[rel.id] = width - - # Validate and apply relationship widths - for rel in self.relationships: - width = all_widths.get(rel.id) - - if width is None: - continue - - if not isinstance(width, (int, float)): - raise ValueError(f"Width for relationship '{rel.id}' must be a real number, but was {width}") - - if width <= 0: - raise ValueError(f"Width for relationship '{rel.id}' must be positive, but was {width}") - - rel.width = width - - @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, - *, - field: str | None = None, - property: str | None = None, - colors: ColorsType | None = None, - color_space: ColorSpace = ColorSpace.DISCRETE, - override: bool = True, - ) -> None: - """ - Color the nodes in the graph based on either a node field, or a node property. - - It's possible to color the nodes based on a discrete or continuous color space. In the discrete case, a new - color from the `colors` provided is assigned to each unique value of the node field/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 field/property. - - Parameters - ---------- - field: - The field of the nodes to base the coloring on. The type of this field must be hashable, or be a - list, set or dict containing only hashable types. Must be None if `property` is provided. - property: - 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. Must be None if `field` is provided. - colors: - The colors to use for the nodes. - If `color_space` is `ColorSpace.DISCRETE`, the colors can be a dictionary mapping from field/property value - to color, or an iterable of colors in which case the colors are used in order. - If `color_space` is `ColorSpace.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. - color_space: - The type of space of the provided `colors`. Either `ColorSpace.DISCRETE` or `ColorSpace.CONTINUOUS`. It determines whether - colors are assigned based on unique field/property values or a gradient of the values of the field/property. - override: - Whether to override existing colors of the nodes, if they have any. - - Examples - -------- - - Given a VisualizationGraph `VG`: - - >>> nodes = [ - ... Node(id="0", properties={"label": "Person", "score": 10}), - ... Node(id="1", properties={"label": "Person", "score": 20}), - ... ] - >>> VG = VisualizationGraph(nodes=nodes) - - Color nodes based on a discrete field such as "label": - - >>> VG.color_nodes(field="label", color_space=ColorSpace.DISCRETE) - - Color nodes based on a continuous field such as "score": - - >>> VG.color_nodes(field="score", color_space=ColorSpace.CONTINUOUS) - - Color nodes based on a custom colors such as from palettable: - - >>> from palettable.wesanderson import Moonrise1_5 # type: ignore[import-untyped] - >>> VG.color_nodes(field="label", colors=Moonrise1_5.colors) - """ - if not ((field is None) ^ (property is None)): - raise ValueError( - f"Exactly one of the arguments `field` (received '{field}') and `property` (received '{property}') must be provided" - ) - - if field is None: - assert property is not None - attribute = property - - def node_to_attr(node: Node) -> Any: - return node.properties.get(attribute) - - else: - assert field is not None - attribute = to_snake(field) - - def node_to_attr(node: Node) -> Any: - return getattr(node, attribute) - - if color_space == ColorSpace.DISCRETE: - if colors is None: - colors = NEO4J_COLORS_DISCRETE - else: - node_map = {node.id: node_to_attr(node) for node in self.nodes if node_to_attr(node) 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 = { - node_to_attr(node): colors[round(normalized_map[node.id] * (num_colors - 1))] - for node in self.nodes - if node_to_attr(node) is not None - } - - if isinstance(colors, dict): - self._color_items_dict(self.nodes, colors, override, node_to_attr) - else: - self._color_items_iter(self.nodes, attribute, colors, override, node_to_attr) - - def color_relationships( - self, - *, - field: str | None = None, - property: str | None = None, - colors: ColorsType | None = None, - color_space: ColorSpace = ColorSpace.DISCRETE, - override: bool = True, - ) -> None: - """ - Color the relationships in the graph based on either a relationship field, or a relationship property. - - It's possible to color the relationships based on a discrete or continuous color space. In the discrete case, - a new color from the `colors` provided is assigned to each unique value of the relationship field/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 relationship field/property. - - Parameters - ---------- - field: - The field of the relationships to base the coloring on. The type of this field must be hashable, or be a - list, set or dict containing only hashable types. Must be None if `property` is provided. - property: - The property of the relationships to base the coloring on. The type of this property must be hashable, or be a - list, set or dict containing only hashable types. Must be None if `field` is provided. - colors: - The colors to use for the relationships. - If `color_space` is `ColorSpace.DISCRETE`, the colors can be a dictionary mapping from field/property value - to color, or an iterable of colors in which case the colors are used in order. - If `color_space` is `ColorSpace.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. - color_space: - The type of space of the provided `colors`. Either `ColorSpace.DISCRETE` or `ColorSpace.CONTINUOUS`. It determines whether - colors are assigned based on unique field/property values or a gradient of the values of the field/property. - override: - Whether to override existing colors of the relationships, if they have any. - - Examples - -------- - - Given a VisualizationGraph `VG`: - - >>> nodes = [Node(id="0"), Node(id="1")] - >>> relationships = [ - ... Relationship(source="0", target="1", caption="ACTED_IN", properties={"score": 10}), - ... Relationship(source="1", target="0", caption="DIRECTED", properties={"score": 20}), - ... ] - >>> VG = VisualizationGraph(nodes=nodes, relationships=relationships) - - Color relationships based on a discrete field such as "caption": - - >>> VG.color_relationships(field="caption", color_space=ColorSpace.DISCRETE) - - Color relationships based on a continuous field such as "score": - - >>> VG.color_relationships(property="score", color_space=ColorSpace.CONTINUOUS) - """ - if not ((field is None) ^ (property is None)): - raise ValueError( - f"Exactly one of the arguments `field` (received '{field}') and `property` (received '{property}') must be provided" - ) - - if field is None: - assert property is not None - attribute = property - - def rel_to_attr(rel: Relationship) -> Any: - return rel.properties.get(attribute) - - else: - assert field is not None - attribute = to_snake(field) - - def rel_to_attr(rel: Relationship) -> Any: - return getattr(rel, attribute) - - if color_space == ColorSpace.DISCRETE: - if colors is None: - colors = NEO4J_COLORS_DISCRETE - else: - rel_map = {rel.id: rel_to_attr(rel) for rel in self.relationships if rel_to_attr(rel) is not None} - normalized_map = self._normalize_values(rel_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 = { - rel_to_attr(rel): colors[round(normalized_map[rel.id] * (num_colors - 1))] - for rel in self.relationships - if rel_to_attr(rel) is not None - } - - if isinstance(colors, dict): - self._color_items_dict(self.relationships, colors, override, rel_to_attr) - else: - self._color_items_iter(self.relationships, attribute, colors, override, rel_to_attr) - - def _color_items_dict( - self, - items: list[Node] | list[Relationship], - colors: dict[Hashable, ColorType], - override: bool, - item_to_attr: Callable[[Any], Any], - ) -> None: - for item in items: - color = colors.get(item_to_attr(item)) - - if color is None: - continue - - if item.color is not None and not override: - continue - - if not isinstance(color, Color): - item.color = Color(color) - else: - item.color = color - - def _color_items_iter( - self, - items: list[Node] | list[Relationship], - attribute: str, - colors: Iterable[ColorType], - override: bool, - item_to_attr: Callable[[Any], Any], - ) -> None: - exhausted_colors = False - prop_to_color = {} - colors_iter = iter(colors) - for item in items: - raw_prop = item_to_attr(item) - try: - prop = self._make_hashable(raw_prop) - except ValueError: - item_type = "nodes" if isinstance(item, Node) else "relationships" - raise ValueError(f"Unable to color {item_type} by unhashable property type '{type(raw_prop)}'") - - if prop not in prop_to_color: - next_color = next(colors_iter, None) - if next_color is None: - exhausted_colors = True - colors_iter = iter(colors) - next_color = next(colors_iter) - prop_to_color[prop] = next_color - - color = prop_to_color[prop] - - if item.color is not None and not override: - continue - - if not isinstance(color, Color): - item.color = Color(color) - else: - item.color = color - - if exhausted_colors: - warnings.warn( - f"Ran out of colors for property '{attribute}'. {len(prop_to_color)} colors were needed, but only " - f"{len(set(prop_to_color.values()))} were given, so reused colors" - ) - - @staticmethod - def _make_hashable(raw_prop: Any) -> Hashable: - prop = raw_prop - if isinstance(raw_prop, list): - prop = tuple(raw_prop) - elif isinstance(raw_prop, set): - prop = frozenset(raw_prop) - elif isinstance(raw_prop, dict): - prop = tuple(sorted(raw_prop.items())) - - try: - hash(prop) - except TypeError: - raise ValueError(f"Unable to convert '{raw_prop}' of type {type(raw_prop)} to a hashable type") - - assert isinstance(prop, Hashable) - - return prop diff --git a/python-wrapper/src/neo4j_viz/widget.py b/python-wrapper/src/neo4j_viz/widget.py index 433d411..abd589e 100644 --- a/python-wrapper/src/neo4j_viz/widget.py +++ b/python-wrapper/src/neo4j_viz/widget.py @@ -2,12 +2,16 @@ import json import pathlib +from functools import cached_property from typing import Any, Union import anywidget import traitlets +from ._graph_entity_operations import GraphEntityOperations, delegate_doc +from .colors import ColorSpace, ColorsType from .node import Node, NodeIdType +from .node_size import RealNumber from .options import RenderOptions from .relationship import Relationship, RelationshipIdType @@ -48,6 +52,9 @@ class GraphWidget(anywidget.AnyWidget): Uses anywidget to render a React-based graph component with two-way data sync between Python and JavaScript. + The widget exposes utility methods that mutate the graph in place and + automatically sync the changes to the frontend. + Dev mode: set ANYWIDGET_HMR=1 and run ``yarn dev`` in js-applet/ for hot module replacement during development. """ @@ -87,6 +94,87 @@ def from_graph_data( def __str__(self) -> str: return f"GraphWidget(nodes={len(self.nodes)}, relationships={len(self.relationships)}, options={self.options}, theme={self.theme}, width={self.width}, height={self.height})" + @cached_property + def _entity_ops(self) -> GraphEntityOperations: + return GraphEntityOperations(self) + + def _sync_entities(self, *, nodes: bool = False, relationships: bool = False) -> None: + """Propagate in-place entity mutations to the frontend. + + The utility methods delegated to :class:`GraphEntityOperations` mutate the `Node` + and `Relationship` objects in place. This does not change the identity (or equality) + of the `nodes`/`relationships` lists, so traitlets does not detect a change and would + not sync. We therefore explicitly push the affected trait(s) to JavaScript, which + re-serializes them via `entity_to_json`. When the widget is not connected to a + frontend (e.g. outside a notebook), `send_state` is a no-op. + """ + keys = [] + if nodes: + keys.append("nodes") + if relationships: + keys.append("relationships") + if keys: + self.send_state(keys if len(keys) > 1 else keys[0]) + + @delegate_doc(GraphEntityOperations.toggle_nodes_pinned) + def toggle_nodes_pinned(self, pinned: dict[NodeIdType, bool]) -> None: + self._entity_ops.toggle_nodes_pinned(pinned) + + @delegate_doc(GraphEntityOperations.set_node_captions) + def set_node_captions( + self, + *, + field: str | None = None, + property: str | None = None, + override: bool = True, + ) -> None: + self._entity_ops.set_node_captions(field=field, property=property, override=override) + + @delegate_doc(GraphEntityOperations.resize_nodes) + def resize_nodes( + self, + sizes: dict[NodeIdType, RealNumber] | None = None, + node_radius_min_max: tuple[RealNumber, RealNumber] | None = (3, 60), + property: str | None = None, + ) -> None: + self._entity_ops.resize_nodes(sizes=sizes, node_radius_min_max=node_radius_min_max, property=property) + + @delegate_doc(GraphEntityOperations.resize_relationships) + def resize_relationships( + self, + widths: dict[str | int, RealNumber] | None = None, + property: str | None = None, + ) -> None: + self._entity_ops.resize_relationships(widths=widths, property=property) + + @delegate_doc(GraphEntityOperations.color_nodes) + def color_nodes( + self, + *, + field: str | None = None, + property: str | None = None, + colors: ColorsType | None = None, + color_space: ColorSpace = ColorSpace.DISCRETE, + override: bool = True, + ) -> None: + self._entity_ops.color_nodes( + field=field, property=property, colors=colors, color_space=color_space, override=override + ) + + @delegate_doc(GraphEntityOperations.color_relationships) + def color_relationships( + self, + *, + field: str | None = None, + property: str | None = None, + colors: ColorsType | None = None, + color_space: ColorSpace = ColorSpace.DISCRETE, + override: bool = True, + ) -> None: + self._entity_ops.color_relationships( + field=field, property=property, colors=colors, color_space=color_space, override=override + ) + def add_data( self, nodes: Node | list[Node] | None = None, relationships: Relationship | list[Relationship] | None = None ) -> None: diff --git a/python-wrapper/tests/test_widget.py b/python-wrapper/tests/test_widget.py index 8033411..8860332 100644 --- a/python-wrapper/tests/test_widget.py +++ b/python-wrapper/tests/test_widget.py @@ -213,6 +213,87 @@ def test_remove_data(self) -> None: assert {r.id for r in widget.relationships} == {43} +class TestWidgetUtilityMethods: + def _spy_send_state(self, widget: GraphWidget) -> list[Any]: + synced: list[Any] = [] + widget.send_state = lambda key=None: synced.append(key) + return synced + + def test_color_nodes(self) -> None: + widget = GraphWidget(nodes=[Node(id="n1", properties={"label": "A"}), Node(id="n2", properties={"label": "B"})]) + synced = self._spy_send_state(widget) + + widget.color_nodes(property="label") + + assert widget.nodes[0].color is not None + assert widget.nodes[1].color is not None + assert widget.nodes[0].color != widget.nodes[1].color + # Mutating in place must still push the updated nodes to the frontend. + assert synced == ["nodes"] + + def test_color_relationships(self) -> None: + widget = GraphWidget( + nodes=[Node(id="n1"), Node(id="n2")], + relationships=[ + Relationship(source="n1", target="n2", caption="KNOWS"), + Relationship(source="n2", target="n1", caption="LIKES"), + ], + ) + synced = self._spy_send_state(widget) + + widget.color_relationships(field="caption") + + assert widget.relationships[0].color is not None + assert widget.relationships[0].color != widget.relationships[1].color + assert synced == ["relationships"] + + def test_resize_nodes(self) -> None: + widget = GraphWidget( + nodes=[ + Node(id="n1", properties={"score": 10}), + Node(id="n2", properties={"score": 20}), + ] + ) + synced = self._spy_send_state(widget) + + widget.resize_nodes(property="score", node_radius_min_max=(10, 50)) + + assert widget.nodes[0].size == 10 + assert widget.nodes[1].size == 50 + assert synced == ["nodes"] + + def test_resize_relationships(self) -> None: + widget = GraphWidget( + nodes=[Node(id="n1"), Node(id="n2")], + relationships=[Relationship(id="r1", source="n1", target="n2")], + ) + synced = self._spy_send_state(widget) + + widget.resize_relationships(widths={"r1": 5}) + + assert widget.relationships[0].width == 5 + assert synced == ["relationships"] + + def test_set_node_captions(self) -> None: + widget = GraphWidget(nodes=[Node(id="n1", properties={"name": "Alice"})]) + synced = self._spy_send_state(widget) + + widget.set_node_captions(property="name") + + assert widget.nodes[0].caption == "Alice" + assert synced == ["nodes"] + + def test_toggle_nodes_pinned(self) -> None: + widget = GraphWidget(nodes=[Node(id="n1", pinned=False), Node(id="n2")]) + synced = self._spy_send_state(widget) + + widget.toggle_nodes_pinned({"n1": True}) + + assert widget.nodes[0].pinned is True + assert widget.nodes[1].pinned is None + assert synced == ["nodes"] + + render_widget_cases = { "default": {}, "force layout": {"layout": Layout.FORCE_DIRECTED}, From e7763913450b8488860dbbf4e25908533ad07ea5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Mon, 8 Jun 2026 13:38:30 +0200 Subject: [PATCH 2/6] Use new methods in getting-started --- examples/getting-started.ipynb | 1717 +----------------------- python-wrapper/src/neo4j_viz/widget.py | 8 + 2 files changed, 71 insertions(+), 1654 deletions(-) diff --git a/examples/getting-started.ipynb b/examples/getting-started.ipynb index 2ae449b..4878c52 100644 --- a/examples/getting-started.ipynb +++ b/examples/getting-started.ipynb @@ -1633,12 +1633,12 @@ "

Expected window.__NEO4J_VIZ_DATA__ to be set.

\n", "

This page should be generated by neo4j_viz's render() method.

\n", " \n", - " `,new Error(\"window.__NEO4J_VIZ_DATA__ is not defined\");const ypr={get(t){return x3[t]},on(){},off(){},set(){},save_changes(){}},_3=document.getElementById(\"neo4j-viz-f81941349bdf\");if(!_3)throw new Error(\"Container element #neo4j-viz-f81941349bdf not found\");_3.style.width=x3.width??\"100%\";_3.style.height=x3.height??\"100vh\";mpr.render({model:ypr,el:_3});\n", + " `,new Error(\"window.__NEO4J_VIZ_DATA__ is not defined\");const ypr={get(t){return x3[t]},on(){},off(){},set(){},save_changes(){}},_3=document.getElementById(\"neo4j-viz-4970d3c5503e\");if(!_3)throw new Error(\"Container element #neo4j-viz-4970d3c5503e not found\");_3.style.width=x3.width??\"100%\";_3.style.height=x3.height??\"100vh\";mpr.render({model:ypr,el:_3});\n", " \n", - " \n", + " \n", "\n", " \n", - "
\n", + "
\n", " \n", "\n" ], @@ -1681,1660 +1681,28 @@ "\n", "VG = VisualizationGraph(nodes=nodes, relationships=relationships)\n", "\n", - "VG.render(initial_zoom=2)" - ] - }, - { - "cell_type": "markdown", - "id": "365a1c31", - "metadata": {}, - "source": [ - "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", - "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", - "Nodes with the same \"caption\" will have the same color.\n", - "We will use the default colorscheme, which is the Neo4j colorscheme.\n" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "d935b3d4", - "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": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ "VG.color_nodes(field=\"size\")\n", "VG.set_node_captions(field=\"size\")\n", "\n", "VG.render(initial_zoom=2)" ] }, + { + "cell_type": "markdown", + "id": "365a1c31", + "metadata": {}, + "source": [ + "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", + "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", + "Nodes with the same \"caption\" will have the same color.\n", + "We will use the default colorscheme, which is the Neo4j colorscheme.\n" + ] + }, { "cell_type": "markdown", "id": "a28bd5aa", @@ -3358,10 +1726,30 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "id": "6j6duo4v7p9", - "metadata": {}, - "outputs": [], + "metadata": { + "tags": [ + "preserve-output" + ] + }, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "93d50cdafdc042078a8d37c3a951bea9", + "version_major": 2, + "version_minor": 1 + }, + "text/plain": [ + "" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "widget = VG.render_widget()\n", "widget" @@ -3395,6 +1783,27 @@ "\n", "widget.add_data(nodes=new_node, relationships=new_rel)" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "686e0beb", + "metadata": {}, + "outputs": [], + "source": [ + "widget.color_relationships(field=\"caption\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c68712f0", + "metadata": {}, + "outputs": [], + "source": [ + "widget.nodes[0].size = 50\n", + "widget.sync_nodes() # manually trigger sync to update widget" + ] } ], "metadata": { diff --git a/python-wrapper/src/neo4j_viz/widget.py b/python-wrapper/src/neo4j_viz/widget.py index abd589e..b1d8e9f 100644 --- a/python-wrapper/src/neo4j_viz/widget.py +++ b/python-wrapper/src/neo4j_viz/widget.py @@ -98,6 +98,14 @@ def __str__(self) -> str: def _entity_ops(self) -> GraphEntityOperations: return GraphEntityOperations(self) + def sync_nodes(self) -> None: + """Manually trigger a sync of the `nodes` list to the frontend.""" + self._sync_entities(nodes=True) + + def sync_relationships(self) -> None: + """Manually trigger a sync of the `relationships` list to the frontend.""" + self._sync_entities(relationships=True) + def _sync_entities(self, *, nodes: bool = False, relationships: bool = False) -> None: """Propagate in-place entity mutations to the frontend. From ba49877a2e0a6e18f14fe21418fc36bd77a14a8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Tue, 9 Jun 2026 13:51:24 +0200 Subject: [PATCH 3/6] Add utiltiy methods for rendering --- changelog.md | 2 + docs/source/api-reference/widget.rst | 2 + examples/getting-started.ipynb | 20 ++- python-wrapper/src/neo4j_viz/options.py | 43 +++++- python-wrapper/src/neo4j_viz/widget.py | 188 ++++++++++++++++++------ python-wrapper/tests/test_widget.py | 97 +++++++++++- 6 files changed, 297 insertions(+), 55 deletions(-) create mode 100644 docs/source/api-reference/widget.rst diff --git a/changelog.md b/changelog.md index 500e1c1..a358f94 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,8 @@ ## New features +* Add `GraphWidget` methods to change render options in place without re-rendering: `set_layout`, `set_zoom`, `set_pan`, `set_renderer`, and `set_show_layout_button` + ## Bug fixes ## Improvements diff --git a/docs/source/api-reference/widget.rst b/docs/source/api-reference/widget.rst new file mode 100644 index 0000000..8ec6510 --- /dev/null +++ b/docs/source/api-reference/widget.rst @@ -0,0 +1,2 @@ +.. autoclass:: neo4j_viz.GraphWidget + :members: diff --git a/examples/getting-started.ipynb b/examples/getting-started.ipynb index 4878c52..7a012a5 100644 --- a/examples/getting-started.ipynb +++ b/examples/getting-started.ipynb @@ -1633,12 +1633,12 @@ "

Expected window.__NEO4J_VIZ_DATA__ to be set.

\n", "

This page should be generated by neo4j_viz's render() method.

\n", " \n", - " `,new Error(\"window.__NEO4J_VIZ_DATA__ is not defined\");const ypr={get(t){return x3[t]},on(){},off(){},set(){},save_changes(){}},_3=document.getElementById(\"neo4j-viz-4970d3c5503e\");if(!_3)throw new Error(\"Container element #neo4j-viz-4970d3c5503e not found\");_3.style.width=x3.width??\"100%\";_3.style.height=x3.height??\"100vh\";mpr.render({model:ypr,el:_3});\n", + " `,new Error(\"window.__NEO4J_VIZ_DATA__ is not defined\");const ypr={get(t){return x3[t]},on(){},off(){},set(){},save_changes(){}},_3=document.getElementById(\"neo4j-viz-75a25c3af547\");if(!_3)throw new Error(\"Container element #neo4j-viz-75a25c3af547 not found\");_3.style.width=x3.width??\"100%\";_3.style.height=x3.height??\"100vh\";mpr.render({model:ypr,el:_3});\n", " \n", - " \n", + " \n", "\n", " \n", - "
\n", + "
\n", " \n", "\n" ], @@ -1737,12 +1737,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "93d50cdafdc042078a8d37c3a951bea9", + "model_id": "8f9849af878743d4b73329f4fd7cc977", "version_major": 2, "version_minor": 1 }, "text/plain": [ - "" + "" ] }, "execution_count": 2, @@ -1804,6 +1804,16 @@ "widget.nodes[0].size = 50\n", "widget.sync_nodes() # manually trigger sync to update widget" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f174b6ed00027bf5", + "metadata": {}, + "outputs": [], + "source": [ + "widget.set_zoom(1.5) # change the rendering options dynamically" + ] } ], "metadata": { diff --git a/python-wrapper/src/neo4j_viz/options.py b/python-wrapper/src/neo4j_viz/options.py index 2c4eb4c..ad36bbf 100644 --- a/python-wrapper/src/neo4j_viz/options.py +++ b/python-wrapper/src/neo4j_viz/options.py @@ -2,7 +2,7 @@ import warnings from enum import Enum -from typing import Any, Optional, Union +from typing import Any, Optional, TypedDict, Union import enum_tools.documentation from pydantic import BaseModel, Field, ValidationError, model_validator @@ -144,6 +144,41 @@ def check(self, renderer: Renderer, num_nodes: int) -> None: } +class PanPosition(TypedDict): + """The ``{x, y}`` pan position consumed by the frontend.""" + + x: float + y: float + + +class NvlOptionsDict(TypedDict, total=False): + """The subset of NVL instance options set from Python, nested under ``nvlOptions``. + + The frontend's ``nvlOptions`` is a ``Partial`` with many more fields; this only + types the keys the Python wrapper writes. Other keys round-trip through unchanged at runtime. + """ + + disableWebGL: bool + minZoom: float + maxZoom: float + allowDynamicMinZoom: bool + + +class RenderOptionsDict(TypedDict, total=False): + """The JS-shaped render options consumed by the ``GraphWidget`` frontend. + + This mirrors the ``GraphOptions`` type in ``js-applet/src/graph-widget.tsx`` and is the + structure stored in :attr:`GraphWidget.options`. + """ + + layout: str + layoutOptions: dict[str, Any] + nvlOptions: NvlOptionsDict + zoom: float + pan: PanPosition + showLayoutButton: bool + + class RenderOptions(BaseModel, extra="allow"): """ Options as documented at https://neo4j.com/docs/nvl/current/base-library/#_options @@ -178,7 +213,7 @@ def check_layout_options_match(self) -> RenderOptions: raise ValueError("layout_options must be of type ForceDirectedLayoutOptions for force-directed layout") return self - def to_js_options(self) -> dict[str, Any]: + def to_js_options(self) -> RenderOptionsDict: """Convert render options to the JS-compatible format for the GraphVisualization component. Returns a dict with keys that map to React component props and NVL options: @@ -188,7 +223,7 @@ def to_js_options(self) -> dict[str, Any]: - ``pan``: ``{x, y}`` pan position - ``layoutOptions``: layout-specific options """ - result: dict[str, Any] = {} + result: RenderOptionsDict = {} if self.layout is not None: match self.layout: @@ -206,7 +241,7 @@ def to_js_options(self) -> dict[str, Any]: if self.layout_options is not None: result["layoutOptions"] = self.layout_options.model_dump(exclude_none=True) - nvl_options: dict[str, Any] = {} + nvl_options: NvlOptionsDict = {} if self.renderer is not None: nvl_options["disableWebGL"] = self.renderer != Renderer.WEB_GL if self.min_zoom is not None: diff --git a/python-wrapper/src/neo4j_viz/widget.py b/python-wrapper/src/neo4j_viz/widget.py index b1d8e9f..5690f6a 100644 --- a/python-wrapper/src/neo4j_viz/widget.py +++ b/python-wrapper/src/neo4j_viz/widget.py @@ -3,7 +3,7 @@ import json import pathlib from functools import cached_property -from typing import Any, Union +from typing import Any, Union, cast import anywidget import traitlets @@ -12,7 +12,15 @@ from .colors import ColorSpace, ColorsType from .node import Node, NodeIdType from .node_size import RealNumber -from .options import RenderOptions +from .options import ( + Layout, + LayoutOptions, + NvlOptionsDict, + Renderer, + RenderOptions, + RenderOptionsDict, + construct_layout_options, +) from .relationship import Relationship, RelationshipIdType @@ -46,6 +54,8 @@ def entity_to_json(entity_list: list[Node | Relationship], widget: anywidget.Any return [_serialize_entity(entity) for entity in entity_list] +# Dev mode: set ANYWIDGET_HMR=1 and run ``yarn dev`` in js-applet/ +# for hot module replacement during development. class GraphWidget(anywidget.AnyWidget): """Jupyter widget for interactive graph visualization. @@ -54,9 +64,6 @@ class GraphWidget(anywidget.AnyWidget): The widget exposes utility methods that mutate the graph in place and automatically sync the changes to the frontend. - - Dev mode: set ANYWIDGET_HMR=1 and run ``yarn dev`` in js-applet/ - for hot module replacement during development. """ _esm = _STATIC / "widget.js" @@ -73,13 +80,13 @@ class GraphWidget(anywidget.AnyWidget): @classmethod def from_graph_data( - cls, - nodes: list[Node], - relationships: list[Relationship], - width: str = "100%", - height: str = "600px", - options: RenderOptions | None = None, - theme: str = "auto", + cls, + nodes: list[Node], + relationships: list[Relationship], + width: str = "100%", + height: str = "600px", + options: RenderOptions | None = None, + theme: str = "auto", ) -> GraphWidget: """Create a GraphWidget from Node and Relationship lists.""" return cls( @@ -130,40 +137,40 @@ def toggle_nodes_pinned(self, pinned: dict[NodeIdType, bool]) -> None: @delegate_doc(GraphEntityOperations.set_node_captions) def set_node_captions( - self, - *, - field: str | None = None, - property: str | None = None, - override: bool = True, + self, + *, + field: str | None = None, + property: str | None = None, + override: bool = True, ) -> None: self._entity_ops.set_node_captions(field=field, property=property, override=override) @delegate_doc(GraphEntityOperations.resize_nodes) def resize_nodes( - self, - sizes: dict[NodeIdType, RealNumber] | None = None, - node_radius_min_max: tuple[RealNumber, RealNumber] | None = (3, 60), - property: str | None = None, + self, + sizes: dict[NodeIdType, RealNumber] | None = None, + node_radius_min_max: tuple[RealNumber, RealNumber] | None = (3, 60), + property: str | None = None, ) -> None: self._entity_ops.resize_nodes(sizes=sizes, node_radius_min_max=node_radius_min_max, property=property) @delegate_doc(GraphEntityOperations.resize_relationships) def resize_relationships( - self, - widths: dict[str | int, RealNumber] | None = None, - property: str | None = None, + self, + widths: dict[str | int, RealNumber] | None = None, + property: str | None = None, ) -> None: self._entity_ops.resize_relationships(widths=widths, property=property) @delegate_doc(GraphEntityOperations.color_nodes) def color_nodes( - self, - *, - field: str | None = None, - property: str | None = None, - colors: ColorsType | None = None, - color_space: ColorSpace = ColorSpace.DISCRETE, - override: bool = True, + self, + *, + field: str | None = None, + property: str | None = None, + colors: ColorsType | None = None, + color_space: ColorSpace = ColorSpace.DISCRETE, + override: bool = True, ) -> None: self._entity_ops.color_nodes( field=field, property=property, colors=colors, color_space=color_space, override=override @@ -171,20 +178,111 @@ def color_nodes( @delegate_doc(GraphEntityOperations.color_relationships) def color_relationships( - self, - *, - field: str | None = None, - property: str | None = None, - colors: ColorsType | None = None, - color_space: ColorSpace = ColorSpace.DISCRETE, - override: bool = True, + self, + *, + field: str | None = None, + property: str | None = None, + colors: ColorsType | None = None, + color_space: ColorSpace = ColorSpace.DISCRETE, + override: bool = True, ) -> None: self._entity_ops.color_relationships( field=field, property=property, colors=colors, color_space=color_space, override=override ) + def _render_options(self) -> RenderOptionsDict: + """Return a typed, mutable copy of the current JS-shaped render options.""" + return cast(RenderOptionsDict, dict(self.options)) + + def set_layout(self, layout: Layout | str, layout_options: dict[str, Any] | LayoutOptions | None = None) -> None: + """ + Change the layout algorithm used to position the graph, in place. + + Parameters + ----------- + layout: + The layout algorithm to use (e.g. `Layout.FORCE_DIRECTED`, `Layout.HIERARCHICAL`). + layout_options: + Optional layout-specific options. Either a `HierarchicalLayoutOptions`/`ForceDirectedLayoutOptions` + instance or a plain dict, which is validated against the chosen layout. Layout options are only + supported for the force-directed and hierarchical layouts. + """ + if isinstance(layout, str): + layout = Layout(layout) + + if isinstance(layout_options, dict): + layout_options = construct_layout_options(layout, layout_options) + + js = RenderOptions(layout=layout, layout_options=layout_options).to_js_options() + + new = self._render_options() + new["layout"] = js["layout"] + if "layoutOptions" in js: + new["layoutOptions"] = js["layoutOptions"] + else: + new.pop("layoutOptions", None) + self.options = dict(new) + + def set_zoom(self, zoom: float) -> None: + """ + Change the zoom level of the graph, in place. + + Parameters + ----------- + zoom: + The zoom level to apply. + """ + new = self._render_options() + new["zoom"] = zoom + self.options = dict(new) + + def set_pan(self, x: float, y: float) -> None: + """ + Change the pan position of the graph, in place. + + Parameters + ----------- + x: + The pan position along the x-axis. + y: + The pan position along the y-axis. + """ + new = self._render_options() + new["pan"] = {"x": x, "y": y} + self.options = dict(new) + + def set_renderer(self, renderer: Renderer) -> None: + """ + Change the renderer used to draw the graph, in place. + + Parameters + ----------- + renderer: + The renderer to use, either `Renderer.WEB_GL` or `Renderer.CANVAS`. + """ + Renderer.check(renderer, len(self.nodes)) + + new = self._render_options() + nvl_options = cast(NvlOptionsDict, dict(new.get("nvlOptions", {}))) + nvl_options["disableWebGL"] = renderer != Renderer.WEB_GL + new["nvlOptions"] = nvl_options + self.options = dict(new) + + def set_show_layout_button(self, show: bool = True) -> None: + """ + Toggle the layout selector button in the widget UI, in place. + + Parameters + ----------- + show: + Whether the layout button should be shown. + """ + new = self._render_options() + new["showLayoutButton"] = show + self.options = dict(new) + def add_data( - self, nodes: Node | list[Node] | None = None, relationships: Relationship | list[Relationship] | None = None + self, nodes: Node | list[Node] | None = None, relationships: Relationship | list[Relationship] | None = None ) -> None: """ Add nodes or relationships to the graph widget. @@ -207,9 +305,9 @@ def add_data( self.relationships = self.relationships + relationships def remove_data( - self, - nodes: Node | list[Node | NodeIdType] | NodeIdType | None = None, - relationships: Relationship | list[Relationship | RelationshipIdType] | RelationshipIdType | None = None, + self, + nodes: Node | list[Node | NodeIdType] | NodeIdType | None = None, + relationships: Relationship | list[Relationship | RelationshipIdType] | RelationshipIdType | None = None, ) -> None: """ Remove nodes or relationships from the graph widget. @@ -244,9 +342,9 @@ def remove_data( def keep_rel(r: Relationship) -> bool: return ( - r.id not in rel_ids_to_remove - and r.source not in node_ids_to_remove - and r.target not in node_ids_to_remove + r.id not in rel_ids_to_remove + and r.source not in node_ids_to_remove + and r.target not in node_ids_to_remove ) if rel_ids_to_remove: diff --git a/python-wrapper/tests/test_widget.py b/python-wrapper/tests/test_widget.py index 8860332..83bc26c 100644 --- a/python-wrapper/tests/test_widget.py +++ b/python-wrapper/tests/test_widget.py @@ -4,7 +4,7 @@ import pytest from neo4j_viz import GraphWidget, Node, Relationship, VisualizationGraph -from neo4j_viz.options import Layout, RenderOptions +from neo4j_viz.options import Layout, Renderer, RenderOptions from neo4j_viz.widget import _serialize_entity @@ -365,3 +365,98 @@ def test_render_widget_options_passed_through(self) -> None: assert widget.options["zoom"] == 2.0 assert widget.options["nvlOptions"]["minZoom"] == 0.1 assert widget.options["nvlOptions"]["maxZoom"] == 5.0 + + +class TestRenderOptionSetters: + def test_set_layout(self) -> None: + widget = GraphWidget() + + widget.set_layout(Layout.HIERARCHICAL) + + assert widget.options["layout"] == "hierarchical" + + def test_set_layout_with_options(self) -> None: + widget = GraphWidget() + + widget.set_layout(Layout.FORCE_DIRECTED, {"gravity": 0.1}) + + assert widget.options["layout"] == "d3Force" + assert widget.options["layoutOptions"] == {"gravity": 0.1} + + def test_set_layout_clears_stale_layout_options(self) -> None: + widget = GraphWidget(options={"layoutOptions": {"gravity": 0.1}}) + + widget.set_layout(Layout.GRID) + + assert widget.options["layout"] == "grid" + assert "layoutOptions" not in widget.options + + def test_set_layout_with_mismatched_options_raises(self) -> None: + widget = GraphWidget() + + with pytest.raises(ValueError): + widget.set_layout(Layout.HIERARCHICAL, {"gravity": 0.1}) + + def test_set_zoom(self) -> None: + widget = GraphWidget() + + widget.set_zoom(2.0) + + assert widget.options["zoom"] == 2.0 + + def test_set_pan(self) -> None: + widget = GraphWidget() + + widget.set_pan(100, 50) + + assert widget.options["pan"] == {"x": 100, "y": 50} + + def test_set_renderer_canvas(self) -> None: + widget = GraphWidget() + + widget.set_renderer(Renderer.CANVAS) + + assert widget.options["nvlOptions"]["disableWebGL"] is True + + def test_set_renderer_webgl(self) -> None: + widget = GraphWidget() + + with pytest.warns(UserWarning): + widget.set_renderer(Renderer.WEB_GL) + + assert widget.options["nvlOptions"]["disableWebGL"] is False + + def test_set_renderer_preserves_other_nvl_options(self) -> None: + widget = GraphWidget(options={"nvlOptions": {"minZoom": 0.1}}) + + widget.set_renderer(Renderer.CANVAS) + + assert widget.options["nvlOptions"]["minZoom"] == 0.1 + assert widget.options["nvlOptions"]["disableWebGL"] is True + + def test_set_show_layout_button(self) -> None: + widget = GraphWidget() + + widget.set_show_layout_button() + assert widget.options["showLayoutButton"] is True + + widget.set_show_layout_button(False) + assert widget.options["showLayoutButton"] is False + + def test_setter_preserves_unrelated_options(self) -> None: + widget = GraphWidget(options={"layout": "hierarchical"}) + + widget.set_zoom(3.0) + + assert widget.options["zoom"] == 3.0 + assert widget.options["layout"] == "hierarchical" + + def test_setter_triggers_sync(self) -> None: + widget = GraphWidget() + changes: list[dict[str, Any]] = [] + widget.observe(lambda change: changes.append(change), names=["options"]) + + widget.set_zoom(2.0) + + assert len(changes) == 1 + assert changes[0]["name"] == "options" From bfe4048efcbbfdae02f80db8494001d9d57e5d12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Tue, 9 Jun 2026 15:42:35 +0200 Subject: [PATCH 4/6] Fix doc strings --- .../src/neo4j_viz/_graph_entity_operations.py | 198 +------------- .../src/neo4j_viz/visualization_graph.py | 175 +++++++++++- python-wrapper/src/neo4j_viz/widget.py | 257 ++++++++++++++---- 3 files changed, 385 insertions(+), 245 deletions(-) diff --git a/python-wrapper/src/neo4j_viz/_graph_entity_operations.py b/python-wrapper/src/neo4j_viz/_graph_entity_operations.py index fd20abe..5ef0ee8 100644 --- a/python-wrapper/src/neo4j_viz/_graph_entity_operations.py +++ b/python-wrapper/src/neo4j_viz/_graph_entity_operations.py @@ -2,7 +2,7 @@ import warnings from collections.abc import Hashable, Iterable -from typing import Any, Callable, Protocol, TypeVar +from typing import Any, Callable, Protocol from pydantic.alias_generators import to_snake from pydantic_extra_types.color import Color, ColorType @@ -12,22 +12,6 @@ from .node_size import RealNumber, verify_radii from .relationship import Relationship -F = TypeVar("F", bound=Callable[..., Any]) - - -def delegate_doc(target: Callable[..., Any]) -> Callable[[F], F]: - """Copy the docstring of `target` onto the decorated function. - - Lets the thin delegating methods on the host classes reuse the canonical docstrings - defined on `GraphEntityOperations` without duplicating the text. - """ - - def decorator(fn: F) -> F: - fn.__doc__ = target.__doc__ - return fn - - return decorator - class EntityHost(Protocol): """The interface a host must expose to be driven by `GraphEntityOperations`.""" @@ -59,14 +43,7 @@ def relationships(self) -> list[Relationship]: return self._host.relationships def toggle_nodes_pinned(self, pinned: dict[NodeIdType, bool]) -> None: - """ - Toggle whether nodes should be pinned or not. - - Parameters - ---------- - pinned: - A dictionary mapping from node ID to whether the node should be pinned or not. - """ + """Pin or unpin nodes. See `VisualizationGraph.toggle_nodes_pinned` for details.""" for node in self.nodes: node_pinned = pinned.get(node.id) @@ -84,43 +61,7 @@ def set_node_captions( property: str | None = None, override: bool = True, ) -> None: - """ - Set the caption for nodes in the graph based on either a node field or a node property. - - Parameters - ---------- - field: - The field of the nodes to use as the caption. Must be None if `property` is provided. - property: - The property of the nodes to use as the caption. Must be None if `field` is provided. - override: - Whether to override existing captions of the nodes, if they have any. - - Examples - -------- - Given a VisualizationGraph `VG`: - - >>> nodes = [ - ... Node(id="0", properties={"name": "Alice", "age": 30}), - ... Node(id="1", properties={"name": "Bob", "age": 25}), - ... ] - >>> VG = VisualizationGraph(nodes=nodes) - - Set node captions from a property: - - >>> VG.set_node_captions(property="name") - - Set node captions from a field, only if not already set: - - >>> VG.set_node_captions(field="id", override=False) - - Set captions from multiple properties with fallback: - - >>> for node in VG.nodes: - ... caption = node.properties.get("name") or node.properties.get("title") or node.id - ... if override or node.caption is None: - ... node.caption = str(caption) - """ + """Set node captions from a field or property. See `VisualizationGraph.set_node_captions` for details.""" if not ((field is None) ^ (property is None)): raise ValueError( f"Exactly one of the arguments `field` (received '{field}') and `property` (received '{property}') must be provided" @@ -154,21 +95,7 @@ def resize_nodes( node_radius_min_max: tuple[RealNumber, RealNumber] | None = (3, 60), property: str | None = None, ) -> None: - """ - Resize the nodes in the graph. - - Parameters - ---------- - sizes: - A dictionary mapping from node ID to the new size of the node. - If a node ID is not in the dictionary, the size of the node is not changed. - Must be None if `property` is provided. - node_radius_min_max: - Minimum and maximum node size radius as a tuple. To avoid tiny or huge nodes in the visualization, the - node sizes are scaled to fit in the given range. If None, the sizes are used as is. - property: - The property of the nodes to use for sizing. Must be None if `sizes` is provided. - """ + """Resize nodes from explicit sizes or a property. See `VisualizationGraph.resize_nodes` for details.""" if sizes is not None and property is not None: raise ValueError("At most one of the arguments `sizes` and `property` can be provided") @@ -226,18 +153,7 @@ def resize_relationships( widths: dict[str | int, RealNumber] | None = None, property: str | None = None, ) -> None: - """ - Resize the width of relationships in the graph. - - Parameters - ---------- - widths: - A dictionary mapping from relationship ID to the new width of the relationship. - If a relationship ID is not in the dictionary, the width of the relationship is not changed. - Must be None if `property` is provided. - property: - The property of the relationships to use for sizing. Must be None if `widths` is provided. - """ + """Resize relationship widths from explicit widths or a property. See `VisualizationGraph.resize_relationships` for details.""" if widths is not None and property is not None: raise ValueError("At most one of the arguments `widths` and `property` can be provided") @@ -305,59 +221,7 @@ def color_nodes( color_space: ColorSpace = ColorSpace.DISCRETE, override: bool = True, ) -> None: - """ - Color the nodes in the graph based on either a node field, or a node property. - - It's possible to color the nodes based on a discrete or continuous color space. In the discrete case, a new - color from the `colors` provided is assigned to each unique value of the node field/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 field/property. - - Parameters - ---------- - field: - The field of the nodes to base the coloring on. The type of this field must be hashable, or be a - list, set or dict containing only hashable types. Must be None if `property` is provided. - property: - 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. Must be None if `field` is provided. - colors: - The colors to use for the nodes. - If `color_space` is `ColorSpace.DISCRETE`, the colors can be a dictionary mapping from field/property value - to color, or an iterable of colors in which case the colors are used in order. - If `color_space` is `ColorSpace.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. - color_space: - The type of space of the provided `colors`. Either `ColorSpace.DISCRETE` or `ColorSpace.CONTINUOUS`. It determines whether - colors are assigned based on unique field/property values or a gradient of the values of the field/property. - override: - Whether to override existing colors of the nodes, if they have any. - - Examples - -------- - - Given a VisualizationGraph `VG`: - - >>> nodes = [ - ... Node(id="0", properties={"label": "Person", "score": 10}), - ... Node(id="1", properties={"label": "Person", "score": 20}), - ... ] - >>> VG = VisualizationGraph(nodes=nodes) - - Color nodes based on a discrete field such as "label": - - >>> VG.color_nodes(field="label", color_space=ColorSpace.DISCRETE) - - Color nodes based on a continuous field such as "score": - - >>> VG.color_nodes(field="score", color_space=ColorSpace.CONTINUOUS) - - Color nodes based on a custom colors such as from palettable: - - >>> from palettable.wesanderson import Moonrise1_5 # type: ignore[import-untyped] - >>> VG.color_nodes(field="label", colors=Moonrise1_5.colors) - """ + """Color nodes by a field or property (discrete or continuous). See `VisualizationGraph.color_nodes` for details.""" if not ((field is None) ^ (property is None)): raise ValueError( f"Exactly one of the arguments `field` (received '{field}') and `property` (received '{property}') must be provided" @@ -413,55 +277,7 @@ def color_relationships( color_space: ColorSpace = ColorSpace.DISCRETE, override: bool = True, ) -> None: - """ - Color the relationships in the graph based on either a relationship field, or a relationship property. - - It's possible to color the relationships based on a discrete or continuous color space. In the discrete case, - a new color from the `colors` provided is assigned to each unique value of the relationship field/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 relationship field/property. - - Parameters - ---------- - field: - The field of the relationships to base the coloring on. The type of this field must be hashable, or be a - list, set or dict containing only hashable types. Must be None if `property` is provided. - property: - The property of the relationships to base the coloring on. The type of this property must be hashable, or be a - list, set or dict containing only hashable types. Must be None if `field` is provided. - colors: - The colors to use for the relationships. - If `color_space` is `ColorSpace.DISCRETE`, the colors can be a dictionary mapping from field/property value - to color, or an iterable of colors in which case the colors are used in order. - If `color_space` is `ColorSpace.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. - color_space: - The type of space of the provided `colors`. Either `ColorSpace.DISCRETE` or `ColorSpace.CONTINUOUS`. It determines whether - colors are assigned based on unique field/property values or a gradient of the values of the field/property. - override: - Whether to override existing colors of the relationships, if they have any. - - Examples - -------- - - Given a VisualizationGraph `VG`: - - >>> nodes = [Node(id="0"), Node(id="1")] - >>> relationships = [ - ... Relationship(source="0", target="1", caption="ACTED_IN", properties={"score": 10}), - ... Relationship(source="1", target="0", caption="DIRECTED", properties={"score": 20}), - ... ] - >>> VG = VisualizationGraph(nodes=nodes, relationships=relationships) - - Color relationships based on a discrete field such as "caption": - - >>> VG.color_relationships(field="caption", color_space=ColorSpace.DISCRETE) - - Color relationships based on a continuous field such as "score": - - >>> VG.color_relationships(property="score", color_space=ColorSpace.CONTINUOUS) - """ + """Color relationships by a field or property (discrete or continuous). See `VisualizationGraph.color_relationships` for details.""" if not ((field is None) ^ (property is None)): raise ValueError( f"Exactly one of the arguments `field` (received '{field}') and `property` (received '{property}') must be provided" diff --git a/python-wrapper/src/neo4j_viz/visualization_graph.py b/python-wrapper/src/neo4j_viz/visualization_graph.py index 67bff0b..28ba65c 100644 --- a/python-wrapper/src/neo4j_viz/visualization_graph.py +++ b/python-wrapper/src/neo4j_viz/visualization_graph.py @@ -5,7 +5,7 @@ from IPython.display import HTML -from ._graph_entity_operations import GraphEntityOperations, delegate_doc +from ._graph_entity_operations import GraphEntityOperations from .colors import ColorSpace, ColorsType from .node import Node, NodeIdType from .node_size import RealNumber @@ -92,11 +92,17 @@ def _entity_ops(self) -> GraphEntityOperations: def _sync_entities(self, *, nodes: bool = False, relationships: bool = False) -> None: """Hook invoked after entities are mutated in place. A no-op for a plain graph.""" - @delegate_doc(GraphEntityOperations.toggle_nodes_pinned) def toggle_nodes_pinned(self, pinned: dict[NodeIdType, bool]) -> None: + """ + Toggle whether nodes should be pinned or not. + + Parameters + ---------- + pinned: + A dictionary mapping from node ID to whether the node should be pinned or not. + """ self._entity_ops.toggle_nodes_pinned(pinned) - @delegate_doc(GraphEntityOperations.set_node_captions) def set_node_captions( self, *, @@ -104,26 +110,80 @@ def set_node_captions( property: str | None = None, override: bool = True, ) -> None: + """ + Set the caption for nodes in the graph based on either a node field or a node property. + + Parameters + ---------- + field: + The field of the nodes to use as the caption. Must be None if `property` is provided. + property: + The property of the nodes to use as the caption. Must be None if `field` is provided. + override: + Whether to override existing captions of the nodes, if they have any. + + Examples + -------- + Given a VisualizationGraph `VG`: + + >>> nodes = [ + ... Node(id="0", properties={"name": "Alice", "age": 30}), + ... Node(id="1", properties={"name": "Bob", "age": 25}), + ... ] + >>> VG = VisualizationGraph(nodes=nodes, relationships=[]) + + Set node captions from a property: + + >>> VG.set_node_captions(property="name") + + Set node captions from a field, only if not already set: + + >>> VG.set_node_captions(field="id", override=False) + """ self._entity_ops.set_node_captions(field=field, property=property, override=override) - @delegate_doc(GraphEntityOperations.resize_nodes) def resize_nodes( self, sizes: dict[NodeIdType, RealNumber] | None = None, node_radius_min_max: tuple[RealNumber, RealNumber] | None = (3, 60), property: str | None = None, ) -> None: + """ + Resize the nodes in the graph. + + Parameters + ---------- + sizes: + A dictionary mapping from node ID to the new size of the node. + If a node ID is not in the dictionary, the size of the node is not changed. + Must be None if `property` is provided. + node_radius_min_max: + Minimum and maximum node size radius as a tuple. To avoid tiny or huge nodes in the visualization, the + node sizes are scaled to fit in the given range. If None, the sizes are used as is. + property: + The property of the nodes to use for sizing. Must be None if `sizes` is provided. + """ self._entity_ops.resize_nodes(sizes=sizes, node_radius_min_max=node_radius_min_max, property=property) - @delegate_doc(GraphEntityOperations.resize_relationships) def resize_relationships( self, widths: dict[str | int, RealNumber] | None = None, property: str | None = None, ) -> None: + """ + Resize the width of relationships in the graph. + + Parameters + ---------- + widths: + A dictionary mapping from relationship ID to the new width of the relationship. + If a relationship ID is not in the dictionary, the width of the relationship is not changed. + Must be None if `property` is provided. + property: + The property of the relationships to use for sizing. Must be None if `widths` is provided. + """ self._entity_ops.resize_relationships(widths=widths, property=property) - @delegate_doc(GraphEntityOperations.color_nodes) def color_nodes( self, *, @@ -133,11 +193,63 @@ def color_nodes( color_space: ColorSpace = ColorSpace.DISCRETE, override: bool = True, ) -> None: + """ + Color the nodes in the graph based on either a node field, or a node property. + + It's possible to color the nodes based on a discrete or continuous color space. In the discrete case, a new + color from the `colors` provided is assigned to each unique value of the node field/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 field/property. + + Parameters + ---------- + field: + The field of the nodes to base the coloring on. The type of this field must be hashable, or be a + list, set or dict containing only hashable types. Must be None if `property` is provided. + property: + 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. Must be None if `field` is provided. + colors: + The colors to use for the nodes. + If `color_space` is `ColorSpace.DISCRETE`, the colors can be a dictionary mapping from field/property value + to color, or an iterable of colors in which case the colors are used in order. + If `color_space` is `ColorSpace.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. + color_space: + The type of space of the provided `colors`. Either `ColorSpace.DISCRETE` or `ColorSpace.CONTINUOUS`. It determines whether + colors are assigned based on unique field/property values or a gradient of the values of the field/property. + override: + Whether to override existing colors of the nodes, if they have any. + + Examples + -------- + + Given a VisualizationGraph `VG`: + + >>> nodes = [ + ... Node(id="0", properties={"label": "Person", "score": 10}), + ... Node(id="1", properties={"label": "Person", "score": 20}), + ... ] + >>> VG = VisualizationGraph(nodes=nodes, relationships=[]) + + Color nodes based on a discrete field such as "label": + + >>> VG.color_nodes(field="label", color_space=ColorSpace.DISCRETE) + + Color nodes based on a continuous field such as "score": + + >>> VG.color_nodes(field="score", color_space=ColorSpace.CONTINUOUS) + + Color nodes based on a custom colors such as from palettable: + + >>> from palettable.wesanderson import Moonrise1_5 # type: ignore[import-untyped] + >>> VG.color_nodes(field="label", colors=Moonrise1_5.colors) + """ self._entity_ops.color_nodes( field=field, property=property, colors=colors, color_space=color_space, override=override ) - @delegate_doc(GraphEntityOperations.color_relationships) def color_relationships( self, *, @@ -147,6 +259,55 @@ def color_relationships( color_space: ColorSpace = ColorSpace.DISCRETE, override: bool = True, ) -> None: + """ + Color the relationships in the graph based on either a relationship field, or a relationship property. + + It's possible to color the relationships based on a discrete or continuous color space. In the discrete case, + a new color from the `colors` provided is assigned to each unique value of the relationship field/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 relationship field/property. + + Parameters + ---------- + field: + The field of the relationships to base the coloring on. The type of this field must be hashable, or be a + list, set or dict containing only hashable types. Must be None if `property` is provided. + property: + The property of the relationships to base the coloring on. The type of this property must be hashable, or be a + list, set or dict containing only hashable types. Must be None if `field` is provided. + colors: + The colors to use for the relationships. + If `color_space` is `ColorSpace.DISCRETE`, the colors can be a dictionary mapping from field/property value + to color, or an iterable of colors in which case the colors are used in order. + If `color_space` is `ColorSpace.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. + color_space: + The type of space of the provided `colors`. Either `ColorSpace.DISCRETE` or `ColorSpace.CONTINUOUS`. It determines whether + colors are assigned based on unique field/property values or a gradient of the values of the field/property. + override: + Whether to override existing colors of the relationships, if they have any. + + Examples + -------- + + Given a VisualizationGraph `VG`: + + >>> nodes = [Node(id="0"), Node(id="1")] + >>> relationships = [ + ... Relationship(source="0", target="1", caption="ACTED_IN", properties={"score": 10}), + ... Relationship(source="1", target="0", caption="DIRECTED", properties={"score": 20}), + ... ] + >>> VG = VisualizationGraph(nodes=nodes, relationships=relationships) + + Color relationships based on a discrete field such as "caption": + + >>> VG.color_relationships(field="caption", color_space=ColorSpace.DISCRETE) + + Color relationships based on a continuous field such as "score": + + >>> VG.color_relationships(property="score", color_space=ColorSpace.CONTINUOUS) + """ self._entity_ops.color_relationships( field=field, property=property, colors=colors, color_space=color_space, override=override ) diff --git a/python-wrapper/src/neo4j_viz/widget.py b/python-wrapper/src/neo4j_viz/widget.py index 5690f6a..0abfa7c 100644 --- a/python-wrapper/src/neo4j_viz/widget.py +++ b/python-wrapper/src/neo4j_viz/widget.py @@ -8,7 +8,7 @@ import anywidget import traitlets -from ._graph_entity_operations import GraphEntityOperations, delegate_doc +from ._graph_entity_operations import GraphEntityOperations from .colors import ColorSpace, ColorsType from .node import Node, NodeIdType from .node_size import RealNumber @@ -56,6 +56,8 @@ def entity_to_json(entity_list: list[Node | Relationship], widget: anywidget.Any # Dev mode: set ANYWIDGET_HMR=1 and run ``yarn dev`` in js-applet/ # for hot module replacement during development. + + class GraphWidget(anywidget.AnyWidget): """Jupyter widget for interactive graph visualization. @@ -80,13 +82,13 @@ class GraphWidget(anywidget.AnyWidget): @classmethod def from_graph_data( - cls, - nodes: list[Node], - relationships: list[Relationship], - width: str = "100%", - height: str = "600px", - options: RenderOptions | None = None, - theme: str = "auto", + cls, + nodes: list[Node], + relationships: list[Relationship], + width: str = "100%", + height: str = "600px", + options: RenderOptions | None = None, + theme: str = "auto", ) -> GraphWidget: """Create a GraphWidget from Node and Relationship lists.""" return cls( @@ -131,61 +133,222 @@ def _sync_entities(self, *, nodes: bool = False, relationships: bool = False) -> if keys: self.send_state(keys if len(keys) > 1 else keys[0]) - @delegate_doc(GraphEntityOperations.toggle_nodes_pinned) def toggle_nodes_pinned(self, pinned: dict[NodeIdType, bool]) -> None: + """ + Toggle whether nodes should be pinned or not. + + Parameters + ---------- + pinned: + A dictionary mapping from node ID to whether the node should be pinned or not. + """ self._entity_ops.toggle_nodes_pinned(pinned) - @delegate_doc(GraphEntityOperations.set_node_captions) def set_node_captions( - self, - *, - field: str | None = None, - property: str | None = None, - override: bool = True, + self, + *, + field: str | None = None, + property: str | None = None, + override: bool = True, ) -> None: + """ + Set the caption for nodes in the graph based on either a node field or a node property. + + Parameters + ---------- + field: + The field of the nodes to use as the caption. Must be None if `property` is provided. + property: + The property of the nodes to use as the caption. Must be None if `field` is provided. + override: + Whether to override existing captions of the nodes, if they have any. + + Examples + -------- + Given a GraphWidget `widget`: + + >>> nodes = [ + ... Node(id="0", properties={"name": "Alice", "age": 30}), + ... Node(id="1", properties={"name": "Bob", "age": 25}), + ... ] + >>> widget = GraphWidget(nodes=nodes) + + Set node captions from a property: + + >>> widget.set_node_captions(property="name") + + Set node captions from a field, only if not already set: + + >>> widget.set_node_captions(field="id", override=False) + """ self._entity_ops.set_node_captions(field=field, property=property, override=override) - @delegate_doc(GraphEntityOperations.resize_nodes) def resize_nodes( - self, - sizes: dict[NodeIdType, RealNumber] | None = None, - node_radius_min_max: tuple[RealNumber, RealNumber] | None = (3, 60), - property: str | None = None, + self, + sizes: dict[NodeIdType, RealNumber] | None = None, + node_radius_min_max: tuple[RealNumber, RealNumber] | None = (3, 60), + property: str | None = None, ) -> None: + """ + Resize the nodes in the graph. + + Parameters + ---------- + sizes: + A dictionary mapping from node ID to the new size of the node. + If a node ID is not in the dictionary, the size of the node is not changed. + Must be None if `property` is provided. + node_radius_min_max: + Minimum and maximum node size radius as a tuple. To avoid tiny or huge nodes in the visualization, the + node sizes are scaled to fit in the given range. If None, the sizes are used as is. + property: + The property of the nodes to use for sizing. Must be None if `sizes` is provided. + """ self._entity_ops.resize_nodes(sizes=sizes, node_radius_min_max=node_radius_min_max, property=property) - @delegate_doc(GraphEntityOperations.resize_relationships) def resize_relationships( - self, - widths: dict[str | int, RealNumber] | None = None, - property: str | None = None, + self, + widths: dict[str | int, RealNumber] | None = None, + property: str | None = None, ) -> None: + """ + Resize the width of relationships in the graph. + + Parameters + ---------- + widths: + A dictionary mapping from relationship ID to the new width of the relationship. + If a relationship ID is not in the dictionary, the width of the relationship is not changed. + Must be None if `property` is provided. + property: + The property of the relationships to use for sizing. Must be None if `widths` is provided. + """ self._entity_ops.resize_relationships(widths=widths, property=property) - @delegate_doc(GraphEntityOperations.color_nodes) def color_nodes( - self, - *, - field: str | None = None, - property: str | None = None, - colors: ColorsType | None = None, - color_space: ColorSpace = ColorSpace.DISCRETE, - override: bool = True, + self, + *, + field: str | None = None, + property: str | None = None, + colors: ColorsType | None = None, + color_space: ColorSpace = ColorSpace.DISCRETE, + override: bool = True, ) -> None: + """ + Color the nodes in the graph based on either a node field, or a node property. + + It's possible to color the nodes based on a discrete or continuous color space. In the discrete case, a new + color from the `colors` provided is assigned to each unique value of the node field/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 field/property. + + Parameters + ---------- + field: + The field of the nodes to base the coloring on. The type of this field must be hashable, or be a + list, set or dict containing only hashable types. Must be None if `property` is provided. + property: + 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. Must be None if `field` is provided. + colors: + The colors to use for the nodes. + If `color_space` is `ColorSpace.DISCRETE`, the colors can be a dictionary mapping from field/property value + to color, or an iterable of colors in which case the colors are used in order. + If `color_space` is `ColorSpace.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. + color_space: + The type of space of the provided `colors`. Either `ColorSpace.DISCRETE` or `ColorSpace.CONTINUOUS`. It determines whether + colors are assigned based on unique field/property values or a gradient of the values of the field/property. + override: + Whether to override existing colors of the nodes, if they have any. + + Examples + -------- + + Given a GraphWidget `widget`: + + >>> nodes = [ + ... Node(id="0", properties={"label": "Person", "score": 10}), + ... Node(id="1", properties={"label": "Person", "score": 20}), + ... ] + >>> widget = GraphWidget(nodes=nodes) + + Color nodes based on a discrete field such as "label": + + >>> widget.color_nodes(field="label", color_space=ColorSpace.DISCRETE) + + Color nodes based on a continuous field such as "score": + + >>> widget.color_nodes(field="score", color_space=ColorSpace.CONTINUOUS) + + Color nodes based on a custom colors such as from palettable: + + >>> from palettable.wesanderson import Moonrise1_5 # type: ignore[import-untyped] + >>> widget.color_nodes(field="label", colors=Moonrise1_5.colors) + """ self._entity_ops.color_nodes( field=field, property=property, colors=colors, color_space=color_space, override=override ) - @delegate_doc(GraphEntityOperations.color_relationships) def color_relationships( - self, - *, - field: str | None = None, - property: str | None = None, - colors: ColorsType | None = None, - color_space: ColorSpace = ColorSpace.DISCRETE, - override: bool = True, + self, + *, + field: str | None = None, + property: str | None = None, + colors: ColorsType | None = None, + color_space: ColorSpace = ColorSpace.DISCRETE, + override: bool = True, ) -> None: + """ + Color the relationships in the graph based on either a relationship field, or a relationship property. + + It's possible to color the relationships based on a discrete or continuous color space. In the discrete case, + a new color from the `colors` provided is assigned to each unique value of the relationship field/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 relationship field/property. + + Parameters + ---------- + field: + The field of the relationships to base the coloring on. The type of this field must be hashable, or be a + list, set or dict containing only hashable types. Must be None if `property` is provided. + property: + The property of the relationships to base the coloring on. The type of this property must be hashable, or be a + list, set or dict containing only hashable types. Must be None if `field` is provided. + colors: + The colors to use for the relationships. + If `color_space` is `ColorSpace.DISCRETE`, the colors can be a dictionary mapping from field/property value + to color, or an iterable of colors in which case the colors are used in order. + If `color_space` is `ColorSpace.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. + color_space: + The type of space of the provided `colors`. Either `ColorSpace.DISCRETE` or `ColorSpace.CONTINUOUS`. It determines whether + colors are assigned based on unique field/property values or a gradient of the values of the field/property. + override: + Whether to override existing colors of the relationships, if they have any. + + Examples + -------- + + Given a GraphWidget `widget`: + + >>> nodes = [Node(id="0"), Node(id="1")] + >>> relationships = [ + ... Relationship(source="0", target="1", caption="ACTED_IN", properties={"score": 10}), + ... Relationship(source="1", target="0", caption="DIRECTED", properties={"score": 20}), + ... ] + >>> widget = GraphWidget(nodes=nodes, relationships=relationships) + + Color relationships based on a discrete field such as "caption": + + >>> widget.color_relationships(field="caption", color_space=ColorSpace.DISCRETE) + + Color relationships based on a continuous field such as "score": + + >>> widget.color_relationships(property="score", color_space=ColorSpace.CONTINUOUS) + """ self._entity_ops.color_relationships( field=field, property=property, colors=colors, color_space=color_space, override=override ) @@ -282,7 +445,7 @@ def set_show_layout_button(self, show: bool = True) -> None: self.options = dict(new) def add_data( - self, nodes: Node | list[Node] | None = None, relationships: Relationship | list[Relationship] | None = None + self, nodes: Node | list[Node] | None = None, relationships: Relationship | list[Relationship] | None = None ) -> None: """ Add nodes or relationships to the graph widget. @@ -305,9 +468,9 @@ def add_data( self.relationships = self.relationships + relationships def remove_data( - self, - nodes: Node | list[Node | NodeIdType] | NodeIdType | None = None, - relationships: Relationship | list[Relationship | RelationshipIdType] | RelationshipIdType | None = None, + self, + nodes: Node | list[Node | NodeIdType] | NodeIdType | None = None, + relationships: Relationship | list[Relationship | RelationshipIdType] | RelationshipIdType | None = None, ) -> None: """ Remove nodes or relationships from the graph widget. @@ -342,9 +505,9 @@ def remove_data( def keep_rel(r: Relationship) -> bool: return ( - r.id not in rel_ids_to_remove - and r.source not in node_ids_to_remove - and r.target not in node_ids_to_remove + r.id not in rel_ids_to_remove + and r.source not in node_ids_to_remove + and r.target not in node_ids_to_remove ) if rel_ids_to_remove: From c85dfa11b535e95e4a3c6a16f981d62553971ac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Tue, 9 Jun 2026 15:43:29 +0200 Subject: [PATCH 5/6] Fix doc strings --- changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.md b/changelog.md index a358f94..9fbd605 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,7 @@ ## New features * Add `GraphWidget` methods to change render options in place without re-rendering: `set_layout`, `set_zoom`, `set_pan`, `set_renderer`, and `set_show_layout_button` +* Add `GraphWidget` methods to change styling in place without re-rendering such as `color_relationships` ## Bug fixes From 58e04f595d447ab2528d4f927ea1ceeb1f51c603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 12 Jun 2026 14:06:50 +0200 Subject: [PATCH 6/6] Use pydantic model for graphwidget options --- changelog.md | 1 + justfile | 1 + python-wrapper/src/neo4j_viz/__init__.py | 6 ++ python-wrapper/src/neo4j_viz/nvl.py | 2 +- python-wrapper/src/neo4j_viz/options.py | 121 ++++++++++++----------- python-wrapper/src/neo4j_viz/widget.py | 66 ++++++++----- python-wrapper/tests/test_options.py | 56 +++++------ python-wrapper/tests/test_widget.py | 53 +++++----- 8 files changed, 170 insertions(+), 136 deletions(-) diff --git a/changelog.md b/changelog.md index 9fbd605..cdec7e8 100644 --- a/changelog.md +++ b/changelog.md @@ -13,5 +13,6 @@ * Support Aura Graph Analytics * Support `gds.v2` endpoints +* Use typed options field in `GraphWidget` ## Other changes diff --git a/justfile b/justfile index f876c3e..837e4c6 100644 --- a/justfile +++ b/justfile @@ -9,6 +9,7 @@ py-style: ./scripts/makestyle.sh && ./scripts/checkstyle.sh py-test: + cd python-wrapper && uv sync --all-extras --group dev cd python-wrapper && uv run --group dev pytest py-test-gds: diff --git a/python-wrapper/src/neo4j_viz/__init__.py b/python-wrapper/src/neo4j_viz/__init__.py index 920e6fe..7d759d5 100644 --- a/python-wrapper/src/neo4j_viz/__init__.py +++ b/python-wrapper/src/neo4j_viz/__init__.py @@ -5,8 +5,11 @@ ForceDirectedLayoutOptions, HierarchicalLayoutOptions, Layout, + NvlOptions, Packing, + PanPosition, Renderer, + WidgetOptions, ) from .relationship import Relationship from .visualization_graph import VisualizationGraph @@ -15,6 +18,9 @@ __all__ = [ "VisualizationGraph", "GraphWidget", + "WidgetOptions", + "NvlOptions", + "PanPosition", "Node", "Relationship", "CaptionAlignment", diff --git a/python-wrapper/src/neo4j_viz/nvl.py b/python-wrapper/src/neo4j_viz/nvl.py index ffabec8..a31dd30 100644 --- a/python-wrapper/src/neo4j_viz/nvl.py +++ b/python-wrapper/src/neo4j_viz/nvl.py @@ -46,7 +46,7 @@ def render( "width": width, "height": height, "theme": theme, - "options": render_options.to_js_options(), + "options": render_options.to_widget_options().to_json(), } data_json = json.dumps(data_dict) container_id = f"neo4j-viz-{uuid.uuid4().hex[:12]}" diff --git a/python-wrapper/src/neo4j_viz/options.py b/python-wrapper/src/neo4j_viz/options.py index ad36bbf..8969c84 100644 --- a/python-wrapper/src/neo4j_viz/options.py +++ b/python-wrapper/src/neo4j_viz/options.py @@ -2,10 +2,11 @@ import warnings from enum import Enum -from typing import Any, Optional, TypedDict, Union +from typing import Any, Optional, Union import enum_tools.documentation from pydantic import BaseModel, Field, ValidationError, model_validator +from pydantic.alias_generators import to_camel @enum_tools.documentation.document_enum @@ -144,39 +145,54 @@ def check(self, renderer: Renderer, num_nodes: int) -> None: } -class PanPosition(TypedDict): - """The ``{x, y}`` pan position consumed by the frontend.""" +class PanPosition(BaseModel): + """The ``{x, y}`` pan position.""" x: float y: float -class NvlOptionsDict(TypedDict, total=False): - """The subset of NVL instance options set from Python, nested under ``nvlOptions``. - - The frontend's ``nvlOptions`` is a ``Partial`` with many more fields; this only - types the keys the Python wrapper writes. Other keys round-trip through unchanged at runtime. - """ - - disableWebGL: bool - minZoom: float - maxZoom: float - allowDynamicMinZoom: bool - - -class RenderOptionsDict(TypedDict, total=False): - """The JS-shaped render options consumed by the ``GraphWidget`` frontend. - - This mirrors the ``GraphOptions`` type in ``js-applet/src/graph-widget.tsx`` and is the - structure stored in :attr:`GraphWidget.options`. - """ - - layout: str - layoutOptions: dict[str, Any] - nvlOptions: NvlOptionsDict - zoom: float - pan: PanPosition - showLayoutButton: bool +# Fields are snake_case in Python; pydantic serializes them to the camelCase keys the +# frontend's Partial expects (and accepts either casing on input). The frontend +# has many more fields, so extra="allow" lets other keys round-trip unchanged. +class NvlOptions( + BaseModel, + extra="allow", + alias_generator=to_camel, + populate_by_name=True, + serialize_by_alias=True, +): + """The subset of NVL instance options set from Python, nested under ``nvl_options``.""" + + # ``to_camel("disable_web_gl")`` would yield ``disableWebGl``; NVL expects ``disableWebGL``. + disable_web_gl: Optional[bool] = Field(None, alias="disableWebGL") + min_zoom: Optional[float] = None + max_zoom: Optional[float] = None + allow_dynamic_min_zoom: Optional[bool] = None + + +# Mirrors the GraphOptions type in js-applet/src/graph-widget.tsx and is the structure stored +# in GraphWidget.options. Fields are snake_case in Python; pydantic serializes them to the +# camelCase wire format the frontend expects (and accepts either casing on input). +class WidgetOptions( + BaseModel, + extra="allow", + alias_generator=to_camel, + populate_by_name=True, + serialize_by_alias=True, +): + """The render options consumed by the ``GraphWidget``.""" + + layout: Optional[str] = None + layout_options: Optional[dict[str, Any]] = None + nvl_options: Optional[NvlOptions] = None + zoom: Optional[float] = None + pan: Optional[PanPosition] = None + show_layout_button: Optional[bool] = None + + def to_json(self) -> dict[str, Any]: + """Serialize to the camelCase dict the frontend consumes, dropping unset fields.""" + return self.model_dump(exclude_none=True) class RenderOptions(BaseModel, extra="allow"): @@ -213,53 +229,46 @@ def check_layout_options_match(self) -> RenderOptions: raise ValueError("layout_options must be of type ForceDirectedLayoutOptions for force-directed layout") return self - def to_js_options(self) -> RenderOptionsDict: - """Convert render options to the JS-compatible format for the GraphVisualization component. - - Returns a dict with keys that map to React component props and NVL options: - - ``layout``: NVL layout name (e.g. ``"d3Force"``, ``"hierarchical"``) - - ``nvlOptions``: dict of NVL instance options (``minZoom``, ``maxZoom``, etc.) - - ``zoom``: initial zoom level - - ``pan``: ``{x, y}`` pan position - - ``layoutOptions``: layout-specific options - """ - result: RenderOptionsDict = {} + def to_widget_options(self) -> WidgetOptions: + result = WidgetOptions() if self.layout is not None: match self.layout: case Layout.FORCE_DIRECTED: - result["layout"] = "d3Force" + result.layout = "d3Force" case Layout.HIERARCHICAL: - result["layout"] = "hierarchical" + result.layout = "hierarchical" case Layout.COORDINATE: - result["layout"] = "free" + result.layout = "free" case Layout.GRID: - result["layout"] = "grid" + result.layout = "grid" case Layout.CIRCULAR: - result["layout"] = "circular" + result.layout = "circular" if self.layout_options is not None: - result["layoutOptions"] = self.layout_options.model_dump(exclude_none=True) + result.layout_options = self.layout_options.model_dump(exclude_none=True) - nvl_options: NvlOptionsDict = {} + nvl_options = NvlOptions() if self.renderer is not None: - nvl_options["disableWebGL"] = self.renderer != Renderer.WEB_GL + nvl_options.disable_web_gl = self.renderer != Renderer.WEB_GL if self.min_zoom is not None: - nvl_options["minZoom"] = self.min_zoom + nvl_options.min_zoom = self.min_zoom if self.max_zoom is not None: - nvl_options["maxZoom"] = self.max_zoom + nvl_options.max_zoom = self.max_zoom if self.allow_dynamic_min_zoom is not None: - nvl_options["allowDynamicMinZoom"] = self.allow_dynamic_min_zoom - if nvl_options: - result["nvlOptions"] = nvl_options + nvl_options.allow_dynamic_min_zoom = self.allow_dynamic_min_zoom + + # check if any nvl options are set + if nvl_options.model_dump(exclude_none=True): + result.nvl_options = nvl_options if self.initial_zoom is not None: - result["zoom"] = self.initial_zoom + result.zoom = self.initial_zoom if self.pan_X is not None or self.pan_Y is not None: - result["pan"] = {"x": self.pan_X or 0, "y": self.pan_Y or 0} + result.pan = PanPosition(x=self.pan_X or 0, y=self.pan_Y or 0) - result["showLayoutButton"] = self.show_layout_button + result.show_layout_button = self.show_layout_button return result diff --git a/python-wrapper/src/neo4j_viz/widget.py b/python-wrapper/src/neo4j_viz/widget.py index 0abfa7c..c201f16 100644 --- a/python-wrapper/src/neo4j_viz/widget.py +++ b/python-wrapper/src/neo4j_viz/widget.py @@ -3,7 +3,7 @@ import json import pathlib from functools import cached_property -from typing import Any, Union, cast +from typing import Any, Union import anywidget import traitlets @@ -15,10 +15,11 @@ from .options import ( Layout, LayoutOptions, - NvlOptionsDict, + NvlOptions, + PanPosition, Renderer, RenderOptions, - RenderOptionsDict, + WidgetOptions, construct_layout_options, ) from .relationship import Relationship, RelationshipIdType @@ -75,11 +76,24 @@ class GraphWidget(anywidget.AnyWidget): relationships: traitlets.List[Relationship] = traitlets.List([]).tag(sync=True, to_json=entity_to_json) width: traitlets.Unicode[str, str | bytes] = traitlets.Unicode("100%").tag(sync=True) height: traitlets.Unicode[str, str | bytes] = traitlets.Unicode("600px").tag(sync=True) - options: traitlets.Dict[str, Any] = traitlets.Dict({}).tag(sync=True) + options: traitlets.Any = traitlets.Any().tag( + sync=True, + to_json=lambda value, widget: value.to_json(), + from_json=lambda value, widget: WidgetOptions.model_validate(value), + ) theme: traitlets.Unicode[str, str | bytes] = traitlets.Unicode( default_value="auto", help="Theme of the graph widget. Can be 'auto', 'light', or 'dark'." ).tag(sync=True) + @traitlets.default("options") + def _default_options(self) -> WidgetOptions: + return WidgetOptions() + + @traitlets.validate("options") + def _coerce_options(self, proposal: dict[str, Any]) -> WidgetOptions: + value = proposal["value"] + return value if isinstance(value, WidgetOptions) else WidgetOptions.model_validate(value) + @classmethod def from_graph_data( cls, @@ -96,7 +110,7 @@ def from_graph_data( relationships=relationships, width=width, height=height, - options=options.to_js_options() if options else {}, + options=options.to_widget_options() if options else WidgetOptions(), theme=theme, ) @@ -353,9 +367,14 @@ def color_relationships( field=field, property=property, colors=colors, color_space=color_space, override=override ) - def _render_options(self) -> RenderOptionsDict: - """Return a typed, mutable copy of the current JS-shaped render options.""" - return cast(RenderOptionsDict, dict(self.options)) + def _render_options(self) -> WidgetOptions: + """Return a mutable copy of the current JS-shaped render options. + + Mutating and then reassigning the returned model to :attr:`options` triggers the + traitlets change notification that syncs the new options to the frontend. + """ + current: WidgetOptions = self.options + return current.model_copy(deep=True) def set_layout(self, layout: Layout | str, layout_options: dict[str, Any] | LayoutOptions | None = None) -> None: """ @@ -376,15 +395,12 @@ def set_layout(self, layout: Layout | str, layout_options: dict[str, Any] | Layo if isinstance(layout_options, dict): layout_options = construct_layout_options(layout, layout_options) - js = RenderOptions(layout=layout, layout_options=layout_options).to_js_options() + js = RenderOptions(layout=layout, layout_options=layout_options).to_widget_options() new = self._render_options() - new["layout"] = js["layout"] - if "layoutOptions" in js: - new["layoutOptions"] = js["layoutOptions"] - else: - new.pop("layoutOptions", None) - self.options = dict(new) + new.layout = js.layout + new.layout_options = js.layout_options + self.options = new def set_zoom(self, zoom: float) -> None: """ @@ -396,8 +412,8 @@ def set_zoom(self, zoom: float) -> None: The zoom level to apply. """ new = self._render_options() - new["zoom"] = zoom - self.options = dict(new) + new.zoom = zoom + self.options = new def set_pan(self, x: float, y: float) -> None: """ @@ -411,8 +427,8 @@ def set_pan(self, x: float, y: float) -> None: The pan position along the y-axis. """ new = self._render_options() - new["pan"] = {"x": x, "y": y} - self.options = dict(new) + new.pan = PanPosition(x=x, y=y) + self.options = new def set_renderer(self, renderer: Renderer) -> None: """ @@ -426,10 +442,10 @@ def set_renderer(self, renderer: Renderer) -> None: Renderer.check(renderer, len(self.nodes)) new = self._render_options() - nvl_options = cast(NvlOptionsDict, dict(new.get("nvlOptions", {}))) - nvl_options["disableWebGL"] = renderer != Renderer.WEB_GL - new["nvlOptions"] = nvl_options - self.options = dict(new) + nvl_options = new.nvl_options or NvlOptions() + nvl_options.disable_web_gl = renderer != Renderer.WEB_GL + new.nvl_options = nvl_options + self.options = new def set_show_layout_button(self, show: bool = True) -> None: """ @@ -441,8 +457,8 @@ def set_show_layout_button(self, show: bool = True) -> None: Whether the layout button should be shown. """ new = self._render_options() - new["showLayoutButton"] = show - self.options = dict(new) + new.show_layout_button = show + self.options = new def add_data( self, nodes: Node | list[Node] | None = None, relationships: Relationship | list[Relationship] | None = None diff --git a/python-wrapper/tests/test_options.py b/python-wrapper/tests/test_options.py index 3dee679..48f76d2 100644 --- a/python-wrapper/tests/test_options.py +++ b/python-wrapper/tests/test_options.py @@ -8,82 +8,82 @@ ) -def test_js_options_empty() -> None: +def test_widget_options_empty() -> None: options = RenderOptions() - assert options.to_js_options() == {"showLayoutButton": False} + assert options.to_widget_options().to_json() == {"showLayoutButton": False} -def test_js_options_layout_force_directed() -> None: +def test_widget_options_layout_force_directed() -> None: options = RenderOptions(layout=Layout.FORCE_DIRECTED) - js = options.to_js_options() - assert js["layout"] == "d3Force" + widget_options = options.to_widget_options().to_json() + assert widget_options["layout"] == "d3Force" -def test_js_options_layout_hierarchical() -> None: +def test_widget_options_layout_hierarchical() -> None: options = RenderOptions(layout=Layout.HIERARCHICAL) - js = options.to_js_options() - assert js["layout"] == "hierarchical" + widget_options = options.to_widget_options().to_json() + assert widget_options["layout"] == "hierarchical" -def test_js_options_layout_coordinate() -> None: +def test_widget_options_layout_coordinate() -> None: options = RenderOptions(layout=Layout.COORDINATE) - js = options.to_js_options() + js = options.to_widget_options().to_json() assert js["layout"] == "free" -def test_js_options_renderer_canvas() -> None: +def test_widget_options_renderer_canvas() -> None: options = RenderOptions(renderer=Renderer.CANVAS) - js = options.to_js_options() + js = options.to_widget_options().to_json() assert js["nvlOptions"]["disableWebGL"] is True -def test_js_options_renderer_webgl() -> None: +def test_widget_options_renderer_webgl() -> None: options = RenderOptions(renderer=Renderer.WEB_GL) - js = options.to_js_options() + js = options.to_widget_options().to_json() assert js["nvlOptions"]["disableWebGL"] is False -def test_js_options_zoom_and_pan() -> None: +def test_widget_options_zoom_and_pan() -> None: options = RenderOptions(initial_zoom=2.0, pan_X=100.0, pan_Y=200.0) - js = options.to_js_options() + js = options.to_widget_options().to_json() assert js["zoom"] == 2.0 assert js["pan"] == {"x": 100.0, "y": 200.0} -def test_js_options_min_max_zoom() -> None: +def test_widget_options_min_max_zoom() -> None: options = RenderOptions(min_zoom=0.1, max_zoom=5.0) - js = options.to_js_options() + js = options.to_widget_options().to_json() assert js["nvlOptions"]["minZoom"] == 0.1 assert js["nvlOptions"]["maxZoom"] == 5.0 -def test_js_options_allow_dynamic_min_zoom() -> None: +def test_widget_options_allow_dynamic_min_zoom() -> None: options = RenderOptions(allow_dynamic_min_zoom=False) - js = options.to_js_options() + js = options.to_widget_options().to_json() assert js["nvlOptions"]["allowDynamicMinZoom"] is False -def test_js_options_with_layout_options() -> None: +def test_widget_options_with_layout_options() -> None: options = RenderOptions( layout=Layout.HIERARCHICAL, layout_options=HierarchicalLayoutOptions(direction=Direction.LEFT), ) - js = options.to_js_options() + js = options.to_widget_options().to_json() assert js["layout"] == "hierarchical" assert js["layoutOptions"] == {"direction": "left"} -def test_js_options_with_force_directed_layout_options() -> None: +def test_widget_options_with_force_directed_layout_options() -> None: options = RenderOptions( layout=Layout.FORCE_DIRECTED, layout_options=ForceDirectedLayoutOptions(gravity=0.5), ) - js = options.to_js_options() - assert js["layout"] == "d3Force" - assert js["layoutOptions"] == {"gravity": 0.5} + widget_options = options.to_widget_options().to_json() + assert widget_options["layout"] == "d3Force" + assert widget_options["layoutOptions"] == {"gravity": 0.5} -def test_js_options_full() -> None: +def test_widget_options_full() -> None: options = RenderOptions( layout=Layout.HIERARCHICAL, layout_options=HierarchicalLayoutOptions(direction=Direction.DOWN), @@ -96,7 +96,7 @@ def test_js_options_full() -> None: pan_Y=-30.0, show_layout_button=True, ) - js = options.to_js_options() + js = options.to_widget_options().to_json() assert js == { "layout": "hierarchical", "layoutOptions": {"direction": "down"}, diff --git a/python-wrapper/tests/test_widget.py b/python-wrapper/tests/test_widget.py index 83bc26c..e66f071 100644 --- a/python-wrapper/tests/test_widget.py +++ b/python-wrapper/tests/test_widget.py @@ -4,7 +4,7 @@ import pytest from neo4j_viz import GraphWidget, Node, Relationship, VisualizationGraph -from neo4j_viz.options import Layout, Renderer, RenderOptions +from neo4j_viz.options import Layout, Renderer, RenderOptions, WidgetOptions from neo4j_viz.widget import _serialize_entity @@ -67,7 +67,7 @@ def test_from_graph_data_basic(self) -> None: assert len(widget.relationships) == 1 assert widget.width == "100%" assert widget.height == "600px" - assert widget.options == {} + assert widget.options == WidgetOptions() def test_from_graph_data_with_options(self) -> None: nodes = [Node(id="n1")] @@ -83,7 +83,7 @@ def test_from_graph_data_with_options(self) -> None: assert widget.width == "800px" assert widget.height == "400px" - assert widget.options == {"layout": "d3Force", "showLayoutButton": False} + assert widget.options == WidgetOptions(layout="d3Force", show_layout_button=False) def test_widget_trait_defaults(self) -> None: widget = GraphWidget() @@ -92,7 +92,7 @@ def test_widget_trait_defaults(self) -> None: assert widget.relationships == [] assert widget.width == "100%" assert widget.height == "600px" - assert widget.options == {} + assert widget.options == WidgetOptions() class TestWidgetDataBinding: @@ -122,9 +122,9 @@ def test_update_relationships(self) -> None: def test_update_options(self) -> None: widget = GraphWidget(options={"layout": "d3Force"}) - widget.options = {"layout": "hierarchical", "zoom": 2.0} - assert widget.options["layout"] == "hierarchical" - assert widget.options["zoom"] == 2.0 + new_options: Any = {"layout": "hierarchical", "zoom": 2.0} + widget.options = new_options + assert widget.options == WidgetOptions(layout="hierarchical", zoom=2.0) def test_update_dimensions(self) -> None: widget = GraphWidget() @@ -361,10 +361,10 @@ def test_render_widget_options_passed_through(self) -> None: max_zoom=5.0, ) - assert widget.options["layout"] == "hierarchical" - assert widget.options["zoom"] == 2.0 - assert widget.options["nvlOptions"]["minZoom"] == 0.1 - assert widget.options["nvlOptions"]["maxZoom"] == 5.0 + assert widget.options.layout == "hierarchical" + assert widget.options.zoom == 2.0 + assert widget.options.nvl_options.min_zoom == 0.1 + assert widget.options.nvl_options.max_zoom == 5.0 class TestRenderOptionSetters: @@ -373,23 +373,23 @@ def test_set_layout(self) -> None: widget.set_layout(Layout.HIERARCHICAL) - assert widget.options["layout"] == "hierarchical" + assert widget.options.layout == "hierarchical" def test_set_layout_with_options(self) -> None: widget = GraphWidget() widget.set_layout(Layout.FORCE_DIRECTED, {"gravity": 0.1}) - assert widget.options["layout"] == "d3Force" - assert widget.options["layoutOptions"] == {"gravity": 0.1} + assert widget.options.layout == "d3Force" + assert widget.options.layout_options == {"gravity": 0.1} def test_set_layout_clears_stale_layout_options(self) -> None: widget = GraphWidget(options={"layoutOptions": {"gravity": 0.1}}) widget.set_layout(Layout.GRID) - assert widget.options["layout"] == "grid" - assert "layoutOptions" not in widget.options + assert widget.options.layout == "grid" + assert widget.options.layout_options is None def test_set_layout_with_mismatched_options_raises(self) -> None: widget = GraphWidget() @@ -402,21 +402,22 @@ def test_set_zoom(self) -> None: widget.set_zoom(2.0) - assert widget.options["zoom"] == 2.0 + assert widget.options.zoom == 2.0 def test_set_pan(self) -> None: widget = GraphWidget() widget.set_pan(100, 50) - assert widget.options["pan"] == {"x": 100, "y": 50} + assert widget.options.pan.x == 100 + assert widget.options.pan.y == 50 def test_set_renderer_canvas(self) -> None: widget = GraphWidget() widget.set_renderer(Renderer.CANVAS) - assert widget.options["nvlOptions"]["disableWebGL"] is True + assert widget.options.nvl_options.disable_web_gl is True def test_set_renderer_webgl(self) -> None: widget = GraphWidget() @@ -424,32 +425,32 @@ def test_set_renderer_webgl(self) -> None: with pytest.warns(UserWarning): widget.set_renderer(Renderer.WEB_GL) - assert widget.options["nvlOptions"]["disableWebGL"] is False + assert widget.options.nvl_options.disable_web_gl is False def test_set_renderer_preserves_other_nvl_options(self) -> None: widget = GraphWidget(options={"nvlOptions": {"minZoom": 0.1}}) widget.set_renderer(Renderer.CANVAS) - assert widget.options["nvlOptions"]["minZoom"] == 0.1 - assert widget.options["nvlOptions"]["disableWebGL"] is True + assert widget.options.nvl_options.min_zoom == 0.1 + assert widget.options.nvl_options.disable_web_gl is True def test_set_show_layout_button(self) -> None: widget = GraphWidget() widget.set_show_layout_button() - assert widget.options["showLayoutButton"] is True + assert widget.options.show_layout_button is True widget.set_show_layout_button(False) - assert widget.options["showLayoutButton"] is False + assert widget.options.show_layout_button is False def test_setter_preserves_unrelated_options(self) -> None: widget = GraphWidget(options={"layout": "hierarchical"}) widget.set_zoom(3.0) - assert widget.options["zoom"] == 3.0 - assert widget.options["layout"] == "hierarchical" + assert widget.options.zoom == 3.0 + assert widget.options.layout == "hierarchical" def test_setter_triggers_sync(self) -> None: widget = GraphWidget()