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/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
+ }