@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/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 and for two-line text.
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(ticks !== 0);
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
- // Convert data and apply circular layout to new nodes only
271
- this.data = dataToGraphData(data);
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
- this.config.cooldownTicks = this.data.nodes.length > 0 ? undefined : 0;
274
- this.config.isLoading = this.data.nodes.length > 0;
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
- .cooldownTicks(this.config.cooldownTicks ?? Infinity);
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
- // Setup forces
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
- // Single-line: measure at a large reference size (20px) where canvas
730
- // metrics are precise, then compute the exact scale to fill the node.
731
- const REF = 20;
732
- ctx.font = `400 ${REF}px SofiaSans`;
733
- const refMetrics = ctx.measureText(line1);
734
- // Use the actual visual bounding box (not advance width) so glyphs
735
- // with overshoot (e.g. "7") are fully accounted for.
736
- const visualWidth = (refMetrics.actualBoundingBoxLeft ?? 0)
737
- + (refMetrics.actualBoundingBoxRight ?? 0);
738
- const refWidth = Math.max(visualWidth, refMetrics.width);
739
- const refHeight = (refMetrics.actualBoundingBoxAscent ?? 0)
740
- + (refMetrics.actualBoundingBoxDescent ?? 0);
741
-
742
- // Inscribed-rectangle-in-circle constraint: every corner of the text
743
- // bounding box must lie inside the circle, i.e.
744
- // sqrt((w/2)² + (h/2)²) r
745
- // Solving for the uniform scale factor s:
746
- // s = 2·r / sqrt(refWidth² + refHeight²)
747
- const r = NODE_TEXT_FILL_RATIO * textRadius;
748
- if (refWidth > 0 && refHeight > 0) {
749
- const diagonal = Math.sqrt(refWidth * refWidth + refHeight * refHeight);
750
- chosenSize = REF * (2 * r / diagonal);
751
- } else if (refWidth > 0) {
752
- chosenSize = REF * (2 * r / refWidth);
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. 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 || 6;
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 || 6;
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 || 6;
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 || 6;
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 || 6;
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 || 6;
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);