pyvis-optimized 4.3.1__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.
- pyvis/__init__.py +2 -0
- pyvis/_version.py +1 -0
- pyvis/edge.py +26 -0
- pyvis/network.py +1498 -0
- pyvis/node.py +35 -0
- pyvis/shiny/__init__.py +213 -0
- pyvis/shiny/bindings.js +1310 -0
- pyvis/shiny/styles.css +382 -0
- pyvis/shiny/wrapper.py +1368 -0
- pyvis/templates/lib/bindings/utils.js +189 -0
- pyvis/templates/lib/tom-select/tom-select.complete.min.js +5021 -0
- pyvis/templates/lib/tom-select/tom-select.css +2 -0
- pyvis/templates/lib/vis-10.0.2/vis-network.min.css +2 -0
- pyvis/templates/lib/vis-10.0.2/vis-network.min.js +78 -0
- pyvis/templates/template.html +911 -0
- pyvis/types/__init__.py +59 -0
- pyvis/types/base.py +109 -0
- pyvis/types/common.py +74 -0
- pyvis/types/configure.py +13 -0
- pyvis/types/edges.py +139 -0
- pyvis/types/interaction.py +42 -0
- pyvis/types/layout.py +34 -0
- pyvis/types/manipulation.py +18 -0
- pyvis/types/network.py +34 -0
- pyvis/types/nodes.py +158 -0
- pyvis/types/physics.py +90 -0
- pyvis/utils.py +26 -0
- pyvis/vis_config.py +24 -0
- pyvis_optimized-4.3.1.dist-info/METADATA +269 -0
- pyvis_optimized-4.3.1.dist-info/RECORD +33 -0
- pyvis_optimized-4.3.1.dist-info/WHEEL +5 -0
- pyvis_optimized-4.3.1.dist-info/licenses/LICENSE_BSD.txt +27 -0
- pyvis_optimized-4.3.1.dist-info/top_level.txt +1 -0
pyvis/network.py
ADDED
|
@@ -0,0 +1,1498 @@
|
|
|
1
|
+
"""Network module for pyvis graph visualization.
|
|
2
|
+
|
|
3
|
+
This module provides the Network class, which is the main interface for creating
|
|
4
|
+
interactive network visualizations using the vis.js library. It supports both
|
|
5
|
+
directed and undirected graphs, various physics simulations, and integration
|
|
6
|
+
with NetworkX and Jupyter notebooks.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import numbers
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import warnings
|
|
17
|
+
import webbrowser
|
|
18
|
+
from collections import defaultdict
|
|
19
|
+
from typing import List, Dict, Optional, Union, Any, Tuple
|
|
20
|
+
|
|
21
|
+
import networkx as nx
|
|
22
|
+
from jinja2 import Environment, FileSystemLoader
|
|
23
|
+
|
|
24
|
+
from .edge import Edge
|
|
25
|
+
from .node import Node
|
|
26
|
+
from .utils import check_html
|
|
27
|
+
from . import vis_config
|
|
28
|
+
|
|
29
|
+
__all__ = ['Network']
|
|
30
|
+
|
|
31
|
+
# Set up logging
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
# Constants for node attributes
|
|
35
|
+
VALID_BATCH_NODE_ARGS = ["size", "value", "title", "x", "y", "label", "color", "shape", "group"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _edge_key(source, to, directed):
|
|
39
|
+
"""Build a hashable key for edge deduplication.
|
|
40
|
+
|
|
41
|
+
For undirected graphs, uses a frozenset of (type name, id) pairs so
|
|
42
|
+
that ids of different types (e.g. 1 and '1') are never conflated,
|
|
43
|
+
regardless of call order.
|
|
44
|
+
"""
|
|
45
|
+
if directed:
|
|
46
|
+
return (source, to)
|
|
47
|
+
return frozenset(((type(source).__name__, source), (type(to).__name__, to)))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Constants for CDN resources
|
|
51
|
+
CDN_LOCAL = "local"
|
|
52
|
+
CDN_INLINE = "in_line"
|
|
53
|
+
CDN_REMOTE = "remote"
|
|
54
|
+
CDN_REMOTE_ESM = "remote_esm"
|
|
55
|
+
VALID_CDN_RESOURCES = [CDN_LOCAL, CDN_INLINE, CDN_REMOTE, CDN_REMOTE_ESM]
|
|
56
|
+
|
|
57
|
+
# CSS validation patterns to prevent CSS injection
|
|
58
|
+
_CSS_DIM_RE = re.compile(r'^\d+(\.\d+)?(px|%|em|rem|vh|vw)$')
|
|
59
|
+
_CSS_COLOR_RE = re.compile(
|
|
60
|
+
r'^(#[0-9a-fA-F]{3,8}|[a-zA-Z]+|rgba?\(\s*[\d.,\s%]+\)|hsla?\(\s*[\d.,\s%]+\))$'
|
|
61
|
+
)
|
|
62
|
+
_SAFE_TOMSELECT_KEYS = frozenset({"sortField", "maxOptions", "placeholder", "create", "closeAfterSelect", "hideSelected"})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _to_json_native(value, _seen=None):
|
|
66
|
+
"""Coerce numpy scalars and other numeric types to plain Python numbers.
|
|
67
|
+
|
|
68
|
+
Recurses into lists, tuples and dicts so that numpy scalars nested
|
|
69
|
+
inside a container (e.g. ``[np.int64(1)]`` or ``{"x": np.int64(1)}``)
|
|
70
|
+
are coerced element-by-element instead of causing the whole container
|
|
71
|
+
to be treated as unserialisable. Dict keys are validated against the
|
|
72
|
+
JSON key rules (``str``, ``int``, ``float``, ``bool`` or ``None`` only)
|
|
73
|
+
rather than recursed into.
|
|
74
|
+
|
|
75
|
+
Container identity (``id()``) is tracked along the current recursion
|
|
76
|
+
path (``_seen``, popped again once a container's children have been
|
|
77
|
+
processed) so a genuine cycle (a container that contains itself,
|
|
78
|
+
directly or indirectly) raises ``ValueError`` instead of overflowing
|
|
79
|
+
the recursion stack. The same container appearing twice in a DAG
|
|
80
|
+
(not on the same path) is legal and is coerced normally each time.
|
|
81
|
+
|
|
82
|
+
A depth cap was deliberately not added: cycle detection already
|
|
83
|
+
prevents unbounded recursion for the failure mode that was reported,
|
|
84
|
+
and an arbitrary depth limit would risk truncating legitimate deep
|
|
85
|
+
(but finite and non-cyclic) attribute structures that have not been
|
|
86
|
+
shown to occur in practice.
|
|
87
|
+
|
|
88
|
+
Returns the value unchanged when it is already JSON-serialisable and
|
|
89
|
+
raises TypeError when it is not. Raises ValueError for a circular
|
|
90
|
+
reference.
|
|
91
|
+
"""
|
|
92
|
+
if isinstance(value, bool) or value is None or isinstance(value, str):
|
|
93
|
+
return value
|
|
94
|
+
if isinstance(value, (list, tuple, dict)):
|
|
95
|
+
if _seen is None:
|
|
96
|
+
_seen = set()
|
|
97
|
+
container_id = id(value)
|
|
98
|
+
if container_id in _seen:
|
|
99
|
+
raise ValueError("Circular reference detected")
|
|
100
|
+
_seen = _seen | {container_id}
|
|
101
|
+
if isinstance(value, dict):
|
|
102
|
+
coerced = {}
|
|
103
|
+
for k, v in value.items():
|
|
104
|
+
if not (isinstance(k, (str, int, float)) or k is None):
|
|
105
|
+
raise TypeError(f"dict key {k!r} is not a valid JSON key")
|
|
106
|
+
coerced[k] = _to_json_native(v, _seen)
|
|
107
|
+
return coerced
|
|
108
|
+
return [_to_json_native(v, _seen) for v in value]
|
|
109
|
+
if isinstance(value, numbers.Integral):
|
|
110
|
+
return int(value)
|
|
111
|
+
if isinstance(value, numbers.Real):
|
|
112
|
+
return float(value)
|
|
113
|
+
if hasattr(value, "item"): # numpy generic
|
|
114
|
+
return _to_json_native(value.item(), _seen)
|
|
115
|
+
json.dumps(value) # raises TypeError for anything else
|
|
116
|
+
return value
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Network:
|
|
120
|
+
"""
|
|
121
|
+
The Network class is the focus of this library. All viz functionality
|
|
122
|
+
should be implemented off of a Network instance.
|
|
123
|
+
|
|
124
|
+
To instantiate:
|
|
125
|
+
|
|
126
|
+
>>> nt = Network()
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(self,
|
|
130
|
+
height: str = "600px",
|
|
131
|
+
width: str = "100%",
|
|
132
|
+
directed: bool = False,
|
|
133
|
+
notebook: bool = False,
|
|
134
|
+
neighborhood_highlight: bool = False,
|
|
135
|
+
select_menu: bool = False,
|
|
136
|
+
filter_menu: bool = False,
|
|
137
|
+
bgcolor: str = "#ffffff",
|
|
138
|
+
font_color: Optional[str] = None,
|
|
139
|
+
layout: Optional[bool] = None,
|
|
140
|
+
heading: str = "",
|
|
141
|
+
cdn_resources: str = "local",
|
|
142
|
+
edge_attribute_edit: bool = False,
|
|
143
|
+
highlight_degree: int = 2,
|
|
144
|
+
tooltip_link_override: Optional[bool] = None,
|
|
145
|
+
select_node_options: Optional[dict] = None,
|
|
146
|
+
filter_exclude: Optional[List[str]] = None):
|
|
147
|
+
"""
|
|
148
|
+
:param height: The height of the canvas (auto-converted to px if bare number).
|
|
149
|
+
:param width: The width of the canvas (auto-converted to px if bare number).
|
|
150
|
+
:param directed: Whether or not to use a directed graph. This is false
|
|
151
|
+
by default.
|
|
152
|
+
:param notebook: True if using jupyter notebook.
|
|
153
|
+
:param select_menu: Adds a dropdown menu to select and navigate to specific nodes.
|
|
154
|
+
:param filter_menu: sets the option to filter nodes and edges based on attributes
|
|
155
|
+
:param bgcolor: The background color of the canvas.
|
|
156
|
+
:param cdn_resources: Where to pull resources for css and js files. Defaults to local.
|
|
157
|
+
Options ['local','in_line','remote','remote_esm'].
|
|
158
|
+
local: pull resources from local lib folder.
|
|
159
|
+
in_line: insert lib resources as inline script tags.
|
|
160
|
+
remote: pull resources from hash checked cdns.
|
|
161
|
+
remote_esm: pull resources from CDN as ES modules.
|
|
162
|
+
:param edge_attribute_edit: Enables edge property editing modal on double-click. Default False.
|
|
163
|
+
:param font_color: The color of the node labels text
|
|
164
|
+
:param layout: Use hierarchical layout if True. Pass a LayoutOptions object for custom layout.
|
|
165
|
+
:param neighborhood_highlight: When True, clicking a node highlights its neighbors. Default False.
|
|
166
|
+
:param heading: Heading text displayed above the visualization. Default "".
|
|
167
|
+
:param highlight_degree: Degree of neighbors to highlight (default 2). Must be a non-negative integer.
|
|
168
|
+
:param tooltip_link_override: Override auto-detection of link tooltips (titles containing "href"). True forces on, False forces off, None auto-detects.
|
|
169
|
+
:param select_node_options: Dict of TomSelect options for node selector. Only safe keys accepted.
|
|
170
|
+
:param filter_exclude: List of node property names to exclude from filter menu.
|
|
171
|
+
|
|
172
|
+
:type height: str, int, or float
|
|
173
|
+
:type width: str, int, or float
|
|
174
|
+
:type directed: bool
|
|
175
|
+
:type notebook: bool
|
|
176
|
+
:type select_menu: bool
|
|
177
|
+
:type filter_menu: bool
|
|
178
|
+
:type bgcolor: str
|
|
179
|
+
:type font_color: str
|
|
180
|
+
:type layout: bool or LayoutOptions
|
|
181
|
+
:type cdn_resources: str
|
|
182
|
+
:type edge_attribute_edit: bool
|
|
183
|
+
"""
|
|
184
|
+
# Node storage - single source of truth
|
|
185
|
+
self.node_map: Dict[Union[str, int], Dict[str, Any]] = {}
|
|
186
|
+
self.edges: List[Dict[str, Any]] = []
|
|
187
|
+
# Edge set for O(1) duplicate detection in undirected graphs
|
|
188
|
+
self._edge_set: set = set()
|
|
189
|
+
# Adjacency list cache
|
|
190
|
+
self._adj_list_cache: Optional[Dict[Union[str, int], set]] = None
|
|
191
|
+
|
|
192
|
+
# Validate CSS values to prevent CSS injection
|
|
193
|
+
# Accept bare int/float/numeric-string as pixel values (legacy API)
|
|
194
|
+
if isinstance(height, (int, float)):
|
|
195
|
+
height = f"{height}px"
|
|
196
|
+
elif isinstance(height, str):
|
|
197
|
+
try:
|
|
198
|
+
float(height)
|
|
199
|
+
height = f"{height}px"
|
|
200
|
+
except ValueError:
|
|
201
|
+
pass
|
|
202
|
+
if isinstance(width, (int, float)):
|
|
203
|
+
width = f"{width}px"
|
|
204
|
+
elif isinstance(width, str):
|
|
205
|
+
try:
|
|
206
|
+
float(width)
|
|
207
|
+
width = f"{width}px"
|
|
208
|
+
except ValueError:
|
|
209
|
+
pass
|
|
210
|
+
if not isinstance(height, str) or not _CSS_DIM_RE.match(height):
|
|
211
|
+
raise ValueError(f"Invalid CSS dimension for height: {height!r}")
|
|
212
|
+
if not isinstance(width, str) or not _CSS_DIM_RE.match(width):
|
|
213
|
+
raise ValueError(f"Invalid CSS dimension for width: {width!r}")
|
|
214
|
+
if not isinstance(bgcolor, str) or not _CSS_COLOR_RE.match(bgcolor):
|
|
215
|
+
raise ValueError(f"Invalid CSS color for bgcolor: {bgcolor!r}")
|
|
216
|
+
# Legacy: font_color=False was used to mean "no color"
|
|
217
|
+
if font_color is False:
|
|
218
|
+
font_color = None
|
|
219
|
+
if font_color is not None:
|
|
220
|
+
if not isinstance(font_color, str) or not _CSS_COLOR_RE.match(font_color):
|
|
221
|
+
raise ValueError(f"Invalid CSS color for font_color: {font_color!r}")
|
|
222
|
+
|
|
223
|
+
if not isinstance(highlight_degree, int) or isinstance(highlight_degree, bool) or highlight_degree < 0:
|
|
224
|
+
raise ValueError(f"highlight_degree must be a non-negative integer, got {highlight_degree!r}")
|
|
225
|
+
|
|
226
|
+
self.height = height
|
|
227
|
+
self.width = width
|
|
228
|
+
self.heading = heading
|
|
229
|
+
self.shape = "dot"
|
|
230
|
+
self.font_color = font_color
|
|
231
|
+
self.directed = directed
|
|
232
|
+
self.bgcolor = bgcolor
|
|
233
|
+
self.use_DOT = False
|
|
234
|
+
self.dot_lang = ""
|
|
235
|
+
self.options = {}
|
|
236
|
+
if layout is True:
|
|
237
|
+
self.options["layout"] = {
|
|
238
|
+
"hierarchical": {"enabled": True},
|
|
239
|
+
"randomSeed": 0,
|
|
240
|
+
"improvedLayout": True,
|
|
241
|
+
}
|
|
242
|
+
elif layout is not None and layout is not False:
|
|
243
|
+
if hasattr(layout, 'to_dict'):
|
|
244
|
+
self.options["layout"] = layout.to_dict()
|
|
245
|
+
else:
|
|
246
|
+
warnings.warn(
|
|
247
|
+
f"layout= expected bool or LayoutOptions, got {type(layout).__name__}. Ignoring.",
|
|
248
|
+
UserWarning, stacklevel=2
|
|
249
|
+
)
|
|
250
|
+
self.widget = False
|
|
251
|
+
self.template = None
|
|
252
|
+
self.neighborhood_highlight = neighborhood_highlight
|
|
253
|
+
self.select_menu = select_menu
|
|
254
|
+
self.filter_menu = filter_menu
|
|
255
|
+
self.edge_attribute_edit = edge_attribute_edit
|
|
256
|
+
self.highlight_degree = highlight_degree
|
|
257
|
+
self.tooltip_link_override = tooltip_link_override
|
|
258
|
+
if select_node_options is not None:
|
|
259
|
+
unknown = set(select_node_options) - _SAFE_TOMSELECT_KEYS
|
|
260
|
+
if unknown:
|
|
261
|
+
warnings.warn(
|
|
262
|
+
f"select_node_options: keys {unknown} are not in the allowed set and were removed.",
|
|
263
|
+
UserWarning, stacklevel=2
|
|
264
|
+
)
|
|
265
|
+
self.select_node_options = {k: v for k, v in select_node_options.items() if k in _SAFE_TOMSELECT_KEYS}
|
|
266
|
+
else:
|
|
267
|
+
self.select_node_options = None
|
|
268
|
+
self.filter_exclude = filter_exclude if filter_exclude is not None else ["hidden", "savedLabel", "hiddenLabel"]
|
|
269
|
+
self.legend = None
|
|
270
|
+
self.groups = {}
|
|
271
|
+
|
|
272
|
+
if cdn_resources not in VALID_CDN_RESOURCES:
|
|
273
|
+
raise ValueError(f"cdn_resources must be one of {VALID_CDN_RESOURCES}")
|
|
274
|
+
|
|
275
|
+
# path is the root template located in the template_dir
|
|
276
|
+
self.path = "template.html"
|
|
277
|
+
self.template_dir = os.path.dirname(__file__) + "/templates/"
|
|
278
|
+
self.templateEnv = Environment(
|
|
279
|
+
loader=FileSystemLoader(self.template_dir),
|
|
280
|
+
autoescape=True,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
if cdn_resources == "local" and notebook:
|
|
284
|
+
logger.warning("When cdn_resources is 'local' jupyter notebook has issues displaying graphics on chrome/safari."
|
|
285
|
+
" Use cdn_resources='in_line' or cdn_resources='remote' if you have issues "
|
|
286
|
+
"viewing graphics in a notebook.")
|
|
287
|
+
self.cdn_resources = cdn_resources
|
|
288
|
+
|
|
289
|
+
if notebook:
|
|
290
|
+
self.prep_notebook()
|
|
291
|
+
|
|
292
|
+
def __str__(self):
|
|
293
|
+
"""
|
|
294
|
+
override print to show readable graph data
|
|
295
|
+
"""
|
|
296
|
+
return str(
|
|
297
|
+
json.dumps(
|
|
298
|
+
{
|
|
299
|
+
"Nodes": list(self.node_map.keys()),
|
|
300
|
+
"Edges": self.edges,
|
|
301
|
+
"Height": self.height,
|
|
302
|
+
"Width": self.width,
|
|
303
|
+
"Heading": self.heading
|
|
304
|
+
},
|
|
305
|
+
indent=4
|
|
306
|
+
)
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
def __repr__(self):
|
|
310
|
+
return (f'{self.__class__.__name__}(nodes={self.num_nodes()}, '
|
|
311
|
+
f'edges={self.num_edges()}, directed={self.directed})')
|
|
312
|
+
|
|
313
|
+
def __len__(self):
|
|
314
|
+
"""Return the number of nodes in the network."""
|
|
315
|
+
return len(self.node_map)
|
|
316
|
+
|
|
317
|
+
def __iter__(self):
|
|
318
|
+
"""Iterate over node dictionaries."""
|
|
319
|
+
return iter(self.node_map.values())
|
|
320
|
+
|
|
321
|
+
def __contains__(self, node_id):
|
|
322
|
+
"""Check if a node ID exists in the network."""
|
|
323
|
+
return node_id in self.node_map
|
|
324
|
+
|
|
325
|
+
def __getitem__(self, node_id):
|
|
326
|
+
"""Get a node by its ID."""
|
|
327
|
+
if node_id not in self.node_map:
|
|
328
|
+
raise KeyError(f"Node '{node_id}' not found in network")
|
|
329
|
+
return self.node_map[node_id]
|
|
330
|
+
|
|
331
|
+
def __enter__(self):
|
|
332
|
+
"""Enter context manager - returns self for use in 'with' statement."""
|
|
333
|
+
return self
|
|
334
|
+
|
|
335
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
336
|
+
"""
|
|
337
|
+
Exit context manager - cleanup resources.
|
|
338
|
+
|
|
339
|
+
Clears the adjacency-list cache. Does not delete files and does
|
|
340
|
+
not suppress exceptions.
|
|
341
|
+
"""
|
|
342
|
+
# Clear caches to free memory
|
|
343
|
+
self._adj_list_cache = None
|
|
344
|
+
|
|
345
|
+
return False # Don't suppress exceptions
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def nodes(self) -> List[Dict[str, Any]]:
|
|
349
|
+
"""Property to maintain backward compatibility - returns list of node dicts."""
|
|
350
|
+
return list(self.node_map.values())
|
|
351
|
+
|
|
352
|
+
@property
|
|
353
|
+
def node_ids(self) -> List[Union[str, int]]:
|
|
354
|
+
"""Property to maintain backward compatibility - returns list of node IDs."""
|
|
355
|
+
return list(self.node_map.keys())
|
|
356
|
+
|
|
357
|
+
def add_node(self, n_id: Union[str, int], label: Optional[Union[str, int]] = None, shape: str = "dot", color: str = '#97c2fc', options=None, **kw_options):
|
|
358
|
+
"""
|
|
359
|
+
This method adds a node to the network, given a mandatory node ID.
|
|
360
|
+
Node labels default to node ids if no label is specified during the
|
|
361
|
+
call.
|
|
362
|
+
|
|
363
|
+
>>> nt = Network("500px", "500px")
|
|
364
|
+
>>> nt.add_node(0, label="Node 0")
|
|
365
|
+
>>> nt.add_node(1, label="Node 1", color = "blue")
|
|
366
|
+
|
|
367
|
+
:param n_id: The id of the node. The id is mandatory for nodes and
|
|
368
|
+
they have to be unique. This should obviously be set per
|
|
369
|
+
node, not globally.
|
|
370
|
+
|
|
371
|
+
:param label: The label is the piece of text shown in or under the
|
|
372
|
+
node, depending on the shape.
|
|
373
|
+
|
|
374
|
+
:param borderWidth: The width of the border of the node.
|
|
375
|
+
|
|
376
|
+
:param borderWidthSelected: The width of the border of the node when
|
|
377
|
+
it is selected. When undefined, the
|
|
378
|
+
borderWidth * 2 is used.
|
|
379
|
+
|
|
380
|
+
:param brokenImage: When the shape is set to image or circularImage,
|
|
381
|
+
this option can be an URL to a backup image in
|
|
382
|
+
case the URL supplied in the image option cannot
|
|
383
|
+
be resolved.
|
|
384
|
+
|
|
385
|
+
:param group: When not undefined, the node will belong to the defined
|
|
386
|
+
group. Styling information of that group will apply to
|
|
387
|
+
this node. Node specific styling overrides group styling.
|
|
388
|
+
|
|
389
|
+
:param hidden: When true, the node will not be shown. It will still be
|
|
390
|
+
part of the physics simulation though!
|
|
391
|
+
|
|
392
|
+
:param image: When the shape is set to image or circularImage, this
|
|
393
|
+
option should be the URL to an image. If the image
|
|
394
|
+
cannot be found, the brokenImage option can be used.
|
|
395
|
+
|
|
396
|
+
:param labelHighlightBold: Determines whether or not the label becomes
|
|
397
|
+
bold when the node is selected.
|
|
398
|
+
|
|
399
|
+
:param level: When using the hierarchical layout, the level determines
|
|
400
|
+
where the node is going to be positioned.
|
|
401
|
+
|
|
402
|
+
:param mass: The barnesHut physics model (which is enabled by default)
|
|
403
|
+
is based on an inverted gravity model. By increasing
|
|
404
|
+
the mass of a node, you increase it's repulsion. Values
|
|
405
|
+
lower than 1 are not recommended.
|
|
406
|
+
|
|
407
|
+
:param physics: When false, the node is not part of the physics
|
|
408
|
+
simulation. It will not move except for from
|
|
409
|
+
manual dragging.
|
|
410
|
+
|
|
411
|
+
:param shape: The shape defines what the node looks like. There are
|
|
412
|
+
two types of nodes. One type has the label inside of
|
|
413
|
+
it and the other type has the label underneath it. The
|
|
414
|
+
types with the label inside of it are: ellipse, circle,
|
|
415
|
+
database, box, text. The ones with the label outside of
|
|
416
|
+
it are: image, circularImage, diamond, dot, star,
|
|
417
|
+
triangle, triangleDown, square and icon.
|
|
418
|
+
|
|
419
|
+
:param font_color: Colour of this node's label, overriding the
|
|
420
|
+
network-wide font_color given to Network(). Omit it
|
|
421
|
+
(or pass None) to inherit the network default; pass
|
|
422
|
+
False to render the label with no explicit colour.
|
|
423
|
+
Accepted as a keyword argument alongside the other
|
|
424
|
+
vis-network node attributes.
|
|
425
|
+
|
|
426
|
+
:param size: The size is used to determine the size of node shapes that
|
|
427
|
+
do not have the label inside of them. These shapes are:
|
|
428
|
+
image, circularImage, diamond, dot, star, triangle,
|
|
429
|
+
triangleDown, square and icon.
|
|
430
|
+
|
|
431
|
+
:param title: Title to be displayed when the user hovers over the node.
|
|
432
|
+
The title is rendered as plain text; HTML is not
|
|
433
|
+
interpreted.
|
|
434
|
+
|
|
435
|
+
:param value: When a value is set, the nodes will be scaled using the
|
|
436
|
+
options in the scaling object defined above.
|
|
437
|
+
|
|
438
|
+
:param x: This gives a node an initial x position. When using the
|
|
439
|
+
hierarchical layout, either the x or y position is set by the
|
|
440
|
+
layout engine depending on the type of view. The other value
|
|
441
|
+
remains untouched. When using stabilization, the stabilized
|
|
442
|
+
position may be different from the initial one. To lock the
|
|
443
|
+
node to that position use the physics or fixed options.
|
|
444
|
+
|
|
445
|
+
:param y: This gives a node an initial y position. When using the
|
|
446
|
+
hierarchical layout,either the x or y position is set by
|
|
447
|
+
the layout engine depending on the type of view. The
|
|
448
|
+
other value remains untouched. When using stabilization,
|
|
449
|
+
the stabilized position may be different from the initial
|
|
450
|
+
one. To lock the node to that position use the physics or
|
|
451
|
+
fixed options.
|
|
452
|
+
|
|
453
|
+
:type n_id: str or int
|
|
454
|
+
:type label: str or int
|
|
455
|
+
:type borderWidth: num (optional)
|
|
456
|
+
:type borderWidthSelected: num (optional)
|
|
457
|
+
:type brokenImage: str (optional)
|
|
458
|
+
:type group: str (optional)
|
|
459
|
+
:type hidden: bool (optional)
|
|
460
|
+
:type image: str (optional)
|
|
461
|
+
:type labelHighlightBold: bool (optional)
|
|
462
|
+
:type level: num (optional)
|
|
463
|
+
:type mass: num (optional)
|
|
464
|
+
:type physics: bool (optional)
|
|
465
|
+
:type shape: str (optional)
|
|
466
|
+
:type size: num (optional)
|
|
467
|
+
:type title: str (optional)
|
|
468
|
+
:type value: num (optional)
|
|
469
|
+
:type x: num (optional)
|
|
470
|
+
:type y: num (optional)
|
|
471
|
+
"""
|
|
472
|
+
if not isinstance(n_id, (str, int)):
|
|
473
|
+
raise TypeError("Node id must be a string or an integer")
|
|
474
|
+
|
|
475
|
+
if n_id not in self.node_map:
|
|
476
|
+
if options is not None:
|
|
477
|
+
if hasattr(options, 'to_dict'):
|
|
478
|
+
opts = options.to_dict()
|
|
479
|
+
elif isinstance(options, dict):
|
|
480
|
+
opts = dict(options)
|
|
481
|
+
else:
|
|
482
|
+
raise TypeError(
|
|
483
|
+
f"options must be a NodeOptions or dict, got {type(options).__name__}"
|
|
484
|
+
)
|
|
485
|
+
if kw_options:
|
|
486
|
+
warnings.warn(
|
|
487
|
+
"Both options= and **kwargs were provided to add_node(). "
|
|
488
|
+
"When options= is used, kwargs are ignored.",
|
|
489
|
+
UserWarning,
|
|
490
|
+
stacklevel=2,
|
|
491
|
+
)
|
|
492
|
+
opts['id'] = n_id
|
|
493
|
+
if 'label' not in opts:
|
|
494
|
+
opts['label'] = label if label is not None else n_id
|
|
495
|
+
if self.font_color:
|
|
496
|
+
font = opts.get('font')
|
|
497
|
+
if isinstance(font, dict):
|
|
498
|
+
font.setdefault('color', self.font_color)
|
|
499
|
+
elif font is None:
|
|
500
|
+
opts['font'] = {'color': self.font_color}
|
|
501
|
+
self.node_map[n_id] = opts
|
|
502
|
+
else:
|
|
503
|
+
# Legacy path: Node() builds the option dict from kwargs
|
|
504
|
+
if label is not None:
|
|
505
|
+
node_label = label
|
|
506
|
+
else:
|
|
507
|
+
node_label = n_id
|
|
508
|
+
# A per-node font_color kwarg (e.g. carried over by from_nx)
|
|
509
|
+
# overrides the network-wide default. None means "inherit";
|
|
510
|
+
# False means "no color" (handled by Node).
|
|
511
|
+
node_font_color = kw_options.pop("font_color", None)
|
|
512
|
+
if node_font_color is None:
|
|
513
|
+
node_font_color = self.font_color
|
|
514
|
+
if "group" in kw_options:
|
|
515
|
+
n = Node(n_id, shape, label=node_label, font_color=node_font_color, **kw_options)
|
|
516
|
+
else:
|
|
517
|
+
n = Node(n_id, shape, label=node_label, color=color, font_color=node_font_color, **kw_options)
|
|
518
|
+
self.node_map[n_id] = n.options
|
|
519
|
+
# Invalidate adjacency list cache
|
|
520
|
+
self._adj_list_cache = None
|
|
521
|
+
else:
|
|
522
|
+
warnings.warn(
|
|
523
|
+
f"Node {n_id!r} already exists and was not updated. "
|
|
524
|
+
f"Use update_node() to modify existing nodes.",
|
|
525
|
+
UserWarning, stacklevel=2
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
def add_nodes(self, nodes: List[Union[str, int]], options=None, **kwargs):
|
|
529
|
+
"""
|
|
530
|
+
This method adds multiple nodes to the network from a list.
|
|
531
|
+
Default behavior uses values of 'nodes' for node ID and node label
|
|
532
|
+
properties. You can also specify other lists of properties to go
|
|
533
|
+
along each node.
|
|
534
|
+
|
|
535
|
+
Example:
|
|
536
|
+
|
|
537
|
+
>>> g = net.Network()
|
|
538
|
+
>>> g.add_nodes([1, 2, 3], size=[2, 4, 6], title=["n1", "n2", "n3"])
|
|
539
|
+
>>> g.nodes
|
|
540
|
+
>>> [{'id': 1, 'label': 1, 'shape': 'dot', 'size': 2, 'title': 'n1'},
|
|
541
|
+
|
|
542
|
+
Output:
|
|
543
|
+
|
|
544
|
+
>>> {'id': 2, 'label': 2, 'shape': 'dot', 'size': 4, 'title': 'n2'},
|
|
545
|
+
>>> {'id': 3, 'label': 3, 'shape': 'dot', 'size': 6, 'title': 'n3'}]
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
:param nodes: A list of nodes.
|
|
549
|
+
|
|
550
|
+
:type nodes: list
|
|
551
|
+
"""
|
|
552
|
+
for k in kwargs:
|
|
553
|
+
if k not in VALID_BATCH_NODE_ARGS:
|
|
554
|
+
raise ValueError(f"invalid arg '{k}'")
|
|
555
|
+
|
|
556
|
+
# Typed options path
|
|
557
|
+
if options is not None:
|
|
558
|
+
if kwargs:
|
|
559
|
+
warnings.warn(
|
|
560
|
+
"Both options= and **kwargs were provided to add_nodes(). "
|
|
561
|
+
"When options= is used, kwargs are ignored.",
|
|
562
|
+
UserWarning,
|
|
563
|
+
stacklevel=2,
|
|
564
|
+
)
|
|
565
|
+
if hasattr(options, 'to_dict'):
|
|
566
|
+
# Single options applied to all nodes
|
|
567
|
+
opts_dict = options.to_dict()
|
|
568
|
+
for node in nodes:
|
|
569
|
+
self.add_node(node, **copy.deepcopy(opts_dict))
|
|
570
|
+
return
|
|
571
|
+
elif isinstance(options, list):
|
|
572
|
+
if len(options) != len(nodes):
|
|
573
|
+
raise ValueError(
|
|
574
|
+
f"options list length ({len(options)}) does not match "
|
|
575
|
+
f"nodes list length ({len(nodes)})"
|
|
576
|
+
)
|
|
577
|
+
for node, opt in zip(nodes, options):
|
|
578
|
+
if hasattr(opt, 'to_dict'):
|
|
579
|
+
self.add_node(node, options=opt)
|
|
580
|
+
else:
|
|
581
|
+
self.add_node(node, **opt)
|
|
582
|
+
return
|
|
583
|
+
|
|
584
|
+
# Validate lengths before the loop to avoid O(n²)
|
|
585
|
+
for k, v in kwargs.items():
|
|
586
|
+
if len(v) != len(nodes):
|
|
587
|
+
raise ValueError(f"keyword arg {k} [length {len(v)}] does not match [length {len(nodes)}] of nodes")
|
|
588
|
+
|
|
589
|
+
nd = defaultdict(dict)
|
|
590
|
+
for i in range(len(nodes)):
|
|
591
|
+
for k, v in kwargs.items():
|
|
592
|
+
nd[nodes[i]].update({k: v[i]})
|
|
593
|
+
|
|
594
|
+
for node in nodes:
|
|
595
|
+
# Check type first (LBYL over EAFP for better performance)
|
|
596
|
+
if isinstance(node, (int, str)):
|
|
597
|
+
self.add_node(node, **nd[node])
|
|
598
|
+
else:
|
|
599
|
+
# Try to convert number-like objects to int
|
|
600
|
+
try:
|
|
601
|
+
node = int(node)
|
|
602
|
+
self.add_node(node, **nd[node])
|
|
603
|
+
except (ValueError, TypeError):
|
|
604
|
+
raise TypeError(f"Node must be string or int, got {type(node)}")
|
|
605
|
+
|
|
606
|
+
def num_nodes(self) -> int:
|
|
607
|
+
"""
|
|
608
|
+
Return number of nodes
|
|
609
|
+
|
|
610
|
+
:returns: :py:class:`int`
|
|
611
|
+
"""
|
|
612
|
+
return len(self.node_map)
|
|
613
|
+
|
|
614
|
+
def num_edges(self) -> int:
|
|
615
|
+
"""
|
|
616
|
+
Return number of edges
|
|
617
|
+
|
|
618
|
+
:returns: :py:class:`int`
|
|
619
|
+
"""
|
|
620
|
+
return len(self.edges)
|
|
621
|
+
|
|
622
|
+
def add_edge(self, source: Union[str, int], to: Union[str, int], options=None, **kw_options):
|
|
623
|
+
"""
|
|
624
|
+
Add an edge between two existing nodes.
|
|
625
|
+
|
|
626
|
+
Order does not matter unless dealing with a directed graph.
|
|
627
|
+
Duplicate edges are silently ignored. In undirected graphs,
|
|
628
|
+
``add_edge(1, 2)`` and ``add_edge(2, 1)`` are treated as the
|
|
629
|
+
same edge; the second call is a no-op.
|
|
630
|
+
|
|
631
|
+
>>> nt.add_edge(0, 1)
|
|
632
|
+
>>> nt.add_edge(0, 1, value=4)
|
|
633
|
+
|
|
634
|
+
:param source: The ID of the source node.
|
|
635
|
+
:param to: The ID of the destination node.
|
|
636
|
+
:param options: Typed EdgeOptions instance (optional). When provided,
|
|
637
|
+
kw_options are ignored.
|
|
638
|
+
:param kw_options: Additional vis-network edge options as keyword
|
|
639
|
+
arguments (e.g., value, width, title, hidden,
|
|
640
|
+
color, arrows, arrowStrikethrough, physics).
|
|
641
|
+
|
|
642
|
+
:type source: str or int
|
|
643
|
+
:type to: str or int
|
|
644
|
+
:type options: EdgeOptions, optional
|
|
645
|
+
"""
|
|
646
|
+
# Verify nodes exist - O(1) lookup with dict
|
|
647
|
+
if source not in self.node_map:
|
|
648
|
+
raise ValueError(f"non existent node '{source}'")
|
|
649
|
+
|
|
650
|
+
if to not in self.node_map:
|
|
651
|
+
raise ValueError(f"non existent node '{to}'")
|
|
652
|
+
|
|
653
|
+
# O(1) duplicate detection using edge set
|
|
654
|
+
edge_key = _edge_key(source, to, self.directed)
|
|
655
|
+
|
|
656
|
+
if edge_key not in self._edge_set:
|
|
657
|
+
if options is not None:
|
|
658
|
+
if hasattr(options, 'to_dict'):
|
|
659
|
+
opts = options.to_dict()
|
|
660
|
+
elif isinstance(options, dict):
|
|
661
|
+
opts = dict(options)
|
|
662
|
+
else:
|
|
663
|
+
raise TypeError(
|
|
664
|
+
f"options must be an EdgeOptions or dict, got {type(options).__name__}"
|
|
665
|
+
)
|
|
666
|
+
if kw_options:
|
|
667
|
+
warnings.warn(
|
|
668
|
+
"Both options= and **kwargs were provided to add_edge(). "
|
|
669
|
+
"When options= is used, kwargs are ignored.",
|
|
670
|
+
UserWarning,
|
|
671
|
+
stacklevel=2,
|
|
672
|
+
)
|
|
673
|
+
# Typed path
|
|
674
|
+
opts['from'] = source
|
|
675
|
+
opts['to'] = to
|
|
676
|
+
if self.directed and 'arrows' not in opts:
|
|
677
|
+
opts['arrows'] = 'to'
|
|
678
|
+
self.edges.append(opts)
|
|
679
|
+
else:
|
|
680
|
+
# Legacy path
|
|
681
|
+
e = Edge(source, to, self.directed, **kw_options)
|
|
682
|
+
self.edges.append(e.options)
|
|
683
|
+
self._edge_set.add(edge_key)
|
|
684
|
+
# Invalidate adjacency list cache
|
|
685
|
+
self._adj_list_cache = None
|
|
686
|
+
|
|
687
|
+
def add_edges(self, edges: List[Union[tuple, list]]):
|
|
688
|
+
"""
|
|
689
|
+
This method serves to add multiple edges between existing nodes
|
|
690
|
+
in the network instance. Adding of the edges is done based off
|
|
691
|
+
of the IDs of the nodes. Order does not matter unless dealing with a
|
|
692
|
+
directed graph.
|
|
693
|
+
|
|
694
|
+
:param edges: A list of tuples, each tuple consists of source of edge,
|
|
695
|
+
edge destination and and optional width.
|
|
696
|
+
|
|
697
|
+
:type edges: list of tuples
|
|
698
|
+
"""
|
|
699
|
+
for edge in edges:
|
|
700
|
+
if len(edge) < 2:
|
|
701
|
+
raise ValueError(
|
|
702
|
+
f"Edge tuple must have at least 2 elements (source, dest), got {edge}"
|
|
703
|
+
)
|
|
704
|
+
if len(edge) > 3:
|
|
705
|
+
warnings.warn(
|
|
706
|
+
f"Edge tuple has {len(edge)} elements; only first 3 (source, dest, width) "
|
|
707
|
+
f"are used. Extra elements will be ignored: {edge}",
|
|
708
|
+
UserWarning,
|
|
709
|
+
stacklevel=2
|
|
710
|
+
)
|
|
711
|
+
# if incoming tuple contains a weight
|
|
712
|
+
if len(edge) >= 3:
|
|
713
|
+
self.add_edge(edge[0], edge[1], width=edge[2])
|
|
714
|
+
else:
|
|
715
|
+
self.add_edge(edge[0], edge[1])
|
|
716
|
+
|
|
717
|
+
def update_node(self, n_id: Union[str, int], options=None, **kwargs):
|
|
718
|
+
"""
|
|
719
|
+
Update attributes of an existing node.
|
|
720
|
+
|
|
721
|
+
>>> nt = Network()
|
|
722
|
+
>>> nt.add_node(1, label="Old")
|
|
723
|
+
>>> nt.update_node(1, label="New", color="red")
|
|
724
|
+
|
|
725
|
+
:param n_id: The ID of the node to update.
|
|
726
|
+
:param options: Typed NodeOptions instance (optional). When provided,
|
|
727
|
+
kwargs are ignored.
|
|
728
|
+
:param kwargs: Node attributes to update (label, color, size, etc.).
|
|
729
|
+
|
|
730
|
+
:raises ValueError: If the node does not exist or if 'id' is in kwargs.
|
|
731
|
+
"""
|
|
732
|
+
if n_id not in self.node_map:
|
|
733
|
+
raise ValueError(f"Node '{n_id}' not found in network")
|
|
734
|
+
|
|
735
|
+
if options is not None and hasattr(options, 'to_dict'):
|
|
736
|
+
if kwargs:
|
|
737
|
+
warnings.warn(
|
|
738
|
+
"Both options= and **kwargs were provided to update_node(). "
|
|
739
|
+
"When options= is used, kwargs are ignored.",
|
|
740
|
+
UserWarning,
|
|
741
|
+
stacklevel=2,
|
|
742
|
+
)
|
|
743
|
+
attrs = options.to_dict()
|
|
744
|
+
else:
|
|
745
|
+
attrs = kwargs
|
|
746
|
+
|
|
747
|
+
if 'id' in attrs:
|
|
748
|
+
raise ValueError(
|
|
749
|
+
"Cannot change node 'id' via update_node(). "
|
|
750
|
+
"Remove the node and add a new one instead."
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
self.node_map[n_id].update(attrs)
|
|
754
|
+
|
|
755
|
+
def update_edge(self, source: Union[str, int], to: Union[str, int],
|
|
756
|
+
options=None, **kwargs):
|
|
757
|
+
"""
|
|
758
|
+
Update attributes of an existing edge.
|
|
759
|
+
|
|
760
|
+
>>> nt = Network()
|
|
761
|
+
>>> nt.add_node(1, label="A")
|
|
762
|
+
>>> nt.add_node(2, label="B")
|
|
763
|
+
>>> nt.add_edge(1, 2)
|
|
764
|
+
>>> nt.update_edge(1, 2, color="red", width=3)
|
|
765
|
+
|
|
766
|
+
:param source: The source node ID.
|
|
767
|
+
:param to: The destination node ID.
|
|
768
|
+
:param options: Typed EdgeOptions instance (optional). When provided,
|
|
769
|
+
kwargs are ignored.
|
|
770
|
+
:param kwargs: Edge attributes to update (color, width, label, etc.).
|
|
771
|
+
|
|
772
|
+
:raises ValueError: If the edge does not exist or if 'from'/'to' is
|
|
773
|
+
in kwargs.
|
|
774
|
+
"""
|
|
775
|
+
if options is not None and hasattr(options, 'to_dict'):
|
|
776
|
+
if kwargs:
|
|
777
|
+
warnings.warn(
|
|
778
|
+
"Both options= and **kwargs were provided to update_edge(). "
|
|
779
|
+
"When options= is used, kwargs are ignored.",
|
|
780
|
+
UserWarning,
|
|
781
|
+
stacklevel=2,
|
|
782
|
+
)
|
|
783
|
+
attrs = options.to_dict()
|
|
784
|
+
else:
|
|
785
|
+
attrs = kwargs
|
|
786
|
+
|
|
787
|
+
for field in ('from', 'to'):
|
|
788
|
+
if field in attrs:
|
|
789
|
+
raise ValueError(
|
|
790
|
+
f"Cannot change edge '{field}' via update_edge(). "
|
|
791
|
+
"Remove the edge and add a new one instead."
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
for edge in self.edges:
|
|
795
|
+
if self.directed:
|
|
796
|
+
if edge['from'] == source and edge['to'] == to:
|
|
797
|
+
edge.update(attrs)
|
|
798
|
+
self._adj_list_cache = None
|
|
799
|
+
return
|
|
800
|
+
else:
|
|
801
|
+
if ((edge['from'] == source and edge['to'] == to) or
|
|
802
|
+
(edge['from'] == to and edge['to'] == source)):
|
|
803
|
+
edge.update(attrs)
|
|
804
|
+
self._adj_list_cache = None
|
|
805
|
+
return
|
|
806
|
+
|
|
807
|
+
raise ValueError(
|
|
808
|
+
f"Edge ({source}, {to}) not found in network"
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
def remove_node(self, n_id: Union[str, int]):
|
|
812
|
+
"""
|
|
813
|
+
Remove a node and all edges connected to it.
|
|
814
|
+
|
|
815
|
+
>>> nt = Network()
|
|
816
|
+
>>> nt.add_node(1, label="A")
|
|
817
|
+
>>> nt.add_node(2, label="B")
|
|
818
|
+
>>> nt.add_edge(1, 2)
|
|
819
|
+
>>> nt.remove_node(1)
|
|
820
|
+
|
|
821
|
+
:param n_id: The ID of the node to remove.
|
|
822
|
+
|
|
823
|
+
:raises ValueError: If the node does not exist.
|
|
824
|
+
"""
|
|
825
|
+
if n_id not in self.node_map:
|
|
826
|
+
raise ValueError(f"Node '{n_id}' not found in network")
|
|
827
|
+
|
|
828
|
+
del self.node_map[n_id]
|
|
829
|
+
|
|
830
|
+
# Remove all edges connected to this node
|
|
831
|
+
edges_to_keep = []
|
|
832
|
+
for edge in self.edges:
|
|
833
|
+
if edge['from'] == n_id or edge['to'] == n_id:
|
|
834
|
+
# Remove from edge set
|
|
835
|
+
edge_key = _edge_key(edge['from'], edge['to'], self.directed)
|
|
836
|
+
self._edge_set.discard(edge_key)
|
|
837
|
+
else:
|
|
838
|
+
edges_to_keep.append(edge)
|
|
839
|
+
self.edges = edges_to_keep
|
|
840
|
+
|
|
841
|
+
self._adj_list_cache = None
|
|
842
|
+
|
|
843
|
+
def remove_edge(self, source: Union[str, int], to: Union[str, int]):
|
|
844
|
+
"""
|
|
845
|
+
Remove an edge between two nodes.
|
|
846
|
+
|
|
847
|
+
>>> nt = Network()
|
|
848
|
+
>>> nt.add_node(1, label="A")
|
|
849
|
+
>>> nt.add_node(2, label="B")
|
|
850
|
+
>>> nt.add_edge(1, 2)
|
|
851
|
+
>>> nt.remove_edge(1, 2)
|
|
852
|
+
|
|
853
|
+
:param source: The source node ID.
|
|
854
|
+
:param to: The destination node ID.
|
|
855
|
+
|
|
856
|
+
:raises ValueError: If the edge does not exist.
|
|
857
|
+
"""
|
|
858
|
+
for i, edge in enumerate(self.edges):
|
|
859
|
+
if self.directed:
|
|
860
|
+
match = edge['from'] == source and edge['to'] == to
|
|
861
|
+
else:
|
|
862
|
+
match = ((edge['from'] == source and edge['to'] == to) or
|
|
863
|
+
(edge['from'] == to and edge['to'] == source))
|
|
864
|
+
if match:
|
|
865
|
+
self.edges.pop(i)
|
|
866
|
+
edge_key = _edge_key(source, to, self.directed)
|
|
867
|
+
self._edge_set.discard(edge_key)
|
|
868
|
+
self._adj_list_cache = None
|
|
869
|
+
return
|
|
870
|
+
|
|
871
|
+
raise ValueError(
|
|
872
|
+
f"Edge ({source}, {to}) not found in network"
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
def get_network_data(self) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], str, str, str, dict]:
|
|
876
|
+
"""
|
|
877
|
+
Extract relevant information about this network in order to inject into
|
|
878
|
+
a Jinja2 template.
|
|
879
|
+
|
|
880
|
+
Returns:
|
|
881
|
+
nodes (list), edges (list), heading (string), height (
|
|
882
|
+
string), width (string), options (dict)
|
|
883
|
+
|
|
884
|
+
Usage:
|
|
885
|
+
|
|
886
|
+
>>> nodes, edges, heading, height, width, options = net.get_network_data()
|
|
887
|
+
"""
|
|
888
|
+
return (self.nodes, self.edges, self.heading, self.height,
|
|
889
|
+
self.width, self.options)
|
|
890
|
+
|
|
891
|
+
def get_network_json(self) -> dict:
|
|
892
|
+
"""
|
|
893
|
+
Return structured network data as a dictionary for Shiny rendering.
|
|
894
|
+
|
|
895
|
+
Unlike generate_html(), this returns raw data that JavaScript can
|
|
896
|
+
use to create a vis.Network directly — no HTML template involved.
|
|
897
|
+
|
|
898
|
+
Returns:
|
|
899
|
+
dict with keys: nodes, edges, options, heading, height, width,
|
|
900
|
+
groups, legend, neighborhood_highlight, select_menu, filter_menu,
|
|
901
|
+
edge_attribute_edit, directed, bgcolor, highlight_degree,
|
|
902
|
+
select_node_options, filter_exclude, font_color,
|
|
903
|
+
tooltip_link_override
|
|
904
|
+
"""
|
|
905
|
+
nodes, edges, heading, height, width, options = self.get_network_data()
|
|
906
|
+
|
|
907
|
+
return {
|
|
908
|
+
"nodes": copy.deepcopy(nodes),
|
|
909
|
+
"edges": copy.deepcopy(edges),
|
|
910
|
+
"options": copy.deepcopy(options),
|
|
911
|
+
"heading": heading,
|
|
912
|
+
"height": height,
|
|
913
|
+
"width": width,
|
|
914
|
+
"groups": copy.deepcopy(self.groups),
|
|
915
|
+
"legend": copy.deepcopy(self.legend) if self.legend else None,
|
|
916
|
+
"neighborhood_highlight": self.neighborhood_highlight,
|
|
917
|
+
"select_menu": self.select_menu,
|
|
918
|
+
"filter_menu": self.filter_menu,
|
|
919
|
+
"edge_attribute_edit": self.edge_attribute_edit,
|
|
920
|
+
"directed": self.directed,
|
|
921
|
+
"bgcolor": self.bgcolor,
|
|
922
|
+
"highlight_degree": self.highlight_degree,
|
|
923
|
+
"select_node_options": copy.deepcopy(self.select_node_options),
|
|
924
|
+
"filter_exclude": copy.deepcopy(self.filter_exclude),
|
|
925
|
+
"font_color": self.font_color,
|
|
926
|
+
"tooltip_link_override": self.tooltip_link_override,
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
def save_graph(self, name):
|
|
930
|
+
"""
|
|
931
|
+
Save the graph as html in the current directory with name.
|
|
932
|
+
|
|
933
|
+
:param name: the name of the html file to save as
|
|
934
|
+
:type name: str
|
|
935
|
+
"""
|
|
936
|
+
check_html(name)
|
|
937
|
+
self.write_html(name)
|
|
938
|
+
|
|
939
|
+
def generate_html(self, notebook=False):
|
|
940
|
+
"""
|
|
941
|
+
This method gets the data structures supporting the nodes, edges,
|
|
942
|
+
and options and updates the template to write the HTML holding
|
|
943
|
+
the visualization.
|
|
944
|
+
|
|
945
|
+
:param notebook: whether to generate notebook-compatible output
|
|
946
|
+
:type notebook: bool
|
|
947
|
+
"""
|
|
948
|
+
# Tooltip link detection
|
|
949
|
+
if self.tooltip_link_override is not None:
|
|
950
|
+
use_link_template = self.tooltip_link_override
|
|
951
|
+
else:
|
|
952
|
+
use_link_template = False
|
|
953
|
+
for n in self.nodes:
|
|
954
|
+
title = n.get("title", None)
|
|
955
|
+
if isinstance(title, str) and "href" in title:
|
|
956
|
+
use_link_template = True
|
|
957
|
+
break
|
|
958
|
+
if not notebook:
|
|
959
|
+
template = self.templateEnv.get_template(self.path)
|
|
960
|
+
else:
|
|
961
|
+
if self.template is None:
|
|
962
|
+
self.prep_notebook()
|
|
963
|
+
template = self.template
|
|
964
|
+
|
|
965
|
+
nodes, edges, heading, height, width, options = self.get_network_data()
|
|
966
|
+
|
|
967
|
+
# check if physics is enabled
|
|
968
|
+
physics_opt = options.get('physics', True)
|
|
969
|
+
if isinstance(physics_opt, bool):
|
|
970
|
+
physics_enabled = physics_opt
|
|
971
|
+
elif isinstance(physics_opt, dict):
|
|
972
|
+
physics_enabled = physics_opt.get('enabled', True)
|
|
973
|
+
else:
|
|
974
|
+
physics_enabled = True
|
|
975
|
+
|
|
976
|
+
html = template.render(height=height,
|
|
977
|
+
width=width,
|
|
978
|
+
nodes=nodes,
|
|
979
|
+
edges=edges,
|
|
980
|
+
heading=heading,
|
|
981
|
+
options=options,
|
|
982
|
+
physics_enabled=physics_enabled,
|
|
983
|
+
use_DOT=self.use_DOT,
|
|
984
|
+
dot_lang=self.dot_lang,
|
|
985
|
+
widget=self.widget,
|
|
986
|
+
bgcolor=self.bgcolor,
|
|
987
|
+
font_color=self.font_color,
|
|
988
|
+
tooltip_link=use_link_template,
|
|
989
|
+
neighborhood_highlight=self.neighborhood_highlight,
|
|
990
|
+
select_menu=self.select_menu,
|
|
991
|
+
filter_menu=self.filter_menu,
|
|
992
|
+
edge_attribute_edit=self.edge_attribute_edit,
|
|
993
|
+
notebook=notebook,
|
|
994
|
+
cdn_resources=self.cdn_resources,
|
|
995
|
+
vis_version=vis_config.VIS_NETWORK_VERSION,
|
|
996
|
+
vis_lib_dir=vis_config.LOCAL_LIB_DIR,
|
|
997
|
+
vis_css_cdn=vis_config.VIS_CSS_UNPKG,
|
|
998
|
+
vis_js_cdn=vis_config.VIS_JS_UNPKG,
|
|
999
|
+
vis_esm_cdn=vis_config.VIS_ESM_UNPKG,
|
|
1000
|
+
legend=self.legend,
|
|
1001
|
+
groups=self.groups,
|
|
1002
|
+
highlight_degree=self.highlight_degree,
|
|
1003
|
+
select_node_options=self.select_node_options,
|
|
1004
|
+
filter_exclude=self.filter_exclude
|
|
1005
|
+
)
|
|
1006
|
+
return html
|
|
1007
|
+
|
|
1008
|
+
def write_html(self, name, local=True, notebook=False, open_browser=False):
|
|
1009
|
+
"""
|
|
1010
|
+
This method gets the data structures supporting the nodes, edges,
|
|
1011
|
+
and options and updates the template to write the HTML holding
|
|
1012
|
+
the visualization.
|
|
1013
|
+
|
|
1014
|
+
To work with the old local methods local is being deprecated, but not removed.
|
|
1015
|
+
:type name: str
|
|
1016
|
+
@param name: name of the file to save the graph as.
|
|
1017
|
+
@param local: Deprecated parameter. Used to be used to determine how the graph needs deploy. Has been removed in favor of using the class cdn_resources instead.
|
|
1018
|
+
@param notebook: If true, this object will return the iframe document for use in juptyer notebook.
|
|
1019
|
+
@param open_browser: If true, will open a web browser with the generated graph.
|
|
1020
|
+
"""
|
|
1021
|
+
if local is not True:
|
|
1022
|
+
warnings.warn(
|
|
1023
|
+
"The 'local' parameter is deprecated and no longer has any effect. "
|
|
1024
|
+
"Use the 'cdn_resources' class parameter instead.",
|
|
1025
|
+
DeprecationWarning,
|
|
1026
|
+
stacklevel=2
|
|
1027
|
+
)
|
|
1028
|
+
getcwd_name = name
|
|
1029
|
+
check_html(getcwd_name)
|
|
1030
|
+
html = self.generate_html(notebook=notebook)
|
|
1031
|
+
|
|
1032
|
+
if self.cdn_resources == CDN_LOCAL:
|
|
1033
|
+
out_dir = os.path.dirname(os.path.abspath(getcwd_name))
|
|
1034
|
+
lib_root = os.path.join(out_dir, "lib")
|
|
1035
|
+
src_root = os.path.join(os.path.dirname(__file__), "templates", "lib")
|
|
1036
|
+
try:
|
|
1037
|
+
for sub in ("bindings", "tom-select", vis_config.LOCAL_LIB_DIR):
|
|
1038
|
+
shutil.copytree(
|
|
1039
|
+
os.path.join(src_root, sub),
|
|
1040
|
+
os.path.join(lib_root, sub),
|
|
1041
|
+
dirs_exist_ok=True,
|
|
1042
|
+
)
|
|
1043
|
+
except OSError as e:
|
|
1044
|
+
raise OSError(
|
|
1045
|
+
f"Failed to copy pyvis resources: {e}. "
|
|
1046
|
+
"Check directory permissions and disk space."
|
|
1047
|
+
) from e
|
|
1048
|
+
with open(getcwd_name, "w+", encoding="utf-8") as out:
|
|
1049
|
+
out.write(html)
|
|
1050
|
+
elif self.cdn_resources in [CDN_INLINE, CDN_REMOTE, CDN_REMOTE_ESM]:
|
|
1051
|
+
with open(getcwd_name, "w+", encoding="utf-8") as out:
|
|
1052
|
+
out.write(html)
|
|
1053
|
+
else:
|
|
1054
|
+
raise ValueError(
|
|
1055
|
+
f"cdn_resources must be one of {VALID_CDN_RESOURCES}, "
|
|
1056
|
+
f"got '{self.cdn_resources}'"
|
|
1057
|
+
)
|
|
1058
|
+
if open_browser: # open the saved file in a new browser window.
|
|
1059
|
+
webbrowser.open(getcwd_name)
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def show(self, name, local=True, notebook=True):
|
|
1063
|
+
"""
|
|
1064
|
+
Writes a static HTML file and saves it locally before opening.
|
|
1065
|
+
|
|
1066
|
+
:param: name: the name of the html file to save as
|
|
1067
|
+
:type name: str
|
|
1068
|
+
"""
|
|
1069
|
+
if local is not True:
|
|
1070
|
+
warnings.warn(
|
|
1071
|
+
"The 'local' parameter is deprecated and no longer has any effect. "
|
|
1072
|
+
"Use the 'cdn_resources' class parameter instead.",
|
|
1073
|
+
DeprecationWarning,
|
|
1074
|
+
stacklevel=2
|
|
1075
|
+
)
|
|
1076
|
+
if notebook:
|
|
1077
|
+
# Ensure template is loaded for notebook mode
|
|
1078
|
+
if self.template is None:
|
|
1079
|
+
self.prep_notebook()
|
|
1080
|
+
self.write_html(name, open_browser=False, notebook=True)
|
|
1081
|
+
else:
|
|
1082
|
+
self.write_html(name, open_browser=True)
|
|
1083
|
+
if notebook:
|
|
1084
|
+
# Lazy import - only load IPython when needed for notebook mode
|
|
1085
|
+
try:
|
|
1086
|
+
from IPython.display import IFrame
|
|
1087
|
+
except ImportError as e:
|
|
1088
|
+
raise ImportError(
|
|
1089
|
+
"show(notebook=True) needs IPython. "
|
|
1090
|
+
"Install with: pip install 'pyvis-optimized[notebook]'"
|
|
1091
|
+
) from e
|
|
1092
|
+
return IFrame(name, width=self.width, height=self.height)
|
|
1093
|
+
|
|
1094
|
+
def prep_notebook(self,
|
|
1095
|
+
custom_template=False, custom_template_path=None):
|
|
1096
|
+
"""
|
|
1097
|
+
Loads the template data into the template attribute of the network.
|
|
1098
|
+
This should be done in a jupyter notebook environment before showing
|
|
1099
|
+
the network.
|
|
1100
|
+
|
|
1101
|
+
Example:
|
|
1102
|
+
>>> net.prep_notebook()
|
|
1103
|
+
>>> net.show("nb.html")
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
:param custom_template: If True, use a custom template file. Default False.
|
|
1107
|
+
:param custom_template_path: Path to the custom template HTML file.
|
|
1108
|
+
"""
|
|
1109
|
+
if custom_template:
|
|
1110
|
+
if not custom_template_path:
|
|
1111
|
+
raise ValueError(
|
|
1112
|
+
"custom_template=True requires custom_template_path to be set"
|
|
1113
|
+
)
|
|
1114
|
+
self.set_template(custom_template_path)
|
|
1115
|
+
self.template = self.templateEnv.get_template(self.path)
|
|
1116
|
+
|
|
1117
|
+
def set_template(self, path_to_template: str):
|
|
1118
|
+
"""
|
|
1119
|
+
Path to full template assumes that it exists inside of a template directory.
|
|
1120
|
+
Use `set_template_dir` to set the relative template path to the template directory along with the directory location itself
|
|
1121
|
+
to change both values otherwise this function will infer the results.
|
|
1122
|
+
:path_to_template path: full os path string value of the template directory
|
|
1123
|
+
"""
|
|
1124
|
+
# Use os.path for cross-platform compatibility
|
|
1125
|
+
template_dir = os.path.dirname(path_to_template)
|
|
1126
|
+
template_file = os.path.basename(path_to_template)
|
|
1127
|
+
# Ensure directory ends with separator
|
|
1128
|
+
if template_dir and not template_dir.endswith(os.sep):
|
|
1129
|
+
template_dir += os.sep
|
|
1130
|
+
self.set_template_dir(template_dir, template_file)
|
|
1131
|
+
|
|
1132
|
+
def set_template_dir(self, template_directory, template_file='template.html'):
|
|
1133
|
+
"""
|
|
1134
|
+
Path to template directory along with the location of the template file.
|
|
1135
|
+
:template_directory path: template directory
|
|
1136
|
+
:template_file path: name of the template file that is going to be used to generate the html doc.
|
|
1137
|
+
|
|
1138
|
+
"""
|
|
1139
|
+
self.path = template_file
|
|
1140
|
+
self.template_dir = template_directory
|
|
1141
|
+
self.templateEnv = Environment(
|
|
1142
|
+
loader=FileSystemLoader(self.template_dir),
|
|
1143
|
+
autoescape=True,
|
|
1144
|
+
)
|
|
1145
|
+
|
|
1146
|
+
def from_DOT(self, dot):
|
|
1147
|
+
"""
|
|
1148
|
+
This method takes the contents of .DOT file and converts it
|
|
1149
|
+
to a PyVis visualization.
|
|
1150
|
+
|
|
1151
|
+
Assuming the contents of test.dot contains:
|
|
1152
|
+
digraph sample3 {
|
|
1153
|
+
A -> {B ; C ; D}
|
|
1154
|
+
C -> {B ; A}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
Usage:
|
|
1158
|
+
|
|
1159
|
+
>>> nt.Network("500px", "500px")
|
|
1160
|
+
>>> nt.from_DOT("test.dot")
|
|
1161
|
+
>>> nt.show("dot.html")
|
|
1162
|
+
|
|
1163
|
+
:param dot: The path of the dotfile being converted.
|
|
1164
|
+
:type dot: .dot file
|
|
1165
|
+
|
|
1166
|
+
"""
|
|
1167
|
+
if not os.path.isfile(dot):
|
|
1168
|
+
raise FileNotFoundError(f"DOT file not found: {dot!r}")
|
|
1169
|
+
with open(dot, "r", encoding="utf-8") as file:
|
|
1170
|
+
s = file.read()
|
|
1171
|
+
if not s.strip():
|
|
1172
|
+
raise ValueError(f"DOT file is empty: {dot!r}")
|
|
1173
|
+
self.use_DOT = True
|
|
1174
|
+
self.dot_lang = " ".join(s.splitlines())
|
|
1175
|
+
|
|
1176
|
+
def get_adj_list(self) -> Dict[Union[str, int], set]:
|
|
1177
|
+
"""
|
|
1178
|
+
This method returns the user an adjacency list representation
|
|
1179
|
+
of the network. Results are cached for performance.
|
|
1180
|
+
|
|
1181
|
+
:returns: dictionary mapping of Node ID to set of Node IDs it
|
|
1182
|
+
is connected to.
|
|
1183
|
+
"""
|
|
1184
|
+
# Return cached result if available
|
|
1185
|
+
if self._adj_list_cache is not None:
|
|
1186
|
+
return self._adj_list_cache
|
|
1187
|
+
|
|
1188
|
+
# Build adjacency list
|
|
1189
|
+
a_list = {node_id: set() for node_id in self.node_map.keys()}
|
|
1190
|
+
|
|
1191
|
+
if self.directed:
|
|
1192
|
+
for e in self.edges:
|
|
1193
|
+
source = e["from"]
|
|
1194
|
+
dest = e["to"]
|
|
1195
|
+
a_list[source].add(dest)
|
|
1196
|
+
else:
|
|
1197
|
+
for e in self.edges:
|
|
1198
|
+
source = e["from"]
|
|
1199
|
+
dest = e["to"]
|
|
1200
|
+
# Simplified logic for undirected graphs
|
|
1201
|
+
a_list[source].add(dest)
|
|
1202
|
+
a_list[dest].add(source)
|
|
1203
|
+
|
|
1204
|
+
# Cache the result
|
|
1205
|
+
self._adj_list_cache = a_list
|
|
1206
|
+
return a_list
|
|
1207
|
+
|
|
1208
|
+
def neighbors(self, node):
|
|
1209
|
+
"""
|
|
1210
|
+
Given a node id, return the set of neighbors of this particular node.
|
|
1211
|
+
|
|
1212
|
+
:param node: The node to get the neighbors from
|
|
1213
|
+
:type node: str or int
|
|
1214
|
+
|
|
1215
|
+
:returns: set
|
|
1216
|
+
"""
|
|
1217
|
+
if not isinstance(node, (str, int)):
|
|
1218
|
+
raise TypeError(f"error: expected int or str for node but got {type(node)}")
|
|
1219
|
+
if node not in self.node_map:
|
|
1220
|
+
raise ValueError(f"error: {node} node not in network")
|
|
1221
|
+
return self.get_adj_list()[node]
|
|
1222
|
+
|
|
1223
|
+
def from_nx(self, nx_graph, node_size_transf=(lambda x: x), edge_weight_transf=(lambda x: x),
|
|
1224
|
+
default_node_size=10, default_edge_weight=1, edge_scaling=False):
|
|
1225
|
+
"""
|
|
1226
|
+
This method takes an exisitng Networkx graph and translates
|
|
1227
|
+
it to a PyVis graph format that can be accepted by the VisJs
|
|
1228
|
+
API in the Jinja2 template. This operation is done in place.
|
|
1229
|
+
|
|
1230
|
+
:param nx_graph: The Networkx graph object that is to be translated.
|
|
1231
|
+
:type nx_graph: networkx.Graph instance
|
|
1232
|
+
:param node_size_transf: function to transform the node size for plotting
|
|
1233
|
+
:type node_size_transf: func
|
|
1234
|
+
:param edge_weight_transf: function to transform the edge weight for plotting
|
|
1235
|
+
:type edge_weight_transf: func
|
|
1236
|
+
:param default_node_size: default node size if not specified
|
|
1237
|
+
:param default_edge_weight: default edge weight if not specified
|
|
1238
|
+
>>> nx_graph = nx.cycle_graph(10)
|
|
1239
|
+
>>> nx_graph.nodes[1]['title'] = 'Number 1'
|
|
1240
|
+
>>> nx_graph.nodes[1]['group'] = 1
|
|
1241
|
+
>>> nx_graph.nodes[3]['title'] = 'I belong to a different group!'
|
|
1242
|
+
>>> nx_graph.nodes[3]['group'] = 10
|
|
1243
|
+
>>> nx_graph.add_node(20, size=20, title='couple', group=2)
|
|
1244
|
+
>>> nx_graph.add_node(21, size=15, title='couple', group=2)
|
|
1245
|
+
>>> nx_graph.add_edge(20, 21, weight=5)
|
|
1246
|
+
>>> nx_graph.add_node(25, size=25, label='lonely', title='lonely node', group=3)
|
|
1247
|
+
>>> nt = Network("500px", "500px")
|
|
1248
|
+
# populates the nodes and edges data structures
|
|
1249
|
+
>>> nt.from_nx(nx_graph)
|
|
1250
|
+
>>> nt.show("nx.html")
|
|
1251
|
+
"""
|
|
1252
|
+
if not isinstance(nx_graph, nx.Graph):
|
|
1253
|
+
raise TypeError("nx_graph must be a NetworkX Graph instance")
|
|
1254
|
+
edges = nx_graph.edges(data=True)
|
|
1255
|
+
nodes = nx_graph.nodes(data=True)
|
|
1256
|
+
|
|
1257
|
+
# Deep copy node and edge data to avoid mutating the original graph
|
|
1258
|
+
node_data = {n: dict(data) for n, data in nodes}
|
|
1259
|
+
edge_list = [(u, v, dict(data)) for u, v, data in edges]
|
|
1260
|
+
|
|
1261
|
+
# Coerce numpy scalars/numeric types and warn about attributes that
|
|
1262
|
+
# are still not JSON-serializable, removing those.
|
|
1263
|
+
for n, data in node_data.items():
|
|
1264
|
+
for k, v in list(data.items()):
|
|
1265
|
+
try:
|
|
1266
|
+
data[k] = _to_json_native(v)
|
|
1267
|
+
except (TypeError, ValueError):
|
|
1268
|
+
warnings.warn(
|
|
1269
|
+
f"Node {n!r} attribute '{k}' is not JSON-serializable "
|
|
1270
|
+
f"(type: {type(v).__name__}) and was removed.",
|
|
1271
|
+
UserWarning, stacklevel=2
|
|
1272
|
+
)
|
|
1273
|
+
del data[k]
|
|
1274
|
+
for e in edge_list:
|
|
1275
|
+
for k, v in list(e[2].items()):
|
|
1276
|
+
try:
|
|
1277
|
+
e[2][k] = _to_json_native(v)
|
|
1278
|
+
except (TypeError, ValueError):
|
|
1279
|
+
warnings.warn(
|
|
1280
|
+
f"Edge ({e[0]}, {e[1]}) attribute '{k}' is not JSON-serializable "
|
|
1281
|
+
f"(type: {type(v).__name__}) and was removed.",
|
|
1282
|
+
UserWarning, stacklevel=2
|
|
1283
|
+
)
|
|
1284
|
+
del e[2][k]
|
|
1285
|
+
|
|
1286
|
+
if len(edge_list) > 0:
|
|
1287
|
+
processed_nodes = set()
|
|
1288
|
+
for e in edge_list:
|
|
1289
|
+
for node_idx in (0, 1):
|
|
1290
|
+
n = e[node_idx]
|
|
1291
|
+
if n not in processed_nodes:
|
|
1292
|
+
if 'size' not in node_data[n]:
|
|
1293
|
+
node_data[n]['size'] = default_node_size
|
|
1294
|
+
node_data[n]['size'] = float(node_size_transf(node_data[n]['size']))
|
|
1295
|
+
processed_nodes.add(n)
|
|
1296
|
+
attrs = {k: v for k, v in node_data[n].items() if k not in ("options", "n_id")}
|
|
1297
|
+
self.add_node(n, **attrs)
|
|
1298
|
+
|
|
1299
|
+
# Only inject weight when user has provided neither value nor width
|
|
1300
|
+
if "value" not in e[2] and "width" not in e[2]:
|
|
1301
|
+
if edge_scaling:
|
|
1302
|
+
width_type = 'value'
|
|
1303
|
+
else:
|
|
1304
|
+
width_type = 'width'
|
|
1305
|
+
if "weight" not in e[2].keys():
|
|
1306
|
+
e[2]["weight"] = default_edge_weight
|
|
1307
|
+
e[2][width_type] = edge_weight_transf(e[2].pop("weight"))
|
|
1308
|
+
self.add_edge(e[0], e[1], **e[2])
|
|
1309
|
+
|
|
1310
|
+
for node in nx.isolates(nx_graph):
|
|
1311
|
+
data = node_data.get(node, {})
|
|
1312
|
+
if 'size' not in data:
|
|
1313
|
+
data['size'] = default_node_size
|
|
1314
|
+
data['size'] = float(node_size_transf(data['size']))
|
|
1315
|
+
attrs = {k: v for k, v in data.items() if k not in ("options", "n_id")}
|
|
1316
|
+
self.add_node(node, **attrs)
|
|
1317
|
+
|
|
1318
|
+
def get_nodes(self) -> List[Union[str, int]]:
|
|
1319
|
+
"""
|
|
1320
|
+
This method returns an iterable list of node ids
|
|
1321
|
+
|
|
1322
|
+
:returns: list
|
|
1323
|
+
"""
|
|
1324
|
+
return list(self.node_map.keys())
|
|
1325
|
+
|
|
1326
|
+
def get_node(self, n_id) -> Dict[str, Any]:
|
|
1327
|
+
"""
|
|
1328
|
+
Lookup node by ID and return it.
|
|
1329
|
+
|
|
1330
|
+
:param n_id: The ID given to the node.
|
|
1331
|
+
:returns: dict containing node properties
|
|
1332
|
+
:raises KeyError: If the node does not exist.
|
|
1333
|
+
"""
|
|
1334
|
+
if n_id not in self.node_map:
|
|
1335
|
+
raise KeyError(f"Node '{n_id}' not found in network")
|
|
1336
|
+
return self.node_map[n_id]
|
|
1337
|
+
|
|
1338
|
+
def get_edges(self) -> List[Dict[str, Any]]:
|
|
1339
|
+
"""
|
|
1340
|
+
This method returns an iterable list of edge objects
|
|
1341
|
+
|
|
1342
|
+
:returns: list
|
|
1343
|
+
"""
|
|
1344
|
+
return [dict(e) for e in self.edges]
|
|
1345
|
+
|
|
1346
|
+
def to_json(self, max_depth=1, **args):
|
|
1347
|
+
"""
|
|
1348
|
+
Serialize Network to JSON using jsonpickle.
|
|
1349
|
+
|
|
1350
|
+
Uses lazy import to avoid loading jsonpickle unless needed.
|
|
1351
|
+
"""
|
|
1352
|
+
import jsonpickle
|
|
1353
|
+
return jsonpickle.encode(self, max_depth=max_depth, **args)
|
|
1354
|
+
|
|
1355
|
+
def set_options(self, options):
|
|
1356
|
+
"""Set global network options.
|
|
1357
|
+
|
|
1358
|
+
Args:
|
|
1359
|
+
options: NetworkOptions dataclass, dict, or JSON string.
|
|
1360
|
+
"""
|
|
1361
|
+
if hasattr(options, 'to_dict'):
|
|
1362
|
+
self.options = options.to_dict()
|
|
1363
|
+
elif isinstance(options, str):
|
|
1364
|
+
import json as _json
|
|
1365
|
+
try:
|
|
1366
|
+
self.options = _json.loads(options)
|
|
1367
|
+
except _json.JSONDecodeError as e:
|
|
1368
|
+
raise ValueError(
|
|
1369
|
+
f"set_options() received invalid JSON string: {e}"
|
|
1370
|
+
) from e
|
|
1371
|
+
elif isinstance(options, dict):
|
|
1372
|
+
self.options = options
|
|
1373
|
+
else:
|
|
1374
|
+
raise TypeError(
|
|
1375
|
+
f"set_options() expects NetworkOptions, dict, or JSON string, "
|
|
1376
|
+
f"got {type(options).__name__}"
|
|
1377
|
+
)
|
|
1378
|
+
|
|
1379
|
+
def set_group(self, group_name: str, **options):
|
|
1380
|
+
"""
|
|
1381
|
+
Define styling options for a node group. Nodes with the 'group'
|
|
1382
|
+
property set to this group name will inherit these styling options.
|
|
1383
|
+
These group definitions are used by add_legend() when useGroups=True.
|
|
1384
|
+
|
|
1385
|
+
Example:
|
|
1386
|
+
>>> net = Network()
|
|
1387
|
+
>>> net.set_group('servers', color='red', shape='box', icon={'face': 'FontAwesome', 'code': '\\uf233'})
|
|
1388
|
+
>>> net.add_node(1, label='Server 1', group='servers')
|
|
1389
|
+
|
|
1390
|
+
:param group_name: The name of the group
|
|
1391
|
+
:param options: Styling options (color, shape, size, icon, etc.)
|
|
1392
|
+
|
|
1393
|
+
:type group_name: str
|
|
1394
|
+
:type options: dict
|
|
1395
|
+
"""
|
|
1396
|
+
# Validate color if provided
|
|
1397
|
+
if 'color' in options:
|
|
1398
|
+
color = options['color']
|
|
1399
|
+
if isinstance(color, str) and not _CSS_COLOR_RE.match(color):
|
|
1400
|
+
raise ValueError(f"Invalid CSS color for group '{group_name}': {color!r}")
|
|
1401
|
+
self.groups[group_name] = options
|
|
1402
|
+
|
|
1403
|
+
def add_legend(self,
|
|
1404
|
+
enabled: bool = True,
|
|
1405
|
+
use_groups: bool = True,
|
|
1406
|
+
add_nodes: Optional[List[Dict[str, Any]]] = None,
|
|
1407
|
+
add_edges: Optional[List[Dict[str, Any]]] = None,
|
|
1408
|
+
show_nodes: bool = True,
|
|
1409
|
+
show_edges: bool = True,
|
|
1410
|
+
width: float = 0.2,
|
|
1411
|
+
position: str = "left",
|
|
1412
|
+
main: Optional[str] = None,
|
|
1413
|
+
ncol: int = 1,
|
|
1414
|
+
step_x: int = 100,
|
|
1415
|
+
step_y: int = 100,
|
|
1416
|
+
zoom: bool = True):
|
|
1417
|
+
"""
|
|
1418
|
+
Add a legend to the network visualization. Similar to R visNetwork's visLegend().
|
|
1419
|
+
|
|
1420
|
+
The legend displays node groups with their visual properties (colors, shapes, icons)
|
|
1421
|
+
and/or custom node/edge entries.
|
|
1422
|
+
|
|
1423
|
+
Example:
|
|
1424
|
+
>>> net = Network()
|
|
1425
|
+
>>> net.set_group('servers', color='red', shape='box')
|
|
1426
|
+
>>> net.set_group('clients', color='blue', shape='dot')
|
|
1427
|
+
>>> net.add_node(1, label='Server 1', group='servers')
|
|
1428
|
+
>>> net.add_node(2, label='Client 1', group='clients')
|
|
1429
|
+
>>> net.add_legend(main='Network Legend', position='right')
|
|
1430
|
+
|
|
1431
|
+
:param enabled: Enable/disable the legend
|
|
1432
|
+
:param use_groups: Automatically include groups defined with set_group()
|
|
1433
|
+
:param add_nodes: List of custom node entries to add to legend
|
|
1434
|
+
:param add_edges: List of custom edge entries to add to legend
|
|
1435
|
+
:param show_nodes: Show node groups/entries in legend (default True)
|
|
1436
|
+
:param show_edges: Show edge entries in legend (default True)
|
|
1437
|
+
:param width: Legend width as proportion (0-1)
|
|
1438
|
+
:param position: Legend position ('left' or 'right')
|
|
1439
|
+
:param main: Legend title
|
|
1440
|
+
:param ncol: Number of columns for legend layout
|
|
1441
|
+
:param step_x: Horizontal spacing between legend items
|
|
1442
|
+
:param step_y: Vertical spacing between legend items
|
|
1443
|
+
:param zoom: Enable zoom capability for legend
|
|
1444
|
+
|
|
1445
|
+
:type enabled: bool
|
|
1446
|
+
:type use_groups: bool
|
|
1447
|
+
:type add_nodes: list
|
|
1448
|
+
:type add_edges: list
|
|
1449
|
+
:type show_nodes: bool
|
|
1450
|
+
:type show_edges: bool
|
|
1451
|
+
:type width: float
|
|
1452
|
+
:type position: str
|
|
1453
|
+
:type main: str
|
|
1454
|
+
:type ncol: int
|
|
1455
|
+
:type step_x: int
|
|
1456
|
+
:type step_y: int
|
|
1457
|
+
:type zoom: bool
|
|
1458
|
+
"""
|
|
1459
|
+
if width < 0 or width > 1:
|
|
1460
|
+
raise ValueError("width must be between 0 and 1")
|
|
1461
|
+
if position not in ["left", "right"]:
|
|
1462
|
+
raise ValueError("position must be 'left' or 'right'")
|
|
1463
|
+
if ncol < 1:
|
|
1464
|
+
raise ValueError("ncol must be >= 1")
|
|
1465
|
+
|
|
1466
|
+
if add_nodes:
|
|
1467
|
+
for node_entry in add_nodes:
|
|
1468
|
+
if isinstance(node_entry, dict) and 'color' in node_entry:
|
|
1469
|
+
c = node_entry['color']
|
|
1470
|
+
if isinstance(c, str) and not _CSS_COLOR_RE.match(c):
|
|
1471
|
+
raise ValueError(f"Invalid CSS color in legend node: {c!r}")
|
|
1472
|
+
if add_edges:
|
|
1473
|
+
for edge_entry in add_edges:
|
|
1474
|
+
if isinstance(edge_entry, dict):
|
|
1475
|
+
if 'color' in edge_entry:
|
|
1476
|
+
c = edge_entry['color']
|
|
1477
|
+
if isinstance(c, str) and not _CSS_COLOR_RE.match(c):
|
|
1478
|
+
raise ValueError(f"Invalid CSS color in legend edge: {c!r}")
|
|
1479
|
+
if 'width' in edge_entry:
|
|
1480
|
+
w = edge_entry['width']
|
|
1481
|
+
if not isinstance(w, (int, float)) or w < 0:
|
|
1482
|
+
raise ValueError(f"Invalid width in legend edge: {w!r}")
|
|
1483
|
+
|
|
1484
|
+
self.legend = {
|
|
1485
|
+
'enabled': enabled,
|
|
1486
|
+
'useGroups': use_groups,
|
|
1487
|
+
'addNodes': add_nodes or [],
|
|
1488
|
+
'addEdges': add_edges or [],
|
|
1489
|
+
'showNodes': show_nodes,
|
|
1490
|
+
'showEdges': show_edges,
|
|
1491
|
+
'width': width,
|
|
1492
|
+
'position': position,
|
|
1493
|
+
'main': main,
|
|
1494
|
+
'ncol': ncol,
|
|
1495
|
+
'stepX': step_x,
|
|
1496
|
+
'stepY': step_y,
|
|
1497
|
+
'zoom': zoom
|
|
1498
|
+
}
|