spanner-graph-notebook 1.0.0__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.
Files changed (40) hide show
  1. assets/authentication.png +0 -0
  2. assets/full_viz.png +0 -0
  3. assets/hero.png +0 -0
  4. assets/hero_with_properties.png +0 -0
  5. assets/jupyter-spin-up.png +0 -0
  6. assets/load_ext.png +0 -0
  7. assets/mock_data_result.png +0 -0
  8. assets/new_notebook.png +0 -0
  9. assets/notebook_package_load.png +0 -0
  10. assets/query_graph.png +0 -0
  11. assets/sample_jupyter.png +0 -0
  12. spanner_graph_notebook-1.0.0.dist-info/LICENSE +202 -0
  13. spanner_graph_notebook-1.0.0.dist-info/METADATA +169 -0
  14. spanner_graph_notebook-1.0.0.dist-info/RECORD +40 -0
  15. spanner_graph_notebook-1.0.0.dist-info/WHEEL +5 -0
  16. spanner_graph_notebook-1.0.0.dist-info/top_level.txt +2 -0
  17. spanner_graphs/__init__.py +19 -0
  18. spanner_graphs/conversion.py +299 -0
  19. spanner_graphs/database.py +199 -0
  20. spanner_graphs/graph_entities.py +405 -0
  21. spanner_graphs/graph_mock_data.csv +51 -0
  22. spanner_graphs/graph_mock_schema.json +681 -0
  23. spanner_graphs/magics.py +211 -0
  24. spanner_graphs/schema_manager.py +60 -0
  25. templates/assets/images/graph-bg.svg +4 -0
  26. templates/spanner-graph/models/edge.js +77 -0
  27. templates/spanner-graph/models/graph-object.js +64 -0
  28. templates/spanner-graph/models/node.js +77 -0
  29. templates/spanner-graph/models/schema.js +327 -0
  30. templates/spanner-graph/spanner-config.js +304 -0
  31. templates/spanner-graph/spanner-store.js +382 -0
  32. templates/spanner-graph/visualization/spanner-forcegraph.js +1380 -0
  33. templates/spanner-graph/visualization/spanner-sidebar.js +904 -0
  34. templates/template-spannergraph.html +210 -0
  35. tests/__init__.py +13 -0
  36. tests/conversion_test.py +163 -0
  37. tests/database_test.py +62 -0
  38. tests/graph_entities_test.py +124 -0
  39. tests/schema_manager_test.py +115 -0
  40. tests/test_notebook.json +23 -0
