@falkordb/canvas 0.0.45 → 0.0.49
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.
- package/README.md +68 -0
- package/dist/canvas-types.d.ts +63 -2
- package/dist/canvas-types.d.ts.map +1 -1
- package/dist/canvas-utils.d.ts +1 -1
- package/dist/canvas-utils.d.ts.map +1 -1
- package/dist/canvas-utils.js +2 -4
- package/dist/canvas-utils.js.map +1 -1
- package/dist/canvas.d.ts +13 -0
- package/dist/canvas.d.ts.map +1 -1
- package/dist/canvas.js +380 -51
- package/dist/canvas.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/layouts.d.ts +4 -0
- package/dist/layouts.d.ts.map +1 -0
- package/dist/layouts.js +822 -0
- package/dist/layouts.js.map +1 -0
- package/package.json +9 -4
- package/src/canvas-types.ts +71 -4
- package/src/canvas-utils.ts +2 -4
- package/src/canvas.ts +433 -54
- package/src/index.ts +31 -1
- package/src/layouts.ts +993 -0
package/dist/canvas.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
/* eslint-disable no-param-reassign */
|
|
2
1
|
import ForceGraph from "force-graph";
|
|
3
2
|
import * as d3 from "d3";
|
|
4
|
-
import { dataToGraphData, getContrastTextColor, getNodeDisplayText, graphDataToData, LINK_DISTANCE, wrapTextForCircularNode, } from "./canvas-utils.js";
|
|
3
|
+
import { dataToGraphData, getContrastTextColor, getNodeDisplayText, graphDataToData, LINK_DISTANCE, NODE_SIZE, wrapTextForCircularNode, } from "./canvas-utils.js";
|
|
4
|
+
import { applyGraphLayout, isForceLayout } from "./layouts.js";
|
|
5
5
|
const PADDING = 2;
|
|
6
6
|
// Arrow geometry constants (shared by self-loop and regular-link drawing paths)
|
|
7
7
|
const ARROW_WH_RATIO = 1.6;
|
|
8
8
|
const ARROW_VLEN_RATIO = 0.2;
|
|
9
9
|
// Multiplier to convert node size → cubic bezier control-point distance for self-loops
|
|
10
10
|
const SELF_LOOP_CURVE_FACTOR = 11.67;
|
|
11
|
-
// Base font size used for the initial measurement
|
|
11
|
+
// Base font size used for the initial wrap-measurement pass.
|
|
12
12
|
const NODE_FONT_SIZE_BASE = 2;
|
|
13
13
|
// Fraction of the chord width that single-line text should fill (0–1).
|
|
14
14
|
// Leaves (1 - ratio)/2 of the radius as horizontal padding on each side.
|
|
@@ -18,6 +18,15 @@ const CHARGE_STRENGTH = -400;
|
|
|
18
18
|
const CENTER_STRENGTH = 0.03;
|
|
19
19
|
const VELOCITY_DECAY = 0.4;
|
|
20
20
|
const ALPHA_MIN = 0.05;
|
|
21
|
+
const NON_FORCE_CHARGE_STRENGTH = -220;
|
|
22
|
+
const NON_FORCE_COLLIDE_PADDING = 18;
|
|
23
|
+
const NON_FORCE_CENTER_STRENGTH = 0.02;
|
|
24
|
+
const NON_FORCE_LINK_STRENGTH = 0.08;
|
|
25
|
+
const NON_FORCE_TARGET_STRENGTH = 0.3;
|
|
26
|
+
const NON_FORCE_VELOCITY_DECAY = 0.5;
|
|
27
|
+
const NON_FORCE_ALPHA_MIN = 0.03;
|
|
28
|
+
const NON_FORCE_LAYOUT_COOLDOWN_TICKS = 120;
|
|
29
|
+
const NON_FORCE_DRAG_COOLDOWN_TICKS = 90;
|
|
21
30
|
// Create styles for the web component
|
|
22
31
|
function createStyles(backgroundColor, foregroundColor) {
|
|
23
32
|
const style = document.createElement("style");
|
|
@@ -63,6 +72,8 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
63
72
|
foregroundColor: '#1A1A1A',
|
|
64
73
|
captionsKeys: [],
|
|
65
74
|
showPropertyKeyPrefix: false,
|
|
75
|
+
layoutMode: "force",
|
|
76
|
+
layoutOptions: {},
|
|
66
77
|
};
|
|
67
78
|
this.nodeMode = 'replace';
|
|
68
79
|
this.linkMode = 'replace';
|
|
@@ -73,8 +84,12 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
73
84
|
this.onFontsLoadingDone = () => {
|
|
74
85
|
this.relationshipsTextCache.clear();
|
|
75
86
|
this.nodeDisplayFontSize.clear();
|
|
87
|
+
for (const node of this.data.nodes) {
|
|
88
|
+
node.displayName = ["", ""];
|
|
89
|
+
}
|
|
76
90
|
this.triggerRender();
|
|
77
91
|
};
|
|
92
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
78
93
|
this.attachShadow({ mode: "open" });
|
|
79
94
|
}
|
|
80
95
|
/**
|
|
@@ -122,12 +137,12 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
122
137
|
this.resizeObserver = null;
|
|
123
138
|
}
|
|
124
139
|
if (this.graph) {
|
|
125
|
-
// eslint-disable-next-line no-underscore-dangle
|
|
126
140
|
this.graph._destructor();
|
|
127
141
|
}
|
|
128
142
|
}
|
|
129
143
|
setConfig(config) {
|
|
130
144
|
this.log('Setting config:', config);
|
|
145
|
+
const layoutChanged = config.layoutMode !== undefined || config.layoutOptions !== undefined;
|
|
131
146
|
// If captionsKeys changed, invalidate cached display names and font sizes
|
|
132
147
|
// so text is recomputed with the new keys on the next render.
|
|
133
148
|
if (config.captionsKeys && JSON.stringify(config.captionsKeys) !== JSON.stringify(this.config.captionsKeys)) {
|
|
@@ -137,6 +152,45 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
137
152
|
}
|
|
138
153
|
}
|
|
139
154
|
Object.assign(this.config, config);
|
|
155
|
+
if (layoutChanged) {
|
|
156
|
+
const previousPositions = this.getNodePositionMap();
|
|
157
|
+
if (this.isForceLayoutMode() && this.config.cooldownTicks === 0 && this.data.nodes.length > 0) {
|
|
158
|
+
this.config.cooldownTicks = undefined;
|
|
159
|
+
}
|
|
160
|
+
this.data = applyGraphLayout(this.data, this.config.layoutMode, this.config.layoutOptions);
|
|
161
|
+
const shouldAnimateNonForceLayout = this.prepareNodePositionsForCurrentLayout(previousPositions);
|
|
162
|
+
if (this.graph) {
|
|
163
|
+
this.calculateNodeDegree();
|
|
164
|
+
this.graph.graphData(this.data);
|
|
165
|
+
this.configureSimulationForCurrentLayout(shouldAnimateNonForceLayout);
|
|
166
|
+
if (this.isForceLayoutMode()) {
|
|
167
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
168
|
+
this.config.isLoading = this.data.nodes.length > 0;
|
|
169
|
+
this.config.onLoadingChange?.(this.config.isLoading);
|
|
170
|
+
this.updateLoadingState();
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
this.config.isLoading = false;
|
|
174
|
+
this.config.onLoadingChange?.(false);
|
|
175
|
+
this.updateLoadingState();
|
|
176
|
+
if (this.data.nodes.length > 0) {
|
|
177
|
+
if (shouldAnimateNonForceLayout) {
|
|
178
|
+
this.shouldZoomToFitOnNonForceSettle = true;
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
182
|
+
this.zoomToFit(1);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
187
|
+
}
|
|
188
|
+
if (!shouldAnimateNonForceLayout) {
|
|
189
|
+
this.triggerRender();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
140
194
|
// Update event handlers if they were provided
|
|
141
195
|
if (config.onNodeClick || config.onLinkClick || config.onNodeRightClick || config.onLinkRightClick ||
|
|
142
196
|
config.onNodeHover || config.onLinkHover || config.onBackgroundClick || config.onBackgroundRightClick || config.onZoom ||
|
|
@@ -197,25 +251,42 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
197
251
|
this.log('Setting cooldown ticks to:', ticks);
|
|
198
252
|
this.config.cooldownTicks = ticks;
|
|
199
253
|
if (this.graph) {
|
|
200
|
-
this.graph.cooldownTicks(ticks ?? Infinity);
|
|
254
|
+
this.graph.cooldownTicks(this.isForceLayoutMode() ? (ticks ?? Infinity) : 0);
|
|
201
255
|
}
|
|
202
|
-
this.updateCanvasSimulationAttribute(ticks !== 0);
|
|
256
|
+
this.updateCanvasSimulationAttribute(this.isForceLayoutMode() && ticks !== 0 && this.data.nodes.length > 0);
|
|
203
257
|
}
|
|
204
258
|
getData() {
|
|
205
259
|
return graphDataToData(this.data);
|
|
206
260
|
}
|
|
207
261
|
setData(data) {
|
|
208
262
|
this.log('setData called with', data.nodes.length, 'nodes and', data.links.length, 'links');
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
263
|
+
const previousPositions = this.getNodePositionMap();
|
|
264
|
+
const oldNodesMap = new Map();
|
|
265
|
+
for (const node of this.data.nodes) {
|
|
266
|
+
oldNodesMap.set(node.id, node);
|
|
267
|
+
}
|
|
268
|
+
// Convert data and preserve positions for existing nodes
|
|
269
|
+
this.data = dataToGraphData(data, undefined, oldNodesMap);
|
|
270
|
+
this.data = applyGraphLayout(this.data, this.config.layoutMode, this.config.layoutOptions);
|
|
271
|
+
const shouldAnimateNonForceLayout = this.prepareNodePositionsForCurrentLayout(previousPositions);
|
|
272
|
+
if (this.isForceLayoutMode()) {
|
|
273
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
274
|
+
this.config.cooldownTicks = this.data.nodes.length > 0 ? undefined : 0;
|
|
275
|
+
this.config.isLoading = this.data.nodes.length > 0;
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
this.config.cooldownTicks = 0;
|
|
279
|
+
this.config.isLoading = false;
|
|
280
|
+
}
|
|
213
281
|
this.log('Loading state:', this.config.isLoading);
|
|
214
282
|
this.config.onLoadingChange?.(this.config.isLoading);
|
|
215
283
|
// Update simulation state
|
|
216
|
-
if (this.data.nodes.length > 0) {
|
|
284
|
+
if (this.data.nodes.length > 0 && this.isForceLayoutMode()) {
|
|
217
285
|
this.updateCanvasSimulationAttribute(true);
|
|
218
286
|
}
|
|
287
|
+
else {
|
|
288
|
+
this.updateCanvasSimulationAttribute(false);
|
|
289
|
+
}
|
|
219
290
|
// Initialize graph if it hasn't been initialized yet
|
|
220
291
|
if (!this.graph && this.container) {
|
|
221
292
|
this.log('Initializing graph');
|
|
@@ -225,11 +296,23 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
225
296
|
return;
|
|
226
297
|
this.log('Calculating node degrees and setting up forces');
|
|
227
298
|
this.calculateNodeDegree();
|
|
228
|
-
this.setupForces();
|
|
229
299
|
// Update graph data and properties
|
|
230
300
|
this.graph
|
|
231
|
-
.graphData(this.data)
|
|
232
|
-
|
|
301
|
+
.graphData(this.data);
|
|
302
|
+
this.configureSimulationForCurrentLayout(shouldAnimateNonForceLayout);
|
|
303
|
+
if (!this.isForceLayoutMode() && this.data.nodes.length > 0) {
|
|
304
|
+
if (shouldAnimateNonForceLayout) {
|
|
305
|
+
this.shouldZoomToFitOnNonForceSettle = true;
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
309
|
+
this.zoomToFit(1);
|
|
310
|
+
this.triggerRender();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
315
|
+
}
|
|
233
316
|
this.updateLoadingState();
|
|
234
317
|
}
|
|
235
318
|
getViewport() {
|
|
@@ -253,13 +336,40 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
253
336
|
}
|
|
254
337
|
setGraphData(data) {
|
|
255
338
|
this.log('setGraphData called with', data.nodes.length, 'nodes and', data.links.length, 'links');
|
|
256
|
-
|
|
339
|
+
const previousPositions = this.getNodePositionMap();
|
|
340
|
+
this.data = applyGraphLayout(data, this.config.layoutMode, this.config.layoutOptions);
|
|
341
|
+
const shouldAnimateNonForceLayout = this.prepareNodePositionsForCurrentLayout(previousPositions);
|
|
342
|
+
if (this.isForceLayoutMode() && this.config.cooldownTicks === 0 && this.data.nodes.length > 0) {
|
|
343
|
+
this.config.cooldownTicks = undefined;
|
|
344
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
345
|
+
}
|
|
257
346
|
if (!this.graph)
|
|
258
347
|
return;
|
|
259
348
|
this.calculateNodeDegree();
|
|
260
|
-
this.setupForces();
|
|
261
349
|
this.graph
|
|
262
350
|
.graphData(this.data);
|
|
351
|
+
this.configureSimulationForCurrentLayout(shouldAnimateNonForceLayout);
|
|
352
|
+
if (this.isForceLayoutMode() && this.data.nodes.length > 0) {
|
|
353
|
+
this.triggerRender();
|
|
354
|
+
}
|
|
355
|
+
if (!this.isForceLayoutMode()) {
|
|
356
|
+
this.config.isLoading = false;
|
|
357
|
+
this.config.onLoadingChange?.(false);
|
|
358
|
+
this.updateLoadingState();
|
|
359
|
+
if (this.data.nodes.length > 0) {
|
|
360
|
+
if (shouldAnimateNonForceLayout) {
|
|
361
|
+
this.shouldZoomToFitOnNonForceSettle = true;
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
365
|
+
this.zoomToFit(1);
|
|
366
|
+
this.triggerRender();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
263
373
|
if (this.viewport) {
|
|
264
374
|
this.log('Applying viewport:', this.viewport);
|
|
265
375
|
this.graph.zoom(this.viewport.zoom, 0);
|
|
@@ -294,6 +404,167 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
294
404
|
// Use the force-graph's built-in zoomToFit method
|
|
295
405
|
this.graph.zoomToFit(500, padding * paddingMultiplier, filter);
|
|
296
406
|
}
|
|
407
|
+
isForceLayoutMode() {
|
|
408
|
+
return isForceLayout(this.config.layoutMode);
|
|
409
|
+
}
|
|
410
|
+
getNodePositionMap() {
|
|
411
|
+
const positions = new Map();
|
|
412
|
+
for (const node of this.data.nodes) {
|
|
413
|
+
if (node.x === undefined || node.y === undefined)
|
|
414
|
+
continue;
|
|
415
|
+
positions.set(node.id, { x: node.x, y: node.y });
|
|
416
|
+
}
|
|
417
|
+
return positions;
|
|
418
|
+
}
|
|
419
|
+
getGraphCenter(positions) {
|
|
420
|
+
if (positions.size === 0)
|
|
421
|
+
return undefined;
|
|
422
|
+
let sumX = 0;
|
|
423
|
+
let sumY = 0;
|
|
424
|
+
for (const position of positions.values()) {
|
|
425
|
+
sumX += position.x;
|
|
426
|
+
sumY += position.y;
|
|
427
|
+
}
|
|
428
|
+
return {
|
|
429
|
+
x: sumX / positions.size,
|
|
430
|
+
y: sumY / positions.size,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
getConnectedExistingPosition(nodeId, previousPositions) {
|
|
434
|
+
let sumX = 0;
|
|
435
|
+
let sumY = 0;
|
|
436
|
+
let count = 0;
|
|
437
|
+
for (const link of this.data.links) {
|
|
438
|
+
const sourceId = link.source.id;
|
|
439
|
+
const targetId = link.target.id;
|
|
440
|
+
if (sourceId === nodeId) {
|
|
441
|
+
const existingPosition = previousPositions.get(targetId);
|
|
442
|
+
if (!existingPosition)
|
|
443
|
+
continue;
|
|
444
|
+
sumX += existingPosition.x;
|
|
445
|
+
sumY += existingPosition.y;
|
|
446
|
+
count += 1;
|
|
447
|
+
}
|
|
448
|
+
else if (targetId === nodeId) {
|
|
449
|
+
const existingPosition = previousPositions.get(sourceId);
|
|
450
|
+
if (!existingPosition)
|
|
451
|
+
continue;
|
|
452
|
+
sumX += existingPosition.x;
|
|
453
|
+
sumY += existingPosition.y;
|
|
454
|
+
count += 1;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (count === 0)
|
|
458
|
+
return undefined;
|
|
459
|
+
return {
|
|
460
|
+
x: sumX / count,
|
|
461
|
+
y: sumY / count,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
clearLayoutTargets() {
|
|
465
|
+
for (const node of this.data.nodes) {
|
|
466
|
+
node.layoutTargetX = undefined;
|
|
467
|
+
node.layoutTargetY = undefined;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
prepareNodePositionsForCurrentLayout(previousPositions) {
|
|
471
|
+
if (this.isForceLayoutMode()) {
|
|
472
|
+
this.clearLayoutTargets();
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
const graphCenter = this.getGraphCenter(previousPositions);
|
|
476
|
+
let shouldAnimate = false;
|
|
477
|
+
for (const node of this.data.nodes) {
|
|
478
|
+
const targetX = node.x ?? 0;
|
|
479
|
+
const targetY = node.y ?? 0;
|
|
480
|
+
node.layoutTargetX = targetX;
|
|
481
|
+
node.layoutTargetY = targetY;
|
|
482
|
+
node.fx = undefined;
|
|
483
|
+
node.fy = undefined;
|
|
484
|
+
node.vx = 0;
|
|
485
|
+
node.vy = 0;
|
|
486
|
+
if (previousPositions.size === 0) {
|
|
487
|
+
node.x = targetX;
|
|
488
|
+
node.y = targetY;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
const previousPosition = previousPositions.get(node.id)
|
|
492
|
+
?? this.getConnectedExistingPosition(node.id, previousPositions)
|
|
493
|
+
?? graphCenter;
|
|
494
|
+
if (!previousPosition) {
|
|
495
|
+
node.x = targetX;
|
|
496
|
+
node.y = targetY;
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
node.x = previousPosition.x;
|
|
500
|
+
node.y = previousPosition.y;
|
|
501
|
+
if (Math.abs(previousPosition.x - targetX) > 0.5
|
|
502
|
+
|| Math.abs(previousPosition.y - targetY) > 0.5) {
|
|
503
|
+
shouldAnimate = true;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return shouldAnimate;
|
|
507
|
+
}
|
|
508
|
+
setupAnchoredLayoutForces() {
|
|
509
|
+
if (!this.graph)
|
|
510
|
+
return;
|
|
511
|
+
const linkForce = this.graph.d3Force("link");
|
|
512
|
+
if (linkForce) {
|
|
513
|
+
linkForce
|
|
514
|
+
.distance((link) => {
|
|
515
|
+
const sourceSize = link.source.size;
|
|
516
|
+
const targetSize = link.target.size;
|
|
517
|
+
return sourceSize + targetSize + LINK_DISTANCE * 1.6;
|
|
518
|
+
})
|
|
519
|
+
.strength(NON_FORCE_LINK_STRENGTH);
|
|
520
|
+
}
|
|
521
|
+
this.graph.d3Force("collide", d3.forceCollide((node) => node.size + NON_FORCE_COLLIDE_PADDING));
|
|
522
|
+
this.graph.d3Force("centerX", d3.forceX(0).strength(NON_FORCE_CENTER_STRENGTH));
|
|
523
|
+
this.graph.d3Force("centerY", d3.forceY(0).strength(NON_FORCE_CENTER_STRENGTH));
|
|
524
|
+
this.graph.d3Force("layoutTargetX", d3.forceX((node) => node.layoutTargetX ?? node.x ?? 0).strength(NON_FORCE_TARGET_STRENGTH));
|
|
525
|
+
this.graph.d3Force("layoutTargetY", d3.forceY((node) => node.layoutTargetY ?? node.y ?? 0).strength(NON_FORCE_TARGET_STRENGTH));
|
|
526
|
+
const chargeForce = this.graph.d3Force("charge");
|
|
527
|
+
if (chargeForce) {
|
|
528
|
+
chargeForce.strength(NON_FORCE_CHARGE_STRENGTH);
|
|
529
|
+
}
|
|
530
|
+
this.graph.d3VelocityDecay(NON_FORCE_VELOCITY_DECAY);
|
|
531
|
+
this.graph.d3AlphaMin(NON_FORCE_ALPHA_MIN);
|
|
532
|
+
}
|
|
533
|
+
startNonForceSettleAnimation(cooldownTicks) {
|
|
534
|
+
if (!this.graph || this.data.nodes.length === 0 || this.isForceLayoutMode())
|
|
535
|
+
return;
|
|
536
|
+
this.graph.cooldownTicks(cooldownTicks);
|
|
537
|
+
this.updateCanvasSimulationAttribute(true);
|
|
538
|
+
this.graph.d3ReheatSimulation();
|
|
539
|
+
}
|
|
540
|
+
applyLayoutTargets() {
|
|
541
|
+
for (const node of this.data.nodes) {
|
|
542
|
+
if (node.layoutTargetX === undefined || node.layoutTargetY === undefined)
|
|
543
|
+
continue;
|
|
544
|
+
node.x = node.layoutTargetX;
|
|
545
|
+
node.y = node.layoutTargetY;
|
|
546
|
+
node.vx = 0;
|
|
547
|
+
node.vy = 0;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
configureSimulationForCurrentLayout(shouldAnimateNonForceLayout = false) {
|
|
551
|
+
if (!this.graph)
|
|
552
|
+
return;
|
|
553
|
+
if (this.isForceLayoutMode()) {
|
|
554
|
+
this.setupForces();
|
|
555
|
+
const cooldownTicks = this.config.cooldownTicks ?? Infinity;
|
|
556
|
+
this.graph.cooldownTicks(cooldownTicks);
|
|
557
|
+
this.updateCanvasSimulationAttribute(cooldownTicks !== 0 && this.data.nodes.length > 0);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
this.setupAnchoredLayoutForces();
|
|
561
|
+
if (shouldAnimateNonForceLayout) {
|
|
562
|
+
this.startNonForceSettleAnimation(NON_FORCE_LAYOUT_COOLDOWN_TICKS);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
this.graph.cooldownTicks(0);
|
|
566
|
+
this.updateCanvasSimulationAttribute(false);
|
|
567
|
+
}
|
|
297
568
|
triggerRender() {
|
|
298
569
|
if (!this.graph || this.graph.cooldownTicks() !== 0)
|
|
299
570
|
return;
|
|
@@ -420,7 +691,6 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
420
691
|
this.calculateNodeDegree();
|
|
421
692
|
// Initialize force-graph
|
|
422
693
|
// Cast to any for the factory call pattern, result is properly typed as ForceGraphInstance
|
|
423
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
424
694
|
this.graph = ForceGraph()(this.container)
|
|
425
695
|
.width(this.config.width || 800)
|
|
426
696
|
.height(this.config.height || 600)
|
|
@@ -435,7 +705,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
435
705
|
.linkCurvature("curve")
|
|
436
706
|
.linkVisibility("visible")
|
|
437
707
|
.nodeVisibility("visible")
|
|
438
|
-
.cooldownTicks(this.config.cooldownTicks ?? Infinity) // undefined = infinite
|
|
708
|
+
.cooldownTicks(this.isForceLayoutMode() ? (this.config.cooldownTicks ?? Infinity) : 0) // undefined = infinite
|
|
439
709
|
.cooldownTime(this.config.cooldownTime ?? 2000)
|
|
440
710
|
.enableNodeDrag(true)
|
|
441
711
|
.enableZoomInteraction(true)
|
|
@@ -464,6 +734,12 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
464
734
|
if (this.config.onNodeHover) {
|
|
465
735
|
this.config.onNodeHover(node);
|
|
466
736
|
}
|
|
737
|
+
})
|
|
738
|
+
.onNodeDrag((node) => {
|
|
739
|
+
this.handleNodeDrag(node);
|
|
740
|
+
})
|
|
741
|
+
.onNodeDragEnd((node) => {
|
|
742
|
+
this.handleNodeDragEnd(node);
|
|
467
743
|
})
|
|
468
744
|
.onLinkHover((link) => {
|
|
469
745
|
if (this.config.onLinkHover) {
|
|
@@ -523,8 +799,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
523
799
|
this.pointerLink(link, color, ctx);
|
|
524
800
|
}
|
|
525
801
|
});
|
|
526
|
-
|
|
527
|
-
this.setupForces();
|
|
802
|
+
this.configureSimulationForCurrentLayout();
|
|
528
803
|
this.log('Force graph initialization complete');
|
|
529
804
|
}
|
|
530
805
|
setupForces() {
|
|
@@ -534,6 +809,8 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
534
809
|
return;
|
|
535
810
|
if (!this.graph)
|
|
536
811
|
return;
|
|
812
|
+
this.graph.d3Force("layoutTargetX", null);
|
|
813
|
+
this.graph.d3Force("layoutTargetY", null);
|
|
537
814
|
// distance based on node size + constant
|
|
538
815
|
linkForce
|
|
539
816
|
.distance((link) => {
|
|
@@ -564,6 +841,28 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
564
841
|
}
|
|
565
842
|
this.log('Force simulation setup complete');
|
|
566
843
|
}
|
|
844
|
+
handleNodeDrag(node) {
|
|
845
|
+
if (this.isForceLayoutMode())
|
|
846
|
+
return;
|
|
847
|
+
if (node.x === undefined || node.y === undefined)
|
|
848
|
+
return;
|
|
849
|
+
node.layoutTargetX = node.x;
|
|
850
|
+
node.layoutTargetY = node.y;
|
|
851
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
852
|
+
this.startNonForceSettleAnimation(NON_FORCE_DRAG_COOLDOWN_TICKS);
|
|
853
|
+
}
|
|
854
|
+
handleNodeDragEnd(node) {
|
|
855
|
+
if (this.isForceLayoutMode())
|
|
856
|
+
return;
|
|
857
|
+
if (node.x === undefined || node.y === undefined)
|
|
858
|
+
return;
|
|
859
|
+
node.layoutTargetX = node.x;
|
|
860
|
+
node.layoutTargetY = node.y;
|
|
861
|
+
node.fx = undefined;
|
|
862
|
+
node.fy = undefined;
|
|
863
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
864
|
+
this.startNonForceSettleAnimation(NON_FORCE_DRAG_COOLDOWN_TICKS);
|
|
865
|
+
}
|
|
567
866
|
drawNode(node, ctx) {
|
|
568
867
|
if (node.x === undefined || node.y === undefined) {
|
|
569
868
|
node.x = 0;
|
|
@@ -591,32 +890,46 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
591
890
|
ctx.font = `400 ${NODE_FONT_SIZE_BASE}px SofiaSans`;
|
|
592
891
|
[line1, line2] = wrapTextForCircularNode(ctx, text, textRadius);
|
|
593
892
|
let chosenSize = NODE_FONT_SIZE_BASE;
|
|
893
|
+
// Measure at a large reference size (20px) where canvas metrics are
|
|
894
|
+
// precise, then compute the exact scale to fill the node.
|
|
895
|
+
const REF = 20;
|
|
896
|
+
ctx.font = `400 ${REF}px SofiaSans`;
|
|
897
|
+
// Switch to "left" for measurement: actualBoundingBoxLeft/Right are
|
|
898
|
+
// unreliable with textAlign="center" and can double on some engines.
|
|
899
|
+
ctx.textAlign = "left";
|
|
900
|
+
const refMetrics = ctx.measureText(line1);
|
|
901
|
+
// Use the actual visual bounding box (not advance width) so glyphs
|
|
902
|
+
// with overshoot (e.g. "7") are fully accounted for.
|
|
903
|
+
const visualWidth = (refMetrics.actualBoundingBoxLeft ?? 0)
|
|
904
|
+
+ (refMetrics.actualBoundingBoxRight ?? 0);
|
|
905
|
+
let refWidth = Math.max(visualWidth, refMetrics.width);
|
|
906
|
+
const singleLineHeight = (refMetrics.actualBoundingBoxAscent ?? 0)
|
|
907
|
+
+ (refMetrics.actualBoundingBoxDescent ?? 0);
|
|
908
|
+
let refHeight;
|
|
594
909
|
if (!line2) {
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
chosenSize = REF * (2 * r / refWidth);
|
|
619
|
-
}
|
|
910
|
+
refHeight = singleLineHeight;
|
|
911
|
+
}
|
|
912
|
+
else {
|
|
913
|
+
// Two-line: use the wider line and account for the vertical span
|
|
914
|
+
// of both lines including the 1.5× spacing used by the rendering code.
|
|
915
|
+
const m2 = ctx.measureText(line2);
|
|
916
|
+
const vis2 = Math.max((m2.actualBoundingBoxLeft ?? 0) + (m2.actualBoundingBoxRight ?? 0), m2.width);
|
|
917
|
+
refWidth = Math.max(refWidth, vis2);
|
|
918
|
+
refHeight = singleLineHeight * 2.5;
|
|
919
|
+
}
|
|
920
|
+
ctx.textAlign = "center";
|
|
921
|
+
// Inscribed-rectangle-in-circle constraint: every corner of the text
|
|
922
|
+
// bounding box must lie inside the circle, i.e.
|
|
923
|
+
// sqrt((w/2)² + (h/2)²) ≤ r
|
|
924
|
+
// Solving for the uniform scale factor s:
|
|
925
|
+
// s = 2·r / sqrt(refWidth² + refHeight²)
|
|
926
|
+
const r = NODE_TEXT_FILL_RATIO * textRadius;
|
|
927
|
+
if (refWidth > 0 && refHeight > 0) {
|
|
928
|
+
const diagonal = Math.sqrt(refWidth * refWidth + refHeight * refHeight);
|
|
929
|
+
chosenSize = REF * (2 * r / diagonal);
|
|
930
|
+
}
|
|
931
|
+
else if (refWidth > 0) {
|
|
932
|
+
chosenSize = REF * (2 * r / refWidth);
|
|
620
933
|
}
|
|
621
934
|
ctx.font = `400 ${chosenSize}px SofiaSans`;
|
|
622
935
|
node.displayName = [line1, line2];
|
|
@@ -675,7 +988,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
675
988
|
// bezier midpoint and the arrow tip are at almost the same position).
|
|
676
989
|
let pendingArrow = null;
|
|
677
990
|
if (start.id === end.id) {
|
|
678
|
-
const nodeSize = start.size ||
|
|
991
|
+
const nodeSize = start.size || NODE_SIZE;
|
|
679
992
|
const d = (link.curve || 0) * nodeSize * SELF_LOOP_CURVE_FACTOR;
|
|
680
993
|
ctx.lineWidth = (isLinkSelected ? 2 : 1) / globalScale;
|
|
681
994
|
if (this.config.linkLineDash)
|
|
@@ -786,7 +1099,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
786
1099
|
// Draw regular link line and arrowhead
|
|
787
1100
|
const arrowHalfWidth = arrowLen / ARROW_WH_RATIO / 2;
|
|
788
1101
|
// Target-side clip: find t where bezier enters target node border + PADDING
|
|
789
|
-
const endNodeSize = end.size ||
|
|
1102
|
+
const endNodeSize = end.size || NODE_SIZE;
|
|
790
1103
|
const borderRadius = endNodeSize + (this.config.isNodeSelected?.(end) ? 1 : 0.5) + PADDING;
|
|
791
1104
|
const borderRadiusSq = borderRadius * borderRadius;
|
|
792
1105
|
let tArrow;
|
|
@@ -815,7 +1128,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
815
1128
|
const tipX = uArrow * uArrow * start.x + 2 * uArrow * tArrow * controlX + tArrow * tArrow * end.x;
|
|
816
1129
|
const tipY = uArrow * uArrow * start.y + 2 * uArrow * tArrow * controlY + tArrow * tArrow * end.y;
|
|
817
1130
|
// Source-side clip: find t where bezier exits source node border + PADDING
|
|
818
|
-
const startNodeSize = start.size ||
|
|
1131
|
+
const startNodeSize = start.size || NODE_SIZE;
|
|
819
1132
|
const srcBorderRadius = startNodeSize + (this.config.isNodeSelected?.(start) ? 1 : 0.5) + PADDING;
|
|
820
1133
|
const srcBorderRadiusSq = srcBorderRadius * srcBorderRadius;
|
|
821
1134
|
let tStart = 0;
|
|
@@ -939,7 +1252,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
939
1252
|
ctx.beginPath();
|
|
940
1253
|
if (start.id === end.id) {
|
|
941
1254
|
// Self-loop: replicate exact cubic bezier clip from drawLink
|
|
942
|
-
const nodeSize = start.size ||
|
|
1255
|
+
const nodeSize = start.size || NODE_SIZE;
|
|
943
1256
|
const d = (link.curve || 0) * nodeSize * SELF_LOOP_CURVE_FACTOR;
|
|
944
1257
|
const nodeStrokeWidth = this.config.isNodeSelected?.(start) ? 1 : 0.5;
|
|
945
1258
|
const borderRadius = nodeSize + nodeStrokeWidth + PADDING;
|
|
@@ -984,7 +1297,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
984
1297
|
const controlX = (start.x + end.x) / 2 + perpX * curvature * distance;
|
|
985
1298
|
const controlY = (start.y + end.y) / 2 + perpY * curvature * distance;
|
|
986
1299
|
// Use the same borderRadius and binary-search clip as drawLink
|
|
987
|
-
const endNodeSize = end.size ||
|
|
1300
|
+
const endNodeSize = end.size || NODE_SIZE;
|
|
988
1301
|
const borderRadius = endNodeSize + (this.config.isNodeSelected?.(end) ? 1 : 0.5) + PADDING;
|
|
989
1302
|
const borderRadiusSq = borderRadius * borderRadius;
|
|
990
1303
|
let tArrow;
|
|
@@ -1013,7 +1326,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
1013
1326
|
const tipX = uArrow * uArrow * start.x + 2 * uArrow * tArrow * controlX + tArrow * tArrow * end.x;
|
|
1014
1327
|
const tipY = uArrow * uArrow * start.y + 2 * uArrow * tArrow * controlY + tArrow * tArrow * end.y;
|
|
1015
1328
|
// Source-side clip: mirror of drawLink source gap
|
|
1016
|
-
const startNodeSize = start.size ||
|
|
1329
|
+
const startNodeSize = start.size || NODE_SIZE;
|
|
1017
1330
|
const srcBorderRadius = startNodeSize + (this.config.isNodeSelected?.(start) ? 1 : 0.5) + PADDING;
|
|
1018
1331
|
const srcBorderRadiusSq = srcBorderRadius * srcBorderRadius;
|
|
1019
1332
|
let tStart = 0;
|
|
@@ -1068,6 +1381,16 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
1068
1381
|
if (!this.graph)
|
|
1069
1382
|
return;
|
|
1070
1383
|
this.log('Engine stopped');
|
|
1384
|
+
if (!this.isForceLayoutMode()) {
|
|
1385
|
+
this.applyLayoutTargets();
|
|
1386
|
+
this.graph.cooldownTicks(0);
|
|
1387
|
+
this.updateCanvasSimulationAttribute(false);
|
|
1388
|
+
if (this.shouldZoomToFitOnNonForceSettle && this.data.nodes.length > 0) {
|
|
1389
|
+
this.zoomToFit(1);
|
|
1390
|
+
}
|
|
1391
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1071
1394
|
// If already stopped, just ensure any leftover loading state is cleared and return
|
|
1072
1395
|
if (this.config.cooldownTicks === 0) {
|
|
1073
1396
|
if (this.config.isLoading) {
|
|
@@ -1136,6 +1459,12 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
1136
1459
|
if (this.config.onNodeHover) {
|
|
1137
1460
|
this.config.onNodeHover(node);
|
|
1138
1461
|
}
|
|
1462
|
+
})
|
|
1463
|
+
.onNodeDrag((node) => {
|
|
1464
|
+
this.handleNodeDrag(node);
|
|
1465
|
+
})
|
|
1466
|
+
.onNodeDragEnd((node) => {
|
|
1467
|
+
this.handleNodeDragEnd(node);
|
|
1139
1468
|
})
|
|
1140
1469
|
.onLinkHover((link) => {
|
|
1141
1470
|
if (this.config.onLinkHover) {
|