neo4j-viz 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- neo4j_viz/__init__.py +21 -2
- neo4j_viz/gds.py +71 -13
- neo4j_viz/gql_create.py +48 -19
- neo4j_viz/neo4j.py +58 -11
- neo4j_viz/node.py +36 -9
- neo4j_viz/nvl.py +26 -13
- neo4j_viz/options.py +97 -2
- neo4j_viz/pandas.py +61 -14
- neo4j_viz/relationship.py +42 -10
- neo4j_viz/visualization_graph.py +22 -2
- {neo4j_viz-0.3.0.dist-info → neo4j_viz-0.4.0.dist-info}/METADATA +1 -1
- neo4j_viz-0.4.0.dist-info/RECORD +22 -0
- {neo4j_viz-0.3.0.dist-info → neo4j_viz-0.4.0.dist-info}/WHEEL +1 -1
- neo4j_viz-0.3.0.dist-info/RECORD +0 -22
- {neo4j_viz-0.3.0.dist-info → neo4j_viz-0.4.0.dist-info}/top_level.txt +0 -0
neo4j_viz/__init__.py
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
from .node import Node
|
|
2
|
-
from .options import
|
|
2
|
+
from .options import (
|
|
3
|
+
CaptionAlignment,
|
|
4
|
+
Direction,
|
|
5
|
+
ForceDirectedLayoutOptions,
|
|
6
|
+
HierarchicalLayoutOptions,
|
|
7
|
+
Layout,
|
|
8
|
+
Packing,
|
|
9
|
+
Renderer,
|
|
10
|
+
)
|
|
3
11
|
from .relationship import Relationship
|
|
4
12
|
from .visualization_graph import VisualizationGraph
|
|
5
13
|
|
|
6
|
-
__all__ = [
|
|
14
|
+
__all__ = [
|
|
15
|
+
"VisualizationGraph",
|
|
16
|
+
"Node",
|
|
17
|
+
"Relationship",
|
|
18
|
+
"CaptionAlignment",
|
|
19
|
+
"Layout",
|
|
20
|
+
"Renderer",
|
|
21
|
+
"ForceDirectedLayoutOptions",
|
|
22
|
+
"HierarchicalLayoutOptions",
|
|
23
|
+
"Direction",
|
|
24
|
+
"Packing",
|
|
25
|
+
]
|
neo4j_viz/gds.py
CHANGED
|
@@ -2,15 +2,17 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from itertools import chain
|
|
4
4
|
from typing import Optional
|
|
5
|
+
from uuid import uuid4
|
|
5
6
|
|
|
6
7
|
import pandas as pd
|
|
7
8
|
from graphdatascience import Graph, GraphDataScience
|
|
9
|
+
from pandas import Series
|
|
8
10
|
|
|
9
11
|
from .pandas import _from_dfs
|
|
10
12
|
from .visualization_graph import VisualizationGraph
|
|
11
13
|
|
|
12
14
|
|
|
13
|
-
def
|
|
15
|
+
def _fetch_node_dfs(
|
|
14
16
|
gds: GraphDataScience, G: Graph, node_properties: list[str], node_labels: list[str]
|
|
15
17
|
) -> dict[str, pd.DataFrame]:
|
|
16
18
|
return {
|
|
@@ -21,7 +23,21 @@ def _node_dfs(
|
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
|
|
24
|
-
def
|
|
26
|
+
def _fetch_rel_df(gds: GraphDataScience, G: Graph) -> pd.DataFrame:
|
|
27
|
+
relationship_properties = G.relationship_properties()
|
|
28
|
+
assert isinstance(relationship_properties, Series)
|
|
29
|
+
|
|
30
|
+
relationship_properties_per_type = relationship_properties.tolist()
|
|
31
|
+
property_set: set[str] = set()
|
|
32
|
+
for props in relationship_properties_per_type:
|
|
33
|
+
if props:
|
|
34
|
+
property_set.update(props)
|
|
35
|
+
|
|
36
|
+
if len(property_set) > 0:
|
|
37
|
+
return gds.graph.relationshipProperties.stream(
|
|
38
|
+
G, relationship_properties=list(property_set), separate_property_columns=True
|
|
39
|
+
)
|
|
40
|
+
|
|
25
41
|
return gds.graph.relationships.stream(G)
|
|
26
42
|
|
|
27
43
|
|
|
@@ -31,6 +47,7 @@ def from_gds(
|
|
|
31
47
|
size_property: Optional[str] = None,
|
|
32
48
|
additional_node_properties: Optional[list[str]] = None,
|
|
33
49
|
node_radius_min_max: Optional[tuple[float, float]] = (3, 60),
|
|
50
|
+
max_node_count: int = 10_000,
|
|
34
51
|
) -> VisualizationGraph:
|
|
35
52
|
"""
|
|
36
53
|
Create a VisualizationGraph from a GraphDataScience object and a Graph object.
|
|
@@ -49,10 +66,14 @@ def from_gds(
|
|
|
49
66
|
size_property : str, optional
|
|
50
67
|
Property to use for node size, by default None.
|
|
51
68
|
additional_node_properties : list[str], optional
|
|
52
|
-
Additional properties to include in the visualization node, by default None
|
|
69
|
+
Additional properties to include in the visualization node, by default None which means that all node
|
|
70
|
+
properties will be fetched.
|
|
53
71
|
node_radius_min_max : tuple[float, float], optional
|
|
54
72
|
Minimum and maximum node radius, by default (3, 60).
|
|
55
73
|
To avoid tiny or huge nodes in the visualization, the node sizes are scaled to fit in the given range.
|
|
74
|
+
max_node_count : int, optional
|
|
75
|
+
The maximum number of nodes to fetch from the graph. The graph will be sampled using random walk with restarts
|
|
76
|
+
if its node count exceeds this number.
|
|
56
77
|
"""
|
|
57
78
|
node_properties_from_gds = G.node_properties()
|
|
58
79
|
assert isinstance(node_properties_from_gds, pd.Series)
|
|
@@ -61,7 +82,9 @@ def from_gds(
|
|
|
61
82
|
if size_property is not None and size_property not in actual_node_properties:
|
|
62
83
|
raise ValueError(f"There is no node property '{size_property}' in graph '{G.name()}'")
|
|
63
84
|
|
|
64
|
-
if additional_node_properties is
|
|
85
|
+
if additional_node_properties is None:
|
|
86
|
+
additional_node_properties = actual_node_properties
|
|
87
|
+
else:
|
|
65
88
|
for prop in additional_node_properties:
|
|
66
89
|
if prop not in actual_node_properties:
|
|
67
90
|
raise ValueError(f"There is no node property '{prop}' in graph '{G.name()}'")
|
|
@@ -69,14 +92,40 @@ def from_gds(
|
|
|
69
92
|
node_properties = set()
|
|
70
93
|
if additional_node_properties is not None:
|
|
71
94
|
node_properties.update(additional_node_properties)
|
|
72
|
-
|
|
73
95
|
if size_property is not None:
|
|
74
96
|
node_properties.add(size_property)
|
|
75
|
-
|
|
76
97
|
node_properties = list(node_properties)
|
|
77
|
-
|
|
98
|
+
|
|
99
|
+
node_count = G.node_count()
|
|
100
|
+
if node_count > max_node_count:
|
|
101
|
+
sampling_ratio = float(max_node_count) / node_count
|
|
102
|
+
sample_name = f"neo4j-viz_sample_{uuid4()}"
|
|
103
|
+
G_fetched, _ = gds.graph.sample.rwr(sample_name, G, samplingRatio=sampling_ratio, nodeLabelStratification=True)
|
|
104
|
+
else:
|
|
105
|
+
G_fetched = G
|
|
106
|
+
|
|
107
|
+
property_name = None
|
|
108
|
+
try:
|
|
109
|
+
# Since GDS does not allow us to only fetch node IDs, we add the degree property
|
|
110
|
+
# as a temporary property to ensure that we have at least one property to fetch
|
|
111
|
+
if len(actual_node_properties) == 0:
|
|
112
|
+
property_name = f"neo4j-viz_property_{uuid4()}"
|
|
113
|
+
gds.degree.mutate(G_fetched, mutateProperty=property_name)
|
|
114
|
+
node_properties = [property_name]
|
|
115
|
+
|
|
116
|
+
node_dfs = _fetch_node_dfs(gds, G_fetched, node_properties, G_fetched.node_labels())
|
|
117
|
+
rel_df = _fetch_rel_df(gds, G_fetched)
|
|
118
|
+
finally:
|
|
119
|
+
if G_fetched.name() != G.name():
|
|
120
|
+
G_fetched.drop()
|
|
121
|
+
elif property_name is not None:
|
|
122
|
+
gds.graph.nodeProperties.drop(G_fetched, node_properties=[property_name])
|
|
123
|
+
|
|
78
124
|
for df in node_dfs.values():
|
|
79
125
|
df.rename(columns={"nodeId": "id"}, inplace=True)
|
|
126
|
+
if property_name is not None and property_name in df.columns:
|
|
127
|
+
df.drop(columns=[property_name], inplace=True)
|
|
128
|
+
rel_df.rename(columns={"sourceNodeId": "source", "targetNodeId": "target"}, inplace=True)
|
|
80
129
|
|
|
81
130
|
node_props_df = pd.concat(node_dfs.values(), ignore_index=True, axis=0).drop_duplicates()
|
|
82
131
|
if size_property is not None:
|
|
@@ -89,12 +138,21 @@ def from_gds(
|
|
|
89
138
|
df.rename(columns={"labels": "__labels"}, inplace=True)
|
|
90
139
|
df["labels"] = lbl
|
|
91
140
|
|
|
92
|
-
|
|
93
|
-
|
|
141
|
+
node_labels_df = pd.concat([df[["id", "labels"]] for df in node_dfs.values()], ignore_index=True, axis=0)
|
|
142
|
+
node_labels_df = node_labels_df.groupby("id").agg({"labels": list})
|
|
94
143
|
|
|
95
|
-
node_df = node_props_df.merge(
|
|
144
|
+
node_df = node_props_df.merge(node_labels_df, on="id")
|
|
96
145
|
|
|
97
|
-
|
|
98
|
-
|
|
146
|
+
if "caption" not in actual_node_properties:
|
|
147
|
+
node_df["caption"] = node_df["labels"].astype(str)
|
|
99
148
|
|
|
100
|
-
|
|
149
|
+
try:
|
|
150
|
+
return _from_dfs(node_df, rel_df, node_radius_min_max=node_radius_min_max, rename_properties={"__size": "size"})
|
|
151
|
+
except ValueError as e:
|
|
152
|
+
err_msg = str(e)
|
|
153
|
+
if "column" in err_msg:
|
|
154
|
+
err_msg = err_msg.replace("column", "property")
|
|
155
|
+
if ("'size'" in err_msg) and (size_property is not None):
|
|
156
|
+
err_msg = err_msg.replace("'size'", f"'{size_property}'")
|
|
157
|
+
raise ValueError(err_msg)
|
|
158
|
+
raise e
|
neo4j_viz/gql_create.py
CHANGED
|
@@ -2,6 +2,8 @@ import re
|
|
|
2
2
|
import uuid
|
|
3
3
|
from typing import Any, Optional
|
|
4
4
|
|
|
5
|
+
from pydantic import BaseModel, ValidationError
|
|
6
|
+
|
|
5
7
|
from neo4j_viz import Node, Relationship, VisualizationGraph
|
|
6
8
|
|
|
7
9
|
|
|
@@ -249,13 +251,22 @@ def from_gql_create(
|
|
|
249
251
|
node_pattern = re.compile(r"^\(([^)]*)\)$")
|
|
250
252
|
rel_pattern = re.compile(r"^\(([^)]*)\)-\s*\[\s*:(\w+)\s*(\{[^}]*\})?\s*\]->\(([^)]*)\)$")
|
|
251
253
|
|
|
252
|
-
node_top_level_keys =
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
254
|
+
node_top_level_keys = Node.all_validation_aliases(exempted_fields=["id"])
|
|
255
|
+
rel_top_level_keys = Relationship.all_validation_aliases(exempted_fields=["id", "source", "target"])
|
|
256
|
+
|
|
257
|
+
def _parse_validation_error(e: ValidationError, entity_type: type[BaseModel]) -> None:
|
|
258
|
+
for err in e.errors():
|
|
259
|
+
loc = err["loc"][0]
|
|
260
|
+
if (loc == "size") and size_property is not None:
|
|
261
|
+
loc = size_property
|
|
262
|
+
if loc == "caption":
|
|
263
|
+
if (entity_type == Node) and (node_caption is not None):
|
|
264
|
+
loc = node_caption
|
|
265
|
+
elif (entity_type == Relationship) and (relationship_caption is not None):
|
|
266
|
+
loc = relationship_caption
|
|
267
|
+
raise ValueError(
|
|
268
|
+
f"Error for {entity_type.__name__.lower()} property '{loc}' with provided input '{err['input']}'. Reason: {err['msg']}"
|
|
269
|
+
)
|
|
259
270
|
|
|
260
271
|
nodes = []
|
|
261
272
|
relationships = []
|
|
@@ -272,7 +283,10 @@ def from_gql_create(
|
|
|
272
283
|
anonymous_count += 1
|
|
273
284
|
if alias not in alias_to_id:
|
|
274
285
|
alias_to_id[alias] = str(uuid.uuid4())
|
|
275
|
-
|
|
286
|
+
try:
|
|
287
|
+
nodes.append(Node(id=alias_to_id[alias], **top_level, properties=props))
|
|
288
|
+
except ValidationError as e:
|
|
289
|
+
_parse_validation_error(e, Node)
|
|
276
290
|
|
|
277
291
|
continue
|
|
278
292
|
|
|
@@ -288,7 +302,10 @@ def from_gql_create(
|
|
|
288
302
|
anonymous_count += 1
|
|
289
303
|
if left_alias not in alias_to_id:
|
|
290
304
|
alias_to_id[left_alias] = str(uuid.uuid4())
|
|
291
|
-
|
|
305
|
+
try:
|
|
306
|
+
nodes.append(Node(id=alias_to_id[left_alias], **left_top_level, properties=left_props))
|
|
307
|
+
except ValidationError as e:
|
|
308
|
+
_parse_validation_error(e, Node)
|
|
292
309
|
elif left_alias not in alias_to_id:
|
|
293
310
|
snippet = _get_snippet(query, query.index(left_node))
|
|
294
311
|
raise ValueError(f"Relationship references unknown node alias: '{left_alias}' near: `{snippet}`.")
|
|
@@ -300,7 +317,10 @@ def from_gql_create(
|
|
|
300
317
|
anonymous_count += 1
|
|
301
318
|
if right_alias not in alias_to_id:
|
|
302
319
|
alias_to_id[right_alias] = str(uuid.uuid4())
|
|
303
|
-
|
|
320
|
+
try:
|
|
321
|
+
nodes.append(Node(id=alias_to_id[right_alias], **right_top_level, properties=right_props))
|
|
322
|
+
except ValidationError as e:
|
|
323
|
+
_parse_validation_error(e, Node)
|
|
304
324
|
elif right_alias not in alias_to_id:
|
|
305
325
|
snippet = _get_snippet(query, query.index(right_node))
|
|
306
326
|
raise ValueError(f"Relationship references unknown node alias: '{right_alias}' near: `{snippet}`.")
|
|
@@ -318,15 +338,20 @@ def from_gql_create(
|
|
|
318
338
|
if "type" in props:
|
|
319
339
|
props["__type"] = props["type"]
|
|
320
340
|
props["type"] = rel_type
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
relationships.append(
|
|
344
|
+
Relationship(
|
|
345
|
+
id=rel_id,
|
|
346
|
+
source=alias_to_id[left_alias],
|
|
347
|
+
target=alias_to_id[right_alias],
|
|
348
|
+
**top_level,
|
|
349
|
+
properties=props,
|
|
350
|
+
)
|
|
328
351
|
)
|
|
329
|
-
|
|
352
|
+
except ValidationError as e:
|
|
353
|
+
_parse_validation_error(e, Relationship)
|
|
354
|
+
|
|
330
355
|
continue
|
|
331
356
|
|
|
332
357
|
snippet = part[:30]
|
|
@@ -351,6 +376,10 @@ def from_gql_create(
|
|
|
351
376
|
|
|
352
377
|
VG = VisualizationGraph(nodes=nodes, relationships=relationships)
|
|
353
378
|
if (node_radius_min_max is not None) and (size_property is not None):
|
|
354
|
-
|
|
379
|
+
try:
|
|
380
|
+
VG.resize_nodes(node_radius_min_max=node_radius_min_max)
|
|
381
|
+
except TypeError:
|
|
382
|
+
loc = "size" if size_property is None else size_property
|
|
383
|
+
raise ValueError(f"Error for node property '{loc}'. Reason: must be a numerical value")
|
|
355
384
|
|
|
356
385
|
return VG
|
neo4j_viz/neo4j.py
CHANGED
|
@@ -4,12 +4,21 @@ from typing import Optional, Union
|
|
|
4
4
|
|
|
5
5
|
import neo4j.graph
|
|
6
6
|
from neo4j import Result
|
|
7
|
+
from pydantic import BaseModel, ValidationError
|
|
7
8
|
|
|
8
9
|
from neo4j_viz.node import Node
|
|
9
10
|
from neo4j_viz.relationship import Relationship
|
|
10
11
|
from neo4j_viz.visualization_graph import VisualizationGraph
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
def _parse_validation_error(e: ValidationError, entity_type: type[BaseModel]) -> None:
|
|
15
|
+
for err in e.errors():
|
|
16
|
+
loc = err["loc"][0]
|
|
17
|
+
raise ValueError(
|
|
18
|
+
f"Error for {entity_type.__name__.lower()} property '{loc}' with provided input '{err['input']}'. Reason: {err['msg']}"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
13
22
|
def from_neo4j(
|
|
14
23
|
result: Union[neo4j.graph.Graph, Result],
|
|
15
24
|
size_property: Optional[str] = None,
|
|
@@ -47,12 +56,33 @@ def from_neo4j(
|
|
|
47
56
|
else:
|
|
48
57
|
raise ValueError(f"Invalid input type `{type(result)}`. Expected `neo4j.Graph` or `neo4j.Result`")
|
|
49
58
|
|
|
50
|
-
|
|
59
|
+
all_node_field_aliases = Node.all_validation_aliases()
|
|
60
|
+
all_rel_field_aliases = Relationship.all_validation_aliases()
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
nodes = [
|
|
64
|
+
_map_node(node, all_node_field_aliases, size_property, caption_property=node_caption)
|
|
65
|
+
for node in graph.nodes
|
|
66
|
+
]
|
|
67
|
+
except ValueError as e:
|
|
68
|
+
err_msg = str(e)
|
|
69
|
+
if ("'size'" in err_msg) and (size_property is not None):
|
|
70
|
+
err_msg = err_msg.replace("'size'", f"'{size_property}'")
|
|
71
|
+
elif ("'caption'" in err_msg) and (node_caption is not None):
|
|
72
|
+
err_msg = err_msg.replace("'caption'", f"'{node_caption}'")
|
|
73
|
+
raise ValueError(err_msg)
|
|
74
|
+
|
|
51
75
|
relationships = []
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
76
|
+
try:
|
|
77
|
+
for rel in graph.relationships:
|
|
78
|
+
mapped_rel = _map_relationship(rel, all_rel_field_aliases, caption_property=relationship_caption)
|
|
79
|
+
if mapped_rel:
|
|
80
|
+
relationships.append(mapped_rel)
|
|
81
|
+
except ValueError as e:
|
|
82
|
+
err_msg = str(e)
|
|
83
|
+
if ("'caption'" in err_msg) and (relationship_caption is not None):
|
|
84
|
+
err_msg = err_msg.replace("'caption'", f"'{relationship_caption}'")
|
|
85
|
+
raise ValueError(err_msg)
|
|
56
86
|
|
|
57
87
|
VG = VisualizationGraph(nodes, relationships)
|
|
58
88
|
|
|
@@ -62,7 +92,12 @@ def from_neo4j(
|
|
|
62
92
|
return VG
|
|
63
93
|
|
|
64
94
|
|
|
65
|
-
def _map_node(
|
|
95
|
+
def _map_node(
|
|
96
|
+
node: neo4j.graph.Node,
|
|
97
|
+
all_node_field_aliases: set[str],
|
|
98
|
+
size_property: Optional[str],
|
|
99
|
+
caption_property: Optional[str],
|
|
100
|
+
) -> Node:
|
|
66
101
|
top_level_fields = {"id": node.element_id}
|
|
67
102
|
|
|
68
103
|
if size_property:
|
|
@@ -78,7 +113,7 @@ def _map_node(node: neo4j.graph.Node, size_property: Optional[str], caption_prop
|
|
|
78
113
|
|
|
79
114
|
properties = {}
|
|
80
115
|
for prop, value in node.items():
|
|
81
|
-
if prop not in
|
|
116
|
+
if prop not in all_node_field_aliases:
|
|
82
117
|
properties[prop] = value
|
|
83
118
|
continue
|
|
84
119
|
|
|
@@ -92,10 +127,17 @@ def _map_node(node: neo4j.graph.Node, size_property: Optional[str], caption_prop
|
|
|
92
127
|
properties["__labels"] = properties["labels"]
|
|
93
128
|
properties["labels"] = labels
|
|
94
129
|
|
|
95
|
-
|
|
130
|
+
try:
|
|
131
|
+
viz_node = Node(**top_level_fields, properties=properties)
|
|
132
|
+
except ValidationError as e:
|
|
133
|
+
_parse_validation_error(e, Node)
|
|
96
134
|
|
|
135
|
+
return viz_node
|
|
97
136
|
|
|
98
|
-
|
|
137
|
+
|
|
138
|
+
def _map_relationship(
|
|
139
|
+
rel: neo4j.graph.Relationship, all_rel_field_aliases: set[str], caption_property: Optional[str]
|
|
140
|
+
) -> Optional[Relationship]:
|
|
99
141
|
if rel.start_node is None or rel.end_node is None:
|
|
100
142
|
return None
|
|
101
143
|
|
|
@@ -109,7 +151,7 @@ def _map_relationship(rel: neo4j.graph.Relationship, caption_property: Optional[
|
|
|
109
151
|
|
|
110
152
|
properties = {}
|
|
111
153
|
for prop, value in rel.items():
|
|
112
|
-
if prop not in
|
|
154
|
+
if prop not in all_rel_field_aliases:
|
|
113
155
|
properties[prop] = value
|
|
114
156
|
continue
|
|
115
157
|
|
|
@@ -123,4 +165,9 @@ def _map_relationship(rel: neo4j.graph.Relationship, caption_property: Optional[
|
|
|
123
165
|
properties["__type"] = properties["type"]
|
|
124
166
|
properties["type"] = rel.type
|
|
125
167
|
|
|
126
|
-
|
|
168
|
+
try:
|
|
169
|
+
viz_rel = Relationship(**top_level_fields, properties=properties)
|
|
170
|
+
except ValidationError as e:
|
|
171
|
+
_parse_validation_error(e, Relationship)
|
|
172
|
+
|
|
173
|
+
return viz_rel
|
neo4j_viz/node.py
CHANGED
|
@@ -2,7 +2,8 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import Any, Optional, Union
|
|
4
4
|
|
|
5
|
-
from pydantic import AliasChoices, BaseModel, Field, field_serializer, field_validator
|
|
5
|
+
from pydantic import AliasChoices, AliasGenerator, BaseModel, Field, field_serializer, field_validator
|
|
6
|
+
from pydantic.alias_generators import to_camel
|
|
6
7
|
from pydantic_extra_types.color import Color, ColorType
|
|
7
8
|
|
|
8
9
|
from .node_size import RealNumber
|
|
@@ -11,29 +12,46 @@ from .options import CaptionAlignment
|
|
|
11
12
|
NodeIdType = Union[str, int]
|
|
12
13
|
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
def create_aliases(field_name: str) -> AliasChoices:
|
|
16
|
+
valid_names = [field_name]
|
|
17
|
+
|
|
18
|
+
if field_name == "id":
|
|
19
|
+
valid_names.extend(["nodeid", "node_id"])
|
|
20
|
+
|
|
21
|
+
choices = [[choice, choice.upper(), to_camel(choice)] for choice in valid_names]
|
|
22
|
+
|
|
23
|
+
return AliasChoices(*[alias for aliases in choices for alias in aliases])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Node(
|
|
27
|
+
BaseModel,
|
|
28
|
+
extra="forbid",
|
|
29
|
+
alias_generator=AliasGenerator(
|
|
30
|
+
validation_alias=create_aliases,
|
|
31
|
+
serialization_alias=lambda field_name: to_camel(field_name),
|
|
32
|
+
),
|
|
33
|
+
):
|
|
15
34
|
"""
|
|
16
35
|
A node in a graph to visualize.
|
|
17
36
|
|
|
37
|
+
Each field is case-insensitive for input, and camelCase is also accepted.
|
|
38
|
+
For example, "CAPTION_ALIGN", "captionAlign" are also valid inputs keys for the `caption_align` field.
|
|
39
|
+
Upon construction however, the field names are converted to snake_case.
|
|
40
|
+
|
|
18
41
|
For more info on each field, see the NVL library docs: https://neo4j.com/docs/nvl/current/base-library/#_nodes
|
|
19
42
|
"""
|
|
20
43
|
|
|
21
44
|
#: Unique identifier for the node
|
|
22
|
-
id: NodeIdType = Field(
|
|
23
|
-
validation_alias=AliasChoices("id", "nodeId", "node_id"), description="Unique identifier for the node"
|
|
24
|
-
)
|
|
45
|
+
id: NodeIdType = Field(description="Unique identifier for the node")
|
|
25
46
|
#: The caption of the node
|
|
26
47
|
caption: Optional[str] = Field(None, description="The caption of the node")
|
|
27
48
|
#: The alignment of the caption text
|
|
28
|
-
caption_align: Optional[CaptionAlignment] = Field(
|
|
29
|
-
None, serialization_alias="captionAlign", description="The alignment of the caption text"
|
|
30
|
-
)
|
|
49
|
+
caption_align: Optional[CaptionAlignment] = Field(None, description="The alignment of the caption text")
|
|
31
50
|
#: The size of the caption text. The font size to node radius ratio
|
|
32
51
|
caption_size: Optional[int] = Field(
|
|
33
52
|
None,
|
|
34
53
|
ge=1,
|
|
35
54
|
le=3,
|
|
36
|
-
serialization_alias="captionSize",
|
|
37
55
|
description="The size of the caption text. The font size to node radius ratio",
|
|
38
56
|
)
|
|
39
57
|
#: The size of the node as radius in pixel
|
|
@@ -70,3 +88,12 @@ class Node(BaseModel, extra="allow"):
|
|
|
70
88
|
|
|
71
89
|
def to_dict(self) -> dict[str, Any]:
|
|
72
90
|
return self.model_dump(exclude_none=True, by_alias=True)
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def all_validation_aliases(exempted_fields: Optional[list[str]] = None) -> set[str]:
|
|
94
|
+
if exempted_fields is None:
|
|
95
|
+
exempted_fields = []
|
|
96
|
+
|
|
97
|
+
by_field = [v.validation_alias.choices for k, v in Node.model_fields.items() if k not in exempted_fields] # type: ignore
|
|
98
|
+
|
|
99
|
+
return {str(alias) for aliases in by_field for alias in aliases}
|
neo4j_viz/nvl.py
CHANGED
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import json
|
|
4
4
|
import uuid
|
|
5
5
|
from importlib.resources import files
|
|
6
|
+
from typing import Union
|
|
6
7
|
|
|
7
8
|
from IPython.display import HTML
|
|
8
9
|
|
|
@@ -40,10 +41,28 @@ class NVL:
|
|
|
40
41
|
with screenshot_path.open("r", encoding="utf-8") as file:
|
|
41
42
|
self.screenshot_svg = file.read()
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
@staticmethod
|
|
45
|
+
def _serialize_entity(entity: Union[Node, Relationship]) -> str:
|
|
46
|
+
try:
|
|
47
|
+
entity_dict = entity.to_dict()
|
|
48
|
+
return json.dumps(entity_dict)
|
|
49
|
+
except TypeError:
|
|
50
|
+
props_as_strings = {}
|
|
51
|
+
for k, v in entity_dict["properties"].items():
|
|
52
|
+
try:
|
|
53
|
+
json.dumps(v)
|
|
54
|
+
except TypeError:
|
|
55
|
+
props_as_strings[k] = str(v)
|
|
56
|
+
entity_dict["properties"].update(props_as_strings)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
return json.dumps(entity_dict)
|
|
60
|
+
except TypeError as e:
|
|
61
|
+
# This should never happen anymore, but just in case
|
|
62
|
+
if "not JSON serializable" in str(e):
|
|
63
|
+
raise ValueError(f"A field of a {type(entity).__name__} object is not supported: {str(e)}")
|
|
64
|
+
else:
|
|
65
|
+
raise e
|
|
47
66
|
|
|
48
67
|
def render(
|
|
49
68
|
self,
|
|
@@ -54,14 +73,8 @@ class NVL:
|
|
|
54
73
|
height: str,
|
|
55
74
|
show_hover_tooltip: bool,
|
|
56
75
|
) -> HTML:
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
except TypeError as e:
|
|
60
|
-
raise self.unsupported_field_type_error(e, "node")
|
|
61
|
-
try:
|
|
62
|
-
rels_json = json.dumps([rel.to_dict() for rel in relationships])
|
|
63
|
-
except TypeError as e:
|
|
64
|
-
raise self.unsupported_field_type_error(e, "relationship")
|
|
76
|
+
nodes_json = f"[{','.join([self._serialize_entity(node) for node in nodes])}]"
|
|
77
|
+
rels_json = f"[{','.join([self._serialize_entity(rel) for rel in relationships])}]"
|
|
65
78
|
|
|
66
79
|
render_options_json = json.dumps(render_options.to_dict())
|
|
67
80
|
container_id = str(uuid.uuid4())
|
|
@@ -112,7 +125,7 @@ class NVL:
|
|
|
112
125
|
<script>
|
|
113
126
|
getTheme = () => {{
|
|
114
127
|
const backgroundColorString = window.getComputedStyle(document.body, null).getPropertyValue('background-color')
|
|
115
|
-
const colorsArray = backgroundColorString.match(
|
|
128
|
+
const colorsArray = backgroundColorString.match(/\\d+/g);
|
|
116
129
|
const brightness = Number(colorsArray[0]) * 0.2126 + Number(colorsArray[1]) * 0.7152 + Number(colorsArray[2]) * 0.0722
|
|
117
130
|
return brightness < 128 ? "dark" : "light"
|
|
118
131
|
}}
|
neo4j_viz/options.py
CHANGED
|
@@ -2,10 +2,10 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import warnings
|
|
4
4
|
from enum import Enum
|
|
5
|
-
from typing import Any, Optional
|
|
5
|
+
from typing import Any, Optional, Union
|
|
6
6
|
|
|
7
7
|
import enum_tools.documentation
|
|
8
|
-
from pydantic import BaseModel, Field
|
|
8
|
+
from pydantic import BaseModel, Field, ValidationError, model_validator
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
@enum_tools.documentation.document_enum
|
|
@@ -33,6 +33,69 @@ class Layout(str, Enum):
|
|
|
33
33
|
GRID = "grid"
|
|
34
34
|
|
|
35
35
|
|
|
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
|
+
|
|
36
99
|
@enum_tools.documentation.document_enum
|
|
37
100
|
class Renderer(str, Enum):
|
|
38
101
|
"""
|
|
@@ -72,6 +135,9 @@ class RenderOptions(BaseModel, extra="allow"):
|
|
|
72
135
|
"""
|
|
73
136
|
|
|
74
137
|
layout: Optional[Layout] = None
|
|
138
|
+
layout_options: Optional[Union[HierarchicalLayoutOptions, ForceDirectedLayoutOptions]] = Field(
|
|
139
|
+
None, serialization_alias="layoutOptions"
|
|
140
|
+
)
|
|
75
141
|
renderer: Optional[Renderer] = None
|
|
76
142
|
|
|
77
143
|
pan_X: Optional[float] = Field(None, serialization_alias="panX")
|
|
@@ -84,5 +150,34 @@ class RenderOptions(BaseModel, extra="allow"):
|
|
|
84
150
|
min_zoom: Optional[float] = Field(None, serialization_alias="minZoom", description="The minimum zoom level allowed")
|
|
85
151
|
allow_dynamic_min_zoom: Optional[bool] = Field(None, serialization_alias="allowDynamicMinZoom")
|
|
86
152
|
|
|
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
|
+
|
|
87
164
|
def to_dict(self) -> dict[str, Any]:
|
|
88
165
|
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
|
+
)
|
neo4j_viz/pandas.py
CHANGED
|
@@ -4,6 +4,7 @@ from collections.abc import Iterable
|
|
|
4
4
|
from typing import Optional, Union
|
|
5
5
|
|
|
6
6
|
from pandas import DataFrame
|
|
7
|
+
from pydantic import BaseModel, ValidationError
|
|
7
8
|
|
|
8
9
|
from .node import Node
|
|
9
10
|
from .relationship import Relationship
|
|
@@ -12,17 +13,55 @@ from .visualization_graph import VisualizationGraph
|
|
|
12
13
|
DFS_TYPE = Union[DataFrame, Iterable[DataFrame]]
|
|
13
14
|
|
|
14
15
|
|
|
16
|
+
def _parse_validation_error(e: ValidationError, entity_type: type[BaseModel]) -> None:
|
|
17
|
+
for err in e.errors():
|
|
18
|
+
loc = err["loc"][0]
|
|
19
|
+
if err["type"] == "missing":
|
|
20
|
+
raise ValueError(
|
|
21
|
+
f"Mandatory {entity_type.__name__.lower()} column '{loc}' is missing. Expected one of {entity_type.model_fields[loc].validation_alias.choices} to be present" # type: ignore
|
|
22
|
+
)
|
|
23
|
+
else:
|
|
24
|
+
raise ValueError(
|
|
25
|
+
f"Error for {entity_type.__name__.lower()} column '{loc}' with provided input '{err['input']}'. Reason: {err['msg']}"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
15
29
|
def _from_dfs(
|
|
16
|
-
node_dfs: DFS_TYPE,
|
|
30
|
+
node_dfs: Optional[DFS_TYPE],
|
|
17
31
|
rel_dfs: DFS_TYPE,
|
|
18
32
|
node_radius_min_max: Optional[tuple[float, float]] = (3, 60),
|
|
19
33
|
rename_properties: Optional[dict[str, str]] = None,
|
|
20
34
|
) -> VisualizationGraph:
|
|
35
|
+
relationships = _parse_relationships(rel_dfs, rename_properties=rename_properties)
|
|
36
|
+
|
|
37
|
+
if node_dfs is None:
|
|
38
|
+
has_size = False
|
|
39
|
+
node_ids = set()
|
|
40
|
+
for rel in relationships:
|
|
41
|
+
node_ids.add(rel.source)
|
|
42
|
+
node_ids.add(rel.target)
|
|
43
|
+
nodes = [Node(id=id) for id in node_ids]
|
|
44
|
+
else:
|
|
45
|
+
nodes, has_size = _parse_nodes(node_dfs, rename_properties=rename_properties)
|
|
46
|
+
|
|
47
|
+
VG = VisualizationGraph(nodes=nodes, relationships=relationships)
|
|
48
|
+
|
|
49
|
+
if node_radius_min_max is not None and has_size:
|
|
50
|
+
VG.resize_nodes(node_radius_min_max=node_radius_min_max)
|
|
51
|
+
|
|
52
|
+
return VG
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_nodes(node_dfs: DFS_TYPE, rename_properties: Optional[dict[str, str]]) -> tuple[list[Node], bool]:
|
|
21
56
|
if isinstance(node_dfs, DataFrame):
|
|
22
57
|
node_dfs_iter: Iterable[DataFrame] = [node_dfs]
|
|
58
|
+
elif node_dfs is None:
|
|
59
|
+
node_dfs_iter = []
|
|
23
60
|
else:
|
|
24
61
|
node_dfs_iter = node_dfs
|
|
25
62
|
|
|
63
|
+
all_node_field_aliases = Node.all_validation_aliases()
|
|
64
|
+
|
|
26
65
|
has_size = True
|
|
27
66
|
nodes = []
|
|
28
67
|
for node_df in node_dfs_iter:
|
|
@@ -31,45 +70,52 @@ def _from_dfs(
|
|
|
31
70
|
top_level = {}
|
|
32
71
|
properties = {}
|
|
33
72
|
for key, value in row.to_dict().items():
|
|
34
|
-
if key in
|
|
73
|
+
if key in all_node_field_aliases:
|
|
35
74
|
top_level[key] = value
|
|
36
75
|
else:
|
|
37
76
|
if rename_properties and key in rename_properties:
|
|
38
77
|
key = rename_properties[key]
|
|
39
78
|
properties[key] = value
|
|
40
79
|
|
|
41
|
-
|
|
80
|
+
try:
|
|
81
|
+
nodes.append(Node(**top_level, properties=properties))
|
|
82
|
+
except ValidationError as e:
|
|
83
|
+
_parse_validation_error(e, Node)
|
|
84
|
+
|
|
85
|
+
return nodes, has_size
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parse_relationships(rel_dfs: DFS_TYPE, rename_properties: Optional[dict[str, str]]) -> list[Relationship]:
|
|
89
|
+
all_rel_field_aliases = Relationship.all_validation_aliases()
|
|
42
90
|
|
|
43
91
|
if isinstance(rel_dfs, DataFrame):
|
|
44
92
|
rel_dfs_iter: Iterable[DataFrame] = [rel_dfs]
|
|
45
93
|
else:
|
|
46
94
|
rel_dfs_iter = rel_dfs
|
|
95
|
+
relationships: list[Relationship] = []
|
|
47
96
|
|
|
48
|
-
relationships = []
|
|
49
97
|
for rel_df in rel_dfs_iter:
|
|
50
98
|
for _, row in rel_df.iterrows():
|
|
51
99
|
top_level = {}
|
|
52
100
|
properties = {}
|
|
53
101
|
for key, value in row.to_dict().items():
|
|
54
|
-
if key in
|
|
102
|
+
if key in all_rel_field_aliases:
|
|
55
103
|
top_level[key] = value
|
|
56
104
|
else:
|
|
57
105
|
if rename_properties and key in rename_properties:
|
|
58
106
|
key = rename_properties[key]
|
|
59
107
|
properties[key] = value
|
|
60
108
|
|
|
61
|
-
|
|
109
|
+
try:
|
|
110
|
+
relationships.append(Relationship(**top_level, properties=properties))
|
|
111
|
+
except ValidationError as e:
|
|
112
|
+
_parse_validation_error(e, Relationship)
|
|
62
113
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if node_radius_min_max is not None and has_size:
|
|
66
|
-
VG.resize_nodes(node_radius_min_max=node_radius_min_max)
|
|
67
|
-
|
|
68
|
-
return VG
|
|
114
|
+
return relationships
|
|
69
115
|
|
|
70
116
|
|
|
71
117
|
def from_dfs(
|
|
72
|
-
node_dfs: DFS_TYPE,
|
|
118
|
+
node_dfs: Optional[DFS_TYPE],
|
|
73
119
|
rel_dfs: DFS_TYPE,
|
|
74
120
|
node_radius_min_max: Optional[tuple[float, float]] = (3, 60),
|
|
75
121
|
) -> VisualizationGraph:
|
|
@@ -82,8 +128,9 @@ def from_dfs(
|
|
|
82
128
|
|
|
83
129
|
Parameters
|
|
84
130
|
----------
|
|
85
|
-
node_dfs: Union[DataFrame, Iterable[DataFrame]]
|
|
131
|
+
node_dfs: Optional[Union[DataFrame, Iterable[DataFrame]]]
|
|
86
132
|
DataFrame or iterable of DataFrames containing node data.
|
|
133
|
+
If None, the nodes will be created from the source and target node ids in the rel_dfs.
|
|
87
134
|
rel_dfs: Union[DataFrame, Iterable[DataFrame]]
|
|
88
135
|
DataFrame or iterable of DataFrames containing relationship data.
|
|
89
136
|
node_radius_min_max : tuple[float, float], optional
|
neo4j_viz/relationship.py
CHANGED
|
@@ -3,16 +3,41 @@ from __future__ import annotations
|
|
|
3
3
|
from typing import Any, Optional, Union
|
|
4
4
|
from uuid import uuid4
|
|
5
5
|
|
|
6
|
-
from pydantic import AliasChoices, BaseModel, Field, field_serializer, field_validator
|
|
6
|
+
from pydantic import AliasChoices, AliasGenerator, BaseModel, Field, field_serializer, field_validator
|
|
7
|
+
from pydantic.alias_generators import to_camel
|
|
7
8
|
from pydantic_extra_types.color import Color, ColorType
|
|
8
9
|
|
|
9
10
|
from .options import CaptionAlignment
|
|
10
11
|
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
def create_aliases(field_name: str) -> AliasChoices:
|
|
14
|
+
valid_names = [field_name]
|
|
15
|
+
|
|
16
|
+
if field_name == "source":
|
|
17
|
+
valid_names.extend(["sourcenodeid", "source_node_id", "from"])
|
|
18
|
+
if field_name == "target":
|
|
19
|
+
valid_names.extend(["targetnodeid", "target_node_id", "to"])
|
|
20
|
+
|
|
21
|
+
choices = [[choice, choice.upper(), to_camel(choice)] for choice in valid_names]
|
|
22
|
+
|
|
23
|
+
return AliasChoices(*[alias for aliases in choices for alias in aliases])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Relationship(
|
|
27
|
+
BaseModel,
|
|
28
|
+
extra="forbid",
|
|
29
|
+
alias_generator=AliasGenerator(
|
|
30
|
+
validation_alias=create_aliases,
|
|
31
|
+
serialization_alias=lambda field_name: to_camel(field_name),
|
|
32
|
+
),
|
|
33
|
+
):
|
|
13
34
|
"""
|
|
14
35
|
A relationship in a graph to visualize.
|
|
15
36
|
|
|
37
|
+
Each field is case-insensitive for input, and camelCase is also accepted.
|
|
38
|
+
For example, "CAPTION_ALIGN", "captionAlign" are also valid inputs keys for the `caption_align` field.
|
|
39
|
+
Upon construction however, the field names are converted to snake_case.
|
|
40
|
+
|
|
16
41
|
For more info on each field, see the NVL library docs: https://neo4j.com/docs/nvl/current/base-library/#_relationships
|
|
17
42
|
"""
|
|
18
43
|
|
|
@@ -23,25 +48,19 @@ class Relationship(BaseModel, extra="allow"):
|
|
|
23
48
|
#: Node ID where the relationship points from
|
|
24
49
|
source: Union[str, int] = Field(
|
|
25
50
|
serialization_alias="from",
|
|
26
|
-
validation_alias=AliasChoices("source", "sourceNodeId", "source_node_id", "from"),
|
|
27
51
|
description="Node ID where the relationship points from",
|
|
28
52
|
)
|
|
29
53
|
#: Node ID where the relationship points to
|
|
30
54
|
target: Union[str, int] = Field(
|
|
31
55
|
serialization_alias="to",
|
|
32
|
-
validation_alias=AliasChoices("target", "targetNodeId", "target_node_id", "to"),
|
|
33
56
|
description="Node ID where the relationship points to",
|
|
34
57
|
)
|
|
35
58
|
#: The caption of the relationship
|
|
36
59
|
caption: Optional[str] = Field(None, description="The caption of the relationship")
|
|
37
60
|
#: The alignment of the caption text
|
|
38
|
-
caption_align: Optional[CaptionAlignment] = Field(
|
|
39
|
-
None, serialization_alias="captionAlign", description="The alignment of the caption text"
|
|
40
|
-
)
|
|
61
|
+
caption_align: Optional[CaptionAlignment] = Field(None, description="The alignment of the caption text")
|
|
41
62
|
#: The size of the caption text
|
|
42
|
-
caption_size: Optional[Union[int, float]] = Field(
|
|
43
|
-
None, gt=0.0, serialization_alias="captionSize", description="The size of the caption text"
|
|
44
|
-
)
|
|
63
|
+
caption_size: Optional[Union[int, float]] = Field(None, gt=0.0, description="The size of the caption text")
|
|
45
64
|
#: The color of the relationship. Allowed input is for example "#FF0000", "red" or (255, 0, 0)
|
|
46
65
|
color: Optional[ColorType] = Field(None, description="The color of the relationship")
|
|
47
66
|
#: Additional properties of the relationship that do not directly impact the visualization
|
|
@@ -76,3 +95,16 @@ class Relationship(BaseModel, extra="allow"):
|
|
|
76
95
|
|
|
77
96
|
def to_dict(self) -> dict[str, Any]:
|
|
78
97
|
return self.model_dump(exclude_none=True, by_alias=True)
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def all_validation_aliases(exempted_fields: Optional[list[str]] = None) -> set[str]:
|
|
101
|
+
if exempted_fields is None:
|
|
102
|
+
exempted_fields = []
|
|
103
|
+
|
|
104
|
+
by_field = [
|
|
105
|
+
v.validation_alias.choices # type: ignore
|
|
106
|
+
for k, v in Relationship.model_fields.items()
|
|
107
|
+
if k not in exempted_fields
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
return {str(alias) for aliases in by_field for alias in aliases}
|
neo4j_viz/visualization_graph.py
CHANGED
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import warnings
|
|
4
4
|
from collections.abc import Iterable
|
|
5
|
-
from typing import Any, Callable, Hashable, Optional
|
|
5
|
+
from typing import Any, Callable, Hashable, Optional, Union
|
|
6
6
|
|
|
7
7
|
from IPython.display import HTML
|
|
8
8
|
from pydantic_extra_types.color import Color, ColorType
|
|
@@ -11,7 +11,13 @@ from .colors import NEO4J_COLORS_CONTINUOUS, NEO4J_COLORS_DISCRETE, ColorSpace,
|
|
|
11
11
|
from .node import Node, NodeIdType
|
|
12
12
|
from .node_size import RealNumber, verify_radii
|
|
13
13
|
from .nvl import NVL
|
|
14
|
-
from .options import
|
|
14
|
+
from .options import (
|
|
15
|
+
Layout,
|
|
16
|
+
LayoutOptions,
|
|
17
|
+
Renderer,
|
|
18
|
+
RenderOptions,
|
|
19
|
+
construct_layout_options,
|
|
20
|
+
)
|
|
15
21
|
from .relationship import Relationship
|
|
16
22
|
|
|
17
23
|
|
|
@@ -42,6 +48,7 @@ class VisualizationGraph:
|
|
|
42
48
|
def render(
|
|
43
49
|
self,
|
|
44
50
|
layout: Optional[Layout] = None,
|
|
51
|
+
layout_options: Union[dict[str, Any], LayoutOptions, None] = None,
|
|
45
52
|
renderer: Renderer = Renderer.CANVAS,
|
|
46
53
|
width: str = "100%",
|
|
47
54
|
height: str = "600px",
|
|
@@ -60,6 +67,8 @@ class VisualizationGraph:
|
|
|
60
67
|
----------
|
|
61
68
|
layout:
|
|
62
69
|
The `Layout` to use.
|
|
70
|
+
layout_options:
|
|
71
|
+
The `LayoutOptions` to use.
|
|
63
72
|
renderer:
|
|
64
73
|
The `Renderer` to use.
|
|
65
74
|
width:
|
|
@@ -92,8 +101,19 @@ class VisualizationGraph:
|
|
|
92
101
|
|
|
93
102
|
Renderer.check(renderer, num_nodes)
|
|
94
103
|
|
|
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
|
+
|
|
95
114
|
render_options = RenderOptions(
|
|
96
115
|
layout=layout,
|
|
116
|
+
layout_options=layout_options_typed,
|
|
97
117
|
renderer=renderer,
|
|
98
118
|
pan_X=pan_position[0] if pan_position is not None else None,
|
|
99
119
|
pan_Y=pan_position[1] if pan_position is not None else None,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
neo4j_viz/__init__.py,sha256=Q-VZlJe3_kAow_-F_-9RsHCQfbOfv5on26YD9ihw27o,504
|
|
2
|
+
neo4j_viz/colors.py,sha256=IvOCTmCu7WTMna_wNLZ3GrThTwFyIoKtNkmZYDLdYac,6694
|
|
3
|
+
neo4j_viz/gds.py,sha256=Cu-0RDi6YtWiuWP1N06h5P0zd5mQx0z5cafr0fs2K24,6637
|
|
4
|
+
neo4j_viz/gql_create.py,sha256=K33cT6dOj8eJPGNNJXiXlCfLIzNxTwcW4n_2AG3_zaY,14751
|
|
5
|
+
neo4j_viz/neo4j.py,sha256=_bZkj1XQKazueRM7Ti0pIPdSI8fVd432mZbQsxFO2yw,6112
|
|
6
|
+
neo4j_viz/node.py,sha256=MiLoghsn2NLs_iV65NuW7u3iaxP8MTKoNy6La9TdreY,3886
|
|
7
|
+
neo4j_viz/node_size.py,sha256=c_sMtQSD8eJ_6Y0Kr6ku0LOs9VoEDxfYCUUzUWZ-1Xo,1197
|
|
8
|
+
neo4j_viz/nvl.py,sha256=ZN3tyWar9ugR88r5N6txW3ThfNEWOt5A1KzrrRnLKwk,5262
|
|
9
|
+
neo4j_viz/options.py,sha256=eOpiLcIfFvUiPoozyT44F9MHGRkqCfBZFmh0u_6DfwY,6400
|
|
10
|
+
neo4j_viz/pandas.py,sha256=Ajn77VRcen4lqrVQp0O9yUG5bZ7QkjiCIpjj0UFCD0w,5162
|
|
11
|
+
neo4j_viz/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
neo4j_viz/relationship.py,sha256=sRgGjNzlqt6wPmiB3WXBTxR_W_5Z40VofpHUZvkRS1I,4143
|
|
13
|
+
neo4j_viz/visualization_graph.py,sha256=Fizmzm8p4p3Fbd3gsiTymkXgI3crpdmr5ipv4gFv3SY,13760
|
|
14
|
+
neo4j_viz/resources/icons/screenshot.svg,sha256=Ns9Yi2Iq4lIaiFvzc0pXBmjxt4fcmBO-I4cI8Xiu1HE,311
|
|
15
|
+
neo4j_viz/resources/icons/zoom-in.svg,sha256=PsO5yFkA1JnGM2QV_qxHKG13qmoR-RrlWARpaXNp5qU,415
|
|
16
|
+
neo4j_viz/resources/icons/zoom-out.svg,sha256=OQRADAoe2bxbCeFufg6W22nR41q5NlI8QspT9l5pXUw,400
|
|
17
|
+
neo4j_viz/resources/nvl_entrypoint/base.js,sha256=QfFjqd7Uo2F-plgFlPfLzUcjqzTzqUh533VaBemJhLI,1816247
|
|
18
|
+
neo4j_viz/resources/nvl_entrypoint/styles.css,sha256=JjeTSB9OJT2KMfb8yFUUMLMG7Rzrf3o60hSCD547zTk,1123
|
|
19
|
+
neo4j_viz-0.4.0.dist-info/METADATA,sha256=J3KTYHc4S8w50RqS4VbTaOQ4EaAWjGqyzVQn9zfU7OY,7074
|
|
20
|
+
neo4j_viz-0.4.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
21
|
+
neo4j_viz-0.4.0.dist-info/top_level.txt,sha256=jPUM3z8MOtxqDanc2VzqkxG4HJn8aaq4S7rnCFNk_Vs,10
|
|
22
|
+
neo4j_viz-0.4.0.dist-info/RECORD,,
|
neo4j_viz-0.3.0.dist-info/RECORD
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
neo4j_viz/__init__.py,sha256=ATDPdaupFWIWaOWtJtfCGVRVuuwXKNe1hNdENofgRLk,270
|
|
2
|
-
neo4j_viz/colors.py,sha256=IvOCTmCu7WTMna_wNLZ3GrThTwFyIoKtNkmZYDLdYac,6694
|
|
3
|
-
neo4j_viz/gds.py,sha256=v6b3XdSoWSoOu-U2MWQM-5QXLiPT3_9gGOv7haxuozE,4135
|
|
4
|
-
neo4j_viz/gql_create.py,sha256=Eqo_fFxtsrsoKV0gjXIpHZdeBqjX2q-vwe_xBCJU7OI,13354
|
|
5
|
-
neo4j_viz/neo4j.py,sha256=0urKoYwtCyyKXMu_tWPspehyjOFF5ETyiWuYVNA1Wfc,4589
|
|
6
|
-
neo4j_viz/node.py,sha256=NgB8preP2v1PWW177iU4YeojvfWafz8yIyY7MQrP6iE,2850
|
|
7
|
-
neo4j_viz/node_size.py,sha256=c_sMtQSD8eJ_6Y0Kr6ku0LOs9VoEDxfYCUUzUWZ-1Xo,1197
|
|
8
|
-
neo4j_viz/nvl.py,sha256=32NTuNCaf78bHEVD1nWQD5jUHS2ZUBemxKktuIpiCK0,4774
|
|
9
|
-
neo4j_viz/options.py,sha256=QBeHLqHOJFtSCjzHvv-2A9TzTbnIaC6_onQq9fUlT_4,3079
|
|
10
|
-
neo4j_viz/pandas.py,sha256=4ArBUV6DKOhwbplD8edJd5DsIilDNKyCpPjqrus0bPk,3269
|
|
11
|
-
neo4j_viz/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
-
neo4j_viz/relationship.py,sha256=DmmBUHup_jtqnenQKACCfz4Mz7CjbCnDmOZfsSj6qiQ,3042
|
|
13
|
-
neo4j_viz/visualization_graph.py,sha256=ogYhxAyPzqOcSogYSesGAV6iRQDLIA-YhBImEShuCPM,13175
|
|
14
|
-
neo4j_viz/resources/icons/screenshot.svg,sha256=Ns9Yi2Iq4lIaiFvzc0pXBmjxt4fcmBO-I4cI8Xiu1HE,311
|
|
15
|
-
neo4j_viz/resources/icons/zoom-in.svg,sha256=PsO5yFkA1JnGM2QV_qxHKG13qmoR-RrlWARpaXNp5qU,415
|
|
16
|
-
neo4j_viz/resources/icons/zoom-out.svg,sha256=OQRADAoe2bxbCeFufg6W22nR41q5NlI8QspT9l5pXUw,400
|
|
17
|
-
neo4j_viz/resources/nvl_entrypoint/base.js,sha256=QfFjqd7Uo2F-plgFlPfLzUcjqzTzqUh533VaBemJhLI,1816247
|
|
18
|
-
neo4j_viz/resources/nvl_entrypoint/styles.css,sha256=JjeTSB9OJT2KMfb8yFUUMLMG7Rzrf3o60hSCD547zTk,1123
|
|
19
|
-
neo4j_viz-0.3.0.dist-info/METADATA,sha256=wCM57O61k4kllIiYOqopDJiQPBzyftooJwhwz9h8oSw,7074
|
|
20
|
-
neo4j_viz-0.3.0.dist-info/WHEEL,sha256=Nw36Djuh_5VDukK0H78QzOX-_FQEo6V37m3nkm96gtU,91
|
|
21
|
-
neo4j_viz-0.3.0.dist-info/top_level.txt,sha256=jPUM3z8MOtxqDanc2VzqkxG4HJn8aaq4S7rnCFNk_Vs,10
|
|
22
|
-
neo4j_viz-0.3.0.dist-info/RECORD,,
|
|
File without changes
|