@@ -0,0 +1,1380 @@
1
+ /**
2
+ * Copyright 2024 Google LLC
3
+ * Licensed under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License.
5
+ * You may obtain a copy of the License at
6
+ *
7
+ * http://www.apache.org/licenses/LICENSE-2.0
8
+ *
9
+ * Unless required by applicable law or agreed to in writing, software
10
+ * distributed under the License is distributed on an "AS IS" BASIS,
11
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ * See the License for the specific language governing permissions and
13
+ * limitations under the License.
14
+ */
15
+
16
+ class GraphVisualization {
17
+ /**
18
+ * The graph store that this visualization is based on.
19
+ * @type {GraphStore}
20
+ */
21
+ store;
22
+
23
+ /**
24
+ * The DOM element that the graph will be rendered in.
25
+ * @type {HTMLElement}
26
+ */
27
+ mount;
28
+
29
+ /**
30
+ * The DOM element that the menu will be rendered in.
31
+ * @type {HTMLElement}
32
+ */
33
+ menuMount;
34
+
35
+ /**
36
+ * The ForceGraph object that renders the graph.
37
+ * @type {ForceGraph}
38
+ */
39
+ graph;
40
+
41
+ /**
42
+ * The Node that the user has clicked on. It will be visually highlighted.
43
+ * @type {?Node}
44
+ */
45
+ selectedNode = null;
46
+
47
+ /**
48
+ * The Edge that the user has clicked on. It will be visually highlighted.
49
+ * @type {?Edge}
50
+ */
51
+ selectedEdge = null;
52
+
53
+
54
+ /**
55
+ * The Nodes that are connected to the selected edge. It will be visually highlighted.
56
+ * @type {Node[]}
57
+ */
58
+ selectedEdgeNeighbors = [];
59
+
60
+ /**
61
+ * The Nodes that are connected to the focused edge. It will be visually highlighted.
62
+ * @type {Node[]}
63
+ */
64
+ focusedEdgeNeighbors = [];
65
+
66
+ /**
67
+ * All the edges connected to the selected node. They will be visually highlighted.
68
+ * @type {Array<Edge>}
69
+ */
70
+ selectedNodeEdges = [];
71
+
72
+ /**
73
+ * Neighboring Nodes to the selected Node. They will be visually highlighted, but not as prominently as the selected node.
74
+ * @type {Array<Node>}
75
+ */
76
+ selectedNodeNeighbors = [];
77
+
78
+ /**
79
+ * The Node that the user is hovering their mouse over. It will be visually highlighted.
80
+ * @type {?Node}
81
+ */
82
+ focusedNode = null;
83
+
84
+ /**
85
+ * All the edges connected to the focused node. They will be visually highlighted.
86
+ * @type {Array<Edge>}
87
+ */
88
+ focusedNodeEdges = [];
89
+
90
+ /**
91
+ * Neighboring Nodes to the focused Node. They will be visually highlighted, but not as prominently as the focused node.
92
+ * @type {Array<Node>}
93
+ */
94
+ focusedNodeNeighbors = [];
95
+
96
+ // The graph will only automatically center upon
97
+ // the initial layout has finished.
98
+ requestedRecenter = false;
99
+
100
+ /**
101
+ * @typedef {Object} ToolsConfig
102
+ * @property {number} zoomInSpeed - Speed of zooming in.
103
+ * @property {number} zoomInIncrement - Increment value for zooming in.
104
+ * @property {number} zoomOutIncrement - Increment value for zooming out.
105
+ * @property {number} zoomOutSpeed - Speed of zooming out.
106
+ * @property {number} recenterSpeed - Speed of recenter.
107
+ */
108
+ /**
109
+ * @typedef {Object} ToolsElements
110
+ * @property {HTMLElement|null} container - The container element.
111
+ * @property {HTMLElement|null} recenter - The recenter button element.
112
+ * @property {HTMLElement|null} zoomIn - The zoom in button element.
113
+ * @property {HTMLElement|null} zoomOut - The zoom out button element.
114
+ * @property {HTMLElement|null} viewMode - The toggle element to switch view modes
115
+ */
116
+ /**
117
+ * @typedef {Object} Tools
118
+ * @property {ToolsElements} elements - The HTML elements used by the tools.
119
+ * @property {ToolsConfig} config - Configuration for the tools.
120
+ */
121
+ /** @type {Tools} */
122
+ tools = {
123
+ elements: {
124
+ container: null,
125
+ recenter: null,
126
+ zoomIn: null,
127
+ zoomOut: null,
128
+ viewMode: null,
129
+ toggleFullscreen: null
130
+ },
131
+ config: {
132
+ zoomInSpeed: 100,
133
+ zoomInIncrement: 2,
134
+ zoomOutIncrement: 0.5,
135
+ zoomOutSpeed: 100,
136
+ recenterSpeed: 200
137
+ }
138
+ };
139
+
140
+ static ClusterMethod = Object.freeze({
141
+ NEIGHBORHOOD: Symbol('Force directed in community'),
142
+ LABEL: Symbol('Force directed')
143
+ });
144
+
145
+ /**
146
+ * @typedef {Object} MenuConfig
147
+ * @property {ClusterMethod} clusterBy - Speed of zooming in.
148
+ */
149
+ /**
150
+ * @typedef {Object} MenuElements
151
+ * @property {HTMLElement|null} cluster - The element that toggles cluster method
152
+ */
153
+
154
+ /**
155
+ *
156
+ * @type {
157
+ * {
158
+ * layout: {lastLayout: null,
159
+ * currentLayout: null},
160
+ * elements: {cluster: HTMLElement,
161
+ * layoutDropdownToggle: HTMLElement,
162
+ * layoutDropdownContent: HTMLElement},
163
+ * config: {clusterBy: symbol}
164
+ * }}
165
+ */
166
+ menu = {
167
+ elements: {
168
+ cluster: null,
169
+ layoutDropdownToggle: null,
170
+ layoutDropdownContent: null,
171
+ },
172
+ config: {
173
+ clusterBy: GraphVisualization.ClusterMethod.NEIGHBORHOOD
174
+ },
175
+ layout: {
176
+ lastLayout: null,
177
+ currentLayout: null
178
+ }
179
+ };
180
+
181
+ // key is the amount of nodes in the ground
182
+ static GraphSizes = Object.freeze({
183
+ SMALL: Symbol('Small'),
184
+ MEDIUM: Symbol('Medium'),
185
+ LARGE: Symbol('Large'),
186
+ UNDEFINED: Symbol('Large'),
187
+ });
188
+
189
+ getNodeRelativeSize() {
190
+ return this.NODE_REL_SIZE;
191
+ }
192
+
193
+ NODE_REL_SIZE = 4;
194
+
195
+ enterFullscreenSvg = `<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#3C4043"><path d="M120-120v-200h80v120h120v80H120Zm520 0v-80h120v-120h80v200H640ZM120-640v-200h200v80H200v120h-80Zm640 0v-120H640v-80h200v200h-80Z"/></svg>`;
196
+ exitFullscreenSvg = `<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#3C4043"><path d="M240-120v-120H120v-80h200v200h-80Zm400 0v-200h200v80H720v120h-80ZM120-640v-80h120v-120h80v200H120Zm520 0v-200h80v120h120v80H640Z"/></svg>`;
197
+
198
+ /**
199
+ * Renders a graph visualization.
200
+ * @param {GraphStore} inStore - The store to derive configuration from.
201
+ * @param {HTMLElement} inMount - The DOM element that the graph will be rendered in.
202
+ * @param {HTMLElement} inMenuMount - The DOM element that the top menu will be rendered in.
203
+ */
204
+ constructor(inStore, inMount, inMenuMount) {
205
+ if (!(inStore instanceof GraphStore)) {
206
+ throw Error('Store must be an instance of GraphStore');
207
+ }
208
+
209
+ if (!(inMount instanceof HTMLElement)) {
210
+ throw Error('Mount must be an instance of HTMLElement');
211
+ }
212
+
213
+ if (!(inMenuMount instanceof HTMLElement)) {
214
+ throw Error('Menu Mount must be an instance of HTMLElement');
215
+ }
216
+
217
+ this.store = inStore;
218
+ this.mount = inMount;
219
+ this.menuMount = inMenuMount;
220
+
221
+ // tooltips are absolutely positioned
222
+ // relative to the mounting element
223
+ this.mount.style.display = 'relative';
224
+
225
+ this.initializeEvents(this.store);
226
+ this.graph = ForceGraph()(this.mount);
227
+ this._setupGraphTools(this.graph);
228
+ this._setupMenu(this.graph);
229
+ this._setupDrawLabelsOnEdges(this.graph);
230
+ this._setupLineLength(this.graph);
231
+ this._setupDrawNodes(this.graph);
232
+ this._setupDrawEdges(this.graph);
233
+
234
+ this.graph.dagMode('');
235
+
236
+ this.render();
237
+ }
238
+
239
+ sanitize(input) {
240
+ if (input === null || input === undefined) return '';
241
+ const str = String(input)
242
+ return str.replace(/[&<>"'`=\/]/g, function (s) {
243
+ return {
244
+ '&': '&amp;',
245
+ '<': '&lt;',
246
+ '>': '&gt;',
247
+ '"': '&quot;',
248
+ "'": '&#39;',
249
+ '`': '&#96;',
250
+ '=': '&#61;',
251
+ '/': '&#47;'
252
+ }[s];
253
+ });
254
+ }
255
+
256
+ /**
257
+ * Registers callbacks for GraphStore events.
258
+ * @param {GraphStore} store
259
+ */
260
+ initializeEvents(store) {
261
+ if (!(store instanceof GraphStore)) {
262
+ throw Error('Store must be an instance of GraphStore');
263
+ }
264
+
265
+ store.addEventListener(GraphStore.EventTypes.VIEW_MODE_CHANGE,
266
+ (viewMode, config) => this.onViewModeChange(viewMode, config));
267
+
268
+ store.addEventListener(GraphStore.EventTypes.CONFIG_CHANGE,
269
+ (config) => this.onStoreConfigChange(config));
270
+
271
+ store.addEventListener(GraphStore.EventTypes.FOCUS_OBJECT,
272
+ (graphObject, config) => {
273
+ this.focusedNode = null;
274
+ this.focusedNodeEdges = [];
275
+ this.focusedNodeNeighbors = []
276
+ this.focusedEdge = null;
277
+ this.focusedEdgeNeighbors = [];
278
+
279
+ if (graphObject instanceof Node) {
280
+ this.onFocusedNodeChanged(graphObject)
281
+ }
282
+
283
+ if (graphObject instanceof Edge) {
284
+ this.onFocusedEdgeChanged(graphObject)
285
+ }
286
+ });
287
+
288
+ store.addEventListener(GraphStore.EventTypes.SELECT_OBJECT,
289
+ (graphObject, config) => {
290
+ this.selectedNode = null;
291
+ this.selectedEdge = null;
292
+ this.selectedNodeEdges = [];
293
+ this.selectedNodeNeighbors = [];
294
+ this.selectedEdgeNeighbors = [];
295
+
296
+ if (graphObject instanceof Node && graphObject) {
297
+ this.onSelectedNodeChanged(graphObject);
298
+ }
299
+
300
+ if (graphObject instanceof Edge && graphObject) {
301
+ this.onSelectedEdgeChanged(graphObject);
302
+ }
303
+ });
304
+
305
+ document.addEventListener('keydown', (event) => {
306
+ if (event.key === 'Escape') {
307
+ this.store.setSelectedObject(null); // Deselect any selected node/edge
308
+ }
309
+ });
310
+ }
311
+
312
+ /**
313
+ * @type ViewModeChangedCallback
314
+ */
315
+ onViewModeChange(viewMode, config) {
316
+ this.requestedRecenter = true;
317
+ this.graph.graphData({
318
+ nodes: this.store.getNodes(),
319
+ links: this._computeCurvature(this.store.getEdges())
320
+ });
321
+
322
+ this.menu.elements.layoutDropdownContent.classList.remove('disabled');
323
+ this.menu.elements.layoutDropdownToggle.classList.remove('disabled');
324
+ if (viewMode === GraphStore.ViewModes.SCHEMA) {
325
+ this.graph.dagMode('');
326
+ this.menu.elements.layoutDropdownContent.classList.add('disabled');
327
+ this.menu.elements.layoutDropdownToggle.classList.add('disabled');
328
+ } else {
329
+ this.graph.dagMode(this.menu.config.currentLayout);
330
+ }
331
+ }
332
+
333
+ _setupGraphTools(graphObject) {
334
+ const html = `
335
+ <button id="graph-zoom-in" title="Zoom In" fill="#3C4043">
336
+ <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" fill="#3C4043">
337
+ <path d="M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Zm-40-60v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Z"/>
338
+ </svg>
339
+ </button>
340
+ <button id="graph-zoom-out" title="Zoom Out" fill="#3C4043">
341
+ <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" fill="#3C4043">
342
+ <path d="M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400ZM280-540v-80h200v80H280Z"/>
343
+ </svg>
344
+ </button>
345
+ <button id="graph-recenter" title="Recenter Graph" class="recenter-button" fill="#3C4043">
346
+ <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#3C4043">
347
+ <path d="M480-320q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47Zm0-80q33 0 56.5-23.5T560-480q0-33-23.5-56.5T480-560q-33 0-56.5 23.5T400-480q0 33 23.5 56.5T480-400Zm0-80ZM200-120q-33 0-56.5-23.5T120-200v-160h80v160h160v80H200Zm400 0v-80h160v-160h80v160q0 33-23.5 56.5T760-120H600ZM120-600v-160q0-33 23.5-56.5T200-840h160v80H200v160h-80Zm640 0v-160H600v-80h160q33 0 56.5 23.5T840-760v160h-80Z"/>
348
+ </svg>
349
+ </button>
350
+ <button id="graph-fullscreen" title="Toggle Fullscreen">
351
+ ${this.enterFullscreenSvg}
352
+ </button>`;
353
+
354
+ this.tools.elements.container = document.createElement('div');
355
+ this.tools.elements.container.id = 'graph-tools';
356
+ this.tools.elements.container.innerHTML = html;
357
+ this.mount.append(this.tools.elements.container);
358
+
359
+ this.tools.elements.recenter = this.tools.elements.container.querySelector('#graph-recenter');
360
+ this.tools.elements.recenter.addEventListener('click', () => {
361
+ graphObject.zoomToFit(this.tools.config.recenterSpeed);
362
+ });
363
+
364
+ this.tools.elements.zoomIn = this.tools.elements.container.querySelector('#graph-zoom-in');
365
+ this.tools.elements.zoomIn.addEventListener('click', () => {
366
+ graphObject.zoom(graphObject.zoom() * this.tools.config.zoomInIncrement, this.tools.config.zoomInSpeed);
367
+ });
368
+
369
+ this.tools.elements.zoomOut = this.tools.elements.container.querySelector('#graph-zoom-out');
370
+ this.tools.elements.zoomOut.addEventListener('click', () => {
371
+ graphObject.zoom(graphObject.zoom() * this.tools.config.zoomOutIncrement, this.tools.config.zoomOutSpeed);
372
+ });
373
+
374
+ this.tools.elements.toggleFullscreen = this.tools.elements.container.querySelector('#graph-fullscreen');
375
+ window.addEventListener('fullscreenchange', () => {
376
+ if (!document.fullscreenElement) {
377
+ this.mount.style.width = window.innerWidth + 'px';
378
+ this.mount.style.height = document.querySelector('body').innerHeight + 'px';
379
+ this.graph.width(this.mount.offsetWidth);
380
+ this.graph.height(this.mount.offsetHeight);
381
+ } else {
382
+ this.mount.style.width = window.innerWidth + 'px';
383
+ this.mount.style.height = document.querySelector('body').innerHeight + 'px';
384
+ this.graph.width(this.mount.offsetWidth);
385
+ this.graph.height(this.mount.offsetHeight);
386
+ }
387
+ });
388
+
389
+ this.tools.elements.toggleFullscreen.addEventListener('click', () => {
390
+ if (!document.fullscreenElement) {
391
+ this.tools.elements.toggleFullscreen.innerHTML = this.exitFullscreenSvg;
392
+ if (document.documentElement.requestFullscreen) {
393
+ document.documentElement.requestFullscreen();
394
+ } else if (document.documentElement.mozRequestFullScreen) { // Firefox
395
+ document.documentElement.mozRequestFullScreen();
396
+ } else if (document.documentElement.webkitRequestFullscreen) { // Chrome, Safari and Opera
397
+ document.documentElement.webkitRequestFullscreen();
398
+ } else if (document.documentElement.msRequestFullscreen) { // IE/Edge
399
+ document.documentElement.msRequestFullscreen();
400
+ }
401
+ } else {
402
+ this.tools.elements.toggleFullscreen.innerHTML = this.enterFullscreenSvg;
403
+ if (document.exitFullscreen) {
404
+ document.exitFullscreen();
405
+ } else if (document.mozCancelFullScreen) {
406
+ document.mozCancelFullScreen();
407
+ } else if (document.webkitExitFullscreen) {
408
+ document.webkitExitFullscreen();
409
+ } else if (document.msExitFullscreen) {
410
+ document.msExitFullscreen();
411
+ }
412
+ }
413
+ });
414
+ }
415
+
416
+ _setupMenu() {
417
+ this.menuMount.innerHTML = `
418
+ <style>
419
+ .menu-bar {
420
+ align-items: center;
421
+ border-bottom: 1px solid #DADCE0;
422
+ box-sizing: border-box;
423
+ color: #3C4043;
424
+ display: flex;
425
+ padding: 16px 24px 16px 16px;
426
+ width: 100%;
427
+ background: #fff;
428
+ }
429
+ .menu-item {
430
+ display: flex;
431
+ margin-right: 16px;
432
+ }
433
+ .toggle-container {
434
+ display: flex;
435
+ align-items: center;
436
+ height: 100%;
437
+ margin-left: 10px;
438
+ }
439
+ .toggle-switch {
440
+ position: relative;
441
+ display: inline-block;
442
+ width: 46px;
443
+ height: 24px;
444
+ flex-shrink: 0;
445
+ }
446
+ .toggle-switch input {
447
+ opacity: 0;
448
+ width: 0;
449
+ height: 0;
450
+ margin: 0;
451
+ padding: 0;
452
+ }
453
+ .toggle-slider {
454
+ position: absolute;
455
+ cursor: pointer;
456
+ top: 0;
457
+ left: 0;
458
+ right: 0;
459
+ bottom: 0;
460
+ background-color: #e9ecef;
461
+ transition: .4s;
462
+ border-radius: 24px;
463
+ }
464
+ .toggle-slider:before {
465
+ position: absolute;
466
+ content: "";
467
+ height: 18px;
468
+ width: 18px;
469
+ left: 3px;
470
+ bottom: 3px;
471
+ background-color: white;
472
+ transition: .3s;
473
+ border-radius: 50%;
474
+ }
475
+ input:checked + .toggle-slider {
476
+ background-color: #228be6;
477
+ }
478
+ input:checked + .toggle-slider:before {
479
+ transform: translateX(22px);
480
+ }
481
+ .toggle-label {
482
+ margin-left: 8px;
483
+ color: #202124;
484
+ line-height: 24px;
485
+ }
486
+ .graph-element-tooltip {
487
+ font: 12px 'Google Sans', 'Roboto', sans-serif;
488
+ position: absolute;
489
+ padding: 4px 8px;
490
+ box-sizing: border-box;
491
+ pointer-events: none;
492
+ transition: opacity 0.2s ease-in-out;
493
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
494
+ border-radius: 4px;
495
+ }
496
+
497
+ /* Ensure .graph-tooltip built-in from force-graph does not interfere */
498
+ .graph-tooltip {
499
+ background: none !important;
500
+ border: none !important;
501
+ padding: 0 !important;
502
+ }
503
+
504
+ .dropdown {
505
+ margin-right: 16px;
506
+ position: relative;
507
+ }
508
+
509
+ .dropdown-toggle {
510
+ appearance: none;
511
+ background: url("data:image/svg+xml;utf8,<svg fill='rgba(73, 80, 87, 1)' height='24' viewBox='0 0 24 24' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M7 10l5 5 5-5z'/></svg>") no-repeat;
512
+ background-position: right 10px center;
513
+ background-color: white;
514
+ padding: 12px 40px 12px 16px;
515
+ border: 1px solid #80868B;
516
+ border-radius: 4px;
517
+ color: #3C4043;
518
+ cursor: pointer;
519
+ font-size: 16px;
520
+ text-align: left;
521
+ width: 260px;
522
+ }
523
+
524
+ .dropdown-toggle.disabled {
525
+ background: url("data:image/svg+xml;utf8,<svg fill='rgba(73, 80, 87, .6)' height='24' viewBox='0 0 24 24' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M7 10l5 5 5-5z'/></svg>") no-repeat;
526
+ background-position: right 10px center;
527
+ background-color: #EBEBE4;
528
+ border: 1px solid #EBEBE4;
529
+ cursor: default;
530
+ }
531
+
532
+ .arrow-down {
533
+ margin-left: 5px;
534
+ font-size: 10px;
535
+ }
536
+
537
+ .dropdown-content {
538
+ display: none;
539
+ position: absolute;
540
+ background-color: #fff;
541
+ box-shadow: 0 2px 5px rgba(0,0,0,0.2);
542
+ z-index: 1;
543
+ top: 100%;
544
+ left: 0;
545
+ padding: 8px 0;
546
+ }
547
+
548
+ .dropdown:hover .dropdown-content:not(.disabled) {
549
+ display: block;
550
+ }
551
+
552
+ .dropdown-item {
553
+ color: #495057;
554
+ padding: 8px 32px 8px 8px;
555
+ text-decoration: none;
556
+ display: flex;
557
+ align-items: center;
558
+ }
559
+
560
+ .dropdown .dropdown-item.selected {
561
+ background: url("data:image/svg+xml;utf8,<svg height='24' viewBox='0 0 24 24' width='24' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M2 6L5 9L10 3' stroke='rgba(73, 80, 87, 1)' stroke-width='2' stroke-linecap='square'/></svg>") no-repeat;
562
+ background-position: left 15px top 8px;
563
+ }
564
+
565
+ .dropdown-item:hover {
566
+ background-color: #f8f9fa;
567
+ }
568
+
569
+ .checkmark {
570
+ width: 20px;
571
+ margin-right: 8px;
572
+ }
573
+
574
+ .element-count {
575
+ color: #000;
576
+ display: flex;
577
+ flex: 1;
578
+ font-weight: 500;
579
+ }
580
+
581
+ .item-text {
582
+ flex: 1;
583
+ }
584
+ </style>
585
+ <div class="menu-bar">
586
+ <div class="dropdown">
587
+ <button class="dropdown-toggle">
588
+ Force layout
589
+ </button>
590
+ <div class="dropdown-content">
591
+ <a href="#" class="dropdown-item selected" data-layout="">
592
+ <span class="checkmark"></span>
593
+ <span class="item-text">Force layout</span>
594
+ </a>
595
+ <a href="#" class="dropdown-item" data-layout="td">
596
+ <span class="checkmark"></span>
597
+ <span class="item-text">Hierarchical: Top down</span>
598
+ </a>
599
+ <a href="#" class="dropdown-item" data-layout="lr">
600
+ <span class="checkmark"></span>
601
+ <span class="item-text">Hierarchical: Left-to-right</span>
602
+ </a>
603
+ <a href="#" class="dropdown-item" data-layout="radialin">
604
+ <span class="checkmark"></span>
605
+ <span class="item-text">Radial: Inward</span>
606
+ </a>
607
+ <a href="#" class="dropdown-item" data-layout="radialout">
608
+ <span class="checkmark"></span>
609
+ <span class="item-text">Radial: Outward</span>
610
+ </a>
611
+ </div>
612
+ </div>
613
+
614
+ <div class="element-count">
615
+ ${this.store.config.nodes.length} nodes, ${this.store.config.edges.length} edges
616
+ </div>
617
+ <div class="toggle-container">
618
+ <label class="toggle-switch">
619
+ <input id="view-schema" type="checkbox">
620
+ <span class="toggle-slider"></span>
621
+ </label>
622
+ <span class="toggle-label">View Schema</span>
623
+ </div>
624
+ <div class="toggle-container">
625
+ <label class="toggle-switch">
626
+ <input id="show-labels" type="checkbox">
627
+ <span class="toggle-slider"></span>
628
+ </label>
629
+ <span class="toggle-label">Show labels</span>
630
+ </div>
631
+ </div>`
632
+
633
+ this.menu.elements.layoutButtons = this.menuMount.querySelectorAll('.dropdown-item');
634
+ this.menu.elements.layoutDropdownToggle = this.menuMount.querySelector('.dropdown-toggle');
635
+ this.menu.elements.layoutDropdownContent = this.menuMount.querySelector('.dropdown-content');
636
+ this.menu.elements.layoutDropdownContent.addEventListener('click', e => {
637
+ const title = this.menuMount.querySelector('.dropdown-toggle');
638
+ const layoutButton = e.target.closest('.dropdown-item');
639
+ if (!layoutButton) return;
640
+
641
+ this.menu.elements.layoutButtons.forEach(button => {
642
+ button.className = button === layoutButton ? 'dropdown-item selected' : 'dropdown-item';
643
+ });
644
+
645
+ if (title) {
646
+ title.textContent = layoutButton.textContent;
647
+ }
648
+
649
+ try {
650
+ const newLayout = layoutButton.getAttribute('data-layout');
651
+ this.menu.config.lastLayout = this.graph.dagMode;
652
+ this.menu.config.currentLayout = newLayout;
653
+
654
+ let dagDistance;
655
+ let collisionRadius = 1; // Default collision radius
656
+
657
+ switch (newLayout) {
658
+ case 'td': // Hierarchical: Top down
659
+ dagDistance = Math.log10(this.store.config.nodes.length) * 50;
660
+ collisionRadius = 12;
661
+ this.menu.config.clusterBy = GraphVisualization.ClusterMethod.LABEL;
662
+ break;
663
+ case 'lr': // Hierarchical: Left-to-right
664
+ dagDistance = Math.log10(this.store.config.nodes.length) * 100;
665
+ collisionRadius = 12;
666
+ break;
667
+ case 'radialin': // Radial: Inward
668
+ dagDistance = Math.log10(this.store.config.nodes.length) * 100;
669
+ collisionRadius = 8;
670
+ break;
671
+ case 'radialout': // Radial: Outward
672
+ dagDistance = Math.log10(this.store.config.nodes.length) * 100;
673
+ collisionRadius = 8;
674
+ break;
675
+ default: // Force layout (default)
676
+ dagDistance = 0; // Not applicable for force layout
677
+ break;
678
+ }
679
+
680
+ this.graph.dagMode(newLayout);
681
+
682
+ // Set dagLevelDistance after setting dagMode
683
+ if (newLayout !== '') {
684
+ this.graph.dagLevelDistance(dagDistance);
685
+ }
686
+
687
+ // Update d3Force collision
688
+ this.requestedRecenter = true;
689
+ this.graph.d3Force('collide', d3.forceCollide(collisionRadius));``
690
+ } catch (error) {
691
+ console.error('Error updating graph layout:', error);
692
+ }
693
+ });
694
+
695
+ this.menu.elements.viewSchema = this.menuMount.querySelector('#view-schema');
696
+ this.menu.elements.viewSchema.addEventListener('change', () => {
697
+ const viewMode = this.menu.elements.viewSchema.checked ?
698
+ GraphStore.ViewModes.SCHEMA : GraphStore.ViewModes.DEFAULT;
699
+ this.store.setViewMode(viewMode);
700
+ });
701
+
702
+ this.menu.elements.showLabels = this.menuMount.querySelector('#show-labels');
703
+ this.menu.elements.showLabels.addEventListener('change', () => {
704
+ this.menu.config.showLabels = this.menu.elements.showLabels.checked;
705
+ });
706
+ }
707
+
708
+ /**
709
+ * Callback for GraphStore.EventTypes.CONFIG_CHANGE events.
710
+ * @param {GraphConfig} config - The new configuration.
711
+ */
712
+ onStoreConfigChange(config) {
713
+ this.render();
714
+ }
715
+
716
+ onSelectedEdgeChanged(edge) {
717
+ this.selectedEdge = edge;
718
+ this.selectedEdgeNeighbors = [];
719
+
720
+ if (!this.selectedEdge) {
721
+ this.selectedEdge = null;
722
+ return;
723
+ }
724
+
725
+ this.selectedEdgeNeighbors = [
726
+ edge.source, edge.target
727
+ ];
728
+ }
729
+
730
+ onSelectedNodeChanged(node) {
731
+ this.selectedNode = node;
732
+
733
+ // We could center the graph on the node
734
+
735
+ // This is duplicated between onSelectedNodeChange and onFocusedNodeChange.
736
+ // This could be extracted to a separate function.
737
+ const { nodes, links } = this.graph.graphData();
738
+
739
+ this.selectedNodeEdges = links.filter(
740
+ link => link.source.id === node.id || link.target.id === node.id);
741
+
742
+ this.selectedNodeNeighbors = nodes.filter(n => {
743
+ const selectedEdges = this.selectedNodeEdges.filter(link => link.source.id === n.id || link.target.id === n.id);
744
+ const containsEdges = selectedEdges.length > 0;
745
+ if (containsEdges) {
746
+ this.selectedNodeEdges.push(...selectedEdges);
747
+ }
748
+
749
+ return containsEdges;
750
+ });
751
+
752
+ this.graph.centerAt(node.x, node.y, 1000);
753
+ this.graph.zoom(4, 1000);
754
+
755
+ }
756
+
757
+ onFocusedEdgeChanged(edge) {
758
+ this.focusedEdge = edge;
759
+ this.focusedEdgeNeighbors = [];
760
+
761
+ if (!edge) {
762
+ // There are multiple conditions that will trigger a false here,
763
+ // and we want to enforce null type.
764
+ this.focusedEdge = null;
765
+ return;
766
+ }
767
+
768
+ this.focusedEdgeNeighbors = [
769
+ edge.source,
770
+ edge.target
771
+ ];
772
+ }
773
+
774
+ /**
775
+ * Highlights the focused node as well as its edges and neighbors
776
+ * @param {Node|null} node - The node to highlight. If null, the highlight is removed.
777
+ */
778
+ onFocusedNodeChanged(node) {
779
+ this.focusedNode = node;
780
+ this.focusedNodeEdges = [];
781
+ this.focusedNodeNeighbors = [];
782
+
783
+ if (!this.focusedNode) {
784
+ return;
785
+ }
786
+
787
+ const { nodes, links } = this.graph.graphData();
788
+ const focusedNodeEdges = links.filter(link => link.source.id === node.id || link.target.id === node.id);
789
+ this.focusedNodeEdges.push(...focusedNodeEdges);
790
+ this.focusedNodeNeighbors = nodes.filter(n => {
791
+ if (n.id === node.id) {
792
+ return false;
793
+ }
794
+
795
+ const focusedEdges = this.focusedNodeEdges.filter(link => link.source.id === n.id || link.target.id === n.id);
796
+ return focusedEdges.length > 0;
797
+ });
798
+ }
799
+
800
+ _setupLineLength(graph) {
801
+ this.graph.calculateLineLengthByCluster = () => {
802
+ this.graph
803
+ .d3Force('link').distance(link => {
804
+ let distance = Math.log10(this.store.config.nodes.length) * 40;
805
+
806
+ // Only apply neighborhood clustering logic if using force layout
807
+ if (this.store.config.nodes.length !== 0 && (this.store.viewMode === GraphStore.ViewModes.SCHEMA || this.graph.dagMode() !== '')) {
808
+ distance = Math.log10(this.store.config.nodes.length) * 50;
809
+ } else if (this.graph.dagMode() === '') {
810
+ distance = link.source.neighborhood === link.target.neighborhood ? distance * 0.5 : distance * 0.8;
811
+ }
812
+ return distance;
813
+ });
814
+ return graph;
815
+ }
816
+ }
817
+
818
+ _computeCurvature(links) {
819
+ let selfLoopLinks = {};
820
+ let sameNodesLinks = {};
821
+ const curvatureMinMax = 0.35;
822
+
823
+ // 1. assign each link a nodePairId that combines their source and target independent of the links direction
824
+ // 2. group links together that share the same two nodes or are self-loops
825
+ for (let i = 0; i < links.length; i++) {
826
+ const link = links[i];
827
+ const sourceId = link.from;
828
+ const targetId = link.to;
829
+ link.curvature = 0;
830
+ link.nodePairId = sourceId <= targetId ? (sourceId + "_" + targetId) : (targetId + "_" + sourceId);
831
+ let map = sourceId === targetId ? selfLoopLinks : sameNodesLinks;
832
+ if (!map[link.nodePairId]) {
833
+ map[link.nodePairId] = [];
834
+ }
835
+ map[link.nodePairId].push(link);
836
+ }
837
+
838
+ // Compute the curvature for self-loop links to avoid overlaps
839
+ Object.keys(selfLoopLinks).forEach(id => {
840
+ let links = selfLoopLinks[id];
841
+ let lastIndex = links.length - 1;
842
+ links[lastIndex].curvature = 1;
843
+ let delta = (1 - curvatureMinMax) / lastIndex;
844
+ for (let i = 0; i < lastIndex; i++) {
845
+ links[i].curvature = curvatureMinMax + i * delta;
846
+ }
847
+ });
848
+
849
+ // Compute the curvature for links sharing the same two nodes to avoid overlaps
850
+ Object.keys(sameNodesLinks).filter(nodePairId => sameNodesLinks[nodePairId].length > 1).forEach(nodePairId => {
851
+ let links = sameNodesLinks[nodePairId];
852
+ let lastIndex = links.length - 1;
853
+ let lastLink = links[lastIndex];
854
+ lastLink.curvature = curvatureMinMax;
855
+ let delta = 2 * curvatureMinMax / lastIndex;
856
+ for (let i = 0; i < lastIndex; i++) {
857
+ const link = links[i];
858
+ links[i].curvature = - curvatureMinMax + i * delta;
859
+ if (lastLink.from !== links[i].from) {
860
+ links[i].curvature *= -1; // flip it around, otherwise they overlap
861
+ }
862
+ }
863
+ });
864
+
865
+ return links;
866
+ }
867
+
868
+ _setupDrawEdges(graph) {
869
+ graph.drawEdges = (node, ctx, globalScale) => {
870
+ this.graph
871
+ // this will be extracted to a wrapper function for
872
+ // -> hiding labels upon zooming out past a threshold
873
+ // -> hide when large amount of edges shown
874
+ // -> show edges for node that the user highlights
875
+ // -> settings menu
876
+ // -> or some other heuristic
877
+ .linkDirectionalArrowLength(4)
878
+ .calculateLineLengthByCluster()
879
+ .linkDirectionalArrowRelPos(0.9875)
880
+ .linkCurvature('curvature')
881
+ .linkWidth(link => {
882
+ let edgeDesign = this.store.getEdgeDesign(link);
883
+ return edgeDesign.width;
884
+ })
885
+ .linkColor(link => {
886
+ let edgeDesign = this.store.getEdgeDesign(link);
887
+
888
+ // Check if ANY node OR edge is focused or selected
889
+ const isAnyElementFocusedOrSelected =
890
+ this.store.config.focusedGraphObject instanceof Node ||
891
+ this.store.config.selectedGraphObject instanceof Node ||
892
+ this.store.config.focusedGraphObject instanceof Edge ||
893
+ this.store.config.selectedGraphObject instanceof Edge;
894
+
895
+ // Lighten the edge color if an element is focused or selected
896
+ // and the edge is NOT connected to it
897
+ if (isAnyElementFocusedOrSelected &&
898
+ !this.store.edgeIsConnectedToFocusedNode(link) &&
899
+ !this.store.edgeIsConnectedToSelectedNode(link) &&
900
+ link !== this.store.config.focusedGraphObject && // Check for focused edge
901
+ link !== this.store.config.selectedGraphObject) { // Check for selected edge
902
+
903
+ const lightenAmount = 0.48;
904
+ const originalColor = edgeDesign.color;
905
+ return this.lightenColor(originalColor, lightenAmount);
906
+ } else {
907
+ return edgeDesign.color; // Return original color
908
+ }
909
+ })
910
+ .labelsOnEdges(2);
911
+
912
+ return graph;
913
+ }
914
+ }
915
+
916
+ // Helper function to lighten a color
917
+ lightenColor(color, amount) {
918
+ const usePound = color[0] === '#';
919
+ let R = parseInt(color.substring(usePound ? 1 : 0, usePound ? 3 : 2), 16);
920
+ let G = parseInt(color.substring(usePound ? 3 : 2, usePound ? 5 : 4), 16);
921
+ let B = parseInt(color.substring(usePound ? 5 : 4, usePound ? 7 : 6), 16);
922
+
923
+ R = Math.floor((R * (1 - amount)) + (255 * amount));
924
+ G = Math.floor((G * (1 - amount)) + (255 * amount));
925
+ B = Math.floor((B * (1 - amount)) + (255 * amount));
926
+
927
+ const RR = ((R.toString(16).length === 1) ? "0" + R.toString(16) : R.toString(16));
928
+ const GG = ((G.toString(16).length === 1) ? "0" + G.toString(16) : G.toString(16));
929
+ const BB = ((B.toString(16).length === 1) ? "0" + B.toString(16) : B.toString(16));
930
+
931
+ return "#" + RR + GG + BB;
932
+ }
933
+
934
+ _setupDrawNodes(graph) {
935
+ graph.drawNodes = () => {
936
+ this.graph
937
+ .nodeCanvasObject(
938
+ /**
939
+ * Callback function to draw nodes based off of the current config state.
940
+ * @param {Node} node - The node object
941
+ * @param {CanvasRenderingContext2D} ctx - The canvas rendering context
942
+ * @param {number} globalScale - The global scale of the graph
943
+ */
944
+ (node, ctx, globalScale) => {
945
+ let lightenAmount = 0;
946
+ const defaultLightenAmount = 0.64;
947
+
948
+ // If a node is selected or focused...
949
+ if (this.store.config.selectedGraphObject instanceof Node ||
950
+ this.store.config.focusedGraphObject instanceof Node) {
951
+
952
+ // Lighten all nodes...
953
+ lightenAmount = defaultLightenAmount;
954
+
955
+ // ...unless it's the selected or hovered node itself...
956
+ if (node === this.store.config.selectedGraphObject ||
957
+ node === this.store.config.focusedGraphObject) {
958
+ lightenAmount = 0;
959
+ }
960
+ // ...or if it's connected to the selected or hovered node.
961
+ else if (this.selectedNodeNeighbors.includes(node) ||
962
+ this.focusedNodeNeighbors.includes(node)) {
963
+ lightenAmount = 0;
964
+ }
965
+ }
966
+
967
+ // If an edge is selected or focused...
968
+ if (this.store.config.selectedGraphObject instanceof Edge ||
969
+ this.store.config.focusedGraphObject instanceof Edge) {
970
+ lightenAmount = defaultLightenAmount;
971
+
972
+ // If the node is a neighbor of a focused or selected edge
973
+ if (this.focusedEdgeNeighbors.includes(node) ||
974
+ this.selectedEdgeNeighbors.includes(node)) {
975
+ lightenAmount = 0;
976
+ }
977
+ }
978
+
979
+ const isFocusedOrHovered = node === this.store.config.selectedGraphObject || node === this.store.config.focusedGraphObject;
980
+ if (isFocusedOrHovered) {
981
+ // draw stroke
982
+ let strokeSize = 0;
983
+ let strokeSeparationSize = this.getNodeRelativeSize() * 0.125;
984
+ if (node === this.store.config.selectedGraphObject) {
985
+ strokeSize = this.getNodeRelativeSize() * .75;
986
+ } else {
987
+ strokeSize = this.getNodeRelativeSize() * 0.5;
988
+ }
989
+
990
+ ctx.beginPath();
991
+ ctx.strokeStyle = `rgba(26, 115, 232, .24)`;
992
+ ctx.lineWidth = strokeSize;
993
+ ctx.fillStyle = 'rgba(0, 0, 0, 0)';
994
+ ctx.arc(node.x, node.y,
995
+ this.getNodeRelativeSize() + strokeSeparationSize + strokeSize / 2,
996
+ 0, 2 * Math.PI, false);
997
+ ctx.stroke();
998
+ }
999
+
1000
+ // This draws the circle for the node
1001
+ ctx.beginPath();
1002
+ ctx.arc(node.x, node.y, this.getNodeRelativeSize(), 0, 2 * Math.PI, false);
1003
+ ctx.fillStyle = this.store.getColorForNode(node);
1004
+ ctx.fill();
1005
+
1006
+ // lighten the node color
1007
+ if (lightenAmount > 0) {
1008
+ ctx.beginPath();
1009
+ ctx.arc(node.x, node.y, this.getNodeRelativeSize(), 0, 2 * Math.PI, false);
1010
+ ctx.fillStyle = `rgba(255, 255, 255, ${lightenAmount})`;
1011
+ ctx.fill();
1012
+ }
1013
+
1014
+ const showLabel = this.menu.config.showLabels ||
1015
+ node === this.store.config.selectedGraphObject ||
1016
+ node === this.store.config.focusedGraphObject ||
1017
+ this.selectedNodeNeighbors.includes(node) ||
1018
+ this.focusedNodeNeighbors.includes(node) ||
1019
+ this.focusedEdgeNeighbors.includes(node) ||
1020
+ this.selectedEdgeNeighbors.includes(node) ||
1021
+ isFocusedOrHovered ||
1022
+ this.store.viewMode === GraphStore.ViewModes.SCHEMA;
1023
+ if (!showLabel) {
1024
+ return;
1025
+ }
1026
+
1027
+ // Init label
1028
+ ctx.save();
1029
+ ctx.translate(node.x, node.y);
1030
+ const fontSize = 2;
1031
+ ctx.font = `${fontSize}px Sans-Serif`;
1032
+
1033
+ // Draw the label's background
1034
+ const padding = 1;
1035
+ const textRect = ctx.measureText(node.label);
1036
+ const rectX = this.getNodeRelativeSize() + padding * 0.5;
1037
+ const rectWidth = textRect.width + padding;
1038
+ const rectHeight = fontSize + padding;
1039
+ const rectY = -rectHeight * 0.5;
1040
+ const borderRadius = padding * 0.5;
1041
+
1042
+ ctx.fillStyle = this.store.getColorForNode(node); // Teal color similar to the image
1043
+ ctx.beginPath();
1044
+ ctx.roundRect(
1045
+ rectX,
1046
+ rectY,
1047
+ rectWidth,
1048
+ rectHeight, borderRadius);
1049
+ ctx.fill();
1050
+
1051
+ // lighten the background
1052
+ if (lightenAmount > 0) {
1053
+ ctx.fillStyle = `rgba(255, 255, 255, ${lightenAmount})`;
1054
+ ctx.beginPath();
1055
+ ctx.roundRect(
1056
+ rectX,
1057
+ rectY,
1058
+ rectWidth,
1059
+ rectHeight, borderRadius);
1060
+ ctx.fill();
1061
+ }
1062
+
1063
+ // Draw the label text
1064
+ ctx.textAlign = 'center';
1065
+ ctx.textBaseline = 'middle';
1066
+ ctx.fillStyle = '#fff';
1067
+ ctx.fillText(
1068
+ node.label,
1069
+ rectX + rectWidth * 0.5,
1070
+ -Math.abs(textRect.actualBoundingBoxAscent - textRect.actualBoundingBoxDescent) * 0.25);
1071
+
1072
+ ctx.restore();
1073
+ });
1074
+
1075
+ return graph;
1076
+ }
1077
+ }
1078
+
1079
+ _generateGraphElementTooltip(element) {
1080
+ let color = "#a9a9a9";
1081
+ if (element instanceof Node) {
1082
+ color = this.store.getColorForNode(element);
1083
+ }
1084
+ let content = `
1085
+ <div class="graph-element-tooltip" style="background-color: ${color}">
1086
+ <div><strong>${this.sanitize(element.label)}</strong></div>`;
1087
+
1088
+ if (element.properties) {
1089
+ if (element.key_property_names.length === 1) {
1090
+ for (const key of element.key_property_names) {
1091
+ if (element.properties.hasOwnProperty(key)) {
1092
+ content += `<div>${this.sanitize(element.properties[key])}</div>`
1093
+ }
1094
+ }
1095
+ } else {
1096
+ for (const key of element.key_property_names) {
1097
+ if (element.properties.hasOwnProperty(key)) {
1098
+ content += `<div>${key}: ${this.sanitize(element.properties[key])}</div>`
1099
+ }
1100
+ }
1101
+ }
1102
+ }
1103
+
1104
+ content += '</div>'
1105
+ return content;
1106
+ }
1107
+
1108
+ _setupDrawLabelsOnEdges(graph) {
1109
+ /**
1110
+ * Draw labels on edges
1111
+ * @param {number} maxGlobalScale - Labels will disappear after the global scale
1112
+ * has exceeded this number (aka the user has zoomed out past a threshold)
1113
+ */
1114
+ graph.labelsOnEdges = (maxGlobalScale) => {
1115
+ graph
1116
+ .linkCanvasObjectMode(() => 'after')
1117
+ .linkLabel(() => {
1118
+ if (this.store.config.selectedGraphObject) {
1119
+ return '';
1120
+ }
1121
+ return ''
1122
+ })
1123
+ .linkCanvasObject(
1124
+ /**
1125
+ * Draw labels on edges
1126
+ * @param {Edge} link - The link object
1127
+ * @param {CanvasRenderingContext2D} ctx - The canvas rendering context
1128
+ * @param {number} globalScale - The global scale of the graph
1129
+ */
1130
+ (link, ctx, globalScale) => {
1131
+
1132
+ const isSelected = this.selectedEdge && link === this.selectedEdge;
1133
+ const isFocused = this.focusedEdge && link === this.focusedEdge;
1134
+
1135
+ const showLabel = () => {
1136
+ // 1. Prioritize focused edge
1137
+ if (this.focusedEdge && link === this.focusedEdge) {
1138
+ return true; // Always show label for focused edge
1139
+ }
1140
+
1141
+ // 2. Show label if a node is connected to a focused or selected node
1142
+ if (this.selectedNode && this.store.edgeIsConnectedToSelectedNode(link) ||
1143
+ this.focusedNode && this.store.edgeIsConnectedToFocusedNode(link)) {
1144
+ return true;
1145
+ }
1146
+
1147
+ // 3. Show label if the edge is selected
1148
+ if (this.selectedEdge && link === this.selectedEdge) {
1149
+ return true; // Always show label for selected edge
1150
+ }
1151
+
1152
+ // 4. Show labels if within zoom tolerance and no node or edge is focused/selected
1153
+ const focusedOrSelectedObjectExists = this.focusedEdge || this.selectedEdge || this.focusedNode || this.selectedNode;
1154
+ const withinZoomTolerance = globalScale > maxGlobalScale;
1155
+ if (withinZoomTolerance && !focusedOrSelectedObjectExists) {
1156
+ return true;
1157
+ }
1158
+
1159
+ // 5. Always show the label if "Show Labels" is selected
1160
+ if (this.menu.config.showLabels) {
1161
+ return true;
1162
+ }
1163
+
1164
+ // Otherwise, hide the label
1165
+ return false;
1166
+ };
1167
+
1168
+ if (!showLabel()) {
1169
+ return;
1170
+ }
1171
+
1172
+ const start = link.source;
1173
+ const end = link.target;
1174
+ if (typeof start !== 'object' || typeof end !== 'object') return;
1175
+
1176
+ // Initialize text position
1177
+ let textPos = {
1178
+ x: (start.x + end.x) * 0.5,
1179
+ y: (start.y + end.y) * 0.5
1180
+ };
1181
+
1182
+ // Get control points
1183
+ const controlPoints = link.__controlPoints;
1184
+
1185
+ const getQuadraticXYFourWays = (
1186
+ t,
1187
+ sx,
1188
+ sy,
1189
+ cp1x,
1190
+ cp1y,
1191
+ cp2x,
1192
+ cp2y,
1193
+ ex,
1194
+ ey,
1195
+ ) => {
1196
+ return {
1197
+ x:
1198
+ (1 - t) * (1 - t) * (1 - t) * sx +
1199
+ 3 * (1 - t) * (1 - t) * t * cp1x +
1200
+ 3 * (1 - t) * t * t * cp2x +
1201
+ t * t * t * ex,
1202
+ y:
1203
+ (1 - t) * (1 - t) * (1 - t) * sy +
1204
+ 3 * (1 - t) * (1 - t) * t * cp1y +
1205
+ 3 * (1 - t) * t * t * cp2y +
1206
+ t * t * t * ey,
1207
+ };
1208
+ };
1209
+
1210
+ const selfLoop = link.source === link.target;
1211
+
1212
+ if (link.curvature !== 0 && controlPoints) {
1213
+ const time = 0.5;
1214
+ if (selfLoop) {
1215
+ // Self-loop
1216
+ textPos = getQuadraticXYFourWays(
1217
+ time, link.source.x, link.source.y,
1218
+ controlPoints[0],
1219
+ controlPoints[1],
1220
+ controlPoints[2],
1221
+ controlPoints[3],
1222
+ link.target.x, link.target.y,
1223
+ );
1224
+ } else {
1225
+ // Use midpoint of quadratic Bezier curve
1226
+ textPos = {
1227
+ x: Math.pow(1 - time, 2) * start.x + 2 * (1 - time) * time * controlPoints[0] + Math.pow(time, 2) * end.x,
1228
+ y: Math.pow(1 - time, 2) * start.y + 2 * (1 - time) * time * controlPoints[1] + Math.pow(time, 2) * end.y
1229
+ };
1230
+ }
1231
+ }
1232
+
1233
+ // Calculate text angle
1234
+ let textAngle = Math.atan2(end.y - start.y, end.x - start.x);
1235
+ if (textAngle > Math.PI / 2) textAngle = -(Math.PI - textAngle);
1236
+ if (textAngle < -Math.PI / 2) textAngle = -(-Math.PI - textAngle);
1237
+
1238
+ // Calculate font size based on link length
1239
+ let label = link.label;
1240
+ let labelTail = '';
1241
+
1242
+ let maxTextLength = 50;
1243
+ if (!selfLoop) {
1244
+ const relLink = { x: end.x - start.x, y: end.y - start.y };
1245
+ const linkLength = Math.sqrt(relLink.x * relLink.x + relLink.y * relLink.y);
1246
+ maxTextLength = linkLength - 5;
1247
+ }
1248
+
1249
+ const fontSize = 2;
1250
+ // Set text style based on focus OR selection
1251
+ const defaultTextStyle = 'normal'; // Default text style
1252
+ const highlightedTextStyle = 'bold'; // Style when focused or selected
1253
+
1254
+ ctx.font = `${isFocused || isSelected ? highlightedTextStyle : defaultTextStyle} ${fontSize}px Sans-Serif`;
1255
+
1256
+ const textWidth = () => (ctx.measureText(label).width + ctx.measureText(labelTail).width) * 2;
1257
+ while (textWidth() > maxTextLength) {
1258
+ if (label.length <= 1) {
1259
+ break;
1260
+ }
1261
+
1262
+ label = label.substring(0, label.length - 1);
1263
+ labelTail = '...';
1264
+ }
1265
+ label = label + labelTail;
1266
+ const textRect = ctx.measureText(label);
1267
+
1268
+ ctx.save();
1269
+ ctx.translate(textPos.x, textPos.y);
1270
+ ctx.rotate(textAngle);
1271
+
1272
+ ctx.fillStyle = 'rgba(255, 255, 255, 1)';
1273
+ ctx.fillRect(
1274
+ (-textRect.width / 2) - 1,
1275
+ (-fontSize / 2) - 1,
1276
+ textRect.width + 2,
1277
+ fontSize + 2, 1);
1278
+
1279
+ // Set text color based on focus OR selection
1280
+ const defaultTextColor = '#9AA0A6'; // Default color
1281
+ const focusedTextColor = '#3C4043'; // Color when focused
1282
+ const selectedTextColor = '#1A73E8'; // Color when selected
1283
+
1284
+ ctx.textAlign = 'center';
1285
+ ctx.textBaseline = 'middle';
1286
+ const textVerticalOffset = -Math.abs(textRect.actualBoundingBoxAscent - textRect.actualBoundingBoxDescent) * 0.25;
1287
+ ctx.fillStyle = isSelected
1288
+ ? selectedTextColor
1289
+ : isFocused
1290
+ ? focusedTextColor
1291
+ : defaultTextColor;
1292
+ ctx.fillText(label, 0, textVerticalOffset);
1293
+
1294
+ ctx.strokeStyle = isSelected
1295
+ ? selectedTextColor
1296
+ : defaultTextColor;
1297
+ ctx.lineWidth = .5;
1298
+ ctx.strokeRect((-textRect.width / 2)-1 , (-fontSize / 2)-1, textRect.width + 2, fontSize + 2);
1299
+
1300
+ ctx.restore();
1301
+ });
1302
+ return graph;
1303
+ };
1304
+ }
1305
+
1306
+ /**
1307
+ * Renders the graph visualization.
1308
+ */
1309
+ render() {
1310
+ this.requestedRecenter = true;
1311
+
1312
+ const graphData = {
1313
+ nodes: this.store.getNodes(),
1314
+ links: this._computeCurvature(this.store.getEdges())
1315
+ };
1316
+
1317
+ const offscreenCanvas = document.createElement('canvas');
1318
+ offscreenCanvas.width = this.mount.offsetWidth;
1319
+ offscreenCanvas.height = this.mount.offsetHeight;
1320
+
1321
+ this.graph
1322
+ // The canvas can sometimes exceed
1323
+ // the dimensions of the HTMLElement
1324
+ // it is mounted to. This fixes
1325
+ // that issue.
1326
+ .width(this.mount.offsetWidth)
1327
+ .height(this.mount.clientHeight)
1328
+ .nodeId('id')
1329
+ .nodeVal('value')
1330
+ .nodeColor('color')
1331
+ .linkSource('source')
1332
+ .linkTarget('target')
1333
+ .linkLabel(link => '')
1334
+ // .enablePointerInteraction(false)
1335
+ // If paused, mouse events stop.
1336
+ // Pausing improves performance,
1337
+ // and is something to keep in mind
1338
+ // when we run into performance bottlenecks.
1339
+ .autoPauseRedraw(false)
1340
+ // These handlers should be extracted to a
1341
+ // wrapper function similar to .drawNodes()
1342
+ .onNodeHover(node => {
1343
+ if (!this.store.config.focusedGraphObject || !(this.store.config.focusedGraphObject instanceof Edge)) {
1344
+ this.store.setFocusedObject(node);
1345
+ }
1346
+ })
1347
+ .onNodeDragEnd(node => {
1348
+ node.fx = node.x;
1349
+ node.fy = node.y;
1350
+ })
1351
+ .onNodeClick(node => {
1352
+ this.store.setSelectedObject(node);
1353
+ })
1354
+ .onLinkHover(link => {
1355
+ this.store.setFocusedObject(link);
1356
+ })
1357
+ .onLinkClick(link => {
1358
+ this.store.setSelectedObject(link);
1359
+ })
1360
+ .onBackgroundClick(event => {
1361
+ this.store.setSelectedObject(null);
1362
+ })
1363
+ .calculateLineLengthByCluster()
1364
+ .drawNodes()
1365
+ .drawEdges()
1366
+ .cooldownTime(1250);
1367
+
1368
+ // fit to canvas when engine stops
1369
+ this.graph.onEngineStop(() => {
1370
+ if (this.requestedRecenter) {
1371
+ this.graph.zoomToFit(1000, 16);
1372
+ this.requestedRecenter = false;
1373
+ }
1374
+ });
1375
+
1376
+ this.graph.graphData(graphData);
1377
+ }
1378
+ }
1379
+
1380
+ window[namespace].GraphVisualization = GraphVisualization;