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.
- assets/authentication.png +0 -0
- assets/full_viz.png +0 -0
- assets/hero.png +0 -0
- assets/hero_with_properties.png +0 -0
- assets/jupyter-spin-up.png +0 -0
- assets/load_ext.png +0 -0
- assets/mock_data_result.png +0 -0
- assets/new_notebook.png +0 -0
- assets/notebook_package_load.png +0 -0
- assets/query_graph.png +0 -0
- assets/sample_jupyter.png +0 -0
- spanner_graph_notebook-1.0.0.dist-info/LICENSE +202 -0
- spanner_graph_notebook-1.0.0.dist-info/METADATA +169 -0
- spanner_graph_notebook-1.0.0.dist-info/RECORD +40 -0
- spanner_graph_notebook-1.0.0.dist-info/WHEEL +5 -0
- spanner_graph_notebook-1.0.0.dist-info/top_level.txt +2 -0
- spanner_graphs/__init__.py +19 -0
- spanner_graphs/conversion.py +299 -0
- spanner_graphs/database.py +199 -0
- spanner_graphs/graph_entities.py +405 -0
- spanner_graphs/graph_mock_data.csv +51 -0
- spanner_graphs/graph_mock_schema.json +681 -0
- spanner_graphs/magics.py +211 -0
- spanner_graphs/schema_manager.py +60 -0
- templates/assets/images/graph-bg.svg +4 -0
- templates/spanner-graph/models/edge.js +77 -0
- templates/spanner-graph/models/graph-object.js +64 -0
- templates/spanner-graph/models/node.js +77 -0
- templates/spanner-graph/models/schema.js +327 -0
- templates/spanner-graph/spanner-config.js +304 -0
- templates/spanner-graph/spanner-store.js +382 -0
- templates/spanner-graph/visualization/spanner-forcegraph.js +1380 -0
- templates/spanner-graph/visualization/spanner-sidebar.js +904 -0
- templates/template-spannergraph.html +210 -0
- tests/__init__.py +13 -0
- tests/conversion_test.py +163 -0
- tests/database_test.py +62 -0
- tests/graph_entities_test.py +124 -0
- tests/schema_manager_test.py +115 -0
- 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
|
+
'&': '&',
|
|
245
|
+
'<': '<',
|
|
246
|
+
'>': '>',
|
|
247
|
+
'"': '"',
|
|
248
|
+
"'": ''',
|
|
249
|
+
'`': '`',
|
|
250
|
+
'=': '=',
|
|
251
|
+
'/': '/'
|
|
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;
|