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.
@@ -0,0 +1,1310 @@
1
+ /**
2
+ * PyVis Network Output Binding for Shiny for Python (v3.0)
3
+ *
4
+ * Direct DOM rendering - no iframe. vis.js Network created directly
5
+ * in the output div. Commands and events use direct object access.
6
+ */
7
+
8
+ if (typeof Shiny !== 'undefined') {
9
+
10
+ // Global registry of network instances
11
+ window.pyvisNetworks = window.pyvisNetworks || {};
12
+
13
+ // Debounce utility
14
+ function pyvisDebounce(fn, delay) {
15
+ let timer;
16
+ return function(...args) {
17
+ clearTimeout(timer);
18
+ timer = setTimeout(() => fn.apply(this, args), delay);
19
+ };
20
+ }
21
+
22
+ // Safe clone — vis.js objects can contain circular references that
23
+ // break JSON.stringify (used internally by Shiny.setInputValue).
24
+ function pyvisSafeClone(obj) {
25
+ try {
26
+ return JSON.parse(JSON.stringify(obj));
27
+ } catch (e) {
28
+ if (typeof obj !== 'object' || obj === null) return obj;
29
+ var result = {};
30
+ Object.keys(obj).forEach(function(key) {
31
+ var val = obj[key];
32
+ if (val === null || typeof val !== 'object') {
33
+ result[key] = val;
34
+ } else {
35
+ try { result[key] = JSON.parse(JSON.stringify(val)); }
36
+ catch (e2) { /* skip circular property */ }
37
+ }
38
+ });
39
+ return result;
40
+ }
41
+ }
42
+
43
+ // Tear down a previous network instance: removes global listeners,
44
+ // deregisters DataSet handlers, disconnects the ResizeObserver, destroys
45
+ // the vis.js network, and clears the registry entry.
46
+ function pyvisDestroy(outputId) {
47
+ const prev = window.pyvisNetworks[outputId];
48
+ if (!prev) return;
49
+ // Remove global keydown handler (Esc for modals)
50
+ if (prev._escHandler) {
51
+ document.removeEventListener('keydown', prev._escHandler);
52
+ }
53
+ // Deregister DataSet listeners
54
+ if (prev.nodes && prev._statusHandler) {
55
+ prev.nodes.off('*', prev._statusHandler);
56
+ }
57
+ if (prev.edges && prev._statusHandler) {
58
+ prev.edges.off('*', prev._statusHandler);
59
+ }
60
+ // Clear DataSets
61
+ if (prev.nodes) prev.nodes.clear();
62
+ if (prev.edges) prev.edges.clear();
63
+ // Disconnect resize observer
64
+ if (prev.resizeObserver) prev.resizeObserver.disconnect();
65
+ // Destroy vis.js network (removes its own event handlers)
66
+ if (prev.network) prev.network.destroy();
67
+ delete window.pyvisNetworks[outputId];
68
+ }
69
+
70
+ // Lazily create (once) a MutationObserver that watches for output
71
+ // elements being removed from the DOM (e.g. Shiny's remove_ui) and
72
+ // tears down the corresponding network instance. Must be called from
73
+ // inside renderValue, never at module scope: bindings.js is loaded in
74
+ // <head> as an HTMLDependency, where document.body is null and
75
+ // observe(null) would throw and abort the whole binding registration.
76
+ function pyvisEnsureRemovalObserver() {
77
+ if (window.__pyvisMutationObserver) return;
78
+ window.__pyvisMutationObserver = new MutationObserver(function() {
79
+ Object.keys(window.pyvisNetworks).forEach(function(id) {
80
+ var ref = window.pyvisNetworks[id];
81
+ if (ref && ref.container && !document.contains(ref.container)) pyvisDestroy(id);
82
+ });
83
+ });
84
+ window.__pyvisMutationObserver.observe(document.documentElement, { childList: true, subtree: true });
85
+ }
86
+
87
+ class PyVisOutputBinding extends Shiny.OutputBinding {
88
+
89
+ find(scope) {
90
+ return scope.find('.pyvis-network-output');
91
+ }
92
+
93
+ renderValue(el, payload) {
94
+ const outputId = el.id;
95
+ pyvisEnsureRemovalObserver();
96
+
97
+ if (!payload) {
98
+ pyvisDestroy(outputId);
99
+ el.innerHTML = '<p style="color:#999;text-align:center;padding:20px;">No network data</p>';
100
+ return;
101
+ }
102
+
103
+ var datasetConfig = {};
104
+ if (el.dataset.pyvisConfig) {
105
+ try { datasetConfig = JSON.parse(el.dataset.pyvisConfig) || {}; } catch (e) { datasetConfig = {}; }
106
+ }
107
+ const config = Object.assign({}, datasetConfig, payload.config || {});
108
+ const theme = config.theme || 'light';
109
+ const showToolbar = config.showToolbar !== false;
110
+ const showSearch = config.showSearch !== false;
111
+ const showLayoutSwitcher = config.showLayoutSwitcher !== false;
112
+ const showExport = config.showExport !== false;
113
+ const showStatus = config.showStatus !== false;
114
+ const fill = config.fill || false;
115
+ const enabledEvents = config.events || null; // null = all
116
+
117
+ // Clean up previous instance
118
+ pyvisDestroy(outputId);
119
+
120
+ // Build container HTML
121
+ el.innerHTML = '';
122
+
123
+ const container = document.createElement('div');
124
+ container.className = 'pyvis-container pyvis-theme-' + theme + (fill ? ' pyvis-fill' : '');
125
+ container.style.height = '100%';
126
+ container.style.width = '100%';
127
+
128
+ // Heading
129
+ if (payload.heading) {
130
+ const heading = document.createElement('h3');
131
+ heading.className = 'pyvis-heading';
132
+ heading.textContent = payload.heading;
133
+ container.appendChild(heading);
134
+ }
135
+
136
+ // Toolbar
137
+ var searchInput = null, searchCount = null, layoutSelect = null;
138
+ if (showToolbar) {
139
+ const toolbar = document.createElement('div');
140
+ toolbar.className = 'pyvis-toolbar';
141
+
142
+ // Search
143
+ if (showSearch) {
144
+ const searchGroup = document.createElement('div');
145
+ searchGroup.className = 'pyvis-search';
146
+
147
+ searchInput = document.createElement('input');
148
+ searchInput.type = 'text';
149
+ searchInput.placeholder = 'Search nodes...';
150
+
151
+ searchCount = document.createElement('span');
152
+ searchCount.className = 'pyvis-search-count';
153
+
154
+ searchGroup.appendChild(searchInput);
155
+ searchGroup.appendChild(searchCount);
156
+ toolbar.appendChild(searchGroup);
157
+ }
158
+
159
+ // Separator
160
+ if (showSearch && (showLayoutSwitcher || showExport)) {
161
+ const sep = document.createElement('div');
162
+ sep.className = 'pyvis-toolbar-separator';
163
+ toolbar.appendChild(sep);
164
+ }
165
+
166
+ // Layout switcher
167
+ if (showLayoutSwitcher) {
168
+ layoutSelect = document.createElement('select');
169
+ layoutSelect.className = 'pyvis-layout-select';
170
+ var layouts = [
171
+ { value: 'barnesHut', label: 'BarnesHut' },
172
+ { value: 'forceAtlas2Based', label: 'ForceAtlas2' },
173
+ { value: 'repulsion', label: 'Repulsion' },
174
+ { value: 'hierarchicalUD', label: 'Hierarchical Down' },
175
+ { value: 'hierarchicalLR', label: 'Hierarchical Right' },
176
+ { value: 'none', label: 'No Physics' }
177
+ ];
178
+ layouts.forEach(function(l) {
179
+ var opt = document.createElement('option');
180
+ opt.value = l.value;
181
+ opt.textContent = l.label;
182
+ layoutSelect.appendChild(opt);
183
+ });
184
+ toolbar.appendChild(layoutSelect);
185
+ }
186
+
187
+ // Export buttons
188
+ if (showExport) {
189
+ var sep2 = document.createElement('div');
190
+ sep2.className = 'pyvis-toolbar-separator';
191
+ toolbar.appendChild(sep2);
192
+
193
+ var exportPng = document.createElement('button');
194
+ exportPng.className = 'pyvis-btn';
195
+ exportPng.textContent = 'Export PNG';
196
+ exportPng.dataset.action = 'exportPng';
197
+ toolbar.appendChild(exportPng);
198
+
199
+ var exportJson = document.createElement('button');
200
+ exportJson.className = 'pyvis-btn';
201
+ exportJson.textContent = 'Export JSON';
202
+ exportJson.dataset.action = 'exportJson';
203
+ toolbar.appendChild(exportJson);
204
+ }
205
+
206
+ container.appendChild(toolbar);
207
+ }
208
+
209
+ // Network canvas
210
+ var canvasDiv = document.createElement('div');
211
+ canvasDiv.className = 'pyvis-network-canvas';
212
+ container.appendChild(canvasDiv);
213
+
214
+ // Status bar
215
+ var statusLeft = null, statusRight = null;
216
+ if (showStatus) {
217
+ var statusBar = document.createElement('div');
218
+ statusBar.className = 'pyvis-status';
219
+ statusLeft = document.createElement('span');
220
+ statusRight = document.createElement('span');
221
+ statusBar.appendChild(statusLeft);
222
+ statusBar.appendChild(statusRight);
223
+ container.appendChild(statusBar);
224
+ }
225
+
226
+ el.appendChild(container);
227
+
228
+ // Create vis.js DataSets and Network
229
+ var nodesDataSet = new vis.DataSet(payload.nodes);
230
+ var edgesDataSet = new vis.DataSet(payload.edges);
231
+ var data = { nodes: nodesDataSet, edges: edgesDataSet };
232
+
233
+ var options = payload.options || {};
234
+ if (typeof options === 'string') {
235
+ options = JSON.parse(options);
236
+ }
237
+
238
+ // === MANIPULATION MODALS ===
239
+ if (options.manipulation && options.manipulation.enabled) {
240
+ // Container must be positioned for absolute overlay
241
+ container.style.position = 'relative';
242
+
243
+ // --- Create Node Modal ---
244
+ var nodeOverlay = document.createElement('div');
245
+ nodeOverlay.className = 'pyvis-modal-overlay';
246
+ nodeOverlay.id = outputId + '-node-modal';
247
+ nodeOverlay.style.display = 'none';
248
+ nodeOverlay.setAttribute('role', 'dialog');
249
+ nodeOverlay.setAttribute('aria-modal', 'true');
250
+ nodeOverlay.setAttribute('aria-labelledby', outputId + '-node-modal-title');
251
+ nodeOverlay.innerHTML =
252
+ '<div class="pyvis-modal">' +
253
+ '<div class="pyvis-modal-header">' +
254
+ '<h3 id="' + outputId + '-node-modal-title">Add Node</h3>' +
255
+ '<button class="pyvis-modal-close" data-action="close" aria-label="Close">&times;</button>' +
256
+ '</div>' +
257
+ '<div class="pyvis-modal-body">' +
258
+ '<div class="pyvis-modal-field pyvis-template-strip" id="' + outputId + '-node-templates" style="display:none">' +
259
+ '<label>Template</label>' +
260
+ '<div class="pyvis-template-options" id="' + outputId + '-node-template-options"></div>' +
261
+ '</div>' +
262
+ '<div class="pyvis-modal-field">' +
263
+ '<label>Label</label>' +
264
+ '<input type="text" id="' + outputId + '-node-label" placeholder="Node label">' +
265
+ '</div>' +
266
+ '<div class="pyvis-modal-field">' +
267
+ '<label>Color</label>' +
268
+ '<input type="color" id="' + outputId + '-node-color" value="#97c2fc">' +
269
+ '</div>' +
270
+ '<div class="pyvis-modal-field">' +
271
+ '<label>Shape</label>' +
272
+ '<select id="' + outputId + '-node-shape">' +
273
+ '<option value="dot">Dot</option>' +
274
+ '<option value="ellipse">Ellipse</option>' +
275
+ '<option value="box">Box</option>' +
276
+ '<option value="diamond">Diamond</option>' +
277
+ '<option value="star">Star</option>' +
278
+ '<option value="triangle">Triangle</option>' +
279
+ '<option value="square">Square</option>' +
280
+ '</select>' +
281
+ '</div>' +
282
+ '<div class="pyvis-modal-field">' +
283
+ '<label>Size</label>' +
284
+ '<input type="number" id="' + outputId + '-node-size" value="25" min="5" max="100">' +
285
+ '</div>' +
286
+ '</div>' +
287
+ '<div class="pyvis-modal-actions">' +
288
+ '<button class="pyvis-btn-cancel" data-action="cancel">Cancel</button>' +
289
+ '<button class="pyvis-btn-save" data-action="save">Save</button>' +
290
+ '</div>' +
291
+ '</div>';
292
+ container.appendChild(nodeOverlay);
293
+
294
+ // --- Create Edge Attributes Modal ---
295
+ var edgeOverlay = document.createElement('div');
296
+ edgeOverlay.className = 'pyvis-modal-overlay';
297
+ edgeOverlay.id = outputId + '-edge-modal';
298
+ edgeOverlay.style.display = 'none';
299
+ edgeOverlay.setAttribute('role', 'dialog');
300
+ edgeOverlay.setAttribute('aria-modal', 'true');
301
+ edgeOverlay.setAttribute('aria-labelledby', outputId + '-edge-modal-title');
302
+ edgeOverlay.innerHTML =
303
+ '<div class="pyvis-modal">' +
304
+ '<div class="pyvis-modal-header">' +
305
+ '<h3 id="' + outputId + '-edge-modal-title">Edit Edge Attributes</h3>' +
306
+ '<button class="pyvis-modal-close" data-action="close" aria-label="Close">&times;</button>' +
307
+ '</div>' +
308
+ '<div class="pyvis-modal-body">' +
309
+ '<div class="pyvis-modal-field">' +
310
+ '<label>Label</label>' +
311
+ '<input type="text" id="' + outputId + '-edge-label" placeholder="Edge label">' +
312
+ '</div>' +
313
+ '<div class="pyvis-modal-field">' +
314
+ '<label>Color</label>' +
315
+ '<input type="color" id="' + outputId + '-edge-color" value="#848484">' +
316
+ '</div>' +
317
+ '<div class="pyvis-modal-field">' +
318
+ '<label>Width</label>' +
319
+ '<input type="number" id="' + outputId + '-edge-width" value="1" min="0.1" max="20" step="0.1">' +
320
+ '</div>' +
321
+ '<div class="pyvis-modal-field">' +
322
+ '<label>Dashes</label>' +
323
+ '<input type="checkbox" id="' + outputId + '-edge-dashes">' +
324
+ '</div>' +
325
+ '<div class="pyvis-modal-field">' +
326
+ '<label>Arrows</label>' +
327
+ '<select id="' + outputId + '-edge-arrows">' +
328
+ '<option value="none">None</option>' +
329
+ '<option value="to">To</option>' +
330
+ '<option value="from">From</option>' +
331
+ '<option value="middle">Middle</option>' +
332
+ '<option value="both">Both (To + From)</option>' +
333
+ '</select>' +
334
+ '</div>' +
335
+ '<div class="pyvis-modal-field">' +
336
+ '<label>Font Size</label>' +
337
+ '<input type="number" id="' + outputId + '-edge-fontsize" value="14" min="8" max="48">' +
338
+ '</div>' +
339
+ '</div>' +
340
+ '<div class="pyvis-modal-actions">' +
341
+ '<button class="pyvis-btn-cancel" data-action="cancel">Cancel</button>' +
342
+ '<button class="pyvis-btn-save" data-action="save">Save</button>' +
343
+ '</div>' +
344
+ '</div>';
345
+ container.appendChild(edgeOverlay);
346
+
347
+ // --- Create Edge Links Modal (reconnect from/to) ---
348
+ var linksOverlay = document.createElement('div');
349
+ linksOverlay.className = 'pyvis-modal-overlay';
350
+ linksOverlay.id = outputId + '-links-modal';
351
+ linksOverlay.style.display = 'none';
352
+ linksOverlay.setAttribute('role', 'dialog');
353
+ linksOverlay.setAttribute('aria-modal', 'true');
354
+ linksOverlay.setAttribute('aria-labelledby', outputId + '-links-modal-title');
355
+ linksOverlay.innerHTML =
356
+ '<div class="pyvis-modal">' +
357
+ '<div class="pyvis-modal-header">' +
358
+ '<h3 id="' + outputId + '-links-modal-title">Edit Edge Links</h3>' +
359
+ '<button class="pyvis-modal-close" data-action="close" aria-label="Close">&times;</button>' +
360
+ '</div>' +
361
+ '<div class="pyvis-modal-body">' +
362
+ '<div class="pyvis-modal-field">' +
363
+ '<label>From</label>' +
364
+ '<select id="' + outputId + '-links-from"></select>' +
365
+ '</div>' +
366
+ '<div class="pyvis-modal-field">' +
367
+ '<label>To</label>' +
368
+ '<select id="' + outputId + '-links-to"></select>' +
369
+ '</div>' +
370
+ '</div>' +
371
+ '<div class="pyvis-modal-actions">' +
372
+ '<button class="pyvis-btn-cancel" data-action="cancel">Cancel</button>' +
373
+ '<button class="pyvis-btn-save" data-action="save">Save</button>' +
374
+ '</div>' +
375
+ '</div>';
376
+ container.appendChild(linksOverlay);
377
+
378
+ // --- Modal helpers ---
379
+ function clamp(val, min, max) { return Math.max(min, Math.min(max, val)); }
380
+ var manipCallback = null;
381
+ var manipNodeData = null;
382
+ var manipEdgeData = null;
383
+
384
+ // Shorthand to look up modal form elements by outputId-scoped ID
385
+ function getEl(suffix) {
386
+ return document.getElementById(outputId + '-' + suffix);
387
+ }
388
+
389
+ function closeModals() {
390
+ nodeOverlay.style.display = 'none';
391
+ edgeOverlay.style.display = 'none';
392
+ linksOverlay.style.display = 'none';
393
+ manipCallback = null;
394
+ manipNodeData = null;
395
+ manipEdgeData = null;
396
+ }
397
+
398
+ function openNodeModal(nodeData, callback, title) {
399
+ manipNodeData = nodeData;
400
+ manipCallback = callback;
401
+ getEl('node-modal-title').textContent = title || 'Add Node';
402
+ getEl('node-label').value = nodeData.label || '';
403
+ // Extract color — vis.js may store as string or { background: ... }
404
+ var color = '#97c2fc';
405
+ if (nodeData.color) {
406
+ if (typeof nodeData.color === 'string') {
407
+ color = nodeData.color;
408
+ } else if (nodeData.color.background) {
409
+ color = nodeData.color.background;
410
+ }
411
+ }
412
+ getEl('node-color').value = color;
413
+ getEl('node-shape').value = nodeData.shape || 'dot';
414
+ getEl('node-size').value = nodeData.size || 25;
415
+
416
+ // Populate template strip if mode is active and this is Add (not Edit)
417
+ var templateStrip = getEl('node-templates');
418
+ var templateOpts = getEl('node-template-options');
419
+ if (nodeTemplateMode && title === 'Add Node') {
420
+ // Collect unique shape+color+size combos from existing nodes
421
+ var seen = {};
422
+ var templates = [];
423
+ nodesDataSet.forEach(function(n) {
424
+ var c = '#97c2fc';
425
+ if (n.color) {
426
+ if (typeof n.color === 'string') c = n.color;
427
+ else if (n.color.background) c = n.color.background;
428
+ }
429
+ var key = (n.shape || 'dot') + '|' + c + '|' + (n.size || 25);
430
+ if (!seen[key]) {
431
+ seen[key] = true;
432
+ templates.push({ shape: n.shape || 'dot', color: c, size: n.size || 25 });
433
+ }
434
+ });
435
+ // Build chips
436
+ templateOpts.textContent = '';
437
+ templates.forEach(function(t) {
438
+ var chip = document.createElement('button');
439
+ chip.type = 'button';
440
+ chip.className = 'pyvis-template-chip';
441
+ chip.dataset.shape = t.shape;
442
+ chip.dataset.color = t.color;
443
+ chip.dataset.size = t.size;
444
+ var swatch = document.createElement('span');
445
+ swatch.className = 'pyvis-template-swatch';
446
+ swatch.style.backgroundColor = t.color;
447
+ chip.appendChild(swatch);
448
+ chip.appendChild(document.createTextNode(t.shape));
449
+ chip.addEventListener('click', function() {
450
+ // Deselect all chips, select this one
451
+ templateOpts.querySelectorAll('.pyvis-template-chip').forEach(function(c) {
452
+ c.classList.remove('pyvis-template-selected');
453
+ });
454
+ chip.classList.add('pyvis-template-selected');
455
+ // Pre-fill form fields
456
+ getEl('node-color').value = t.color;
457
+ getEl('node-shape').value = t.shape;
458
+ getEl('node-size').value = t.size;
459
+ });
460
+ templateOpts.appendChild(chip);
461
+ });
462
+ templateStrip.style.display = templates.length > 0 ? '' : 'none';
463
+ } else {
464
+ templateStrip.style.display = 'none';
465
+ }
466
+
467
+ nodeOverlay.style.display = 'flex';
468
+ getEl('node-label').focus();
469
+ }
470
+
471
+ function saveNode() {
472
+ if (!manipNodeData || !manipCallback) return;
473
+ manipNodeData.label = getEl('node-label').value || 'new';
474
+ manipNodeData.color = getEl('node-color').value;
475
+ manipNodeData.shape = getEl('node-shape').value;
476
+ manipNodeData.size = clamp(parseInt(getEl('node-size').value, 10) || 25, 5, 100);
477
+ var cb = manipCallback;
478
+ var nd = manipNodeData;
479
+ closeModals();
480
+ cb(nd);
481
+ }
482
+
483
+ function openEdgeModal(edgeData, callback) {
484
+ manipEdgeData = edgeData;
485
+ manipCallback = callback;
486
+ getEl('edge-label').value = edgeData.label || '';
487
+ // Extract color
488
+ var color = '#848484';
489
+ if (edgeData.color) {
490
+ if (typeof edgeData.color === 'string') {
491
+ color = edgeData.color;
492
+ } else if (edgeData.color.color) {
493
+ color = edgeData.color.color;
494
+ }
495
+ }
496
+ getEl('edge-color').value = color;
497
+ getEl('edge-width').value = edgeData.width || 1;
498
+ getEl('edge-dashes').checked = !!edgeData.dashes;
499
+ // Determine arrow setting
500
+ var arrows = 'none';
501
+ if (edgeData.arrows) {
502
+ var a = edgeData.arrows;
503
+ if (typeof a === 'string') {
504
+ arrows = a;
505
+ } else {
506
+ var hasTo = a.to && (a.to === true || a.to.enabled);
507
+ var hasFrom = a.from && (a.from === true || a.from.enabled);
508
+ var hasMiddle = a.middle && (a.middle === true || a.middle.enabled);
509
+ if (hasTo && hasFrom) arrows = 'both';
510
+ else if (hasTo) arrows = 'to';
511
+ else if (hasFrom) arrows = 'from';
512
+ else if (hasMiddle) arrows = 'middle';
513
+ }
514
+ }
515
+ getEl('edge-arrows').value = arrows;
516
+ getEl('edge-fontsize').value = (edgeData.font && edgeData.font.size) || 14;
517
+ edgeOverlay.style.display = 'flex';
518
+ getEl('edge-label').focus();
519
+ }
520
+
521
+ function saveEdge() {
522
+ if (!manipEdgeData || !manipCallback) return;
523
+ var updatedEdge = { id: manipEdgeData.id };
524
+ updatedEdge.label = getEl('edge-label').value;
525
+ updatedEdge.color = { color: getEl('edge-color').value };
526
+ updatedEdge.width = clamp(parseFloat(getEl('edge-width').value) || 1, 0.1, 20);
527
+ updatedEdge.dashes = getEl('edge-dashes').checked;
528
+ var arrowVal = getEl('edge-arrows').value;
529
+ if (arrowVal === 'none') {
530
+ updatedEdge.arrows = { to: { enabled: false }, from: { enabled: false }, middle: { enabled: false } };
531
+ } else if (arrowVal === 'to') {
532
+ updatedEdge.arrows = { to: { enabled: true }, from: { enabled: false }, middle: { enabled: false } };
533
+ } else if (arrowVal === 'from') {
534
+ updatedEdge.arrows = { to: { enabled: false }, from: { enabled: true }, middle: { enabled: false } };
535
+ } else if (arrowVal === 'middle') {
536
+ updatedEdge.arrows = { to: { enabled: false }, from: { enabled: false }, middle: { enabled: true } };
537
+ } else if (arrowVal === 'both') {
538
+ updatedEdge.arrows = { to: { enabled: true }, from: { enabled: true }, middle: { enabled: false } };
539
+ }
540
+ updatedEdge.font = { size: clamp(parseInt(getEl('edge-fontsize').value, 10) || 14, 8, 48) };
541
+ // Update via DataSet directly, then tell vis.js we handled it
542
+ edgesDataSet.update(updatedEdge);
543
+ var cb = manipCallback;
544
+ closeModals();
545
+ cb(null);
546
+ }
547
+
548
+ function openEdgeLinksModal(edgeData, callback) {
549
+ manipEdgeData = edgeData;
550
+ manipCallback = callback;
551
+ // Populate from/to dropdowns with all current nodes
552
+ var fromSelect = getEl('links-from');
553
+ var toSelect = getEl('links-to');
554
+ fromSelect.innerHTML = '';
555
+ toSelect.innerHTML = '';
556
+ nodesDataSet.forEach(function(node) {
557
+ var label = (node.label || node.id) + ' (' + node.id + ')';
558
+ var optFrom = document.createElement('option');
559
+ optFrom.value = node.id;
560
+ optFrom.textContent = label;
561
+ fromSelect.appendChild(optFrom);
562
+ var optTo = document.createElement('option');
563
+ optTo.value = node.id;
564
+ optTo.textContent = label;
565
+ toSelect.appendChild(optTo);
566
+ });
567
+ fromSelect.value = edgeData.from;
568
+ toSelect.value = edgeData.to;
569
+ linksOverlay.style.display = 'flex';
570
+ fromSelect.focus();
571
+ }
572
+
573
+ function saveEdgeLinks() {
574
+ if (!manipEdgeData || !manipCallback) return;
575
+ var updatedEdge = {
576
+ id: manipEdgeData.id,
577
+ from: getEl('links-from').value,
578
+ to: getEl('links-to').value
579
+ };
580
+ // Resolve against the real node ids: <select> values are always
581
+ // strings, but the DataSet may key nodes by number. Never coerce
582
+ // blindly (that would turn '007' into 7).
583
+ function realId(value) {
584
+ var ids = nodesDataSet.getIds();
585
+ for (var i = 0; i < ids.length; i++) {
586
+ if (String(ids[i]) === String(value)) return ids[i];
587
+ }
588
+ return value;
589
+ }
590
+ updatedEdge.from = realId(updatedEdge.from);
591
+ updatedEdge.to = realId(updatedEdge.to);
592
+ edgesDataSet.update(updatedEdge);
593
+ var cb = manipCallback;
594
+ closeModals();
595
+ cb(null);
596
+ }
597
+
598
+ // --- Click-outside and button handlers ---
599
+ function setupModalHandlers(overlay, onSave) {
600
+ overlay.addEventListener('click', function(e) {
601
+ if (e.target === overlay) closeModals();
602
+ var action = e.target.dataset && e.target.dataset.action;
603
+ if (action === 'close' || action === 'cancel') closeModals();
604
+ if (action === 'save') onSave();
605
+ });
606
+ }
607
+ setupModalHandlers(nodeOverlay, saveNode);
608
+ setupModalHandlers(edgeOverlay, saveEdge);
609
+ setupModalHandlers(linksOverlay, saveEdgeLinks);
610
+
611
+ // Esc key closes modals (handler stored on ref later for cleanup)
612
+ var escHandler = function(e) {
613
+ if (e.key === 'Escape') closeModals();
614
+ };
615
+ document.addEventListener('keydown', escHandler);
616
+
617
+ // Enter key saves in modals
618
+ nodeOverlay.addEventListener('keydown', function(e) {
619
+ if (e.key === 'Enter') saveNode();
620
+ });
621
+ edgeOverlay.addEventListener('keydown', function(e) {
622
+ if (e.key === 'Enter') saveEdge();
623
+ });
624
+ linksOverlay.addEventListener('keydown', function(e) {
625
+ if (e.key === 'Enter') saveEdgeLinks();
626
+ });
627
+
628
+ // Edge edit mode: 'attributes' (modal) or 'links' (reconnect modal)
629
+ var edgeEditMode = 'attributes';
630
+ // Node template mode: when true, Add Node modal shows existing shapes
631
+ var nodeTemplateMode = false;
632
+
633
+ // --- Wire up vis.js manipulation callbacks ---
634
+ options.manipulation = Object.assign({}, options.manipulation, {
635
+ addNode: function(nodeData, callback) {
636
+ openNodeModal(nodeData, callback, 'Add Node');
637
+ },
638
+ editNode: function(nodeData, callback) {
639
+ openNodeModal(nodeData, callback, 'Edit Node');
640
+ },
641
+ addEdge: function(edgeData, callback) {
642
+ // vis.js handles the drawing — just confirm
643
+ callback(edgeData);
644
+ },
645
+ editEdge: {
646
+ editWithoutDrag: function(edgeData, callback) {
647
+ if (edgeEditMode === 'links') {
648
+ openEdgeLinksModal(edgeData, callback);
649
+ } else {
650
+ openEdgeModal(edgeData, callback);
651
+ }
652
+ }
653
+ },
654
+ deleteNode: function(data, callback) {
655
+ if (confirm('Delete ' + data.nodes.length + ' node(s) and ' + data.edges.length + ' connected edge(s)?')) {
656
+ callback(data);
657
+ } else {
658
+ callback(null);
659
+ }
660
+ },
661
+ deleteEdge: function(data, callback) {
662
+ if (confirm('Delete ' + data.edges.length + ' edge(s)?')) {
663
+ callback(data);
664
+ } else {
665
+ callback(null);
666
+ }
667
+ }
668
+ });
669
+
670
+ // Store mode setters and manipulation options for command handler access
671
+ container._pyvisEdgeEditMode = function(mode) {
672
+ edgeEditMode = mode;
673
+ };
674
+ container._pyvisNodeTemplateMode = function(enabled) {
675
+ nodeTemplateMode = enabled;
676
+ };
677
+ container._pyvisManipOptions = options.manipulation;
678
+ }
679
+
680
+ var network = new vis.Network(canvasDiv, data, options);
681
+
682
+ // Store reference
683
+ var ref = {
684
+ network: network,
685
+ nodes: nodesDataSet,
686
+ edges: edgesDataSet,
687
+ container: container,
688
+ canvasDiv: canvasDiv,
689
+ outputId: outputId,
690
+ resizeObserver: null
691
+ };
692
+ window.pyvisNetworks[outputId] = ref;
693
+
694
+ // Attach deferred handler references for cleanup on re-render
695
+ if (typeof escHandler !== 'undefined') {
696
+ ref._escHandler = escHandler;
697
+ }
698
+
699
+ // Update status bar
700
+ function updateStatus() {
701
+ if (statusLeft) {
702
+ statusLeft.textContent = nodesDataSet.length + ' nodes, ' + edgesDataSet.length + ' edges';
703
+ }
704
+ }
705
+ updateStatus();
706
+ nodesDataSet.on('*', updateStatus);
707
+ edgesDataSet.on('*', updateStatus);
708
+ ref._statusHandler = updateStatus;
709
+
710
+ // === EVENTS ===
711
+ function shouldBind(eventName) {
712
+ return !enabledEvents || enabledEvents.indexOf(eventName) !== -1;
713
+ }
714
+
715
+ if (shouldBind('click')) {
716
+ network.on('click', function(params) {
717
+ Shiny.setInputValue(outputId + '_click', {
718
+ nodes: params.nodes,
719
+ edges: params.edges,
720
+ pointer: params.pointer
721
+ }, {priority: 'event'});
722
+ });
723
+ }
724
+
725
+ if (shouldBind('doubleClick')) {
726
+ network.on('doubleClick', function(params) {
727
+ Shiny.setInputValue(outputId + '_doubleClick', {
728
+ nodes: params.nodes,
729
+ edges: params.edges,
730
+ pointer: params.pointer
731
+ }, {priority: 'event'});
732
+ });
733
+ }
734
+
735
+ if (shouldBind('contextMenu')) {
736
+ network.on('oncontext', function(params) {
737
+ var nodeAtPos = network.getNodeAt(params.pointer.DOM);
738
+ var edgeAtPos = network.getEdgeAt(params.pointer.DOM);
739
+ Shiny.setInputValue(outputId + '_contextMenu', {
740
+ nodes: nodeAtPos != null ? [nodeAtPos] : [],
741
+ edges: edgeAtPos != null ? [edgeAtPos] : [],
742
+ pointer: params.pointer
743
+ }, {priority: 'event'});
744
+ });
745
+ }
746
+
747
+ if (shouldBind('selectNode')) {
748
+ network.on('selectNode', function(params) {
749
+ var nodeId = params.nodes[0];
750
+ Shiny.setInputValue(outputId + '_selectNode', {
751
+ nodeId: nodeId,
752
+ nodeIds: params.nodes,
753
+ nodeData: pyvisSafeClone(nodesDataSet.get(nodeId)),
754
+ connectedNodes: network.getConnectedNodes(nodeId),
755
+ connectedEdges: network.getConnectedEdges(nodeId),
756
+ edges: params.edges
757
+ }, {priority: 'event'});
758
+ });
759
+ }
760
+
761
+ if (shouldBind('deselectNode')) {
762
+ network.on('deselectNode', function(params) {
763
+ Shiny.setInputValue(outputId + '_deselectNode', {
764
+ previousSelection: pyvisSafeClone(params.previousSelection)
765
+ }, {priority: 'event'});
766
+ });
767
+ }
768
+
769
+ if (shouldBind('selectEdge')) {
770
+ network.on('selectEdge', function(params) {
771
+ var edgeId = params.edges[0];
772
+ Shiny.setInputValue(outputId + '_selectEdge', {
773
+ edgeId: edgeId,
774
+ edgeIds: params.edges,
775
+ edgeData: pyvisSafeClone(edgesDataSet.get(edgeId))
776
+ }, {priority: 'event'});
777
+ });
778
+ }
779
+
780
+ if (shouldBind('deselectEdge')) {
781
+ network.on('deselectEdge', function(params) {
782
+ Shiny.setInputValue(outputId + '_deselectEdge', {
783
+ previousSelection: pyvisSafeClone(params.previousSelection)
784
+ }, {priority: 'event'});
785
+ });
786
+ }
787
+
788
+ if (shouldBind('dragStart')) {
789
+ network.on('dragStart', function(params) {
790
+ Shiny.setInputValue(outputId + '_dragStart', {
791
+ nodes: params.nodes
792
+ }, {priority: 'event'});
793
+ });
794
+ }
795
+
796
+ if (shouldBind('dragEnd')) {
797
+ network.on('dragEnd', function(params) {
798
+ Shiny.setInputValue(outputId + '_dragEnd', {
799
+ nodes: params.nodes,
800
+ positions: network.getPositions(params.nodes)
801
+ }, {priority: 'event'});
802
+ });
803
+ }
804
+
805
+ // Debounced events
806
+ if (shouldBind('hoverNode')) {
807
+ network.on('hoverNode', pyvisDebounce(function(params) {
808
+ Shiny.setInputValue(outputId + '_hoverNode', {
809
+ nodeId: params.node,
810
+ nodeData: pyvisSafeClone(nodesDataSet.get(params.node))
811
+ }, {priority: 'event'});
812
+ }, 100));
813
+ }
814
+
815
+ if (shouldBind('blurNode')) {
816
+ network.on('blurNode', function(params) {
817
+ Shiny.setInputValue(outputId + '_blurNode', {
818
+ nodeId: params.node
819
+ }, {priority: 'event'});
820
+ });
821
+ }
822
+
823
+ if (shouldBind('hoverEdge')) {
824
+ network.on('hoverEdge', pyvisDebounce(function(params) {
825
+ Shiny.setInputValue(outputId + '_hoverEdge', {
826
+ edgeId: params.edge,
827
+ edgeData: pyvisSafeClone(edgesDataSet.get(params.edge))
828
+ }, {priority: 'event'});
829
+ }, 100));
830
+ }
831
+
832
+ if (shouldBind('blurEdge')) {
833
+ network.on('blurEdge', function(params) {
834
+ Shiny.setInputValue(outputId + '_blurEdge', {
835
+ edgeId: params.edge
836
+ }, {priority: 'event'});
837
+ });
838
+ }
839
+
840
+ if (shouldBind('zoom')) {
841
+ network.on('zoom', pyvisDebounce(function(params) {
842
+ Shiny.setInputValue(outputId + '_zoom', {
843
+ direction: params.direction,
844
+ scale: params.scale
845
+ }, {priority: 'event'});
846
+ }, 100));
847
+ }
848
+
849
+ if (shouldBind('stabilizationProgress')) {
850
+ network.on('stabilizationProgress', pyvisDebounce(function(params) {
851
+ Shiny.setInputValue(outputId + '_stabilizationProgress', {
852
+ iterations: params.iterations,
853
+ total: params.total
854
+ }, {priority: 'event'});
855
+ }, 200));
856
+ }
857
+
858
+ if (shouldBind('stabilized')) {
859
+ network.on('stabilizationIterationsDone', function() {
860
+ Shiny.setInputValue(outputId + '_stabilized', {
861
+ timestamp: Date.now()
862
+ }, {priority: 'event'});
863
+ });
864
+ }
865
+
866
+ if (shouldBind('animationFinished')) {
867
+ network.on('animationFinished', function() {
868
+ Shiny.setInputValue(outputId + '_animationFinished', {
869
+ timestamp: Date.now()
870
+ }, {priority: 'event'});
871
+ });
872
+ }
873
+
874
+ // Bound unconditionally; the gate is inside so the configurator
875
+ // handler exists even when `events` omits it.
876
+ network.on('configChange', function(params) {
877
+ if (shouldBind('configChange')) {
878
+ Shiny.setInputValue(outputId + '_configChange', pyvisSafeClone(params), {priority: 'event'});
879
+ }
880
+ });
881
+
882
+ // Ready event (always sent)
883
+ Shiny.setInputValue(outputId + '_ready', {
884
+ nodeCount: nodesDataSet.length,
885
+ edgeCount: edgesDataSet.length,
886
+ timestamp: Date.now()
887
+ }, {priority: 'event'});
888
+
889
+ // === SEARCH ===
890
+ if (searchInput) {
891
+ var originalColors = {};
892
+
893
+ searchInput.addEventListener('input', pyvisDebounce(function() {
894
+ var query = searchInput.value.trim().toLowerCase();
895
+
896
+ if (!query) {
897
+ // Restore all
898
+ var updates = [];
899
+ Object.keys(originalColors).forEach(function(key) {
900
+ var entry = originalColors[key];
901
+ updates.push({ id: entry.id, color: entry.color, opacity: 1.0 });
902
+ });
903
+ if (updates.length) nodesDataSet.update(updates);
904
+ originalColors = {};
905
+ searchCount.textContent = '';
906
+ network.unselectAll();
907
+ return;
908
+ }
909
+
910
+ var allNodes = nodesDataSet.get();
911
+ var matches = [];
912
+ var dims = [];
913
+
914
+ allNodes.forEach(function(node) {
915
+ var label = (node.label || '').toLowerCase();
916
+ var title = (node.title || '').toLowerCase();
917
+ var id = String(node.id).toLowerCase();
918
+
919
+ if (label.indexOf(query) !== -1 || title.indexOf(query) !== -1 || id.indexOf(query) !== -1) {
920
+ matches.push(node);
921
+ } else {
922
+ dims.push(node);
923
+ }
924
+ });
925
+
926
+ // Save originals and dim non-matches
927
+ var updates = [];
928
+ dims.forEach(function(node) {
929
+ if (!(node.id in originalColors)) {
930
+ // Store the real id alongside the color: object keys
931
+ // stringify, so numeric ids would be lost otherwise.
932
+ originalColors[node.id] = { id: node.id, color: node.color };
933
+ }
934
+ updates.push({ id: node.id, opacity: 0.15 });
935
+ });
936
+ matches.forEach(function(node) {
937
+ if (originalColors[node.id]) {
938
+ updates.push({ id: node.id, color: originalColors[node.id].color, opacity: 1.0 });
939
+ delete originalColors[node.id];
940
+ } else {
941
+ updates.push({ id: node.id, opacity: 1.0 });
942
+ }
943
+ });
944
+ if (updates.length) nodesDataSet.update(updates);
945
+
946
+ searchCount.textContent = matches.length + '/' + allNodes.length;
947
+
948
+ // Select first match
949
+ if (matches.length > 0) {
950
+ network.selectNodes([matches[0].id]);
951
+ network.focus(matches[0].id, { scale: 1.2, animation: true });
952
+ }
953
+ }, 200));
954
+
955
+ searchInput.addEventListener('keydown', function(e) {
956
+ if (e.key === 'Escape') {
957
+ searchInput.value = '';
958
+ searchInput.dispatchEvent(new Event('input'));
959
+ }
960
+ });
961
+ }
962
+
963
+ // === LAYOUT SWITCHER ===
964
+ if (layoutSelect) {
965
+ layoutSelect.addEventListener('change', function() {
966
+ var val = layoutSelect.value;
967
+ var newOptions;
968
+
969
+ if (val === 'none') {
970
+ newOptions = {
971
+ physics: { enabled: false },
972
+ layout: { hierarchical: false }
973
+ };
974
+ } else if (val === 'hierarchicalUD') {
975
+ newOptions = {
976
+ physics: { enabled: true },
977
+ layout: {
978
+ hierarchical: {
979
+ enabled: true,
980
+ direction: 'UD',
981
+ sortMethod: 'directed'
982
+ }
983
+ }
984
+ };
985
+ } else if (val === 'hierarchicalLR') {
986
+ newOptions = {
987
+ physics: { enabled: true },
988
+ layout: {
989
+ hierarchical: {
990
+ enabled: true,
991
+ direction: 'LR',
992
+ sortMethod: 'directed'
993
+ }
994
+ }
995
+ };
996
+ } else {
997
+ newOptions = {
998
+ layout: { hierarchical: false },
999
+ physics: {
1000
+ enabled: true,
1001
+ solver: val
1002
+ }
1003
+ };
1004
+ }
1005
+ network.setOptions(newOptions);
1006
+ });
1007
+ }
1008
+
1009
+ // === EXPORT ===
1010
+ if (showExport) {
1011
+ container.addEventListener('click', function(e) {
1012
+ var btn = e.target.closest('.pyvis-btn');
1013
+ if (!btn) return;
1014
+
1015
+ if (btn.dataset.action === 'exportPng') {
1016
+ var canvas = canvasDiv.querySelector('canvas');
1017
+ if (canvas) {
1018
+ var link = document.createElement('a');
1019
+ link.download = 'network.png';
1020
+ link.href = canvas.toDataURL('image/png');
1021
+ link.click();
1022
+ }
1023
+ } else if (btn.dataset.action === 'exportJson') {
1024
+ var exportData = {
1025
+ nodes: nodesDataSet.get(),
1026
+ edges: edgesDataSet.get(),
1027
+ positions: network.getPositions()
1028
+ };
1029
+ var blob = new Blob([JSON.stringify(exportData, null, 2)], {type: 'application/json'});
1030
+ var link = document.createElement('a');
1031
+ link.download = 'network.json';
1032
+ link.href = URL.createObjectURL(blob);
1033
+ link.click();
1034
+ URL.revokeObjectURL(link.href);
1035
+ }
1036
+ });
1037
+ }
1038
+
1039
+ // === RESIZE OBSERVER ===
1040
+ var resizeHandler = pyvisDebounce(function() {
1041
+ network.setSize(canvasDiv.offsetWidth + 'px', canvasDiv.offsetHeight + 'px');
1042
+ network.redraw();
1043
+ }, 150);
1044
+
1045
+ var resizeObserver = new ResizeObserver(resizeHandler);
1046
+ resizeObserver.observe(el);
1047
+ ref.resizeObserver = resizeObserver;
1048
+
1049
+ // Scale info in status bar
1050
+ if (statusRight) {
1051
+ network.on('zoom', pyvisDebounce(function() {
1052
+ statusRight.textContent = 'Zoom: ' + (network.getScale() * 100).toFixed(0) + '%';
1053
+ }, 100));
1054
+ statusRight.textContent = 'Zoom: 100%';
1055
+ }
1056
+
1057
+ console.debug('PyVis Shiny v3: initialized ' + outputId + ' (' + nodesDataSet.length + ' nodes, ' + edgesDataSet.length + ' edges)');
1058
+
1059
+ // Replay any commands that arrived before this network existed.
1060
+ var pending = window.pyvisPendingCommands[outputId] || [];
1061
+ delete window.pyvisPendingCommands[outputId];
1062
+ pending.forEach(pyvisHandleCommand);
1063
+ }
1064
+ }
1065
+
1066
+ // Register the output binding
1067
+ Shiny.outputBindings.register(
1068
+ new PyVisOutputBinding(),
1069
+ 'pyvis-network-output'
1070
+ );
1071
+
1072
+ // === COMMAND HANDLER ===
1073
+ window.pyvisPendingCommands = window.pyvisPendingCommands || {};
1074
+
1075
+ // Treat an explicit null argument as absent so vis.js applies its default
1076
+ // (e.g. getPositions(undefined) returns every node).
1077
+ function pyvisUndefNulls(obj) {
1078
+ if (!obj || typeof obj !== 'object') return obj;
1079
+ Object.keys(obj).forEach(function(k) { if (obj[k] === null) obj[k] = undefined; });
1080
+ return obj;
1081
+ }
1082
+
1083
+ function pyvisHandleCommand(message) {
1084
+ var outputId = message.outputId;
1085
+ var command = message.command;
1086
+ var args = pyvisUndefNulls(message.args || {});
1087
+ var ref = window.pyvisNetworks[outputId];
1088
+
1089
+ if (!ref || !ref.network) {
1090
+ (window.pyvisPendingCommands[outputId] = window.pyvisPendingCommands[outputId] || []).push(message);
1091
+ console.debug('PyVis: queued command until render:', command, outputId);
1092
+ return;
1093
+ }
1094
+
1095
+ var network = ref.network;
1096
+ var nodes = ref.nodes;
1097
+ var edges = ref.edges;
1098
+
1099
+ try {
1100
+ switch (command) {
1101
+ // Selection
1102
+ case 'selectNodes':
1103
+ network.selectNodes(args.nodeIds || [], args.highlightEdges !== false);
1104
+ break;
1105
+ case 'selectEdges':
1106
+ network.selectEdges(args.edgeIds || []);
1107
+ break;
1108
+ case 'unselectAll':
1109
+ network.unselectAll();
1110
+ break;
1111
+
1112
+ // Viewport
1113
+ case 'fit':
1114
+ network.fit(args);
1115
+ break;
1116
+ case 'focus':
1117
+ network.focus(args.nodeId, args.options || {});
1118
+ break;
1119
+ case 'moveTo':
1120
+ network.moveTo(args);
1121
+ break;
1122
+
1123
+ // Physics
1124
+ case 'startSimulation':
1125
+ network.startSimulation();
1126
+ break;
1127
+ case 'stopSimulation':
1128
+ network.stopSimulation();
1129
+ break;
1130
+ case 'stabilize':
1131
+ network.stabilize(args.iterations || 100);
1132
+ break;
1133
+
1134
+ // Data manipulation
1135
+ case 'addNode':
1136
+ nodes.add(args.node);
1137
+ break;
1138
+ case 'addNodes':
1139
+ nodes.add(args.nodes);
1140
+ break;
1141
+ case 'updateNode':
1142
+ nodes.update(args.node);
1143
+ break;
1144
+ case 'removeNode':
1145
+ nodes.remove(args.nodeId);
1146
+ break;
1147
+ case 'addEdge':
1148
+ edges.add(args.edge);
1149
+ break;
1150
+ case 'addEdges':
1151
+ edges.add(args.edges);
1152
+ break;
1153
+ case 'updateEdge':
1154
+ edges.update(args.edge);
1155
+ break;
1156
+ case 'removeEdge':
1157
+ edges.remove(args.edgeId);
1158
+ break;
1159
+
1160
+ // Batch update with diff
1161
+ case 'updateData':
1162
+ if (args.nodes) {
1163
+ // Sets preserve id types; object keys would stringify them.
1164
+ var keepNodes = new Set(args.nodes.map(function(n) { return n.id; }));
1165
+ var dropNodes = nodes.getIds().filter(function(id) { return !keepNodes.has(id); });
1166
+ if (dropNodes.length) nodes.remove(dropNodes);
1167
+ nodes.update(args.nodes);
1168
+ }
1169
+ if (args.edges) {
1170
+ var keepEdges = new Set(args.edges.map(function(e) { return e.id; }));
1171
+ var dropEdges = edges.getIds().filter(function(id) { return !keepEdges.has(id); });
1172
+ if (dropEdges.length) edges.remove(dropEdges);
1173
+ edges.update(args.edges);
1174
+ }
1175
+ break;
1176
+
1177
+ // Clustering
1178
+ // 'cluster' is not supported: vis.js requires a joinCondition function which cannot cross JSON.
1179
+ case 'clusterByConnection':
1180
+ network.clusterByConnection(args.nodeId, args.options);
1181
+ break;
1182
+ case 'clusterByHubsize':
1183
+ network.clusterByHubsize(args.hubsize, args.options);
1184
+ break;
1185
+ case 'openCluster':
1186
+ network.openCluster(args.nodeId);
1187
+ break;
1188
+
1189
+ // Options
1190
+ case 'setOptions':
1191
+ network.setOptions(args.options);
1192
+ break;
1193
+
1194
+ // Theme
1195
+ case 'setTheme':
1196
+ var newTheme = args.theme || 'light';
1197
+ ref.container.className = ref.container.className
1198
+ .replace(/pyvis-theme-\w+/, 'pyvis-theme-' + newTheme);
1199
+ break;
1200
+
1201
+ // Queries - responses sent back as Shiny inputs
1202
+ case 'getPositions':
1203
+ Shiny.setInputValue(outputId + '_response_positions',
1204
+ network.getPositions(args.nodeIds), {priority: 'event'});
1205
+ break;
1206
+ case 'getSelection':
1207
+ Shiny.setInputValue(outputId + '_response_selection',
1208
+ network.getSelection(), {priority: 'event'});
1209
+ break;
1210
+ case 'getScale':
1211
+ Shiny.setInputValue(outputId + '_response_scale',
1212
+ network.getScale(), {priority: 'event'});
1213
+ break;
1214
+ case 'getViewPosition':
1215
+ Shiny.setInputValue(outputId + '_response_viewPosition',
1216
+ network.getViewPosition(), {priority: 'event'});
1217
+ break;
1218
+ case 'getAllData':
1219
+ Shiny.setInputValue(outputId + '_response_allData', pyvisSafeClone({
1220
+ nodes: nodes.get(),
1221
+ edges: edges.get(),
1222
+ positions: network.getPositions(),
1223
+ scale: network.getScale(),
1224
+ viewPosition: network.getViewPosition()
1225
+ }), {priority: 'event'});
1226
+ break;
1227
+
1228
+ // Manipulation mode
1229
+ case 'setEdgeEditMode':
1230
+ if (ref.container._pyvisEdgeEditMode) {
1231
+ ref.container._pyvisEdgeEditMode(args.mode || 'attributes');
1232
+ }
1233
+ break;
1234
+
1235
+ case 'setNodeTemplateMode':
1236
+ if (ref.container._pyvisNodeTemplateMode) {
1237
+ ref.container._pyvisNodeTemplateMode(!!args.enabled);
1238
+ }
1239
+ break;
1240
+
1241
+ case 'toggleManipulation':
1242
+ // Use CSS display toggling instead of network.setOptions() to
1243
+ // avoid vis.js rebuilding (and losing) the manipulation toolbar.
1244
+ var manipDiv = ref.canvasDiv.querySelector('.vis-manipulation');
1245
+ var editDiv = ref.canvasDiv.querySelector('.vis-edit-mode');
1246
+ if (args.enabled) {
1247
+ if (manipDiv) manipDiv.style.display = '';
1248
+ if (editDiv) editDiv.style.display = '';
1249
+ } else {
1250
+ if (manipDiv) manipDiv.style.display = 'none';
1251
+ if (editDiv) editDiv.style.display = 'none';
1252
+ }
1253
+ break;
1254
+
1255
+ default:
1256
+ console.warn('PyVis: unknown command', command);
1257
+ }
1258
+ } catch (e) {
1259
+ console.error('PyVis: command "' + command + '" failed for "' + outputId + '":', e);
1260
+ }
1261
+ }
1262
+
1263
+ Shiny.addCustomMessageHandler('pyvis-command', pyvisHandleCommand);
1264
+
1265
+ // Safe DOM class operations for app-level theming.
1266
+ // Accepts: { selector: "body", action: "add"|"remove"|"toggle", className: "app-light" }
1267
+ // Legacy compat: { js: "document.body.classList.add('app-light');" } is parsed safely.
1268
+ Shiny.addCustomMessageHandler('pyvis-run-js', function(message) {
1269
+ var selector, action, className;
1270
+
1271
+ if (message.selector && message.action && message.className) {
1272
+ selector = message.selector;
1273
+ action = message.action;
1274
+ className = message.className;
1275
+ } else if (message.js && typeof message.js === 'string') {
1276
+ // Parse legacy format: "document.body.classList.add('app-light');"
1277
+ var m = message.js.match(
1278
+ /document\.body\.classList\.(add|remove|toggle)\(\s*['"]([^'"]+)['"]\s*\)/
1279
+ );
1280
+ if (m) {
1281
+ selector = 'body';
1282
+ action = m[1];
1283
+ className = m[2];
1284
+ } else {
1285
+ console.warn('PyVis run-js: unsupported operation. Use {selector, action, className} format.');
1286
+ return;
1287
+ }
1288
+ } else {
1289
+ return;
1290
+ }
1291
+
1292
+ if (['add', 'remove', 'toggle'].indexOf(action) === -1) {
1293
+ console.warn('PyVis run-js: action must be add, remove, or toggle');
1294
+ return;
1295
+ }
1296
+ // Validate className is a simple CSS class name (no spaces, no special chars)
1297
+ if (!/^[a-zA-Z_-][a-zA-Z0-9_-]*$/.test(className)) {
1298
+ console.warn('PyVis run-js: invalid className');
1299
+ return;
1300
+ }
1301
+ try {
1302
+ var el = document.querySelector(selector);
1303
+ if (el) el.classList[action](className);
1304
+ } catch (e) {
1305
+ console.warn('PyVis run-js error:', e);
1306
+ }
1307
+ });
1308
+
1309
+ console.debug('PyVis Shiny bindings v3.0 registered');
1310
+ }