@falkordb/canvas 0.0.45 → 0.0.50

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,25 @@ 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 };
54
+
55
+ /** Axis-aligned bounding box in world-space coordinates. */
56
+ type WorldBounds = {
57
+ minX: number;
58
+ maxX: number;
59
+ minY: number;
60
+ maxY: number;
61
+ };
43
62
 
44
63
  // Create styles for the web component
45
64
  function createStyles(backgroundColor: string, foregroundColor: string): HTMLStyleElement {
@@ -92,6 +111,8 @@ class FalkorDBCanvas extends HTMLElement {
92
111
  foregroundColor: '#1A1A1A',
93
112
  captionsKeys: [],
94
113
  showPropertyKeyPrefix: false,
114
+ layoutMode: "force",
115
+ layoutOptions: {},
95
116
  };
96
117
 
97
118
  private nodeMode: CanvasRenderMode = 'replace';
@@ -112,14 +133,32 @@ class FalkorDBCanvas extends HTMLElement {
112
133
  }
113
134
  > = new Map();
114
135
 
136
+ /**
137
+ * Cached world-space axis-aligned bounding box of the currently visible
138
+ * viewport. Updated on every zoom/pan event and on resize.
139
+ * `null` means culling is disabled or not yet computed.
140
+ */
141
+ private cullingBounds: WorldBounds | null = null;
142
+
143
+ /** Current zoom level, cached alongside cullingBounds. */
144
+ private cullingZoom: number = 1;
145
+
146
+ /** Last d3-zoom transform, cached so bounds can be recomputed on resize. */
147
+ private lastTransform: Transform | null = null;
148
+
115
149
  private onFontsLoadingDone = () => {
116
150
  this.relationshipsTextCache.clear();
117
151
  this.nodeDisplayFontSize.clear();
152
+ for (const node of this.data.nodes) {
153
+ node.displayName = ["", ""];
154
+ }
118
155
  this.triggerRender();
119
156
  };
120
157
 
121
158
  private viewport: ViewportState;
122
159
 
160
+ private shouldZoomToFitOnNonForceSettle: boolean = false;
161
+
123
162
  constructor() {
124
163
  super();
125
164
  this.attachShadow({ mode: "open" });
@@ -176,13 +215,13 @@ class FalkorDBCanvas extends HTMLElement {
176
215
  this.resizeObserver = null;
177
216
  }
178
217
  if (this.graph) {
179
- // eslint-disable-next-line no-underscore-dangle
180
218
  this.graph._destructor();
181
219
  }
182
220
  }
183
221
 
184
222
  setConfig(config: Partial<ForceGraphConfig>) {
185
223
  this.log('Setting config:', config);
224
+ const layoutChanged = config.layoutMode !== undefined || config.layoutOptions !== undefined;
186
225
 
187
226
  // If captionsKeys changed, invalidate cached display names and font sizes
188
227
  // so text is recomputed with the new keys on the next render.
@@ -193,7 +232,59 @@ class FalkorDBCanvas extends HTMLElement {
193
232
  }
194
233
  }
195
234
 
196
- Object.assign(this.config, config);
235
+ // Deep-merge largeGraph to avoid wiping sibling fields on partial updates.
236
+ if (config.largeGraph && typeof config.largeGraph === 'object' && this.config.largeGraph) {
237
+ const mergedLargeGraph = { ...this.config.largeGraph, ...config.largeGraph };
238
+ Object.assign(this.config, config, { largeGraph: mergedLargeGraph });
239
+ } else {
240
+ Object.assign(this.config, config);
241
+ }
242
+
243
+ // Recompute or clear culling bounds when largeGraph config changes.
244
+ if ('largeGraph' in config) {
245
+ if (this.config.largeGraph?.enabled) {
246
+ this.recomputeCullingBoundsIfNeeded();
247
+ } else {
248
+ this.cullingBounds = null;
249
+ }
250
+ }
251
+
252
+ if (layoutChanged) {
253
+ const previousPositions = this.getNodePositionMap();
254
+ if (this.isForceLayoutMode() && this.config.cooldownTicks === 0 && this.data.nodes.length > 0) {
255
+ this.config.cooldownTicks = undefined;
256
+ }
257
+ this.data = applyGraphLayout(this.data, this.config.layoutMode, this.config.layoutOptions);
258
+ const shouldAnimateNonForceLayout = this.prepareNodePositionsForCurrentLayout(previousPositions);
259
+ if (this.graph) {
260
+ this.calculateNodeDegree();
261
+ this.graph.graphData(this.data);
262
+ this.configureSimulationForCurrentLayout(shouldAnimateNonForceLayout);
263
+ if (this.isForceLayoutMode()) {
264
+ this.shouldZoomToFitOnNonForceSettle = false;
265
+ this.config.isLoading = this.data.nodes.length > 0;
266
+ this.config.onLoadingChange?.(this.config.isLoading);
267
+ this.updateLoadingState();
268
+ } else {
269
+ this.config.isLoading = false;
270
+ this.config.onLoadingChange?.(false);
271
+ this.updateLoadingState();
272
+ if (this.data.nodes.length > 0) {
273
+ if (shouldAnimateNonForceLayout) {
274
+ this.shouldZoomToFitOnNonForceSettle = true;
275
+ } else {
276
+ this.shouldZoomToFitOnNonForceSettle = false;
277
+ this.zoomToFit(1);
278
+ }
279
+ } else {
280
+ this.shouldZoomToFitOnNonForceSettle = false;
281
+ }
282
+ if (!shouldAnimateNonForceLayout) {
283
+ this.triggerRender();
284
+ }
285
+ }
286
+ }
287
+ }
197
288
 
198
289
  // Update event handlers if they were provided
199
290
  if (config.onNodeClick || config.onLinkClick || config.onNodeRightClick || config.onLinkRightClick ||
@@ -210,6 +301,7 @@ class FalkorDBCanvas extends HTMLElement {
210
301
  this.config.width = width;
211
302
  if (this.graph) {
212
303
  this.graph.width(width);
304
+ this.recomputeCullingBoundsIfNeeded();
213
305
  }
214
306
  }
215
307
 
@@ -219,6 +311,7 @@ class FalkorDBCanvas extends HTMLElement {
219
311
  this.config.height = height;
220
312
  if (this.graph) {
221
313
  this.graph.height(height);
314
+ this.recomputeCullingBoundsIfNeeded();
222
315
  }
223
316
  }
224
317
 
@@ -255,10 +348,12 @@ class FalkorDBCanvas extends HTMLElement {
255
348
  this.log('Setting cooldown ticks to:', ticks);
256
349
  this.config.cooldownTicks = ticks;
257
350
  if (this.graph) {
258
- this.graph.cooldownTicks(ticks ?? Infinity);
351
+ this.graph.cooldownTicks(this.isForceLayoutMode() ? (ticks ?? Infinity) : 0);
259
352
  }
260
353
 
261
- this.updateCanvasSimulationAttribute(ticks !== 0);
354
+ this.updateCanvasSimulationAttribute(
355
+ this.isForceLayoutMode() && ticks !== 0 && this.data.nodes.length > 0
356
+ );
262
357
  }
263
358
 
264
359
  getData(): Data {
@@ -267,17 +362,33 @@ class FalkorDBCanvas extends HTMLElement {
267
362
 
268
363
  setData(data: Data) {
269
364
  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);
365
+ const previousPositions = this.getNodePositionMap();
366
+ const oldNodesMap = new Map<number, GraphNode>();
367
+ for (const node of this.data.nodes) {
368
+ oldNodesMap.set(node.id, node);
369
+ }
370
+
371
+ // Convert data and preserve positions for existing nodes
372
+ this.data = dataToGraphData(data, undefined, oldNodesMap);
373
+ this.data = applyGraphLayout(this.data, this.config.layoutMode, this.config.layoutOptions);
374
+ const shouldAnimateNonForceLayout = this.prepareNodePositionsForCurrentLayout(previousPositions);
272
375
 
273
- this.config.cooldownTicks = this.data.nodes.length > 0 ? undefined : 0;
274
- this.config.isLoading = this.data.nodes.length > 0;
376
+ if (this.isForceLayoutMode()) {
377
+ this.shouldZoomToFitOnNonForceSettle = false;
378
+ this.config.cooldownTicks = this.data.nodes.length > 0 ? undefined : 0;
379
+ this.config.isLoading = this.data.nodes.length > 0;
380
+ } else {
381
+ this.config.cooldownTicks = 0;
382
+ this.config.isLoading = false;
383
+ }
275
384
  this.log('Loading state:', this.config.isLoading);
276
385
  this.config.onLoadingChange?.(this.config.isLoading);
277
386
 
278
387
  // Update simulation state
279
- if (this.data.nodes.length > 0) {
388
+ if (this.data.nodes.length > 0 && this.isForceLayoutMode()) {
280
389
  this.updateCanvasSimulationAttribute(true);
390
+ } else {
391
+ this.updateCanvasSimulationAttribute(false);
281
392
  }
282
393
 
283
394
  // Initialize graph if it hasn't been initialized yet
@@ -290,12 +401,23 @@ class FalkorDBCanvas extends HTMLElement {
290
401
 
291
402
  this.log('Calculating node degrees and setting up forces');
292
403
  this.calculateNodeDegree();
293
- this.setupForces();
294
404
 
295
405
  // Update graph data and properties
296
406
  this.graph
297
- .graphData(this.data)
298
- .cooldownTicks(this.config.cooldownTicks ?? Infinity);
407
+ .graphData(this.data);
408
+ this.configureSimulationForCurrentLayout(shouldAnimateNonForceLayout);
409
+
410
+ if (!this.isForceLayoutMode() && this.data.nodes.length > 0) {
411
+ if (shouldAnimateNonForceLayout) {
412
+ this.shouldZoomToFitOnNonForceSettle = true;
413
+ } else {
414
+ this.shouldZoomToFitOnNonForceSettle = false;
415
+ this.zoomToFit(1);
416
+ this.triggerRender();
417
+ }
418
+ } else {
419
+ this.shouldZoomToFitOnNonForceSettle = false;
420
+ }
299
421
 
300
422
  this.updateLoadingState();
301
423
  }
@@ -325,16 +447,28 @@ class FalkorDBCanvas extends HTMLElement {
325
447
 
326
448
  setGraphData(data: GraphData) {
327
449
  this.log('setGraphData called with', data.nodes.length, 'nodes and', data.links.length, 'links');
328
-
329
- this.data = data;
450
+ this.data = applyGraphLayout(data, this.config.layoutMode, this.config.layoutOptions);
451
+ this.shouldZoomToFitOnNonForceSettle = false;
330
452
 
331
453
  if (!this.graph) return;
332
454
 
333
455
  this.calculateNodeDegree();
334
- this.setupForces();
335
456
 
336
457
  this.graph
337
- .graphData(this.data)
458
+ .graphData(this.data);
459
+
460
+ // setGraphData restores pre-positioned data — freeze simulation, just render.
461
+ this.config.cooldownTicks = 0;
462
+ this.graph.cooldownTicks(0);
463
+ this.updateCanvasSimulationAttribute(false);
464
+
465
+ if (this.data.nodes.length > 0) {
466
+ this.triggerRender();
467
+ }
468
+
469
+ this.config.isLoading = false;
470
+ this.config.onLoadingChange?.(false);
471
+ this.updateLoadingState();
338
472
 
339
473
  if (this.viewport) {
340
474
  this.log('Applying viewport:', this.viewport);
@@ -377,6 +511,214 @@ class FalkorDBCanvas extends HTMLElement {
377
511
  this.graph.zoomToFit(500, padding * paddingMultiplier, filter);
378
512
  }
379
513
 
514
+ private isForceLayoutMode() {
515
+ return isForceLayout(this.config.layoutMode);
516
+ }
517
+ private getNodePositionMap(): Map<number, NodePosition> {
518
+ const positions = new Map<number, NodePosition>();
519
+
520
+ for (const node of this.data.nodes) {
521
+ if (node.x === undefined || node.y === undefined) continue;
522
+ positions.set(node.id, { x: node.x, y: node.y });
523
+ }
524
+
525
+ return positions;
526
+ }
527
+
528
+ private getGraphCenter(positions: Map<number, NodePosition>): NodePosition | undefined {
529
+ if (positions.size === 0) return undefined;
530
+
531
+ let sumX = 0;
532
+ let sumY = 0;
533
+
534
+ for (const position of positions.values()) {
535
+ sumX += position.x;
536
+ sumY += position.y;
537
+ }
538
+
539
+ return {
540
+ x: sumX / positions.size,
541
+ y: sumY / positions.size,
542
+ };
543
+ }
544
+
545
+ private getConnectedExistingPosition(
546
+ nodeId: number,
547
+ previousPositions: Map<number, NodePosition>
548
+ ): NodePosition | undefined {
549
+ let sumX = 0;
550
+ let sumY = 0;
551
+ let count = 0;
552
+
553
+ for (const link of this.data.links) {
554
+ const sourceId = link.source.id;
555
+ const targetId = link.target.id;
556
+
557
+ if (sourceId === nodeId) {
558
+ const existingPosition = previousPositions.get(targetId);
559
+ if (!existingPosition) continue;
560
+ sumX += existingPosition.x;
561
+ sumY += existingPosition.y;
562
+ count += 1;
563
+ } else if (targetId === nodeId) {
564
+ const existingPosition = previousPositions.get(sourceId);
565
+ if (!existingPosition) continue;
566
+ sumX += existingPosition.x;
567
+ sumY += existingPosition.y;
568
+ count += 1;
569
+ }
570
+ }
571
+
572
+ if (count === 0) return undefined;
573
+ return {
574
+ x: sumX / count,
575
+ y: sumY / count,
576
+ };
577
+ }
578
+
579
+ private clearLayoutTargets() {
580
+ for (const node of this.data.nodes) {
581
+ node.layoutTargetX = undefined;
582
+ node.layoutTargetY = undefined;
583
+ }
584
+ }
585
+
586
+ private prepareNodePositionsForCurrentLayout(previousPositions: Map<number, NodePosition>): boolean {
587
+ if (this.isForceLayoutMode()) {
588
+ this.clearLayoutTargets();
589
+ return false;
590
+ }
591
+
592
+ const graphCenter = this.getGraphCenter(previousPositions);
593
+ let shouldAnimate = false;
594
+
595
+ for (const node of this.data.nodes) {
596
+ const targetX = node.x ?? 0;
597
+ const targetY = node.y ?? 0;
598
+
599
+ node.layoutTargetX = targetX;
600
+ node.layoutTargetY = targetY;
601
+ node.fx = undefined;
602
+ node.fy = undefined;
603
+ node.vx = 0;
604
+ node.vy = 0;
605
+
606
+ if (previousPositions.size === 0) {
607
+ node.x = targetX;
608
+ node.y = targetY;
609
+ continue;
610
+ }
611
+
612
+ const previousPosition = previousPositions.get(node.id)
613
+ ?? this.getConnectedExistingPosition(node.id, previousPositions)
614
+ ?? graphCenter;
615
+
616
+ if (!previousPosition) {
617
+ node.x = targetX;
618
+ node.y = targetY;
619
+ continue;
620
+ }
621
+
622
+ node.x = previousPosition.x;
623
+ node.y = previousPosition.y;
624
+
625
+ if (
626
+ Math.abs(previousPosition.x - targetX) > 0.5
627
+ || Math.abs(previousPosition.y - targetY) > 0.5
628
+ ) {
629
+ shouldAnimate = true;
630
+ }
631
+ }
632
+
633
+ return shouldAnimate;
634
+ }
635
+
636
+ private setupAnchoredLayoutForces() {
637
+ if (!this.graph) return;
638
+
639
+ const linkForce = this.graph.d3Force("link");
640
+ if (linkForce) {
641
+ linkForce
642
+ .distance((link: GraphLink) => {
643
+ const sourceSize = link.source.size;
644
+ const targetSize = link.target.size;
645
+ return sourceSize + targetSize + LINK_DISTANCE * 1.6;
646
+ })
647
+ .strength(NON_FORCE_LINK_STRENGTH);
648
+ }
649
+
650
+ this.graph.d3Force(
651
+ "collide",
652
+ d3.forceCollide((node: GraphNode) => node.size + NON_FORCE_COLLIDE_PADDING)
653
+ );
654
+
655
+ this.graph.d3Force(
656
+ "centerX",
657
+ d3.forceX(0).strength(NON_FORCE_CENTER_STRENGTH)
658
+ );
659
+
660
+ this.graph.d3Force(
661
+ "centerY",
662
+ d3.forceY(0).strength(NON_FORCE_CENTER_STRENGTH)
663
+ );
664
+
665
+ this.graph.d3Force(
666
+ "layoutTargetX",
667
+ d3.forceX((node: GraphNode) => node.layoutTargetX ?? node.x ?? 0).strength(NON_FORCE_TARGET_STRENGTH)
668
+ );
669
+
670
+ this.graph.d3Force(
671
+ "layoutTargetY",
672
+ d3.forceY((node: GraphNode) => node.layoutTargetY ?? node.y ?? 0).strength(NON_FORCE_TARGET_STRENGTH)
673
+ );
674
+
675
+ const chargeForce = this.graph.d3Force("charge");
676
+ if (chargeForce) {
677
+ chargeForce.strength(NON_FORCE_CHARGE_STRENGTH);
678
+ }
679
+
680
+ this.graph.d3VelocityDecay(NON_FORCE_VELOCITY_DECAY);
681
+ this.graph.d3AlphaMin(NON_FORCE_ALPHA_MIN);
682
+ }
683
+
684
+ private startNonForceSettleAnimation(cooldownTicks: number) {
685
+ if (!this.graph || this.data.nodes.length === 0 || this.isForceLayoutMode()) return;
686
+
687
+ this.graph.cooldownTicks(cooldownTicks);
688
+ this.updateCanvasSimulationAttribute(true);
689
+ this.graph.d3ReheatSimulation();
690
+ }
691
+
692
+ private applyLayoutTargets() {
693
+ for (const node of this.data.nodes) {
694
+ if (node.layoutTargetX === undefined || node.layoutTargetY === undefined) continue;
695
+ node.x = node.layoutTargetX;
696
+ node.y = node.layoutTargetY;
697
+ node.vx = 0;
698
+ node.vy = 0;
699
+ }
700
+ }
701
+
702
+ private configureSimulationForCurrentLayout(shouldAnimateNonForceLayout = false) {
703
+ if (!this.graph) return;
704
+
705
+ if (this.isForceLayoutMode()) {
706
+ this.setupForces();
707
+ const cooldownTicks = this.config.cooldownTicks ?? Infinity;
708
+ this.graph.cooldownTicks(cooldownTicks);
709
+ this.updateCanvasSimulationAttribute(cooldownTicks !== 0 && this.data.nodes.length > 0);
710
+ return;
711
+ }
712
+ this.setupAnchoredLayoutForces();
713
+ if (shouldAnimateNonForceLayout) {
714
+ this.startNonForceSettleAnimation(NON_FORCE_LAYOUT_COOLDOWN_TICKS);
715
+ return;
716
+ }
717
+
718
+ this.graph.cooldownTicks(0);
719
+ this.updateCanvasSimulationAttribute(false);
720
+ }
721
+
380
722
  private triggerRender() {
381
723
  if (!this.graph || this.graph.cooldownTicks() !== 0) return;
382
724
 
@@ -516,6 +858,7 @@ class FalkorDBCanvas extends HTMLElement {
516
858
  if (this.graph && width > 0 && height > 0) {
517
859
  this.log('Container resized to:', width, 'x', height);
518
860
  this.graph.width(width).height(height);
861
+ this.recomputeCullingBoundsIfNeeded();
519
862
  }
520
863
  }
521
864
  });
@@ -531,7 +874,6 @@ class FalkorDBCanvas extends HTMLElement {
531
874
 
532
875
  // Initialize force-graph
533
876
  // 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
877
  this.graph = (ForceGraph as any)()(this.container)
536
878
  .width(this.config.width || 800)
537
879
  .height(this.config.height || 600)
@@ -548,7 +890,7 @@ class FalkorDBCanvas extends HTMLElement {
548
890
  .linkCurvature("curve")
549
891
  .linkVisibility("visible")
550
892
  .nodeVisibility("visible")
551
- .cooldownTicks(this.config.cooldownTicks ?? Infinity) // undefined = infinite
893
+ .cooldownTicks(this.isForceLayoutMode() ? (this.config.cooldownTicks ?? Infinity) : 0) // undefined = infinite
552
894
  .cooldownTime(this.config.cooldownTime ?? 2000)
553
895
  .enableNodeDrag(true)
554
896
  .enableZoomInteraction(true)
@@ -578,6 +920,12 @@ class FalkorDBCanvas extends HTMLElement {
578
920
  this.config.onNodeHover(node);
579
921
  }
580
922
  })
923
+ .onNodeDrag((node: GraphNode) => {
924
+ this.handleNodeDrag(node);
925
+ })
926
+ .onNodeDragEnd((node: GraphNode) => {
927
+ this.handleNodeDragEnd(node);
928
+ })
581
929
  .onLinkHover((link: GraphLink | null) => {
582
930
  if (this.config.onLinkHover) {
583
931
  this.config.onLinkHover(link);
@@ -594,6 +942,7 @@ class FalkorDBCanvas extends HTMLElement {
594
942
  }
595
943
  })
596
944
  .onZoom((transform: Transform) => {
945
+ this.updateCullingBounds(transform);
597
946
  if (this.config.onZoom) {
598
947
  this.config.onZoom(transform);
599
948
  }
@@ -633,8 +982,7 @@ class FalkorDBCanvas extends HTMLElement {
633
982
  }
634
983
  });
635
984
 
636
- // Setup forces
637
- this.setupForces();
985
+ this.configureSimulationForCurrentLayout();
638
986
  this.log('Force graph initialization complete');
639
987
  }
640
988
 
@@ -645,6 +993,9 @@ class FalkorDBCanvas extends HTMLElement {
645
993
  if (!linkForce) return;
646
994
  if (!this.graph) return;
647
995
 
996
+ this.graph.d3Force("layoutTargetX", null);
997
+ this.graph.d3Force("layoutTargetY", null);
998
+
648
999
  // distance based on node size + constant
649
1000
  linkForce
650
1001
  .distance((link: GraphLink) => {
@@ -688,6 +1039,152 @@ class FalkorDBCanvas extends HTMLElement {
688
1039
  this.log('Force simulation setup complete');
689
1040
  }
690
1041
 
1042
+ /**
1043
+ * Recompute the world-space culling bounds from the d3-zoom transform delivered
1044
+ * by force-graph's `onZoom` callback.
1045
+ *
1046
+ * The d3-zoom transform maps world → screen as:
1047
+ * screen_x = world_x * k + tx
1048
+ * screen_y = world_y * k + ty
1049
+ * Inverting for the canvas edges (screen_x ∈ [0, W], screen_y ∈ [0, H]):
1050
+ * world_x ∈ [(0 − tx) / k, (W − tx) / k]
1051
+ * world_y ∈ [(0 − ty) / k, (H − ty) / k]
1052
+ */
1053
+ private updateCullingBounds(transform: Transform) {
1054
+ this.lastTransform = transform;
1055
+ if (!this.config.largeGraph?.enabled) {
1056
+ this.cullingBounds = null;
1057
+ return;
1058
+ }
1059
+
1060
+ const w = this.graph?.width() ?? 0;
1061
+ const h = this.graph?.height() ?? 0;
1062
+ const { k, x: tx, y: ty } = transform;
1063
+
1064
+ if (k <= 0 || w <= 0 || h <= 0) {
1065
+ this.cullingBounds = null;
1066
+ this.cullingZoom = 1;
1067
+ return;
1068
+ }
1069
+
1070
+ const padding = this.config.largeGraph?.viewportPadding ?? 0;
1071
+
1072
+ this.cullingBounds = {
1073
+ minX: -tx / k - padding,
1074
+ maxX: (w - tx) / k + padding,
1075
+ minY: -ty / k - padding,
1076
+ maxY: (h - ty) / k + padding,
1077
+ };
1078
+ this.cullingZoom = k;
1079
+ }
1080
+
1081
+ /** Recompute culling bounds using the last known transform (e.g. after resize). */
1082
+ private recomputeCullingBoundsIfNeeded() {
1083
+ if (!this.config.largeGraph?.enabled) return;
1084
+ if (this.lastTransform) {
1085
+ this.updateCullingBounds(this.lastTransform);
1086
+ } else if (this.graph) {
1087
+ // Seed initial transform from current graph state before first onZoom fires.
1088
+ const k = this.graph.zoom() ?? 1;
1089
+ const center = this.graph.centerAt() ?? { x: 0, y: 0 };
1090
+ const w = this.graph.width() ?? 0;
1091
+ const h = this.graph.height() ?? 0;
1092
+ if (k > 0 && w > 0 && h > 0) {
1093
+ const tx = w / 2 - center.x * k;
1094
+ const ty = h / 2 - center.y * k;
1095
+ this.updateCullingBounds({ k, x: tx, y: ty });
1096
+ }
1097
+ }
1098
+ }
1099
+
1100
+ /**
1101
+ * Returns `true` when the node is (at least partially) inside the current
1102
+ * culling viewport, or when culling is disabled / bounds are not yet known.
1103
+ */
1104
+ private isNodeInCullingBounds(node: GraphNode): boolean {
1105
+ if (!this.cullingBounds) return true;
1106
+ const { minX, maxX, minY, maxY } = this.cullingBounds;
1107
+ const r = node.size + PADDING;
1108
+ const x = node.x ?? 0;
1109
+ const y = node.y ?? 0;
1110
+ return x + r >= minX && x - r <= maxX && y + r >= minY && y - r <= maxY;
1111
+ }
1112
+
1113
+ /**
1114
+ * Returns `true` when a link's visual representation overlaps the current
1115
+ * culling viewport, or when culling is disabled / bounds are not yet known.
1116
+ *
1117
+ * For straight / quadratic-bezier links the test uses the convex-hull bounding
1118
+ * box of (source, control point, target), which is always a conservative
1119
+ * (never-false-negative) bound. For self-loops the test uses a square of
1120
+ * side ≈ the loop diameter centred on the node.
1121
+ */
1122
+ private isLinkInCullingBounds(link: GraphLink): boolean {
1123
+ if (!this.cullingBounds) return true;
1124
+ const { minX, maxX, minY, maxY } = this.cullingBounds;
1125
+
1126
+ const sx = link.source.x ?? 0;
1127
+ const sy = link.source.y ?? 0;
1128
+ const ex = link.target.x ?? 0;
1129
+ const ey = link.target.y ?? 0;
1130
+
1131
+ if (link.source.id === link.target.id) {
1132
+ // Self-loop: the cubic bezier extends roughly |curve| * nodeSize * factor
1133
+ // away from the node centre. Use that as a conservative radius.
1134
+ const nodeSize = link.source.size || NODE_SIZE;
1135
+ const loopRadius = Math.abs(link.curve || 1) * nodeSize * SELF_LOOP_CURVE_FACTOR;
1136
+ return (
1137
+ sx + loopRadius >= minX && sx - loopRadius <= maxX &&
1138
+ sy + loopRadius >= minY && sy - loopRadius <= maxY
1139
+ );
1140
+ }
1141
+
1142
+ // Compute quadratic-bezier control point (same formula as drawLink).
1143
+ const dx = ex - sx;
1144
+ const dy = ey - sy;
1145
+ const distance = Math.sqrt(dx * dx + dy * dy);
1146
+ if (distance === 0) {
1147
+ // Co-located nodes: just check the point.
1148
+ return sx >= minX && sx <= maxX && sy >= minY && sy <= maxY;
1149
+ }
1150
+
1151
+ const curvature = link.curve ?? 0;
1152
+ const perpX = dy / distance;
1153
+ const perpY = -dx / distance;
1154
+ const cx = (sx + ex) / 2 + perpX * curvature * distance;
1155
+ const cy = (sy + ey) / 2 + perpY * curvature * distance;
1156
+
1157
+ // Convex-hull AABB of the three control points.
1158
+ const lMinX = Math.min(sx, ex, cx);
1159
+ const lMaxX = Math.max(sx, ex, cx);
1160
+ const lMinY = Math.min(sy, ey, cy);
1161
+ const lMaxY = Math.max(sy, ey, cy);
1162
+
1163
+ return lMaxX >= minX && lMinX <= maxX && lMaxY >= minY && lMinY <= maxY;
1164
+ }
1165
+
1166
+ private handleNodeDrag(node: GraphNode) {
1167
+ if (this.isForceLayoutMode()) return;
1168
+ if (node.x === undefined || node.y === undefined) return;
1169
+
1170
+ node.layoutTargetX = node.x;
1171
+ node.layoutTargetY = node.y;
1172
+ this.shouldZoomToFitOnNonForceSettle = false;
1173
+ this.startNonForceSettleAnimation(NON_FORCE_DRAG_COOLDOWN_TICKS);
1174
+ }
1175
+
1176
+ private handleNodeDragEnd(node: GraphNode) {
1177
+ if (this.isForceLayoutMode()) return;
1178
+ if (node.x === undefined || node.y === undefined) return;
1179
+
1180
+ node.layoutTargetX = node.x;
1181
+ node.layoutTargetY = node.y;
1182
+ node.fx = undefined;
1183
+ node.fy = undefined;
1184
+ this.shouldZoomToFitOnNonForceSettle = false;
1185
+ this.startNonForceSettleAnimation(NON_FORCE_DRAG_COOLDOWN_TICKS);
1186
+ }
1187
+
691
1188
  private drawNode(node: GraphNode, ctx: CanvasRenderingContext2D) {
692
1189
 
693
1190
  if (node.x === undefined || node.y === undefined) {
@@ -695,6 +1192,9 @@ class FalkorDBCanvas extends HTMLElement {
695
1192
  node.y = 0;
696
1193
  }
697
1194
 
1195
+ // Viewport culling: skip nodes that are entirely outside the visible area.
1196
+ if (this.config.largeGraph?.enabled && !this.isNodeInCullingBounds(node)) return;
1197
+
698
1198
  ctx.lineWidth = this.config.isNodeSelected?.(node) ? 1 : 0.5;
699
1199
  ctx.strokeStyle = this.config.foregroundColor;
700
1200
  ctx.fillStyle = node.color;
@@ -709,6 +1209,12 @@ class FalkorDBCanvas extends HTMLElement {
709
1209
  ctx.arc(node.x, node.y, node.size, 0, 2 * Math.PI, false);
710
1210
  ctx.fill();
711
1211
 
1212
+ // Low-zoom optimisation: skip labels when they would be too small to read.
1213
+ const skipLabels = this.config.largeGraph?.enabled &&
1214
+ (this.config.largeGraph?.skipLabelsAtLowZoom ?? true) &&
1215
+ this.cullingZoom < (this.config.largeGraph?.lowZoomThreshold ?? 0.5);
1216
+ if (skipLabels) return;
1217
+
712
1218
  // Draw text
713
1219
  ctx.fillStyle = getContrastTextColor(node.color);
714
1220
  ctx.textAlign = "center";
@@ -725,32 +1231,49 @@ class FalkorDBCanvas extends HTMLElement {
725
1231
  [line1, line2] = wrapTextForCircularNode(ctx, text, textRadius);
726
1232
 
727
1233
  let chosenSize = NODE_FONT_SIZE_BASE;
1234
+ // Measure at a large reference size (20px) where canvas metrics are
1235
+ // precise, then compute the exact scale to fill the node.
1236
+ const REF = 20;
1237
+ ctx.font = `400 ${REF}px SofiaSans`;
1238
+ // Switch to "left" for measurement: actualBoundingBoxLeft/Right are
1239
+ // unreliable with textAlign="center" and can double on some engines.
1240
+ ctx.textAlign = "left";
1241
+ const refMetrics = ctx.measureText(line1);
1242
+ // Use the actual visual bounding box (not advance width) so glyphs
1243
+ // with overshoot (e.g. "7") are fully accounted for.
1244
+ const visualWidth = (refMetrics.actualBoundingBoxLeft ?? 0)
1245
+ + (refMetrics.actualBoundingBoxRight ?? 0);
1246
+ let refWidth = Math.max(visualWidth, refMetrics.width);
1247
+ const singleLineHeight = (refMetrics.actualBoundingBoxAscent ?? 0)
1248
+ + (refMetrics.actualBoundingBoxDescent ?? 0);
1249
+
1250
+ let refHeight: number;
728
1251
  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
- }
1252
+ refHeight = singleLineHeight;
1253
+ } else {
1254
+ // Two-line: use the wider line and account for the vertical span
1255
+ // of both lines including the 1. spacing used by the rendering code.
1256
+ const m2 = ctx.measureText(line2);
1257
+ const vis2 = Math.max(
1258
+ (m2.actualBoundingBoxLeft ?? 0) + (m2.actualBoundingBoxRight ?? 0),
1259
+ m2.width,
1260
+ );
1261
+ refWidth = Math.max(refWidth, vis2);
1262
+ refHeight = singleLineHeight * 2.5;
1263
+ }
1264
+ ctx.textAlign = "center";
1265
+
1266
+ // Inscribed-rectangle-in-circle constraint: every corner of the text
1267
+ // bounding box must lie inside the circle, i.e.
1268
+ // sqrt((w/2)² + (h/2)²) r
1269
+ // Solving for the uniform scale factor s:
1270
+ // s = 2·r / sqrt(refWidth² + refHeight²)
1271
+ const r = NODE_TEXT_FILL_RATIO * textRadius;
1272
+ if (refWidth > 0 && refHeight > 0) {
1273
+ const diagonal = Math.sqrt(refWidth * refWidth + refHeight * refHeight);
1274
+ chosenSize = REF * (2 * r / diagonal);
1275
+ } else if (refWidth > 0) {
1276
+ chosenSize = REF * (2 * r / refWidth);
754
1277
  }
755
1278
 
756
1279
  ctx.font = `400 ${chosenSize}px SofiaSans`;
@@ -788,6 +1311,9 @@ class FalkorDBCanvas extends HTMLElement {
788
1311
  node.y = 0;
789
1312
  };
790
1313
 
1314
+ // Viewport culling: skip hit-test painting for offscreen nodes.
1315
+ if (this.config.largeGraph?.enabled && !this.isNodeInCullingBounds(node)) return;
1316
+
791
1317
  const radius = node.size + PADDING;
792
1318
 
793
1319
  ctx.fillStyle = color;
@@ -807,6 +1333,11 @@ class FalkorDBCanvas extends HTMLElement {
807
1333
  end.y = 0;
808
1334
  }
809
1335
 
1336
+ // Viewport culling: skip links whose visual extent is entirely outside the
1337
+ // visible area. The check is conservative (convex-hull AABB) so it never
1338
+ // produces false negatives.
1339
+ if (this.config.largeGraph?.enabled && !this.isLinkInCullingBounds(link)) return;
1340
+
810
1341
  let textX;
811
1342
  let textY;
812
1343
  let angle;
@@ -814,13 +1345,19 @@ class FalkorDBCanvas extends HTMLElement {
814
1345
  const isLinkSelected = this.config.isLinkSelected?.(link) ?? false;
815
1346
  const arrowLen = isLinkSelected ? 4 : 2;
816
1347
 
1348
+ // Low-zoom flags – evaluated once per link draw.
1349
+ const lowZoomThreshold = this.config.largeGraph?.lowZoomThreshold ?? 0.5;
1350
+ const atLowZoom = this.config.largeGraph?.enabled && this.cullingZoom < lowZoomThreshold;
1351
+ const skipArrows = atLowZoom && (this.config.largeGraph?.skipArrowsAtLowZoom ?? true);
1352
+ const skipLinkLabels = atLowZoom && (this.config.largeGraph?.skipLinkLabelsAtLowZoom ?? true);
1353
+
817
1354
  // Deferred arrowhead — drawn after the label so it is never covered by
818
1355
  // the label background rect (which happens for short links where the
819
1356
  // bezier midpoint and the arrow tip are at almost the same position).
820
1357
  let pendingArrow: { tipX: number; tipY: number; nx: number; ny: number; arrowLen: number; arrowHalfWidth: number } | null = null;
821
1358
 
822
1359
  if (start.id === end.id) {
823
- const nodeSize = start.size || 6;
1360
+ const nodeSize = start.size || NODE_SIZE;
824
1361
  const d = (link.curve || 0) * nodeSize * SELF_LOOP_CURVE_FACTOR;
825
1362
 
826
1363
  ctx.lineWidth = (isLinkSelected ? 2 : 1) / globalScale;
@@ -891,7 +1428,7 @@ class FalkorDBCanvas extends HTMLElement {
891
1428
  // Guard against zero-length tangent vector (e.g. when d ≈ 0) to avoid NaN
892
1429
  // normals and invalid arrowhead geometry. Also skip when d is too small to
893
1430
  // place the arrowhead at the node border (canReachBorder is false).
894
- if (tLen !== 0 && canReachBorder) {
1431
+ if (!skipArrows && tLen !== 0 && canReachBorder) {
895
1432
  const nx = tdx / tLen;
896
1433
  const ny = tdy / tLen;
897
1434
  pendingArrow = { tipX, tipY, nx, ny, arrowLen, arrowHalfWidth };
@@ -947,7 +1484,7 @@ class FalkorDBCanvas extends HTMLElement {
947
1484
  const arrowHalfWidth = arrowLen / ARROW_WH_RATIO / 2;
948
1485
 
949
1486
  // Target-side clip: find t where bezier enters target node border + PADDING
950
- const endNodeSize = end.size || 6;
1487
+ const endNodeSize = end.size || NODE_SIZE;
951
1488
  const borderRadius = endNodeSize + (this.config.isNodeSelected?.(end) ? 1 : 0.5) + PADDING;
952
1489
  const borderRadiusSq = borderRadius * borderRadius;
953
1490
 
@@ -975,7 +1512,7 @@ class FalkorDBCanvas extends HTMLElement {
975
1512
  const tipY = uArrow * uArrow * start.y + 2 * uArrow * tArrow * controlY + tArrow * tArrow * end.y;
976
1513
 
977
1514
  // Source-side clip: find t where bezier exits source node border + PADDING
978
- const startNodeSize = start.size || 6;
1515
+ const startNodeSize = start.size || NODE_SIZE;
979
1516
  const srcBorderRadius = startNodeSize + (this.config.isNodeSelected?.(start) ? 1 : 0.5) + PADDING;
980
1517
  const srcBorderRadiusSq = srcBorderRadius * srcBorderRadius;
981
1518
 
@@ -1026,7 +1563,7 @@ class FalkorDBCanvas extends HTMLElement {
1026
1563
  const aty = 2 * uArrow * (controlY - start.y) + 2 * tArrow * (end.y - controlY);
1027
1564
  const atLen = Math.sqrt(atx * atx + aty * aty);
1028
1565
 
1029
- if (atLen !== 0) {
1566
+ if (!skipArrows && atLen !== 0) {
1030
1567
  const nx = atx / atLen;
1031
1568
  const ny = aty / atLen;
1032
1569
  pendingArrow = { tipX, tipY, nx, ny, arrowLen, arrowHalfWidth };
@@ -1038,52 +1575,54 @@ class FalkorDBCanvas extends HTMLElement {
1038
1575
  // Draw text with alphabetic baseline, positioned so visual center is at y=0
1039
1576
  ctx.textBaseline = "alphabetic";
1040
1577
 
1041
- // Separate cache entries per weight so each state is measured with its own
1042
- // font, giving equal visual padding regardless of selection state.
1043
- const cacheKey = `${link.relationship}_${isLinkSelected ? "700" : "400"}`;
1044
- let cached = this.relationshipsTextCache.get(cacheKey);
1045
-
1046
- if (!cached) {
1047
- // ctx.font is already set to the correct weight above; measure it directly.
1048
- const metrics = ctx.measureText(link.relationship);
1049
- // Use actual ink bounds for vertical metrics; fontBoundingBox* is the full
1050
- // line-box and adds excessive space for lighter weights.
1051
- // Use metrics.width for horizontal extent: actualBoundingBoxLeft/Right are
1052
- // unreliable with textAlign="center" and can double the value on some engines.
1053
- const inkAscent = metrics.actualBoundingBoxAscent ?? metrics.fontBoundingBoxAscent;
1054
- const inkDescent = metrics.actualBoundingBoxDescent ?? metrics.fontBoundingBoxDescent;
1055
- const inkWidth = metrics.width;
1056
- const bgPadding = 0.3;
1057
-
1058
- cached = {
1059
- textWidth: inkWidth + bgPadding * 2,
1060
- textHeight: inkAscent + inkDescent + bgPadding * 2,
1061
- // Shift baseline up so the ink block is centred inside the bg rect.
1062
- textYOffset: (inkAscent - inkDescent) / 2,
1063
- };
1064
- this.relationshipsTextCache.set(cacheKey, cached);
1065
- }
1578
+ if (!skipLinkLabels) {
1579
+ // Separate cache entries per weight so each state is measured with its own
1580
+ // font, giving equal visual padding regardless of selection state.
1581
+ const cacheKey = `${link.relationship}_${isLinkSelected ? "700" : "400"}`;
1582
+ let cached = this.relationshipsTextCache.get(cacheKey);
1583
+
1584
+ if (!cached) {
1585
+ // ctx.font is already set to the correct weight above; measure it directly.
1586
+ const metrics = ctx.measureText(link.relationship);
1587
+ // Use actual ink bounds for vertical metrics; fontBoundingBox* is the full
1588
+ // line-box and adds excessive space for lighter weights.
1589
+ // Use metrics.width for horizontal extent: actualBoundingBoxLeft/Right are
1590
+ // unreliable with textAlign="center" and can double the value on some engines.
1591
+ const inkAscent = metrics.actualBoundingBoxAscent ?? metrics.fontBoundingBoxAscent;
1592
+ const inkDescent = metrics.actualBoundingBoxDescent ?? metrics.fontBoundingBoxDescent;
1593
+ const inkWidth = metrics.width;
1594
+ const bgPadding = 0.3;
1595
+
1596
+ cached = {
1597
+ textWidth: inkWidth + bgPadding * 2,
1598
+ textHeight: inkAscent + inkDescent + bgPadding * 2,
1599
+ // Shift baseline up so the ink block is centred inside the bg rect.
1600
+ textYOffset: (inkAscent - inkDescent) / 2,
1601
+ };
1602
+ this.relationshipsTextCache.set(cacheKey, cached);
1603
+ }
1066
1604
 
1067
- const { textWidth, textHeight, textYOffset } = cached;
1605
+ const { textWidth, textHeight, textYOffset } = cached;
1068
1606
 
1069
- ctx.save();
1070
- ctx.translate(textX, textY);
1071
- ctx.rotate(angle);
1607
+ ctx.save();
1608
+ ctx.translate(textX, textY);
1609
+ ctx.rotate(angle);
1072
1610
 
1073
- // Draw background centered on the link line (y=0)
1074
- ctx.fillStyle = this.config.backgroundColor;
1611
+ // Draw background centered on the link line (y=0)
1612
+ ctx.fillStyle = this.config.backgroundColor;
1075
1613
 
1076
- // Offset background to match text visual center
1077
- ctx.fillRect(
1078
- -textWidth / 2,
1079
- -textHeight / 2,
1080
- textWidth,
1081
- textHeight
1082
- );
1614
+ // Offset background to match text visual center
1615
+ ctx.fillRect(
1616
+ -textWidth / 2,
1617
+ -textHeight / 2,
1618
+ textWidth,
1619
+ textHeight
1620
+ );
1083
1621
 
1084
- ctx.fillStyle = getContrastTextColor(this.config.backgroundColor);
1085
- ctx.fillText(link.relationship, 0, textYOffset);
1086
- ctx.restore();
1622
+ ctx.fillStyle = getContrastTextColor(this.config.backgroundColor);
1623
+ ctx.fillText(link.relationship, 0, textYOffset);
1624
+ ctx.restore();
1625
+ }
1087
1626
 
1088
1627
  // Draw arrowhead last so it always appears on top of the label background.
1089
1628
  if (pendingArrow) {
@@ -1104,6 +1643,9 @@ class FalkorDBCanvas extends HTMLElement {
1104
1643
 
1105
1644
  if (start.x == null || start.y == null || end.x == null || end.y == null) return;
1106
1645
 
1646
+ // Viewport culling: skip hit-test painting for offscreen links.
1647
+ if (this.config.largeGraph?.enabled && !this.isLinkInCullingBounds(link)) return;
1648
+
1107
1649
  ctx.strokeStyle = color;
1108
1650
  const basePointerWidth = 10; // Desired on-screen pointer area thickness
1109
1651
  const transform = typeof ctx.getTransform === 'function' ? ctx.getTransform() : null;
@@ -1119,7 +1661,7 @@ class FalkorDBCanvas extends HTMLElement {
1119
1661
 
1120
1662
  if (start.id === end.id) {
1121
1663
  // Self-loop: replicate exact cubic bezier clip from drawLink
1122
- const nodeSize = start.size || 6;
1664
+ const nodeSize = start.size || NODE_SIZE;
1123
1665
  const d = (link.curve || 0) * nodeSize * SELF_LOOP_CURVE_FACTOR;
1124
1666
 
1125
1667
  const nodeStrokeWidth = this.config.isNodeSelected?.(start) ? 1 : 0.5;
@@ -1170,7 +1712,7 @@ class FalkorDBCanvas extends HTMLElement {
1170
1712
  const controlY = (start.y + end.y) / 2 + perpY * curvature * distance;
1171
1713
 
1172
1714
  // Use the same borderRadius and binary-search clip as drawLink
1173
- const endNodeSize = end.size || 6;
1715
+ const endNodeSize = end.size || NODE_SIZE;
1174
1716
  const borderRadius = endNodeSize + (this.config.isNodeSelected?.(end) ? 1 : 0.5) + PADDING;
1175
1717
  const borderRadiusSq = borderRadius * borderRadius;
1176
1718
 
@@ -1197,7 +1739,7 @@ class FalkorDBCanvas extends HTMLElement {
1197
1739
  const tipY = uArrow * uArrow * start.y + 2 * uArrow * tArrow * controlY + tArrow * tArrow * end.y;
1198
1740
 
1199
1741
  // Source-side clip: mirror of drawLink source gap
1200
- const startNodeSize = start.size || 6;
1742
+ const startNodeSize = start.size || NODE_SIZE;
1201
1743
  const srcBorderRadius = startNodeSize + (this.config.isNodeSelected?.(start) ? 1 : 0.5) + PADDING;
1202
1744
  const srcBorderRadiusSq = srcBorderRadius * srcBorderRadius;
1203
1745
 
@@ -1254,6 +1796,16 @@ class FalkorDBCanvas extends HTMLElement {
1254
1796
  if (!this.graph) return;
1255
1797
 
1256
1798
  this.log('Engine stopped');
1799
+ if (!this.isForceLayoutMode()) {
1800
+ this.applyLayoutTargets();
1801
+ this.graph.cooldownTicks(0);
1802
+ this.updateCanvasSimulationAttribute(false);
1803
+ if (this.shouldZoomToFitOnNonForceSettle && this.data.nodes.length > 0) {
1804
+ this.zoomToFit(1);
1805
+ }
1806
+ this.shouldZoomToFitOnNonForceSettle = false;
1807
+ return;
1808
+ }
1257
1809
  // If already stopped, just ensure any leftover loading state is cleared and return
1258
1810
  if (this.config.cooldownTicks === 0) {
1259
1811
  if (this.config.isLoading) {
@@ -1326,6 +1878,12 @@ class FalkorDBCanvas extends HTMLElement {
1326
1878
  this.config.onNodeHover(node);
1327
1879
  }
1328
1880
  })
1881
+ .onNodeDrag((node: GraphNode) => {
1882
+ this.handleNodeDrag(node);
1883
+ })
1884
+ .onNodeDragEnd((node: GraphNode) => {
1885
+ this.handleNodeDragEnd(node);
1886
+ })
1329
1887
  .onLinkHover((link: GraphLink | null) => {
1330
1888
  if (this.config.onLinkHover) {
1331
1889
  this.config.onLinkHover(link);
@@ -1342,6 +1900,7 @@ class FalkorDBCanvas extends HTMLElement {
1342
1900
  }
1343
1901
  })
1344
1902
  .onZoom((transform: Transform) => {
1903
+ this.updateCullingBounds(transform);
1345
1904
  if (this.config.onZoom) {
1346
1905
  this.config.onZoom(transform);
1347
1906
  }
@@ -1372,7 +1931,9 @@ class FalkorDBCanvas extends HTMLElement {
1372
1931
  this.config.node!.nodePointerAreaPaint(node, color, ctx);
1373
1932
  });
1374
1933
  } else {
1375
- this.graph.nodePointerAreaPaint();
1934
+ this.graph.nodePointerAreaPaint((node: GraphNode, color: string, ctx: CanvasRenderingContext2D) => {
1935
+ this.pointerNode(node, color, ctx);
1936
+ });
1376
1937
  }
1377
1938
 
1378
1939
  if (this.config.link) {
@@ -1380,7 +1941,9 @@ class FalkorDBCanvas extends HTMLElement {
1380
1941
  this.config.link!.linkPointerAreaPaint(link, color, ctx);
1381
1942
  });
1382
1943
  } else {
1383
- this.graph.linkPointerAreaPaint();
1944
+ this.graph.linkPointerAreaPaint((link: GraphLink, color: string, ctx: CanvasRenderingContext2D) => {
1945
+ this.pointerLink(link, color, ctx);
1946
+ });
1384
1947
  }
1385
1948
  }
1386
1949