@falkordb/canvas 0.0.44 → 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 +65 -4
- package/dist/canvas-types.d.ts.map +1 -1
- package/dist/canvas-utils.d.ts +3 -3
- package/dist/canvas-utils.d.ts.map +1 -1
- package/dist/canvas-utils.js +22 -16
- 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 +73 -6
- package/src/canvas-utils.ts +33 -20
- package/src/canvas.ts +433 -54
- package/src/index.ts +31 -1
- package/src/layouts.ts +993 -0
package/src/canvas.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
/* eslint-disable no-param-reassign */
|
|
2
|
-
|
|
3
1
|
import ForceGraph from "force-graph";
|
|
4
2
|
import * as d3 from "d3";
|
|
5
3
|
import {
|
|
@@ -20,8 +18,10 @@ import {
|
|
|
20
18
|
getNodeDisplayText,
|
|
21
19
|
graphDataToData,
|
|
22
20
|
LINK_DISTANCE,
|
|
21
|
+
NODE_SIZE,
|
|
23
22
|
wrapTextForCircularNode,
|
|
24
23
|
} from "./canvas-utils.js";
|
|
24
|
+
import { applyGraphLayout, isForceLayout } from "./layouts.js";
|
|
25
25
|
|
|
26
26
|
const PADDING = 2;
|
|
27
27
|
// Arrow geometry constants (shared by self-loop and regular-link drawing paths)
|
|
@@ -29,7 +29,7 @@ const ARROW_WH_RATIO = 1.6;
|
|
|
29
29
|
const ARROW_VLEN_RATIO = 0.2;
|
|
30
30
|
// Multiplier to convert node size → cubic bezier control-point distance for self-loops
|
|
31
31
|
const SELF_LOOP_CURVE_FACTOR = 11.67;
|
|
32
|
-
// Base font size used for the initial measurement
|
|
32
|
+
// Base font size used for the initial wrap-measurement pass.
|
|
33
33
|
const NODE_FONT_SIZE_BASE = 2;
|
|
34
34
|
// Fraction of the chord width that single-line text should fill (0–1).
|
|
35
35
|
// Leaves (1 - ratio)/2 of the radius as horizontal padding on each side.
|
|
@@ -40,6 +40,17 @@ const CHARGE_STRENGTH = -400;
|
|
|
40
40
|
const CENTER_STRENGTH = 0.03;
|
|
41
41
|
const VELOCITY_DECAY = 0.4;
|
|
42
42
|
const ALPHA_MIN = 0.05;
|
|
43
|
+
const NON_FORCE_CHARGE_STRENGTH = -220;
|
|
44
|
+
const NON_FORCE_COLLIDE_PADDING = 18;
|
|
45
|
+
const NON_FORCE_CENTER_STRENGTH = 0.02;
|
|
46
|
+
const NON_FORCE_LINK_STRENGTH = 0.08;
|
|
47
|
+
const NON_FORCE_TARGET_STRENGTH = 0.3;
|
|
48
|
+
const NON_FORCE_VELOCITY_DECAY = 0.5;
|
|
49
|
+
const NON_FORCE_ALPHA_MIN = 0.03;
|
|
50
|
+
const NON_FORCE_LAYOUT_COOLDOWN_TICKS = 120;
|
|
51
|
+
const NON_FORCE_DRAG_COOLDOWN_TICKS = 90;
|
|
52
|
+
|
|
53
|
+
type NodePosition = { x: number; y: number };
|
|
43
54
|
|
|
44
55
|
// Create styles for the web component
|
|
45
56
|
function createStyles(backgroundColor: string, foregroundColor: string): HTMLStyleElement {
|
|
@@ -92,6 +103,8 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
92
103
|
foregroundColor: '#1A1A1A',
|
|
93
104
|
captionsKeys: [],
|
|
94
105
|
showPropertyKeyPrefix: false,
|
|
106
|
+
layoutMode: "force",
|
|
107
|
+
layoutOptions: {},
|
|
95
108
|
};
|
|
96
109
|
|
|
97
110
|
private nodeMode: CanvasRenderMode = 'replace';
|
|
@@ -115,11 +128,16 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
115
128
|
private onFontsLoadingDone = () => {
|
|
116
129
|
this.relationshipsTextCache.clear();
|
|
117
130
|
this.nodeDisplayFontSize.clear();
|
|
131
|
+
for (const node of this.data.nodes) {
|
|
132
|
+
node.displayName = ["", ""];
|
|
133
|
+
}
|
|
118
134
|
this.triggerRender();
|
|
119
135
|
};
|
|
120
136
|
|
|
121
137
|
private viewport: ViewportState;
|
|
122
138
|
|
|
139
|
+
private shouldZoomToFitOnNonForceSettle: boolean = false;
|
|
140
|
+
|
|
123
141
|
constructor() {
|
|
124
142
|
super();
|
|
125
143
|
this.attachShadow({ mode: "open" });
|
|
@@ -176,13 +194,13 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
176
194
|
this.resizeObserver = null;
|
|
177
195
|
}
|
|
178
196
|
if (this.graph) {
|
|
179
|
-
// eslint-disable-next-line no-underscore-dangle
|
|
180
197
|
this.graph._destructor();
|
|
181
198
|
}
|
|
182
199
|
}
|
|
183
200
|
|
|
184
201
|
setConfig(config: Partial<ForceGraphConfig>) {
|
|
185
202
|
this.log('Setting config:', config);
|
|
203
|
+
const layoutChanged = config.layoutMode !== undefined || config.layoutOptions !== undefined;
|
|
186
204
|
|
|
187
205
|
// If captionsKeys changed, invalidate cached display names and font sizes
|
|
188
206
|
// so text is recomputed with the new keys on the next render.
|
|
@@ -195,6 +213,43 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
195
213
|
|
|
196
214
|
Object.assign(this.config, config);
|
|
197
215
|
|
|
216
|
+
if (layoutChanged) {
|
|
217
|
+
const previousPositions = this.getNodePositionMap();
|
|
218
|
+
if (this.isForceLayoutMode() && this.config.cooldownTicks === 0 && this.data.nodes.length > 0) {
|
|
219
|
+
this.config.cooldownTicks = undefined;
|
|
220
|
+
}
|
|
221
|
+
this.data = applyGraphLayout(this.data, this.config.layoutMode, this.config.layoutOptions);
|
|
222
|
+
const shouldAnimateNonForceLayout = this.prepareNodePositionsForCurrentLayout(previousPositions);
|
|
223
|
+
if (this.graph) {
|
|
224
|
+
this.calculateNodeDegree();
|
|
225
|
+
this.graph.graphData(this.data);
|
|
226
|
+
this.configureSimulationForCurrentLayout(shouldAnimateNonForceLayout);
|
|
227
|
+
if (this.isForceLayoutMode()) {
|
|
228
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
229
|
+
this.config.isLoading = this.data.nodes.length > 0;
|
|
230
|
+
this.config.onLoadingChange?.(this.config.isLoading);
|
|
231
|
+
this.updateLoadingState();
|
|
232
|
+
} else {
|
|
233
|
+
this.config.isLoading = false;
|
|
234
|
+
this.config.onLoadingChange?.(false);
|
|
235
|
+
this.updateLoadingState();
|
|
236
|
+
if (this.data.nodes.length > 0) {
|
|
237
|
+
if (shouldAnimateNonForceLayout) {
|
|
238
|
+
this.shouldZoomToFitOnNonForceSettle = true;
|
|
239
|
+
} else {
|
|
240
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
241
|
+
this.zoomToFit(1);
|
|
242
|
+
}
|
|
243
|
+
} else {
|
|
244
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
245
|
+
}
|
|
246
|
+
if (!shouldAnimateNonForceLayout) {
|
|
247
|
+
this.triggerRender();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
198
253
|
// Update event handlers if they were provided
|
|
199
254
|
if (config.onNodeClick || config.onLinkClick || config.onNodeRightClick || config.onLinkRightClick ||
|
|
200
255
|
config.onNodeHover || config.onLinkHover || config.onBackgroundClick || config.onBackgroundRightClick || config.onZoom ||
|
|
@@ -255,10 +310,12 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
255
310
|
this.log('Setting cooldown ticks to:', ticks);
|
|
256
311
|
this.config.cooldownTicks = ticks;
|
|
257
312
|
if (this.graph) {
|
|
258
|
-
this.graph.cooldownTicks(ticks ?? Infinity);
|
|
313
|
+
this.graph.cooldownTicks(this.isForceLayoutMode() ? (ticks ?? Infinity) : 0);
|
|
259
314
|
}
|
|
260
315
|
|
|
261
|
-
this.updateCanvasSimulationAttribute(
|
|
316
|
+
this.updateCanvasSimulationAttribute(
|
|
317
|
+
this.isForceLayoutMode() && ticks !== 0 && this.data.nodes.length > 0
|
|
318
|
+
);
|
|
262
319
|
}
|
|
263
320
|
|
|
264
321
|
getData(): Data {
|
|
@@ -267,17 +324,33 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
267
324
|
|
|
268
325
|
setData(data: Data) {
|
|
269
326
|
this.log('setData called with', data.nodes.length, 'nodes and', data.links.length, 'links');
|
|
270
|
-
|
|
271
|
-
|
|
327
|
+
const previousPositions = this.getNodePositionMap();
|
|
328
|
+
const oldNodesMap = new Map<number, GraphNode>();
|
|
329
|
+
for (const node of this.data.nodes) {
|
|
330
|
+
oldNodesMap.set(node.id, node);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Convert data and preserve positions for existing nodes
|
|
334
|
+
this.data = dataToGraphData(data, undefined, oldNodesMap);
|
|
335
|
+
this.data = applyGraphLayout(this.data, this.config.layoutMode, this.config.layoutOptions);
|
|
336
|
+
const shouldAnimateNonForceLayout = this.prepareNodePositionsForCurrentLayout(previousPositions);
|
|
272
337
|
|
|
273
|
-
|
|
274
|
-
|
|
338
|
+
if (this.isForceLayoutMode()) {
|
|
339
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
340
|
+
this.config.cooldownTicks = this.data.nodes.length > 0 ? undefined : 0;
|
|
341
|
+
this.config.isLoading = this.data.nodes.length > 0;
|
|
342
|
+
} else {
|
|
343
|
+
this.config.cooldownTicks = 0;
|
|
344
|
+
this.config.isLoading = false;
|
|
345
|
+
}
|
|
275
346
|
this.log('Loading state:', this.config.isLoading);
|
|
276
347
|
this.config.onLoadingChange?.(this.config.isLoading);
|
|
277
348
|
|
|
278
349
|
// Update simulation state
|
|
279
|
-
if (this.data.nodes.length > 0) {
|
|
350
|
+
if (this.data.nodes.length > 0 && this.isForceLayoutMode()) {
|
|
280
351
|
this.updateCanvasSimulationAttribute(true);
|
|
352
|
+
} else {
|
|
353
|
+
this.updateCanvasSimulationAttribute(false);
|
|
281
354
|
}
|
|
282
355
|
|
|
283
356
|
// Initialize graph if it hasn't been initialized yet
|
|
@@ -290,12 +363,23 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
290
363
|
|
|
291
364
|
this.log('Calculating node degrees and setting up forces');
|
|
292
365
|
this.calculateNodeDegree();
|
|
293
|
-
this.setupForces();
|
|
294
366
|
|
|
295
367
|
// Update graph data and properties
|
|
296
368
|
this.graph
|
|
297
|
-
.graphData(this.data)
|
|
298
|
-
|
|
369
|
+
.graphData(this.data);
|
|
370
|
+
this.configureSimulationForCurrentLayout(shouldAnimateNonForceLayout);
|
|
371
|
+
|
|
372
|
+
if (!this.isForceLayoutMode() && this.data.nodes.length > 0) {
|
|
373
|
+
if (shouldAnimateNonForceLayout) {
|
|
374
|
+
this.shouldZoomToFitOnNonForceSettle = true;
|
|
375
|
+
} else {
|
|
376
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
377
|
+
this.zoomToFit(1);
|
|
378
|
+
this.triggerRender();
|
|
379
|
+
}
|
|
380
|
+
} else {
|
|
381
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
382
|
+
}
|
|
299
383
|
|
|
300
384
|
this.updateLoadingState();
|
|
301
385
|
}
|
|
@@ -325,16 +409,41 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
325
409
|
|
|
326
410
|
setGraphData(data: GraphData) {
|
|
327
411
|
this.log('setGraphData called with', data.nodes.length, 'nodes and', data.links.length, 'links');
|
|
328
|
-
|
|
329
|
-
this.data = data;
|
|
330
|
-
|
|
412
|
+
const previousPositions = this.getNodePositionMap();
|
|
413
|
+
this.data = applyGraphLayout(data, this.config.layoutMode, this.config.layoutOptions);
|
|
414
|
+
const shouldAnimateNonForceLayout = this.prepareNodePositionsForCurrentLayout(previousPositions);
|
|
415
|
+
if (this.isForceLayoutMode() && this.config.cooldownTicks === 0 && this.data.nodes.length > 0) {
|
|
416
|
+
this.config.cooldownTicks = undefined;
|
|
417
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
418
|
+
}
|
|
331
419
|
if (!this.graph) return;
|
|
332
420
|
|
|
333
421
|
this.calculateNodeDegree();
|
|
334
|
-
this.setupForces();
|
|
335
422
|
|
|
336
423
|
this.graph
|
|
337
|
-
.graphData(this.data)
|
|
424
|
+
.graphData(this.data);
|
|
425
|
+
this.configureSimulationForCurrentLayout(shouldAnimateNonForceLayout);
|
|
426
|
+
|
|
427
|
+
if (this.isForceLayoutMode() && this.data.nodes.length > 0) {
|
|
428
|
+
this.triggerRender();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (!this.isForceLayoutMode()) {
|
|
432
|
+
this.config.isLoading = false;
|
|
433
|
+
this.config.onLoadingChange?.(false);
|
|
434
|
+
this.updateLoadingState();
|
|
435
|
+
if (this.data.nodes.length > 0) {
|
|
436
|
+
if (shouldAnimateNonForceLayout) {
|
|
437
|
+
this.shouldZoomToFitOnNonForceSettle = true;
|
|
438
|
+
} else {
|
|
439
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
440
|
+
this.zoomToFit(1);
|
|
441
|
+
this.triggerRender();
|
|
442
|
+
}
|
|
443
|
+
} else {
|
|
444
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
338
447
|
|
|
339
448
|
if (this.viewport) {
|
|
340
449
|
this.log('Applying viewport:', this.viewport);
|
|
@@ -377,6 +486,214 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
377
486
|
this.graph.zoomToFit(500, padding * paddingMultiplier, filter);
|
|
378
487
|
}
|
|
379
488
|
|
|
489
|
+
private isForceLayoutMode() {
|
|
490
|
+
return isForceLayout(this.config.layoutMode);
|
|
491
|
+
}
|
|
492
|
+
private getNodePositionMap(): Map<number, NodePosition> {
|
|
493
|
+
const positions = new Map<number, NodePosition>();
|
|
494
|
+
|
|
495
|
+
for (const node of this.data.nodes) {
|
|
496
|
+
if (node.x === undefined || node.y === undefined) continue;
|
|
497
|
+
positions.set(node.id, { x: node.x, y: node.y });
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return positions;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private getGraphCenter(positions: Map<number, NodePosition>): NodePosition | undefined {
|
|
504
|
+
if (positions.size === 0) return undefined;
|
|
505
|
+
|
|
506
|
+
let sumX = 0;
|
|
507
|
+
let sumY = 0;
|
|
508
|
+
|
|
509
|
+
for (const position of positions.values()) {
|
|
510
|
+
sumX += position.x;
|
|
511
|
+
sumY += position.y;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return {
|
|
515
|
+
x: sumX / positions.size,
|
|
516
|
+
y: sumY / positions.size,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private getConnectedExistingPosition(
|
|
521
|
+
nodeId: number,
|
|
522
|
+
previousPositions: Map<number, NodePosition>
|
|
523
|
+
): NodePosition | undefined {
|
|
524
|
+
let sumX = 0;
|
|
525
|
+
let sumY = 0;
|
|
526
|
+
let count = 0;
|
|
527
|
+
|
|
528
|
+
for (const link of this.data.links) {
|
|
529
|
+
const sourceId = link.source.id;
|
|
530
|
+
const targetId = link.target.id;
|
|
531
|
+
|
|
532
|
+
if (sourceId === nodeId) {
|
|
533
|
+
const existingPosition = previousPositions.get(targetId);
|
|
534
|
+
if (!existingPosition) continue;
|
|
535
|
+
sumX += existingPosition.x;
|
|
536
|
+
sumY += existingPosition.y;
|
|
537
|
+
count += 1;
|
|
538
|
+
} else if (targetId === nodeId) {
|
|
539
|
+
const existingPosition = previousPositions.get(sourceId);
|
|
540
|
+
if (!existingPosition) continue;
|
|
541
|
+
sumX += existingPosition.x;
|
|
542
|
+
sumY += existingPosition.y;
|
|
543
|
+
count += 1;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (count === 0) return undefined;
|
|
548
|
+
return {
|
|
549
|
+
x: sumX / count,
|
|
550
|
+
y: sumY / count,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private clearLayoutTargets() {
|
|
555
|
+
for (const node of this.data.nodes) {
|
|
556
|
+
node.layoutTargetX = undefined;
|
|
557
|
+
node.layoutTargetY = undefined;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private prepareNodePositionsForCurrentLayout(previousPositions: Map<number, NodePosition>): boolean {
|
|
562
|
+
if (this.isForceLayoutMode()) {
|
|
563
|
+
this.clearLayoutTargets();
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const graphCenter = this.getGraphCenter(previousPositions);
|
|
568
|
+
let shouldAnimate = false;
|
|
569
|
+
|
|
570
|
+
for (const node of this.data.nodes) {
|
|
571
|
+
const targetX = node.x ?? 0;
|
|
572
|
+
const targetY = node.y ?? 0;
|
|
573
|
+
|
|
574
|
+
node.layoutTargetX = targetX;
|
|
575
|
+
node.layoutTargetY = targetY;
|
|
576
|
+
node.fx = undefined;
|
|
577
|
+
node.fy = undefined;
|
|
578
|
+
node.vx = 0;
|
|
579
|
+
node.vy = 0;
|
|
580
|
+
|
|
581
|
+
if (previousPositions.size === 0) {
|
|
582
|
+
node.x = targetX;
|
|
583
|
+
node.y = targetY;
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const previousPosition = previousPositions.get(node.id)
|
|
588
|
+
?? this.getConnectedExistingPosition(node.id, previousPositions)
|
|
589
|
+
?? graphCenter;
|
|
590
|
+
|
|
591
|
+
if (!previousPosition) {
|
|
592
|
+
node.x = targetX;
|
|
593
|
+
node.y = targetY;
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
node.x = previousPosition.x;
|
|
598
|
+
node.y = previousPosition.y;
|
|
599
|
+
|
|
600
|
+
if (
|
|
601
|
+
Math.abs(previousPosition.x - targetX) > 0.5
|
|
602
|
+
|| Math.abs(previousPosition.y - targetY) > 0.5
|
|
603
|
+
) {
|
|
604
|
+
shouldAnimate = true;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return shouldAnimate;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
private setupAnchoredLayoutForces() {
|
|
612
|
+
if (!this.graph) return;
|
|
613
|
+
|
|
614
|
+
const linkForce = this.graph.d3Force("link");
|
|
615
|
+
if (linkForce) {
|
|
616
|
+
linkForce
|
|
617
|
+
.distance((link: GraphLink) => {
|
|
618
|
+
const sourceSize = link.source.size;
|
|
619
|
+
const targetSize = link.target.size;
|
|
620
|
+
return sourceSize + targetSize + LINK_DISTANCE * 1.6;
|
|
621
|
+
})
|
|
622
|
+
.strength(NON_FORCE_LINK_STRENGTH);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
this.graph.d3Force(
|
|
626
|
+
"collide",
|
|
627
|
+
d3.forceCollide((node: GraphNode) => node.size + NON_FORCE_COLLIDE_PADDING)
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
this.graph.d3Force(
|
|
631
|
+
"centerX",
|
|
632
|
+
d3.forceX(0).strength(NON_FORCE_CENTER_STRENGTH)
|
|
633
|
+
);
|
|
634
|
+
|
|
635
|
+
this.graph.d3Force(
|
|
636
|
+
"centerY",
|
|
637
|
+
d3.forceY(0).strength(NON_FORCE_CENTER_STRENGTH)
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
this.graph.d3Force(
|
|
641
|
+
"layoutTargetX",
|
|
642
|
+
d3.forceX((node: GraphNode) => node.layoutTargetX ?? node.x ?? 0).strength(NON_FORCE_TARGET_STRENGTH)
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
this.graph.d3Force(
|
|
646
|
+
"layoutTargetY",
|
|
647
|
+
d3.forceY((node: GraphNode) => node.layoutTargetY ?? node.y ?? 0).strength(NON_FORCE_TARGET_STRENGTH)
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
const chargeForce = this.graph.d3Force("charge");
|
|
651
|
+
if (chargeForce) {
|
|
652
|
+
chargeForce.strength(NON_FORCE_CHARGE_STRENGTH);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
this.graph.d3VelocityDecay(NON_FORCE_VELOCITY_DECAY);
|
|
656
|
+
this.graph.d3AlphaMin(NON_FORCE_ALPHA_MIN);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
private startNonForceSettleAnimation(cooldownTicks: number) {
|
|
660
|
+
if (!this.graph || this.data.nodes.length === 0 || this.isForceLayoutMode()) return;
|
|
661
|
+
|
|
662
|
+
this.graph.cooldownTicks(cooldownTicks);
|
|
663
|
+
this.updateCanvasSimulationAttribute(true);
|
|
664
|
+
this.graph.d3ReheatSimulation();
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
private applyLayoutTargets() {
|
|
668
|
+
for (const node of this.data.nodes) {
|
|
669
|
+
if (node.layoutTargetX === undefined || node.layoutTargetY === undefined) continue;
|
|
670
|
+
node.x = node.layoutTargetX;
|
|
671
|
+
node.y = node.layoutTargetY;
|
|
672
|
+
node.vx = 0;
|
|
673
|
+
node.vy = 0;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
private configureSimulationForCurrentLayout(shouldAnimateNonForceLayout = false) {
|
|
678
|
+
if (!this.graph) return;
|
|
679
|
+
|
|
680
|
+
if (this.isForceLayoutMode()) {
|
|
681
|
+
this.setupForces();
|
|
682
|
+
const cooldownTicks = this.config.cooldownTicks ?? Infinity;
|
|
683
|
+
this.graph.cooldownTicks(cooldownTicks);
|
|
684
|
+
this.updateCanvasSimulationAttribute(cooldownTicks !== 0 && this.data.nodes.length > 0);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
this.setupAnchoredLayoutForces();
|
|
688
|
+
if (shouldAnimateNonForceLayout) {
|
|
689
|
+
this.startNonForceSettleAnimation(NON_FORCE_LAYOUT_COOLDOWN_TICKS);
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
this.graph.cooldownTicks(0);
|
|
694
|
+
this.updateCanvasSimulationAttribute(false);
|
|
695
|
+
}
|
|
696
|
+
|
|
380
697
|
private triggerRender() {
|
|
381
698
|
if (!this.graph || this.graph.cooldownTicks() !== 0) return;
|
|
382
699
|
|
|
@@ -531,7 +848,6 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
531
848
|
|
|
532
849
|
// Initialize force-graph
|
|
533
850
|
// Cast to any for the factory call pattern, result is properly typed as ForceGraphInstance
|
|
534
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
535
851
|
this.graph = (ForceGraph as any)()(this.container)
|
|
536
852
|
.width(this.config.width || 800)
|
|
537
853
|
.height(this.config.height || 600)
|
|
@@ -548,7 +864,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
548
864
|
.linkCurvature("curve")
|
|
549
865
|
.linkVisibility("visible")
|
|
550
866
|
.nodeVisibility("visible")
|
|
551
|
-
.cooldownTicks(this.config.cooldownTicks ?? Infinity) // undefined = infinite
|
|
867
|
+
.cooldownTicks(this.isForceLayoutMode() ? (this.config.cooldownTicks ?? Infinity) : 0) // undefined = infinite
|
|
552
868
|
.cooldownTime(this.config.cooldownTime ?? 2000)
|
|
553
869
|
.enableNodeDrag(true)
|
|
554
870
|
.enableZoomInteraction(true)
|
|
@@ -578,6 +894,12 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
578
894
|
this.config.onNodeHover(node);
|
|
579
895
|
}
|
|
580
896
|
})
|
|
897
|
+
.onNodeDrag((node: GraphNode) => {
|
|
898
|
+
this.handleNodeDrag(node);
|
|
899
|
+
})
|
|
900
|
+
.onNodeDragEnd((node: GraphNode) => {
|
|
901
|
+
this.handleNodeDragEnd(node);
|
|
902
|
+
})
|
|
581
903
|
.onLinkHover((link: GraphLink | null) => {
|
|
582
904
|
if (this.config.onLinkHover) {
|
|
583
905
|
this.config.onLinkHover(link);
|
|
@@ -633,8 +955,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
633
955
|
}
|
|
634
956
|
});
|
|
635
957
|
|
|
636
|
-
|
|
637
|
-
this.setupForces();
|
|
958
|
+
this.configureSimulationForCurrentLayout();
|
|
638
959
|
this.log('Force graph initialization complete');
|
|
639
960
|
}
|
|
640
961
|
|
|
@@ -645,6 +966,9 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
645
966
|
if (!linkForce) return;
|
|
646
967
|
if (!this.graph) return;
|
|
647
968
|
|
|
969
|
+
this.graph.d3Force("layoutTargetX", null);
|
|
970
|
+
this.graph.d3Force("layoutTargetY", null);
|
|
971
|
+
|
|
648
972
|
// distance based on node size + constant
|
|
649
973
|
linkForce
|
|
650
974
|
.distance((link: GraphLink) => {
|
|
@@ -688,6 +1012,28 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
688
1012
|
this.log('Force simulation setup complete');
|
|
689
1013
|
}
|
|
690
1014
|
|
|
1015
|
+
private handleNodeDrag(node: GraphNode) {
|
|
1016
|
+
if (this.isForceLayoutMode()) return;
|
|
1017
|
+
if (node.x === undefined || node.y === undefined) return;
|
|
1018
|
+
|
|
1019
|
+
node.layoutTargetX = node.x;
|
|
1020
|
+
node.layoutTargetY = node.y;
|
|
1021
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
1022
|
+
this.startNonForceSettleAnimation(NON_FORCE_DRAG_COOLDOWN_TICKS);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
private handleNodeDragEnd(node: GraphNode) {
|
|
1026
|
+
if (this.isForceLayoutMode()) return;
|
|
1027
|
+
if (node.x === undefined || node.y === undefined) return;
|
|
1028
|
+
|
|
1029
|
+
node.layoutTargetX = node.x;
|
|
1030
|
+
node.layoutTargetY = node.y;
|
|
1031
|
+
node.fx = undefined;
|
|
1032
|
+
node.fy = undefined;
|
|
1033
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
1034
|
+
this.startNonForceSettleAnimation(NON_FORCE_DRAG_COOLDOWN_TICKS);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
691
1037
|
private drawNode(node: GraphNode, ctx: CanvasRenderingContext2D) {
|
|
692
1038
|
|
|
693
1039
|
if (node.x === undefined || node.y === undefined) {
|
|
@@ -725,32 +1071,49 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
725
1071
|
[line1, line2] = wrapTextForCircularNode(ctx, text, textRadius);
|
|
726
1072
|
|
|
727
1073
|
let chosenSize = NODE_FONT_SIZE_BASE;
|
|
1074
|
+
// Measure at a large reference size (20px) where canvas metrics are
|
|
1075
|
+
// precise, then compute the exact scale to fill the node.
|
|
1076
|
+
const REF = 20;
|
|
1077
|
+
ctx.font = `400 ${REF}px SofiaSans`;
|
|
1078
|
+
// Switch to "left" for measurement: actualBoundingBoxLeft/Right are
|
|
1079
|
+
// unreliable with textAlign="center" and can double on some engines.
|
|
1080
|
+
ctx.textAlign = "left";
|
|
1081
|
+
const refMetrics = ctx.measureText(line1);
|
|
1082
|
+
// Use the actual visual bounding box (not advance width) so glyphs
|
|
1083
|
+
// with overshoot (e.g. "7") are fully accounted for.
|
|
1084
|
+
const visualWidth = (refMetrics.actualBoundingBoxLeft ?? 0)
|
|
1085
|
+
+ (refMetrics.actualBoundingBoxRight ?? 0);
|
|
1086
|
+
let refWidth = Math.max(visualWidth, refMetrics.width);
|
|
1087
|
+
const singleLineHeight = (refMetrics.actualBoundingBoxAscent ?? 0)
|
|
1088
|
+
+ (refMetrics.actualBoundingBoxDescent ?? 0);
|
|
1089
|
+
|
|
1090
|
+
let refHeight: number;
|
|
728
1091
|
if (!line2) {
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
const
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
1092
|
+
refHeight = singleLineHeight;
|
|
1093
|
+
} else {
|
|
1094
|
+
// Two-line: use the wider line and account for the vertical span
|
|
1095
|
+
// of both lines including the 1.5× spacing used by the rendering code.
|
|
1096
|
+
const m2 = ctx.measureText(line2);
|
|
1097
|
+
const vis2 = Math.max(
|
|
1098
|
+
(m2.actualBoundingBoxLeft ?? 0) + (m2.actualBoundingBoxRight ?? 0),
|
|
1099
|
+
m2.width,
|
|
1100
|
+
);
|
|
1101
|
+
refWidth = Math.max(refWidth, vis2);
|
|
1102
|
+
refHeight = singleLineHeight * 2.5;
|
|
1103
|
+
}
|
|
1104
|
+
ctx.textAlign = "center";
|
|
1105
|
+
|
|
1106
|
+
// Inscribed-rectangle-in-circle constraint: every corner of the text
|
|
1107
|
+
// bounding box must lie inside the circle, i.e.
|
|
1108
|
+
// sqrt((w/2)² + (h/2)²) ≤ r
|
|
1109
|
+
// Solving for the uniform scale factor s:
|
|
1110
|
+
// s = 2·r / sqrt(refWidth² + refHeight²)
|
|
1111
|
+
const r = NODE_TEXT_FILL_RATIO * textRadius;
|
|
1112
|
+
if (refWidth > 0 && refHeight > 0) {
|
|
1113
|
+
const diagonal = Math.sqrt(refWidth * refWidth + refHeight * refHeight);
|
|
1114
|
+
chosenSize = REF * (2 * r / diagonal);
|
|
1115
|
+
} else if (refWidth > 0) {
|
|
1116
|
+
chosenSize = REF * (2 * r / refWidth);
|
|
754
1117
|
}
|
|
755
1118
|
|
|
756
1119
|
ctx.font = `400 ${chosenSize}px SofiaSans`;
|
|
@@ -820,7 +1183,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
820
1183
|
let pendingArrow: { tipX: number; tipY: number; nx: number; ny: number; arrowLen: number; arrowHalfWidth: number } | null = null;
|
|
821
1184
|
|
|
822
1185
|
if (start.id === end.id) {
|
|
823
|
-
const nodeSize = start.size ||
|
|
1186
|
+
const nodeSize = start.size || NODE_SIZE;
|
|
824
1187
|
const d = (link.curve || 0) * nodeSize * SELF_LOOP_CURVE_FACTOR;
|
|
825
1188
|
|
|
826
1189
|
ctx.lineWidth = (isLinkSelected ? 2 : 1) / globalScale;
|
|
@@ -947,7 +1310,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
947
1310
|
const arrowHalfWidth = arrowLen / ARROW_WH_RATIO / 2;
|
|
948
1311
|
|
|
949
1312
|
// Target-side clip: find t where bezier enters target node border + PADDING
|
|
950
|
-
const endNodeSize = end.size ||
|
|
1313
|
+
const endNodeSize = end.size || NODE_SIZE;
|
|
951
1314
|
const borderRadius = endNodeSize + (this.config.isNodeSelected?.(end) ? 1 : 0.5) + PADDING;
|
|
952
1315
|
const borderRadiusSq = borderRadius * borderRadius;
|
|
953
1316
|
|
|
@@ -975,7 +1338,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
975
1338
|
const tipY = uArrow * uArrow * start.y + 2 * uArrow * tArrow * controlY + tArrow * tArrow * end.y;
|
|
976
1339
|
|
|
977
1340
|
// Source-side clip: find t where bezier exits source node border + PADDING
|
|
978
|
-
const startNodeSize = start.size ||
|
|
1341
|
+
const startNodeSize = start.size || NODE_SIZE;
|
|
979
1342
|
const srcBorderRadius = startNodeSize + (this.config.isNodeSelected?.(start) ? 1 : 0.5) + PADDING;
|
|
980
1343
|
const srcBorderRadiusSq = srcBorderRadius * srcBorderRadius;
|
|
981
1344
|
|
|
@@ -1119,7 +1482,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
1119
1482
|
|
|
1120
1483
|
if (start.id === end.id) {
|
|
1121
1484
|
// Self-loop: replicate exact cubic bezier clip from drawLink
|
|
1122
|
-
const nodeSize = start.size ||
|
|
1485
|
+
const nodeSize = start.size || NODE_SIZE;
|
|
1123
1486
|
const d = (link.curve || 0) * nodeSize * SELF_LOOP_CURVE_FACTOR;
|
|
1124
1487
|
|
|
1125
1488
|
const nodeStrokeWidth = this.config.isNodeSelected?.(start) ? 1 : 0.5;
|
|
@@ -1170,7 +1533,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
1170
1533
|
const controlY = (start.y + end.y) / 2 + perpY * curvature * distance;
|
|
1171
1534
|
|
|
1172
1535
|
// Use the same borderRadius and binary-search clip as drawLink
|
|
1173
|
-
const endNodeSize = end.size ||
|
|
1536
|
+
const endNodeSize = end.size || NODE_SIZE;
|
|
1174
1537
|
const borderRadius = endNodeSize + (this.config.isNodeSelected?.(end) ? 1 : 0.5) + PADDING;
|
|
1175
1538
|
const borderRadiusSq = borderRadius * borderRadius;
|
|
1176
1539
|
|
|
@@ -1197,7 +1560,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
1197
1560
|
const tipY = uArrow * uArrow * start.y + 2 * uArrow * tArrow * controlY + tArrow * tArrow * end.y;
|
|
1198
1561
|
|
|
1199
1562
|
// Source-side clip: mirror of drawLink source gap
|
|
1200
|
-
const startNodeSize = start.size ||
|
|
1563
|
+
const startNodeSize = start.size || NODE_SIZE;
|
|
1201
1564
|
const srcBorderRadius = startNodeSize + (this.config.isNodeSelected?.(start) ? 1 : 0.5) + PADDING;
|
|
1202
1565
|
const srcBorderRadiusSq = srcBorderRadius * srcBorderRadius;
|
|
1203
1566
|
|
|
@@ -1254,6 +1617,16 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
1254
1617
|
if (!this.graph) return;
|
|
1255
1618
|
|
|
1256
1619
|
this.log('Engine stopped');
|
|
1620
|
+
if (!this.isForceLayoutMode()) {
|
|
1621
|
+
this.applyLayoutTargets();
|
|
1622
|
+
this.graph.cooldownTicks(0);
|
|
1623
|
+
this.updateCanvasSimulationAttribute(false);
|
|
1624
|
+
if (this.shouldZoomToFitOnNonForceSettle && this.data.nodes.length > 0) {
|
|
1625
|
+
this.zoomToFit(1);
|
|
1626
|
+
}
|
|
1627
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1257
1630
|
// If already stopped, just ensure any leftover loading state is cleared and return
|
|
1258
1631
|
if (this.config.cooldownTicks === 0) {
|
|
1259
1632
|
if (this.config.isLoading) {
|
|
@@ -1326,6 +1699,12 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
1326
1699
|
this.config.onNodeHover(node);
|
|
1327
1700
|
}
|
|
1328
1701
|
})
|
|
1702
|
+
.onNodeDrag((node: GraphNode) => {
|
|
1703
|
+
this.handleNodeDrag(node);
|
|
1704
|
+
})
|
|
1705
|
+
.onNodeDragEnd((node: GraphNode) => {
|
|
1706
|
+
this.handleNodeDragEnd(node);
|
|
1707
|
+
})
|
|
1329
1708
|
.onLinkHover((link: GraphLink | null) => {
|
|
1330
1709
|
if (this.config.onLinkHover) {
|
|
1331
1710
|
this.config.onLinkHover(link);
|