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/shiny/wrapper.py ADDED
@@ -0,0 +1,1368 @@
1
+ """
2
+ PyVis Shiny Integration Module
3
+
4
+ This module provides enhanced integration between PyVis (vis.js) networks
5
+ and Shiny for Python applications, including:
6
+
7
+ - Custom output binding with proper Shiny integration
8
+ - Two-way communication (network events sent to Shiny inputs)
9
+ - Easy-to-use render decorator
10
+ - Python functions to control the network (selection, viewport, physics)
11
+ - Reusable Shiny module for network visualization
12
+
13
+ Basic Usage:
14
+ -----------
15
+ ```python
16
+ from shiny import App, ui, render
17
+ from pyvis.shiny import output_pyvis_network, render_pyvis_network
18
+ from pyvis.network import Network
19
+
20
+ app_ui = ui.page_fluid(
21
+ output_pyvis_network("my_network", height="600px")
22
+ )
23
+
24
+ def server(input, output, session):
25
+ @render_pyvis_network
26
+ def my_network():
27
+ net = Network()
28
+ net.add_node(1, label="Node 1")
29
+ net.add_node(2, label="Node 2")
30
+ net.add_edge(1, 2)
31
+ return net
32
+
33
+ app = App(app_ui, server)
34
+ ```
35
+
36
+ Event Handling:
37
+ --------------
38
+ The network automatically sends events to Shiny inputs:
39
+ - `input.{output_id}_click` - When network is clicked
40
+ - `input.{output_id}_selectNode` - When a node is selected
41
+ - `input.{output_id}_selectEdge` - When an edge is selected
42
+ - `input.{output_id}_deselectNode` - When a node is deselected
43
+ - `input.{output_id}_deselectEdge` - When an edge is deselected
44
+ - `input.{output_id}_doubleClick` - When network is double-clicked
45
+ - `input.{output_id}_contextMenu` - When right-clicked
46
+ - `input.{output_id}_hoverNode` - When hovering over a node
47
+ - `input.{output_id}_blurNode` - When hover leaves a node
48
+ - `input.{output_id}_hoverEdge` - When hovering over an edge
49
+ - `input.{output_id}_blurEdge` - When hover leaves an edge
50
+ - `input.{output_id}_dragStart` - When dragging starts
51
+ - `input.{output_id}_dragEnd` - When dragging ends (includes positions)
52
+ - `input.{output_id}_zoom` - When zooming
53
+ - `input.{output_id}_stabilizationProgress` - Physics stabilization progress
54
+ - `input.{output_id}_stabilized` - When physics stabilization finishes
55
+ - `input.{output_id}_ready` - When network is fully initialized
56
+ - `input.{output_id}_animationFinished` - When animation completes
57
+ - `input.{output_id}_configChange` - When configurator changes options (requires configure widget enabled)
58
+
59
+ Network Control (from Python):
60
+ -----------------------------
61
+ Use PyVisNetworkController to send commands to the network:
62
+
63
+ ```python
64
+ from pyvis.shiny import PyVisNetworkController
65
+
66
+ def server(input, output, session):
67
+ # Create controller for the network
68
+ net_ctrl = PyVisNetworkController("my_network", session)
69
+
70
+ @reactive.effect
71
+ @reactive.event(input.fit_btn)
72
+ def fit_network():
73
+ net_ctrl.fit()
74
+
75
+ @reactive.effect
76
+ @reactive.event(input.select_btn)
77
+ def select_nodes():
78
+ net_ctrl.select_nodes([1, 2, 3])
79
+ ```
80
+ """
81
+
82
+ import json as _json
83
+ import logging as _logging
84
+ import os
85
+ import re as _re
86
+ from pathlib import Path
87
+ from typing import Optional, Dict, Any, Union, List, Callable
88
+
89
+ try:
90
+ from pyvis._version import __version__ as _PYVIS_VERSION
91
+ except ImportError:
92
+ _PYVIS_VERSION = "0.0.0"
93
+
94
+ _CSS_DIM_RE = _re.compile(r'^\d+(\.\d+)?(px|%|em|rem|vh|vw)$')
95
+
96
+
97
+ def _validate_css_dim(value: str, name: str) -> str:
98
+ """Validate a CSS dimension value to prevent CSS injection."""
99
+ if isinstance(value, (int, float)):
100
+ value = f"{value}px"
101
+ elif isinstance(value, str):
102
+ try:
103
+ float(value)
104
+ value = f"{value}px"
105
+ except ValueError:
106
+ pass
107
+ if not isinstance(value, str) or not _CSS_DIM_RE.match(value):
108
+ raise ValueError(f"Invalid CSS dimension for {name}: {value!r}")
109
+ return value
110
+
111
+ try:
112
+ from shiny import ui, render, reactive, module
113
+ from shiny.module import resolve_id
114
+ from shiny.render.renderer import Renderer, Jsonifiable
115
+ from shiny.session import Session
116
+ from htmltools import HTMLDependency, Tag
117
+ SHINY_AVAILABLE = True
118
+ except ImportError:
119
+ SHINY_AVAILABLE = False
120
+ ui = None
121
+ render = None
122
+ reactive = None
123
+ module = None
124
+ resolve_id = lambda x: x
125
+ Renderer = object
126
+ Jsonifiable = dict
127
+ Session = object
128
+ HTMLDependency = None
129
+ Tag = None
130
+
131
+
132
+ from ..types.base import OptionsBase
133
+
134
+ __all__ = [
135
+ 'render_network',
136
+ 'output_pyvis_network',
137
+ 'render_pyvis_network',
138
+ 'pyvis_network_ui',
139
+ 'pyvis_network_server',
140
+ 'PyVisNetworkController',
141
+ 'network_select_nodes',
142
+ 'network_select_edges',
143
+ 'network_unselect_all',
144
+ 'network_fit',
145
+ 'network_focus',
146
+ 'network_move_to',
147
+ 'network_start_physics',
148
+ 'network_stop_physics',
149
+ 'network_stabilize',
150
+ 'network_add_node',
151
+ 'network_add_edge',
152
+ 'network_update_node',
153
+ 'network_update_edge',
154
+ 'network_remove_node',
155
+ 'network_remove_edge',
156
+ 'network_open_cluster',
157
+ 'network_set_options',
158
+ 'network_set_theme',
159
+ 'network_toggle_manipulation',
160
+ 'network_set_edge_edit_mode',
161
+ 'network_set_node_template_mode',
162
+ 'network_get_positions',
163
+ 'network_get_selection',
164
+ 'network_add_nodes',
165
+ 'network_add_edges',
166
+ 'network_cluster_by_connection',
167
+ 'network_cluster_by_hubsize',
168
+ 'network_get_scale',
169
+ 'network_get_view_position',
170
+ 'network_get_all_data',
171
+ 'network_get_data',
172
+ 'network_update_data',
173
+ ]
174
+
175
+
176
+ # Get path to bindings.js
177
+ _BINDINGS_PATH = Path(__file__).parent
178
+
179
+ _logger = _logging.getLogger("pyvis.shiny")
180
+
181
+
182
+ def _log_task_exception(task):
183
+ """Callback for asyncio tasks to log exceptions that would otherwise be silently lost."""
184
+ if not task.cancelled() and task.exception() is not None:
185
+ _logger.error(
186
+ "PyVis command failed: %s",
187
+ task.exception(),
188
+ exc_info=task.exception(),
189
+ )
190
+
191
+
192
+ def _get_pyvis_dependency() -> 'List[HTMLDependency]':
193
+ """Create HTMLDependencies for vis-network and PyVis Shiny bindings.
194
+
195
+ Returns a list of two HTMLDependency objects:
196
+ - vis-network: the vis.js library (JS + CSS)
197
+ - pyvis-shiny: our custom output binding and styles
198
+ """
199
+ if not SHINY_AVAILABLE:
200
+ raise ImportError("Shiny is required for this functionality")
201
+
202
+ from pyvis.vis_config import VIS_NETWORK_VERSION, LOCAL_LIB_DIR
203
+
204
+ vis_lib_path = Path(__file__).resolve().parent.parent / "templates" / "lib" / LOCAL_LIB_DIR
205
+
206
+ return [
207
+ HTMLDependency(
208
+ name="vis-network",
209
+ version=VIS_NETWORK_VERSION,
210
+ source={"subdir": str(vis_lib_path)},
211
+ script={"src": "vis-network.min.js"},
212
+ stylesheet={"href": "vis-network.min.css"},
213
+ ),
214
+ HTMLDependency(
215
+ name="pyvis-shiny",
216
+ version=_PYVIS_VERSION,
217
+ source={"subdir": str(_BINDINGS_PATH)},
218
+ script={"src": "bindings.js"},
219
+ stylesheet={"href": "styles.css"},
220
+ ),
221
+ ]
222
+
223
+
224
+ def render_network(network: 'PyVisNetwork', height: str = "600px", width: str = "100%") -> 'Tag':
225
+ """
226
+ Renders a Pyvis network as a Shiny UI element (iframe).
227
+
228
+ This is the simple/legacy approach using just an iframe with srcdoc.
229
+ For better Shiny integration with event handling, use output_pyvis_network
230
+ and render_pyvis_network instead.
231
+
232
+ Args:
233
+ network: The Pyvis Network instance.
234
+ height: Height of the iframe (default: "600px").
235
+ width: Width of the iframe (default: "100%").
236
+
237
+ Returns:
238
+ A shiny.ui.tags.iframe element containing the network.
239
+
240
+ Example:
241
+ ```python
242
+ from shiny import App, ui, render
243
+ from pyvis.network import Network
244
+ from pyvis.shiny import render_network
245
+
246
+ def server(input, output, session):
247
+ @render.ui
248
+ def my_network():
249
+ net = Network()
250
+ net.add_nodes([1, 2, 3])
251
+ net.add_edge(1, 2)
252
+ return render_network(net)
253
+ ```
254
+ """
255
+ if not SHINY_AVAILABLE:
256
+ raise ImportError(
257
+ "The 'shiny' package is required to use this function. "
258
+ "Please install it with 'pip install shiny'."
259
+ )
260
+
261
+ height = _validate_css_dim(height, "height")
262
+ width = _validate_css_dim(width, "width")
263
+
264
+ # Import constants for CDN comparison
265
+ from pyvis.network import CDN_LOCAL, CDN_INLINE
266
+
267
+ # Ensure resources are compatible with iframe (inline or remote).
268
+ # Deep copy to avoid mutating the original and to prevent races
269
+ # if another coroutine modifies the network concurrently.
270
+ if network.cdn_resources == CDN_LOCAL:
271
+ import copy
272
+ net_copy = copy.deepcopy(network)
273
+ net_copy.cdn_resources = CDN_INLINE
274
+ html_content = net_copy.generate_html()
275
+ else:
276
+ html_content = network.generate_html()
277
+
278
+ return ui.tags.iframe(
279
+ srcdoc=html_content,
280
+ style=f"width:{width}; height:{height}; border:none;",
281
+ width=width,
282
+ height=height
283
+ )
284
+
285
+
286
+ def output_pyvis_network(
287
+ id: str,
288
+ height: str = "600px",
289
+ width: str = "100%",
290
+ theme: str = "light",
291
+ show_toolbar: bool = True,
292
+ show_search: bool = True,
293
+ show_layout_switcher: bool = True,
294
+ show_export: bool = True,
295
+ show_status: bool = True,
296
+ fill: bool = False,
297
+ events: Optional[List[str]] = None,
298
+ **kwargs
299
+ ) -> 'Tag':
300
+ """
301
+ Create a PyVis network output placeholder for Shiny.
302
+
303
+ This creates a container that will be populated by render_pyvis_network.
304
+ Events from the network (clicks, selections) are automatically sent
305
+ to Shiny inputs.
306
+
307
+ Args:
308
+ id: The output ID. Must match the function name decorated with
309
+ @render_pyvis_network.
310
+ height: Height of the network container (default: "600px").
311
+ width: Width of the network container (default: "100%").
312
+ theme: Color theme, "light" or "dark" (default: "light").
313
+ show_toolbar: Show the toolbar panel (default: True).
314
+ show_search: Show node search input (default: True).
315
+ show_layout_switcher: Show layout toggle (default: True).
316
+ show_export: Show export button (default: True).
317
+ show_status: Show status bar (default: True).
318
+ fill: Whether the container should fill its parent (default: False).
319
+ events: List of event names to bind, or None for all (default: None).
320
+ **kwargs: Additional attributes to pass to the container div.
321
+
322
+ Returns:
323
+ A Shiny UI element for the network output.
324
+
325
+ Example:
326
+ ```python
327
+ app_ui = ui.page_fluid(
328
+ output_pyvis_network("my_network", height="500px"),
329
+ ui.output_text_verbatim("selected_node")
330
+ )
331
+ ```
332
+ """
333
+ if not SHINY_AVAILABLE:
334
+ raise ImportError(
335
+ "The 'shiny' package is required. Install with 'pip install shiny'."
336
+ )
337
+
338
+ height = _validate_css_dim(height, "height")
339
+ width = _validate_css_dim(width, "width")
340
+
341
+ resolved_id = resolve_id(id)
342
+
343
+ config = {
344
+ "theme": theme,
345
+ "showToolbar": show_toolbar,
346
+ "showSearch": show_search,
347
+ "showLayoutSwitcher": show_layout_switcher,
348
+ "showExport": show_export,
349
+ "showStatus": show_status,
350
+ "fill": fill,
351
+ "events": events,
352
+ }
353
+
354
+ # data-pyvis-config carries the base configuration read by the JS binding.
355
+ # The render payload from transform() only carries the options explicitly
356
+ # set on the renderer (it is {} by default), and those override this base.
357
+ return ui.div(
358
+ *_get_pyvis_dependency(),
359
+ id=resolved_id,
360
+ class_="pyvis-network-output shiny-html-output",
361
+ style=f"width: {width}; height: {height}; min-height: 200px;",
362
+ data_pyvis_config=_json.dumps(config),
363
+ **kwargs
364
+ )
365
+
366
+
367
+ if SHINY_AVAILABLE:
368
+ from pyvis.network import Network as PyVisNetwork
369
+
370
+ class render_pyvis_network(Renderer[PyVisNetwork]):
371
+ """
372
+ Decorator to render a PyVis Network in Shiny.
373
+
374
+ The decorated function should return a PyVis Network instance.
375
+ The network will be rendered with event bindings that send
376
+ click/selection events back to Shiny.
377
+
378
+ Args:
379
+ height: Height of the rendered network (default: "600px").
380
+ width: Width of the rendered network (default: "100%").
381
+ theme: Color theme, "light" or "dark" (default: None, inherits
382
+ from ``output_pyvis_network``).
383
+ show_toolbar: Show the toolbar panel (default: None, inherits).
384
+ show_search: Show node search input (default: None, inherits).
385
+ show_layout_switcher: Show layout toggle (default: None, inherits).
386
+ show_export: Show export button (default: None, inherits).
387
+ show_status: Show status bar (default: None, inherits).
388
+ fill: Whether the container should fill its parent (default: None,
389
+ inherits).
390
+ events: List of event names to bind, or None for all (default: None).
391
+
392
+ Unset (``None``) options are omitted from the payload so the values
393
+ already encoded in the output container's ``data-pyvis-config``
394
+ attribute apply.
395
+
396
+ Example:
397
+ ```python
398
+ @render_pyvis_network
399
+ def my_network():
400
+ net = Network()
401
+ net.add_node(1, label="Node 1")
402
+ net.add_node(2, label="Node 2")
403
+ net.add_edge(1, 2)
404
+ return net
405
+
406
+ # Or with custom dimensions:
407
+ @render_pyvis_network(height="800px")
408
+ def my_network():
409
+ ...
410
+ ```
411
+ """
412
+
413
+ height: str = "600px"
414
+ width: str = "100%"
415
+
416
+ def __init__(
417
+ self,
418
+ _fn=None,
419
+ *,
420
+ height: str = "600px",
421
+ width: str = "100%",
422
+ theme: Optional[str] = None,
423
+ show_toolbar: Optional[bool] = None,
424
+ show_search: Optional[bool] = None,
425
+ show_layout_switcher: Optional[bool] = None,
426
+ show_export: Optional[bool] = None,
427
+ show_status: Optional[bool] = None,
428
+ fill: Optional[bool] = None,
429
+ events: Optional[List[str]] = None,
430
+ ):
431
+ self.height = height
432
+ self.width = width
433
+ self.theme = theme
434
+ self.show_toolbar = show_toolbar
435
+ self.show_search = show_search
436
+ self.show_layout_switcher = show_layout_switcher
437
+ self.show_export = show_export
438
+ self.show_status = show_status
439
+ self.fill = fill
440
+ self.events = events
441
+ super().__init__(_fn)
442
+
443
+ def auto_output_ui(self, id: str = "") -> 'Tag':
444
+ """Generate the output UI for Shiny Express mode."""
445
+ overrides = {
446
+ "theme": self.theme,
447
+ "show_toolbar": self.show_toolbar,
448
+ "show_search": self.show_search,
449
+ "show_layout_switcher": self.show_layout_switcher,
450
+ "show_export": self.show_export,
451
+ "show_status": self.show_status,
452
+ "fill": self.fill,
453
+ "events": self.events,
454
+ }
455
+ return output_pyvis_network(
456
+ id or self.output_id,
457
+ height=self.height,
458
+ width=self.width,
459
+ **{k: v for k, v in overrides.items() if v is not None},
460
+ )
461
+
462
+ async def transform(self, value: PyVisNetwork) -> Jsonifiable:
463
+ """
464
+ Transform a PyVis Network into JSON-serializable data.
465
+
466
+ Returns the network data as a dict (nodes, edges, options, etc.)
467
+ along with a ``config`` key containing UI configuration. The
468
+ JavaScript output binding uses this to create a vis.Network
469
+ directly in the DOM (no iframe).
470
+ """
471
+ if value is None:
472
+ return None
473
+
474
+ if not isinstance(value, PyVisNetwork):
475
+ raise TypeError(
476
+ f"Expected a pyvis.network.Network, got {type(value).__name__}. "
477
+ "Make sure your render function returns a Network instance."
478
+ )
479
+
480
+ data = value.get_network_json()
481
+
482
+ # Dimensions belong to the output container (set by
483
+ # output_pyvis_network), not to the rendered payload.
484
+ data.pop("height", None)
485
+ data.pop("width", None)
486
+
487
+ # Only send keys the user actually set, so the container's
488
+ # data-pyvis-config attribute can supply the rest.
489
+ data["config"] = {k: v for k, v in {
490
+ "theme": self.theme,
491
+ "showToolbar": self.show_toolbar,
492
+ "showSearch": self.show_search,
493
+ "showLayoutSwitcher": self.show_layout_switcher,
494
+ "showExport": self.show_export,
495
+ "showStatus": self.show_status,
496
+ "fill": self.fill,
497
+ "events": self.events,
498
+ }.items() if v is not None}
499
+
500
+ return data
501
+
502
+ else:
503
+ # Fallback when Shiny is not available
504
+ class render_pyvis_network:
505
+ """Placeholder when Shiny is not installed."""
506
+ def __init__(self, *args, **kwargs):
507
+ raise ImportError(
508
+ "The 'shiny' package is required. Install with 'pip install shiny'."
509
+ )
510
+
511
+
512
+ # =============================================================================
513
+ # Network Controller - Send commands from Python to vis.js network
514
+ # =============================================================================
515
+
516
+ if SHINY_AVAILABLE:
517
+
518
+ class PyVisNetworkController:
519
+ """
520
+ Controller for sending commands to a PyVis network from Python.
521
+
522
+ This class provides methods to control the vis.js network from
523
+ the Shiny server, including selection, viewport control, physics,
524
+ data manipulation, and clustering.
525
+
526
+ Example:
527
+ ```python
528
+ def server(input, output, session):
529
+ # Create controller
530
+ ctrl = PyVisNetworkController("my_network", session)
531
+
532
+ # Select nodes when button is clicked
533
+ @reactive.effect
534
+ @reactive.event(input.select_btn)
535
+ def _():
536
+ ctrl.select_nodes([1, 2, 3])
537
+
538
+ # Fit network to view
539
+ @reactive.effect
540
+ @reactive.event(input.fit_btn)
541
+ def _():
542
+ ctrl.fit()
543
+ ```
544
+ """
545
+
546
+ def __init__(self, output_id: str, session: 'Session'):
547
+ """
548
+ Initialize the controller.
549
+
550
+ Args:
551
+ output_id: The ID of the network output (same as used in
552
+ output_pyvis_network and @render_pyvis_network)
553
+ session: The Shiny session object
554
+ """
555
+ self.output_id = resolve_id(output_id)
556
+ self.session = session
557
+
558
+ # === Selection Methods ===
559
+
560
+ def select_nodes(
561
+ self,
562
+ node_ids: List[Any],
563
+ highlight_edges: bool = True
564
+ ):
565
+ """
566
+ Select nodes by their IDs.
567
+
568
+ Args:
569
+ node_ids: List of node IDs to select
570
+ highlight_edges: Whether to highlight connected edges
571
+ """
572
+ network_select_nodes(self.session, self.output_id, node_ids, highlight_edges)
573
+
574
+ def select_edges(self, edge_ids: List[Any]):
575
+ """
576
+ Select edges by their IDs.
577
+
578
+ Args:
579
+ edge_ids: List of edge IDs to select
580
+ """
581
+ network_select_edges(self.session, self.output_id, edge_ids)
582
+
583
+ def unselect_all(self):
584
+ """Clear all selections."""
585
+ network_unselect_all(self.session, self.output_id)
586
+
587
+ # === Viewport Methods ===
588
+
589
+ def fit(
590
+ self,
591
+ node_ids: Optional[List[Any]] = None,
592
+ animation: Union[bool, Dict] = True
593
+ ):
594
+ """
595
+ Zoom to fit the network or specific nodes in view.
596
+
597
+ Args:
598
+ node_ids: Optional list of node IDs to focus on
599
+ animation: True for default animation, False for instant,
600
+ or dict with duration/easingFunction
601
+ """
602
+ network_fit(self.session, self.output_id, node_ids, animation)
603
+
604
+ def focus(
605
+ self,
606
+ node_id: Any,
607
+ scale: float = 1.0,
608
+ animation: Union[bool, Dict] = True,
609
+ locked: bool = True
610
+ ):
611
+ """
612
+ Focus the camera on a specific node.
613
+
614
+ Args:
615
+ node_id: ID of the node to focus on
616
+ scale: Zoom scale (1.0 = default)
617
+ animation: Animation settings
618
+ locked: Whether to lock view to this node
619
+ """
620
+ network_focus(self.session, self.output_id, node_id, scale, animation, locked)
621
+
622
+ def move_to(
623
+ self,
624
+ position: Optional[Dict[str, float]] = None,
625
+ scale: Optional[float] = None,
626
+ animation: Union[bool, Dict] = True
627
+ ):
628
+ """
629
+ Move the camera to a specific position.
630
+
631
+ Args:
632
+ position: Dict with 'x' and 'y' coordinates
633
+ scale: Zoom scale
634
+ animation: Animation settings
635
+ """
636
+ network_move_to(self.session, self.output_id, position, scale, animation)
637
+
638
+ # === Physics Methods ===
639
+
640
+ def start_physics(self):
641
+ """Start the physics simulation."""
642
+ network_start_physics(self.session, self.output_id)
643
+
644
+ def stop_physics(self):
645
+ """Stop the physics simulation."""
646
+ network_stop_physics(self.session, self.output_id)
647
+
648
+ def stabilize(self, iterations: int = 100):
649
+ """
650
+ Run physics simulation until stabilized.
651
+
652
+ Args:
653
+ iterations: Maximum iterations to run
654
+ """
655
+ network_stabilize(self.session, self.output_id, iterations)
656
+
657
+ # === Data Manipulation Methods ===
658
+
659
+ def add_node(self, node):
660
+ """Add a new node. Accepts dict or typed NodeOptions (must include 'id' in dict, or set id separately).
661
+
662
+ Args:
663
+ node: Node data dict or typed NodeOptions with at least 'id',
664
+ optionally 'label', 'color', 'shape', etc.
665
+ """
666
+ network_add_node(self.session, self.output_id, node)
667
+
668
+ def add_nodes(self, nodes):
669
+ """Add multiple nodes. Accepts list of dicts or typed NodeOptions.
670
+
671
+ Args:
672
+ nodes: List of node data dicts or typed NodeOptions
673
+ """
674
+ network_add_nodes(self.session, self.output_id, nodes)
675
+
676
+ def update_node(self, node):
677
+ """Update an existing node. Accepts dict or typed NodeOptions.
678
+
679
+ Args:
680
+ node: Node data dict or typed NodeOptions with 'id' and properties to update
681
+ """
682
+ network_update_node(self.session, self.output_id, node)
683
+
684
+ def remove_node(self, node_id: Any):
685
+ """
686
+ Remove a node from the network.
687
+
688
+ Args:
689
+ node_id: ID of the node to remove
690
+ """
691
+ network_remove_node(self.session, self.output_id, node_id)
692
+
693
+ def add_edge(self, edge):
694
+ """Add a new edge. Accepts dict or typed EdgeOptions.
695
+
696
+ Args:
697
+ edge: Edge data dict or typed EdgeOptions with 'from', 'to',
698
+ and optionally 'id', 'label', 'color', etc.
699
+ """
700
+ network_add_edge(self.session, self.output_id, edge)
701
+
702
+ def add_edges(self, edges):
703
+ """Add multiple edges. Accepts list of dicts or typed EdgeOptions.
704
+
705
+ Args:
706
+ edges: List of edge data dicts or typed EdgeOptions
707
+ """
708
+ network_add_edges(self.session, self.output_id, edges)
709
+
710
+ def update_edge(self, edge):
711
+ """Update an existing edge. Accepts dict or typed EdgeOptions.
712
+
713
+ Args:
714
+ edge: Edge data dict or typed EdgeOptions with 'id' and properties to update
715
+ """
716
+ network_update_edge(self.session, self.output_id, edge)
717
+
718
+ def remove_edge(self, edge_id: Any):
719
+ """
720
+ Remove an edge from the network.
721
+
722
+ Args:
723
+ edge_id: ID of the edge to remove
724
+ """
725
+ network_remove_edge(self.session, self.output_id, edge_id)
726
+
727
+ # === Clustering Methods ===
728
+
729
+ def cluster_by_connection(
730
+ self,
731
+ node_id: Any,
732
+ cluster_node_properties: Optional[Dict] = None
733
+ ):
734
+ """
735
+ Cluster all nodes connected to a specific node.
736
+
737
+ Args:
738
+ node_id: ID of the node to cluster around
739
+ cluster_node_properties: Properties for the cluster node
740
+ """
741
+ network_cluster_by_connection(self.session, self.output_id, node_id,
742
+ cluster_node_properties)
743
+
744
+ def cluster_by_hubsize(
745
+ self,
746
+ hubsize: Optional[int] = None,
747
+ cluster_node_properties: Optional[Dict] = None
748
+ ):
749
+ """
750
+ Cluster all nodes with more than hubsize connections.
751
+
752
+ Args:
753
+ hubsize: Minimum connection count to cluster (default: auto)
754
+ cluster_node_properties: Properties for cluster nodes
755
+ """
756
+ network_cluster_by_hubsize(self.session, self.output_id, hubsize,
757
+ cluster_node_properties)
758
+
759
+ def open_cluster(self, cluster_node_id: Any):
760
+ """
761
+ Open (expand) a cluster node.
762
+
763
+ Args:
764
+ cluster_node_id: ID of the cluster node to open
765
+ """
766
+ network_open_cluster(self.session, self.output_id, cluster_node_id)
767
+
768
+ # === Options Methods ===
769
+
770
+ def set_options(self, options):
771
+ """Update network options. Accepts dict or typed NetworkOptions.
772
+
773
+ Args:
774
+ options: vis.js network options dict or typed NetworkOptions
775
+ """
776
+ network_set_options(self.session, self.output_id, options)
777
+
778
+ # === Query Methods (responses come back as inputs) ===
779
+
780
+ def get_positions(self, node_ids: Optional[List[Any]] = None):
781
+ """
782
+ Request node positions.
783
+ Response available at: input.{output_id}_response_positions
784
+
785
+ Args:
786
+ node_ids: Optional list of node IDs (default: all nodes)
787
+ """
788
+ network_get_positions(self.session, self.output_id, node_ids)
789
+
790
+ def get_selection(self):
791
+ """
792
+ Request current selection.
793
+ Response available at: input.{output_id}_response_selection
794
+ """
795
+ network_get_selection(self.session, self.output_id)
796
+
797
+ def get_scale(self):
798
+ """
799
+ Request current zoom scale.
800
+ Response available at: input.{output_id}_response_scale
801
+ """
802
+ network_get_scale(self.session, self.output_id)
803
+
804
+ def get_view_position(self):
805
+ """
806
+ Request current view position.
807
+ Response available at: input.{output_id}_response_viewPosition
808
+ """
809
+ network_get_view_position(self.session, self.output_id)
810
+
811
+ def get_all_data(self):
812
+ """
813
+ Request all network data (nodes, edges, positions, view).
814
+ Response available at: input.{output_id}_response_allData
815
+ """
816
+ network_get_all_data(self.session, self.output_id)
817
+
818
+ # === Theme ===
819
+
820
+ def set_theme(self, theme: str):
821
+ """Switch the network container theme without re-rendering.
822
+
823
+ Args:
824
+ theme: "light" or "dark"
825
+ """
826
+ network_set_theme(self.session, self.output_id, theme)
827
+
828
+ # === Manipulation ===
829
+
830
+ def toggle_manipulation(self, enabled: bool) -> None:
831
+ """Show or hide the native manipulation toolbar.
832
+
833
+ Uses CSS display toggling to preserve the toolbar DOM and avoid
834
+ vis.js rebuild issues. Requires ManipulationOptions(enabled=True).
835
+
836
+ Args:
837
+ enabled: True to show the toolbar, False to hide it.
838
+ """
839
+ network_toggle_manipulation(self.session, self.output_id, enabled)
840
+
841
+ def set_edge_edit_mode(self, mode: str) -> None:
842
+ """Switch edge editing between attribute modal and link reconnection.
843
+
844
+ Args:
845
+ mode: "attributes" to edit edge properties (color, width, dashes,
846
+ arrows) via a modal, or "links" to reconnect edge endpoints
847
+ (from/to nodes) via a dropdown modal.
848
+ """
849
+ network_set_edge_edit_mode(self.session, self.output_id, mode)
850
+
851
+ def set_node_template_mode(self, enabled: bool) -> None:
852
+ """Toggle template-from-existing mode for the Add Node modal.
853
+
854
+ When enabled, the Add Node modal displays clickable chips for each
855
+ unique shape+color+size combination found in the current graph.
856
+ Clicking a chip pre-fills the Color, Shape, and Size fields.
857
+
858
+ Args:
859
+ enabled: True to show template chips, False for default behavior.
860
+ """
861
+ network_set_node_template_mode(self.session, self.output_id, enabled)
862
+
863
+ # === Diff-based Update ===
864
+
865
+ def update_data(self, nodes: List[Dict[str, Any]], edges: List[Dict[str, Any]]):
866
+ """Send complete node/edge data for diff-based update.
867
+
868
+ The JavaScript side will diff the incoming data against the current
869
+ DataSet and apply only the necessary add/update/remove operations.
870
+
871
+ Args:
872
+ nodes: Full list of node dicts (each must have an 'id' key).
873
+ edges: Full list of edge dicts. Each must carry a stable 'id';
874
+ edges without an id are treated as new on every call.
875
+ """
876
+ network_update_data(self.session, self.output_id, nodes, edges)
877
+
878
+ else:
879
+
880
+ class PyVisNetworkController:
881
+ """Placeholder when Shiny is not installed."""
882
+ def __init__(self, *args, **kwargs):
883
+ raise ImportError("Shiny is required")
884
+
885
+
886
+ # =============================================================================
887
+ # Standalone command functions (alternative to controller class)
888
+ # =============================================================================
889
+
890
+ def _send_network_command(
891
+ session: 'Session',
892
+ output_id: str,
893
+ command: str,
894
+ args: Optional[Dict] = None
895
+ ):
896
+ """Helper to send command to network."""
897
+ if not SHINY_AVAILABLE:
898
+ raise ImportError("Shiny is required")
899
+
900
+ import asyncio
901
+
902
+ message = {
903
+ "outputId": resolve_id(output_id),
904
+ "command": command,
905
+ "args": args or {}
906
+ }
907
+ try:
908
+ loop = asyncio.get_running_loop()
909
+ task = loop.create_task(
910
+ session.send_custom_message("pyvis-command", message)
911
+ )
912
+ task.add_done_callback(_log_task_exception)
913
+ except RuntimeError:
914
+ raise RuntimeError(
915
+ f"_send_network_command requires a running async event loop; "
916
+ f"command '{command}' was not sent. Call from an async context."
917
+ )
918
+
919
+
920
+ def network_select_nodes(
921
+ session: 'Session',
922
+ output_id: str,
923
+ node_ids: List[Any],
924
+ highlight_edges: bool = True
925
+ ):
926
+ """Select nodes in a network."""
927
+ _send_network_command(session, output_id, "selectNodes", {
928
+ "nodeIds": node_ids,
929
+ "highlightEdges": highlight_edges
930
+ })
931
+
932
+
933
+ def network_select_edges(session: 'Session', output_id: str, edge_ids: List[Any]):
934
+ """Select edges in a network."""
935
+ _send_network_command(session, output_id, "selectEdges", {"edgeIds": edge_ids})
936
+
937
+
938
+ def network_unselect_all(session: 'Session', output_id: str):
939
+ """Clear all selections in a network."""
940
+ _send_network_command(session, output_id, "unselectAll")
941
+
942
+
943
+ def network_fit(
944
+ session: 'Session',
945
+ output_id: str,
946
+ node_ids: Optional[List[Any]] = None,
947
+ animation: Union[bool, Dict] = True
948
+ ):
949
+ """Fit network or specific nodes in view."""
950
+ args = {"animation": animation}
951
+ if node_ids is not None:
952
+ args["nodes"] = node_ids
953
+ _send_network_command(session, output_id, "fit", args)
954
+
955
+
956
+ def network_focus(
957
+ session: 'Session',
958
+ output_id: str,
959
+ node_id: Any,
960
+ scale: float = 1.0,
961
+ animation: Union[bool, Dict] = True,
962
+ locked: bool = True
963
+ ):
964
+ """Focus camera on a specific node."""
965
+ _send_network_command(session, output_id, "focus", {
966
+ "nodeId": node_id,
967
+ "options": {"scale": scale, "animation": animation, "locked": locked}
968
+ })
969
+
970
+
971
+ def network_move_to(
972
+ session: 'Session',
973
+ output_id: str,
974
+ position: Optional[Dict[str, float]] = None,
975
+ scale: Optional[float] = None,
976
+ animation: Union[bool, Dict] = True
977
+ ):
978
+ """Move camera to a position."""
979
+ args = {"animation": animation}
980
+ if position is not None:
981
+ args["position"] = position
982
+ if scale is not None:
983
+ args["scale"] = scale
984
+ _send_network_command(session, output_id, "moveTo", args)
985
+
986
+
987
+ def network_start_physics(session: 'Session', output_id: str):
988
+ """Start physics simulation."""
989
+ _send_network_command(session, output_id, "startSimulation")
990
+
991
+
992
+ def network_stop_physics(session: 'Session', output_id: str):
993
+ """Stop physics simulation."""
994
+ _send_network_command(session, output_id, "stopSimulation")
995
+
996
+
997
+ def network_stabilize(session: 'Session', output_id: str, iterations: int = 100):
998
+ """Run physics until stabilized."""
999
+ _send_network_command(session, output_id, "stabilize", {"iterations": iterations})
1000
+
1001
+
1002
+ def network_add_node(session: 'Session', output_id: str, node):
1003
+ """Add a node to the network. Accepts dict or typed NodeOptions."""
1004
+ if isinstance(node, OptionsBase):
1005
+ node = node.to_dict()
1006
+ _send_network_command(session, output_id, "addNode", {"node": node})
1007
+
1008
+
1009
+ def network_add_nodes(session: 'Session', output_id: str, nodes: List[Any]):
1010
+ """Add several nodes. Accepts dicts or typed NodeOptions."""
1011
+ nodes = [n.to_dict() if isinstance(n, OptionsBase) else n for n in nodes]
1012
+ _send_network_command(session, output_id, "addNodes", {"nodes": nodes})
1013
+
1014
+
1015
+ def network_add_edge(session: 'Session', output_id: str, edge):
1016
+ """Add an edge to the network. Accepts dict or typed EdgeOptions."""
1017
+ if isinstance(edge, OptionsBase):
1018
+ edge = edge.to_dict()
1019
+ _send_network_command(session, output_id, "addEdge", {"edge": edge})
1020
+
1021
+
1022
+ def network_add_edges(session: 'Session', output_id: str, edges: List[Any]):
1023
+ """Add several edges. Accepts dicts or typed EdgeOptions."""
1024
+ edges = [e.to_dict() if isinstance(e, OptionsBase) else e for e in edges]
1025
+ _send_network_command(session, output_id, "addEdges", {"edges": edges})
1026
+
1027
+
1028
+ def network_update_node(session: 'Session', output_id: str, node):
1029
+ """Update a node's properties. Accepts dict or typed NodeOptions."""
1030
+ if isinstance(node, OptionsBase):
1031
+ node = node.to_dict()
1032
+ _send_network_command(session, output_id, "updateNode", {"node": node})
1033
+
1034
+
1035
+ def network_update_edge(session: 'Session', output_id: str, edge):
1036
+ """Update an edge's properties. Accepts dict or typed EdgeOptions."""
1037
+ if isinstance(edge, OptionsBase):
1038
+ edge = edge.to_dict()
1039
+ _send_network_command(session, output_id, "updateEdge", {"edge": edge})
1040
+
1041
+
1042
+ def network_remove_node(session: 'Session', output_id: str, node_id: Any):
1043
+ """Remove a node from the network."""
1044
+ _send_network_command(session, output_id, "removeNode", {"nodeId": node_id})
1045
+
1046
+
1047
+ def network_remove_edge(session: 'Session', output_id: str, edge_id: Any):
1048
+ """Remove an edge from the network."""
1049
+ _send_network_command(session, output_id, "removeEdge", {"edgeId": edge_id})
1050
+
1051
+
1052
+ def network_cluster_by_connection(
1053
+ session: 'Session',
1054
+ output_id: str,
1055
+ node_id: Any,
1056
+ cluster_node_properties: Optional[Dict] = None
1057
+ ):
1058
+ """Cluster all nodes connected to a specific node."""
1059
+ _send_network_command(session, output_id, "clusterByConnection", {
1060
+ "nodeId": node_id,
1061
+ "options": cluster_node_properties or {}
1062
+ })
1063
+
1064
+
1065
+ def network_cluster_by_hubsize(
1066
+ session: 'Session',
1067
+ output_id: str,
1068
+ hubsize: Optional[int] = None,
1069
+ cluster_node_properties: Optional[Dict] = None
1070
+ ):
1071
+ """Cluster all nodes with more than hubsize connections.
1072
+
1073
+ ``hubsize`` is omitted from the payload when None so vis.js applies its
1074
+ own automatic threshold.
1075
+ """
1076
+ args: Dict[str, Any] = {"options": cluster_node_properties or {}}
1077
+ if hubsize is not None:
1078
+ args["hubsize"] = hubsize
1079
+ _send_network_command(session, output_id, "clusterByHubsize", args)
1080
+
1081
+
1082
+ def network_open_cluster(session: 'Session', output_id: str, cluster_node_id: Any):
1083
+ """Open/expand a cluster."""
1084
+ _send_network_command(session, output_id, "openCluster", {"nodeId": cluster_node_id})
1085
+
1086
+
1087
+ def network_set_options(session: 'Session', output_id: str, options):
1088
+ """Update network options. Accepts dict or typed NetworkOptions."""
1089
+ if isinstance(options, OptionsBase):
1090
+ options = options.to_dict()
1091
+ _send_network_command(session, output_id, "setOptions", {"options": options})
1092
+
1093
+
1094
+ def network_set_theme(session: 'Session', output_id: str, theme: str):
1095
+ """Switch the network container theme without re-rendering.
1096
+
1097
+ Args:
1098
+ session: The Shiny session object.
1099
+ output_id: The ID of the network output.
1100
+ theme: "light" or "dark"
1101
+ """
1102
+ _send_network_command(session, output_id, "setTheme", {"theme": theme})
1103
+
1104
+
1105
+ def network_toggle_manipulation(session: 'Session', output_id: str, enabled: bool):
1106
+ """Show or hide the native manipulation toolbar.
1107
+
1108
+ Args:
1109
+ session: The Shiny session object.
1110
+ output_id: The ID of the network output.
1111
+ enabled: True to show the toolbar, False to hide it.
1112
+ """
1113
+ _send_network_command(session, output_id, "toggleManipulation", {"enabled": enabled})
1114
+
1115
+
1116
+ def network_set_edge_edit_mode(session: 'Session', output_id: str, mode: str):
1117
+ """Switch edge editing between attribute modal and link reconnection.
1118
+
1119
+ Args:
1120
+ session: The Shiny session object.
1121
+ output_id: The ID of the network output.
1122
+ mode: "attributes" or "links"
1123
+ """
1124
+ _send_network_command(session, output_id, "setEdgeEditMode", {"mode": mode})
1125
+
1126
+
1127
+ def network_set_node_template_mode(session: 'Session', output_id: str, enabled: bool):
1128
+ """Toggle template-from-existing mode for the Add Node modal.
1129
+
1130
+ Args:
1131
+ session: The Shiny session object.
1132
+ output_id: The ID of the network output.
1133
+ enabled: True to show template chips, False for default behavior.
1134
+ """
1135
+ _send_network_command(session, output_id, "setNodeTemplateMode", {"enabled": enabled})
1136
+
1137
+
1138
+ def network_get_positions(
1139
+ session: 'Session',
1140
+ output_id: str,
1141
+ node_ids: Optional[List[Any]] = None
1142
+ ):
1143
+ """Request node positions (response: input.{output_id}_response_positions).
1144
+
1145
+ ``node_ids`` is omitted from the payload when None so vis.js returns
1146
+ positions for all nodes instead of an empty result.
1147
+ """
1148
+ args: Dict[str, Any] = {}
1149
+ if node_ids is not None:
1150
+ args["nodeIds"] = node_ids
1151
+ _send_network_command(session, output_id, "getPositions", args)
1152
+
1153
+
1154
+ def network_get_selection(session: 'Session', output_id: str):
1155
+ """Request current selection (response: input.{output_id}_response_selection)."""
1156
+ _send_network_command(session, output_id, "getSelection")
1157
+
1158
+
1159
+ def network_get_scale(session: 'Session', output_id: str):
1160
+ """Request the current zoom scale (response: input.{output_id}_response_scale)."""
1161
+ _send_network_command(session, output_id, "getScale")
1162
+
1163
+
1164
+ def network_get_view_position(session: 'Session', output_id: str):
1165
+ """Request the view centre (response: input.{output_id}_response_viewPosition)."""
1166
+ _send_network_command(session, output_id, "getViewPosition")
1167
+
1168
+
1169
+ def network_get_all_data(session: 'Session', output_id: str):
1170
+ """Request all network data (response: input.{output_id}_response_allData)."""
1171
+ _send_network_command(session, output_id, "getAllData")
1172
+
1173
+
1174
+ #: Backwards-compatible alias for :func:`network_get_all_data`.
1175
+ network_get_data = network_get_all_data
1176
+
1177
+
1178
+ def network_update_data(
1179
+ session: 'Session',
1180
+ output_id: str,
1181
+ nodes: List[Dict[str, Any]],
1182
+ edges: List[Dict[str, Any]],
1183
+ ):
1184
+ """Send complete node/edge data for diff-based update.
1185
+
1186
+ The JavaScript side will diff the incoming data against the current
1187
+ DataSet and apply only the necessary add/update/remove operations.
1188
+
1189
+ Args:
1190
+ session: The Shiny session object.
1191
+ output_id: The ID of the network output.
1192
+ nodes: Full list of node dicts (each must have an 'id' key).
1193
+ edges: Full list of edge dicts. Each must carry a stable 'id';
1194
+ edges without an id are treated as new on every call.
1195
+ """
1196
+ _send_network_command(session, output_id, "updateData", {
1197
+ "nodes": nodes,
1198
+ "edges": edges,
1199
+ })
1200
+
1201
+
1202
+ def _network_from_spec(spec: Dict[str, Any]) -> 'PyVisNetwork':
1203
+ """Build a Network from {'nodes': [...], 'edges': [...]}.
1204
+
1205
+ Nodes may be bare ids or dicts with an 'id' key. Edges may be
1206
+ (from, to) pairs or dicts with 'from'/'source' and 'to' keys. Any
1207
+ other dict keys are passed through as vis.js options.
1208
+ """
1209
+ from pyvis.network import Network
1210
+ net = Network(height="100%", width="100%")
1211
+ for node in spec.get('nodes', []):
1212
+ if isinstance(node, dict):
1213
+ attrs = dict(node)
1214
+ if 'id' not in attrs:
1215
+ raise ValueError(f"node dict is missing the 'id' key: {node!r}")
1216
+ n_id = attrs.pop('id')
1217
+ net.add_node(n_id, **attrs)
1218
+ else:
1219
+ net.add_node(node)
1220
+ for edge in spec.get('edges', []):
1221
+ if isinstance(edge, dict):
1222
+ attrs = dict(edge)
1223
+ source = attrs.pop('from', attrs.pop('source', None))
1224
+ to = attrs.pop('to', None)
1225
+ if source is None or to is None:
1226
+ raise ValueError(f"edge dict needs 'from' (or 'source') and 'to' keys: {edge!r}")
1227
+ net.add_edge(source, to, **attrs)
1228
+ elif isinstance(edge, (list, tuple)) and len(edge) >= 2:
1229
+ net.add_edge(edge[0], edge[1])
1230
+ else:
1231
+ raise ValueError(f"unsupported edge spec: {edge!r}")
1232
+ return net
1233
+
1234
+
1235
+ def _apply_physics(net: 'PyVisNetwork', enabled: bool) -> 'PyVisNetwork':
1236
+ """Return a copy of net with physics.enabled merged into its options.
1237
+
1238
+ Shallow copy plus a deep copy of ``options`` on purpose: ``copy.deepcopy(net)``
1239
+ raises TypeError once ``net.template`` holds a jinja2 Template.
1240
+ """
1241
+ import copy
1242
+ out = copy.copy(net)
1243
+ out.options = copy.deepcopy(net.options)
1244
+ physics = out.options.get('physics')
1245
+ if not isinstance(physics, dict):
1246
+ physics = {}
1247
+ physics['enabled'] = bool(enabled)
1248
+ out.options['physics'] = physics
1249
+ return out
1250
+
1251
+
1252
+ # =============================================================================
1253
+ # Shiny Module for reusable network visualization
1254
+ # =============================================================================
1255
+
1256
+ if SHINY_AVAILABLE:
1257
+
1258
+ @module.ui
1259
+ def pyvis_network_ui(
1260
+ height: str = "600px",
1261
+ width: str = "100%",
1262
+ show_controls: bool = True
1263
+ ) -> 'Tag':
1264
+ """
1265
+ UI function for the PyVis network module.
1266
+
1267
+ Creates a network output with optional control panel.
1268
+
1269
+ Args:
1270
+ height: Height of the network.
1271
+ width: Width of the network.
1272
+ show_controls: Whether to show physics and layout controls.
1273
+
1274
+ Returns:
1275
+ Shiny UI element.
1276
+ """
1277
+ controls = []
1278
+ if show_controls:
1279
+ controls = [
1280
+ ui.accordion(
1281
+ ui.accordion_panel(
1282
+ "Network Controls",
1283
+ ui.input_checkbox("physics", "Enable Physics", value=True),
1284
+ ),
1285
+ open=False
1286
+ )
1287
+ ]
1288
+
1289
+ return ui.div(
1290
+ *controls,
1291
+ output_pyvis_network("network", height=height, width=width),
1292
+ ui.div(
1293
+ ui.output_text_verbatim("selection_info"),
1294
+ style="margin-top: 10px; font-size: 12px;"
1295
+ )
1296
+ )
1297
+
1298
+ @module.server
1299
+ def pyvis_network_server(
1300
+ input, output, session,
1301
+ network_data: reactive.Value,
1302
+ on_node_select: Optional[Callable[[Dict[str, Any]], None]] = None,
1303
+ on_edge_select: Optional[Callable[[Dict[str, Any]], None]] = None,
1304
+ show_controls: bool = True
1305
+ ):
1306
+ """
1307
+ Server function for the PyVis network module.
1308
+
1309
+ Args:
1310
+ input, output, session: Shiny server parameters.
1311
+ network_data: A reactive.Value containing a PyVis Network or
1312
+ data to create one (dict with 'nodes' and 'edges').
1313
+ on_node_select: Callback when a node is selected.
1314
+ on_edge_select: Callback when an edge is selected.
1315
+ show_controls: Whether the physics control is rendered by the UI.
1316
+ Pass the same value you gave `pyvis_network_ui`; when False,
1317
+ the physics input is not read.
1318
+ """
1319
+ from pyvis.network import Network
1320
+
1321
+ @render_pyvis_network
1322
+ def network():
1323
+ data = network_data()
1324
+ if data is None:
1325
+ return None
1326
+
1327
+ if isinstance(data, Network):
1328
+ net = data
1329
+ elif isinstance(data, dict):
1330
+ # Create network from dict specification
1331
+ net = _network_from_spec(data)
1332
+ else:
1333
+ raise TypeError(f"Expected Network or dict, got {type(data)}")
1334
+
1335
+ if show_controls and 'physics' in input:
1336
+ net = _apply_physics(net, input.physics())
1337
+ return net
1338
+
1339
+ @output
1340
+ @render.text
1341
+ def selection_info():
1342
+ info = []
1343
+ if 'network_selectNode' in input and input.network_selectNode():
1344
+ info.append(f"Selected Node: {input.network_selectNode().get('nodeId', 'N/A')}")
1345
+ if 'network_selectEdge' in input and input.network_selectEdge():
1346
+ info.append(f"Selected Edge: {input.network_selectEdge().get('edgeId', 'N/A')}")
1347
+ return "\n".join(info) if info else "Click on a node or edge to see details"
1348
+
1349
+ # Handle callbacks
1350
+ @reactive.effect
1351
+ @reactive.event(input.network_selectNode)
1352
+ def _handle_node_select():
1353
+ if on_node_select and input.network_selectNode():
1354
+ on_node_select(input.network_selectNode())
1355
+
1356
+ @reactive.effect
1357
+ @reactive.event(input.network_selectEdge)
1358
+ def _handle_edge_select():
1359
+ if on_edge_select and input.network_selectEdge():
1360
+ on_edge_select(input.network_selectEdge())
1361
+
1362
+ else:
1363
+ # Placeholders when Shiny not available
1364
+ def pyvis_network_ui(*args, **kwargs):
1365
+ raise ImportError("Shiny is required")
1366
+
1367
+ def pyvis_network_server(*args, **kwargs):
1368
+ raise ImportError("Shiny is required")