Skip to content

Commit 3cb68b5

Browse files
authored
Merge pull request #176 from neo4j/expose-layout-options
expose layout options
2 parents a666b13 + 5d60f97 commit 3cb68b5

File tree

8 files changed

+202
-12
lines changed

8 files changed

+202
-12
lines changed

changelog.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
## New features
88

99
* Allow visualization based only on relationship DataFrames, without specifying node DataFrames in `from_dfs`
10-
* Add relationship properties to `VisualizationGraph` when constructing via `from_gds`.
10+
* Add relationship properties to `VisualizationGraph` when constructing via `from_gds`
11+
* Allow setting `layout_options` for `VisualizationGraph::render`
12+
1113

1214
## Bug fixes
1315

docs/source/api-reference/render_options.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,20 @@
44
.. autoenum:: neo4j_viz.Layout
55
:members:
66

7+
.. autoclass:: neo4j_viz.Force-Directed
8+
:members:
9+
:exclude-members: model_config
10+
11+
.. autoclass:: neo4j_viz.HierarchicalLayoutOptions
12+
:members:
13+
:exclude-members: model_config
14+
15+
.. autoenum:: neo4j_viz.options.Direction
16+
:members:
17+
18+
.. autoenum:: neo4j_viz.options.Packing
19+
:members:
20+
21+
722
.. autoenum:: neo4j_viz.Renderer
823
:members:

docs/source/rendering.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ You can provide these either as a percentage of the available space (eg. ``"80%"
3535

3636
Further you can change the layout of the graph using the ``layout`` parameter, which can be set to one of the following values:
3737

38-
* ``Layout.FORCE_DIRECTED`` - Nodes are arranged using the Force-Directed algorithm, which simulates physical forces
39-
* ``Layout.HIERARCHICAL`` - Arranges nodes by the directionality of their relationships, creating a tree-like structure
38+
* ``Layout.FORCE_DIRECTED`` - Nodes are arranged using the Force-Directed algorithm, which simulates physical forces. To customize the layout, use `ForceDirectedOptions` via `layout_options`.`
39+
* ``Layout.HIERARCHICAL`` - Arranges nodes by the directionality of their relationships, creating a tree-like structure. To customize the layout use `HierarchicalLayoutOptions` via `layout_options`.`
4040
* ``Layout.COORDINATE`` - Arranges nodes based on coordinates defined in `x` and `y` properties on each node.
4141

4242
Another thing of note is the ``max_allowed_nodes`` parameter, which controls the maximum number of nodes that is allowed
Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
11
from .node import Node
2-
from .options import CaptionAlignment, Layout, Renderer
2+
from .options import (
3+
CaptionAlignment,
4+
Direction,
5+
ForceDirectedLayoutOptions,
6+
HierarchicalLayoutOptions,
7+
Layout,
8+
Packing,
9+
Renderer,
10+
)
311
from .relationship import Relationship
412
from .visualization_graph import VisualizationGraph
513

6-
__all__ = ["VisualizationGraph", "Node", "Relationship", "CaptionAlignment", "Layout", "Renderer"]
14+
__all__ = [
15+
"VisualizationGraph",
16+
"Node",
17+
"Relationship",
18+
"CaptionAlignment",
19+
"Layout",
20+
"Renderer",
21+
"ForceDirectedLayoutOptions",
22+
"HierarchicalLayoutOptions",
23+
"Direction",
24+
"Packing",
25+
]

python-wrapper/src/neo4j_viz/options.py

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22

33
import warnings
44
from enum import Enum
5-
from typing import Any, Optional
5+
from typing import Any, Optional, Union
66

77
import enum_tools.documentation
8-
from pydantic import BaseModel, Field
8+
from pydantic import BaseModel, Field, ValidationError, model_validator
99

1010

1111
@enum_tools.documentation.document_enum
@@ -33,6 +33,69 @@ class Layout(str, Enum):
3333
GRID = "grid"
3434

3535

36+
@enum_tools.documentation.document_enum
37+
class Direction(str, Enum):
38+
"""
39+
The direction in which the layout should be oriented
40+
"""
41+
42+
LEFT = "left"
43+
RIGHT = "right"
44+
UP = "up"
45+
DOWN = "down"
46+
47+
48+
@enum_tools.documentation.document_enum
49+
class Packing(str, Enum):
50+
"""
51+
The packing method to be used
52+
"""
53+
54+
BIN = "bin"
55+
STACK = "stack"
56+
57+
58+
class HierarchicalLayoutOptions(BaseModel, extra="forbid"):
59+
"""
60+
The options for the hierarchical layout.
61+
"""
62+
63+
direction: Optional[Direction] = None
64+
packaging: Optional[Packing] = None
65+
66+
67+
class ForceDirectedLayoutOptions(BaseModel, extra="forbid"):
68+
"""
69+
The options for the force-directed layout.
70+
"""
71+
72+
gravity: Optional[float] = None
73+
simulationStopVelocity: Optional[float] = None
74+
75+
76+
LayoutOptions = Union[HierarchicalLayoutOptions, ForceDirectedLayoutOptions]
77+
78+
79+
def construct_layout_options(layout: Layout, options: dict[str, Any]) -> Optional[LayoutOptions]:
80+
if not options:
81+
return None
82+
83+
if layout == Layout.FORCE_DIRECTED:
84+
try:
85+
return ForceDirectedLayoutOptions(**options)
86+
except ValidationError as e:
87+
_parse_validation_error(e, ForceDirectedLayoutOptions)
88+
elif layout == Layout.HIERARCHICAL:
89+
try:
90+
return HierarchicalLayoutOptions(**options)
91+
except ValidationError as e:
92+
_parse_validation_error(e, ForceDirectedLayoutOptions)
93+
94+
raise ValueError(
95+
f"Layout options only supported for layouts `{Layout.FORCE_DIRECTED}` and `{Layout.HIERARCHICAL}`, but was `{layout}`"
96+
)
97+
98+
3699
@enum_tools.documentation.document_enum
37100
class Renderer(str, Enum):
38101
"""
@@ -72,6 +135,9 @@ class RenderOptions(BaseModel, extra="allow"):
72135
"""
73136

74137
layout: Optional[Layout] = None
138+
layout_options: Optional[Union[HierarchicalLayoutOptions, ForceDirectedLayoutOptions]] = Field(
139+
None, serialization_alias="layoutOptions"
140+
)
75141
renderer: Optional[Renderer] = None
76142

77143
pan_X: Optional[float] = Field(None, serialization_alias="panX")
@@ -84,5 +150,34 @@ class RenderOptions(BaseModel, extra="allow"):
84150
min_zoom: Optional[float] = Field(None, serialization_alias="minZoom", description="The minimum zoom level allowed")
85151
allow_dynamic_min_zoom: Optional[bool] = Field(None, serialization_alias="allowDynamicMinZoom")
86152

153+
@model_validator(mode="after")
154+
def check_layout_options_match(self) -> RenderOptions:
155+
if self.layout_options is None:
156+
return self
157+
158+
if self.layout == Layout.HIERARCHICAL and not isinstance(self.layout_options, HierarchicalLayoutOptions):
159+
raise ValueError("layout_options must be of type HierarchicalLayoutOptions for hierarchical layout")
160+
if self.layout == Layout.FORCE_DIRECTED and not isinstance(self.layout_options, ForceDirectedLayoutOptions):
161+
raise ValueError("layout_options must be of type ForceDirectedLayoutOptions for force-directed layout")
162+
return self
163+
87164
def to_dict(self) -> dict[str, Any]:
88165
return self.model_dump(exclude_none=True, by_alias=True)
166+
167+
168+
def _parse_validation_error(e: ValidationError, entity_type: type[BaseModel]) -> None:
169+
for err in e.errors():
170+
loc = err["loc"][0]
171+
if err["type"] == "missing":
172+
raise ValueError(
173+
f"Mandatory `{entity_type.__name__}` parameter '{loc}' is missing. Expected one of {entity_type.model_fields[loc].validation_alias.choices} to be present" # type: ignore
174+
)
175+
elif err["type"] == "extra_forbidden":
176+
raise ValueError(
177+
f"Unexpected `{entity_type.__name__}` parameter '{loc}' with provided input '{err['input']}'. "
178+
f"Allowed parameters are: {', '.join(entity_type.model_fields.keys())}"
179+
)
180+
else:
181+
raise ValueError(
182+
f"Error for `{entity_type.__name__}` parameter '{loc}' with provided input '{err['input']}'. Reason: {err['msg']}"
183+
)

python-wrapper/src/neo4j_viz/visualization_graph.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import warnings
44
from collections.abc import Iterable
5-
from typing import Any, Callable, Hashable, Optional
5+
from typing import Any, Callable, Hashable, Optional, Union
66

77
from IPython.display import HTML
88
from pydantic_extra_types.color import Color, ColorType
@@ -11,7 +11,13 @@
1111
from .node import Node, NodeIdType
1212
from .node_size import RealNumber, verify_radii
1313
from .nvl import NVL
14-
from .options import Layout, Renderer, RenderOptions
14+
from .options import (
15+
Layout,
16+
LayoutOptions,
17+
Renderer,
18+
RenderOptions,
19+
construct_layout_options,
20+
)
1521
from .relationship import Relationship
1622

1723

@@ -42,6 +48,7 @@ def __init__(self, nodes: list[Node], relationships: list[Relationship]) -> None
4248
def render(
4349
self,
4450
layout: Optional[Layout] = None,
51+
layout_options: Union[dict[str, Any], LayoutOptions, None] = None,
4552
renderer: Renderer = Renderer.CANVAS,
4653
width: str = "100%",
4754
height: str = "600px",
@@ -60,6 +67,8 @@ def render(
6067
----------
6168
layout:
6269
The `Layout` to use.
70+
layout_options:
71+
The `LayoutOptions` to use.
6372
renderer:
6473
The `Renderer` to use.
6574
width:
@@ -92,8 +101,19 @@ def render(
92101

93102
Renderer.check(renderer, num_nodes)
94103

104+
if not layout:
105+
layout = Layout.FORCE_DIRECTED
106+
if not layout_options:
107+
layout_options = {}
108+
109+
if isinstance(layout_options, dict):
110+
layout_options_typed = construct_layout_options(layout, layout_options)
111+
else:
112+
layout_options_typed = layout_options
113+
95114
render_options = RenderOptions(
96115
layout=layout,
116+
layout_options=layout_options_typed,
97117
renderer=renderer,
98118
pan_X=pan_position[0] if pan_position is not None else None,
99119
pan_Y=pan_position[1] if pan_position is not None else None,
Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,24 @@
1-
from neo4j_viz.options import Layout, RenderOptions
1+
import pytest
2+
3+
from neo4j_viz.options import Direction, HierarchicalLayoutOptions, Layout, RenderOptions
24

35

46
def test_render_options() -> None:
5-
options = RenderOptions(layout=Layout.FORCE_DIRECTED)
7+
options = RenderOptions(layout=Layout.HIERARCHICAL)
8+
9+
assert options.to_dict() == {"layout": "hierarchical"}
10+
11+
12+
def test_render_options_with_layout_options() -> None:
13+
options = RenderOptions(
14+
layout=Layout.HIERARCHICAL, layout_options=HierarchicalLayoutOptions(direction=Direction.LEFT)
15+
)
16+
17+
assert options.to_dict() == {"layout": "hierarchical", "layoutOptions": {"direction": "left"}}
18+
619

7-
assert options.to_dict() == {"layout": "forcedirected"}
20+
def test_layout_options_match() -> None:
21+
with pytest.raises(
22+
ValueError, match="layout_options must be of type ForceDirectedLayoutOptions for force-directed layout"
23+
):
24+
RenderOptions(layout=Layout.FORCE_DIRECTED, layout_options=HierarchicalLayoutOptions(direction=Direction.LEFT))

python-wrapper/tests/test_render.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
"force layout": {"layout": Layout.FORCE_DIRECTED},
1515
"grid layout": {"layout": Layout.GRID},
1616
"coordinate layout": {"layout": Layout.COORDINATE},
17+
"hierarchical layout + options": {"layout": Layout.HIERARCHICAL, "layout_options": {"direction": "left"}},
18+
"with layout options": {"layout_options": {"gravity": 0.1}},
1719
}
1820

1921

@@ -111,3 +113,23 @@ def test_render_non_json_serializable() -> None:
111113
VG = VisualizationGraph(nodes=[node], relationships=[])
112114
# Should not raise an error
113115
VG.render()
116+
117+
118+
def test_render_with_wrong_layout_options() -> None:
119+
nodes = [
120+
Node(id="4:d09f48a4-5fca-421d-921d-a30a896c604d:0", caption="Person", x=1, y=10),
121+
]
122+
123+
VG = VisualizationGraph(nodes=nodes, relationships=[])
124+
125+
with pytest.raises(
126+
ValueError,
127+
match="Unexpected `ForceDirectedLayoutOptions` parameter 'direction' with provided input 'left'",
128+
):
129+
VG.render(layout_options={"direction": "left"})
130+
131+
with pytest.raises(
132+
ValueError,
133+
match="Unexpected `ForceDirectedLayoutOptions` parameter 'direction' with provided input 'left'",
134+
):
135+
VG.render(layout=Layout.FORCE_DIRECTED, layout_options={"direction": "left"})

0 commit comments

Comments
 (0)