pyvis-optimized 4.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyvis/__init__.py +2 -0
- pyvis/_version.py +1 -0
- pyvis/edge.py +26 -0
- pyvis/network.py +1498 -0
- pyvis/node.py +35 -0
- pyvis/shiny/__init__.py +213 -0
- pyvis/shiny/bindings.js +1310 -0
- pyvis/shiny/styles.css +382 -0
- pyvis/shiny/wrapper.py +1368 -0
- pyvis/templates/lib/bindings/utils.js +189 -0
- pyvis/templates/lib/tom-select/tom-select.complete.min.js +5021 -0
- pyvis/templates/lib/tom-select/tom-select.css +2 -0
- pyvis/templates/lib/vis-10.0.2/vis-network.min.css +2 -0
- pyvis/templates/lib/vis-10.0.2/vis-network.min.js +78 -0
- pyvis/templates/template.html +911 -0
- pyvis/types/__init__.py +59 -0
- pyvis/types/base.py +109 -0
- pyvis/types/common.py +74 -0
- pyvis/types/configure.py +13 -0
- pyvis/types/edges.py +139 -0
- pyvis/types/interaction.py +42 -0
- pyvis/types/layout.py +34 -0
- pyvis/types/manipulation.py +18 -0
- pyvis/types/network.py +34 -0
- pyvis/types/nodes.py +158 -0
- pyvis/types/physics.py +90 -0
- pyvis/utils.py +26 -0
- pyvis/vis_config.py +24 -0
- pyvis_optimized-4.3.1.dist-info/METADATA +269 -0
- pyvis_optimized-4.3.1.dist-info/RECORD +33 -0
- pyvis_optimized-4.3.1.dist-info/WHEEL +5 -0
- pyvis_optimized-4.3.1.dist-info/licenses/LICENSE_BSD.txt +27 -0
- pyvis_optimized-4.3.1.dist-info/top_level.txt +1 -0
pyvis/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")
|