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/bindings.js
ADDED
|
@@ -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">×</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">×</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">×</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
|
+
}
|