@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/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 and for two-line text.
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
- // Convert data and apply circular layout to new nodes only
210
- this.data = dataToGraphData(data);
211
- this.config.cooldownTicks = this.data.nodes.length > 0 ? undefined : 0;
212
- this.config.isLoading = this.data.nodes.length > 0;
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
- .cooldownTicks(this.config.cooldownTicks ?? Infinity);
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
- this.data = data;
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
- // Setup forces
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
- // Single-line: measure at a large reference size (20px) where canvas
596
- // metrics are precise, then compute the exact scale to fill the node.
597
- const REF = 20;
598
- ctx.font = `400 ${REF}px SofiaSans`;
599
- const refMetrics = ctx.measureText(line1);
600
- // Use the actual visual bounding box (not advance width) so glyphs
601
- // with overshoot (e.g. "7") are fully accounted for.
602
- const visualWidth = (refMetrics.actualBoundingBoxLeft ?? 0)
603
- + (refMetrics.actualBoundingBoxRight ?? 0);
604
- const refWidth = Math.max(visualWidth, refMetrics.width);
605
- const refHeight = (refMetrics.actualBoundingBoxAscent ?? 0)
606
- + (refMetrics.actualBoundingBoxDescent ?? 0);
607
- // Inscribed-rectangle-in-circle constraint: every corner of the text
608
- // bounding box must lie inside the circle, i.e.
609
- // sqrt((w/2)² + (h/2)²) r
610
- // Solving for the uniform scale factor s:
611
- // s = 2·r / sqrt(refWidth² + refHeight²)
612
- const r = NODE_TEXT_FILL_RATIO * textRadius;
613
- if (refWidth > 0 && refHeight > 0) {
614
- const diagonal = Math.sqrt(refWidth * refWidth + refHeight * refHeight);
615
- chosenSize = REF * (2 * r / diagonal);
616
- }
617
- else if (refWidth > 0) {
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 || 6;
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 || 6;
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 || 6;
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 || 6;
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 || 6;
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 || 6;
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) {