@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/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';
@@ -70,11 +81,25 @@ class FalkorDBCanvas extends HTMLElement {
70
81
  // Per-node font size cache: computed once per node, read every frame.
71
82
  this.nodeDisplayFontSize = new Map();
72
83
  this.relationshipsTextCache = new Map();
84
+ /**
85
+ * Cached world-space axis-aligned bounding box of the currently visible
86
+ * viewport. Updated on every zoom/pan event and on resize.
87
+ * `null` means culling is disabled or not yet computed.
88
+ */
89
+ this.cullingBounds = null;
90
+ /** Current zoom level, cached alongside cullingBounds. */
91
+ this.cullingZoom = 1;
92
+ /** Last d3-zoom transform, cached so bounds can be recomputed on resize. */
93
+ this.lastTransform = null;
73
94
  this.onFontsLoadingDone = () => {
74
95
  this.relationshipsTextCache.clear();
75
96
  this.nodeDisplayFontSize.clear();
97
+ for (const node of this.data.nodes) {
98
+ node.displayName = ["", ""];
99
+ }
76
100
  this.triggerRender();
77
101
  };
102
+ this.shouldZoomToFitOnNonForceSettle = false;
78
103
  this.attachShadow({ mode: "open" });
79
104
  }
80
105
  /**
@@ -122,12 +147,12 @@ class FalkorDBCanvas extends HTMLElement {
122
147
  this.resizeObserver = null;
123
148
  }
124
149
  if (this.graph) {
125
- // eslint-disable-next-line no-underscore-dangle
126
150
  this.graph._destructor();
127
151
  }
128
152
  }
129
153
  setConfig(config) {
130
154
  this.log('Setting config:', config);
155
+ const layoutChanged = config.layoutMode !== undefined || config.layoutOptions !== undefined;
131
156
  // If captionsKeys changed, invalidate cached display names and font sizes
132
157
  // so text is recomputed with the new keys on the next render.
133
158
  if (config.captionsKeys && JSON.stringify(config.captionsKeys) !== JSON.stringify(this.config.captionsKeys)) {
@@ -136,7 +161,62 @@ class FalkorDBCanvas extends HTMLElement {
136
161
  node.displayName = ["", ""];
137
162
  }
138
163
  }
139
- Object.assign(this.config, config);
164
+ // Deep-merge largeGraph to avoid wiping sibling fields on partial updates.
165
+ if (config.largeGraph && typeof config.largeGraph === 'object' && this.config.largeGraph) {
166
+ const mergedLargeGraph = { ...this.config.largeGraph, ...config.largeGraph };
167
+ Object.assign(this.config, config, { largeGraph: mergedLargeGraph });
168
+ }
169
+ else {
170
+ Object.assign(this.config, config);
171
+ }
172
+ // Recompute or clear culling bounds when largeGraph config changes.
173
+ if ('largeGraph' in config) {
174
+ if (this.config.largeGraph?.enabled) {
175
+ this.recomputeCullingBoundsIfNeeded();
176
+ }
177
+ else {
178
+ this.cullingBounds = null;
179
+ }
180
+ }
181
+ if (layoutChanged) {
182
+ const previousPositions = this.getNodePositionMap();
183
+ if (this.isForceLayoutMode() && this.config.cooldownTicks === 0 && this.data.nodes.length > 0) {
184
+ this.config.cooldownTicks = undefined;
185
+ }
186
+ this.data = applyGraphLayout(this.data, this.config.layoutMode, this.config.layoutOptions);
187
+ const shouldAnimateNonForceLayout = this.prepareNodePositionsForCurrentLayout(previousPositions);
188
+ if (this.graph) {
189
+ this.calculateNodeDegree();
190
+ this.graph.graphData(this.data);
191
+ this.configureSimulationForCurrentLayout(shouldAnimateNonForceLayout);
192
+ if (this.isForceLayoutMode()) {
193
+ this.shouldZoomToFitOnNonForceSettle = false;
194
+ this.config.isLoading = this.data.nodes.length > 0;
195
+ this.config.onLoadingChange?.(this.config.isLoading);
196
+ this.updateLoadingState();
197
+ }
198
+ else {
199
+ this.config.isLoading = false;
200
+ this.config.onLoadingChange?.(false);
201
+ this.updateLoadingState();
202
+ if (this.data.nodes.length > 0) {
203
+ if (shouldAnimateNonForceLayout) {
204
+ this.shouldZoomToFitOnNonForceSettle = true;
205
+ }
206
+ else {
207
+ this.shouldZoomToFitOnNonForceSettle = false;
208
+ this.zoomToFit(1);
209
+ }
210
+ }
211
+ else {
212
+ this.shouldZoomToFitOnNonForceSettle = false;
213
+ }
214
+ if (!shouldAnimateNonForceLayout) {
215
+ this.triggerRender();
216
+ }
217
+ }
218
+ }
219
+ }
140
220
  // Update event handlers if they were provided
141
221
  if (config.onNodeClick || config.onLinkClick || config.onNodeRightClick || config.onLinkRightClick ||
142
222
  config.onNodeHover || config.onLinkHover || config.onBackgroundClick || config.onBackgroundRightClick || config.onZoom ||
@@ -152,6 +232,7 @@ class FalkorDBCanvas extends HTMLElement {
152
232
  this.config.width = width;
153
233
  if (this.graph) {
154
234
  this.graph.width(width);
235
+ this.recomputeCullingBoundsIfNeeded();
155
236
  }
156
237
  }
157
238
  setHeight(height) {
@@ -161,6 +242,7 @@ class FalkorDBCanvas extends HTMLElement {
161
242
  this.config.height = height;
162
243
  if (this.graph) {
163
244
  this.graph.height(height);
245
+ this.recomputeCullingBoundsIfNeeded();
164
246
  }
165
247
  }
166
248
  setBackgroundColor(color) {
@@ -197,25 +279,42 @@ class FalkorDBCanvas extends HTMLElement {
197
279
  this.log('Setting cooldown ticks to:', ticks);
198
280
  this.config.cooldownTicks = ticks;
199
281
  if (this.graph) {
200
- this.graph.cooldownTicks(ticks ?? Infinity);
282
+ this.graph.cooldownTicks(this.isForceLayoutMode() ? (ticks ?? Infinity) : 0);
201
283
  }
202
- this.updateCanvasSimulationAttribute(ticks !== 0);
284
+ this.updateCanvasSimulationAttribute(this.isForceLayoutMode() && ticks !== 0 && this.data.nodes.length > 0);
203
285
  }
204
286
  getData() {
205
287
  return graphDataToData(this.data);
206
288
  }
207
289
  setData(data) {
208
290
  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;
291
+ const previousPositions = this.getNodePositionMap();
292
+ const oldNodesMap = new Map();
293
+ for (const node of this.data.nodes) {
294
+ oldNodesMap.set(node.id, node);
295
+ }
296
+ // Convert data and preserve positions for existing nodes
297
+ this.data = dataToGraphData(data, undefined, oldNodesMap);
298
+ this.data = applyGraphLayout(this.data, this.config.layoutMode, this.config.layoutOptions);
299
+ const shouldAnimateNonForceLayout = this.prepareNodePositionsForCurrentLayout(previousPositions);
300
+ if (this.isForceLayoutMode()) {
301
+ this.shouldZoomToFitOnNonForceSettle = false;
302
+ this.config.cooldownTicks = this.data.nodes.length > 0 ? undefined : 0;
303
+ this.config.isLoading = this.data.nodes.length > 0;
304
+ }
305
+ else {
306
+ this.config.cooldownTicks = 0;
307
+ this.config.isLoading = false;
308
+ }
213
309
  this.log('Loading state:', this.config.isLoading);
214
310
  this.config.onLoadingChange?.(this.config.isLoading);
215
311
  // Update simulation state
216
- if (this.data.nodes.length > 0) {
312
+ if (this.data.nodes.length > 0 && this.isForceLayoutMode()) {
217
313
  this.updateCanvasSimulationAttribute(true);
218
314
  }
315
+ else {
316
+ this.updateCanvasSimulationAttribute(false);
317
+ }
219
318
  // Initialize graph if it hasn't been initialized yet
220
319
  if (!this.graph && this.container) {
221
320
  this.log('Initializing graph');
@@ -225,11 +324,23 @@ class FalkorDBCanvas extends HTMLElement {
225
324
  return;
226
325
  this.log('Calculating node degrees and setting up forces');
227
326
  this.calculateNodeDegree();
228
- this.setupForces();
229
327
  // Update graph data and properties
230
328
  this.graph
231
- .graphData(this.data)
232
- .cooldownTicks(this.config.cooldownTicks ?? Infinity);
329
+ .graphData(this.data);
330
+ this.configureSimulationForCurrentLayout(shouldAnimateNonForceLayout);
331
+ if (!this.isForceLayoutMode() && this.data.nodes.length > 0) {
332
+ if (shouldAnimateNonForceLayout) {
333
+ this.shouldZoomToFitOnNonForceSettle = true;
334
+ }
335
+ else {
336
+ this.shouldZoomToFitOnNonForceSettle = false;
337
+ this.zoomToFit(1);
338
+ this.triggerRender();
339
+ }
340
+ }
341
+ else {
342
+ this.shouldZoomToFitOnNonForceSettle = false;
343
+ }
233
344
  this.updateLoadingState();
234
345
  }
235
346
  getViewport() {
@@ -253,13 +364,23 @@ class FalkorDBCanvas extends HTMLElement {
253
364
  }
254
365
  setGraphData(data) {
255
366
  this.log('setGraphData called with', data.nodes.length, 'nodes and', data.links.length, 'links');
256
- this.data = data;
367
+ this.data = applyGraphLayout(data, this.config.layoutMode, this.config.layoutOptions);
368
+ this.shouldZoomToFitOnNonForceSettle = false;
257
369
  if (!this.graph)
258
370
  return;
259
371
  this.calculateNodeDegree();
260
- this.setupForces();
261
372
  this.graph
262
373
  .graphData(this.data);
374
+ // setGraphData restores pre-positioned data — freeze simulation, just render.
375
+ this.config.cooldownTicks = 0;
376
+ this.graph.cooldownTicks(0);
377
+ this.updateCanvasSimulationAttribute(false);
378
+ if (this.data.nodes.length > 0) {
379
+ this.triggerRender();
380
+ }
381
+ this.config.isLoading = false;
382
+ this.config.onLoadingChange?.(false);
383
+ this.updateLoadingState();
263
384
  if (this.viewport) {
264
385
  this.log('Applying viewport:', this.viewport);
265
386
  this.graph.zoom(this.viewport.zoom, 0);
@@ -294,6 +415,167 @@ class FalkorDBCanvas extends HTMLElement {
294
415
  // Use the force-graph's built-in zoomToFit method
295
416
  this.graph.zoomToFit(500, padding * paddingMultiplier, filter);
296
417
  }
418
+ isForceLayoutMode() {
419
+ return isForceLayout(this.config.layoutMode);
420
+ }
421
+ getNodePositionMap() {
422
+ const positions = new Map();
423
+ for (const node of this.data.nodes) {
424
+ if (node.x === undefined || node.y === undefined)
425
+ continue;
426
+ positions.set(node.id, { x: node.x, y: node.y });
427
+ }
428
+ return positions;
429
+ }
430
+ getGraphCenter(positions) {
431
+ if (positions.size === 0)
432
+ return undefined;
433
+ let sumX = 0;
434
+ let sumY = 0;
435
+ for (const position of positions.values()) {
436
+ sumX += position.x;
437
+ sumY += position.y;
438
+ }
439
+ return {
440
+ x: sumX / positions.size,
441
+ y: sumY / positions.size,
442
+ };
443
+ }
444
+ getConnectedExistingPosition(nodeId, previousPositions) {
445
+ let sumX = 0;
446
+ let sumY = 0;
447
+ let count = 0;
448
+ for (const link of this.data.links) {
449
+ const sourceId = link.source.id;
450
+ const targetId = link.target.id;
451
+ if (sourceId === nodeId) {
452
+ const existingPosition = previousPositions.get(targetId);
453
+ if (!existingPosition)
454
+ continue;
455
+ sumX += existingPosition.x;
456
+ sumY += existingPosition.y;
457
+ count += 1;
458
+ }
459
+ else if (targetId === nodeId) {
460
+ const existingPosition = previousPositions.get(sourceId);
461
+ if (!existingPosition)
462
+ continue;
463
+ sumX += existingPosition.x;
464
+ sumY += existingPosition.y;
465
+ count += 1;
466
+ }
467
+ }
468
+ if (count === 0)
469
+ return undefined;
470
+ return {
471
+ x: sumX / count,
472
+ y: sumY / count,
473
+ };
474
+ }
475
+ clearLayoutTargets() {
476
+ for (const node of this.data.nodes) {
477
+ node.layoutTargetX = undefined;
478
+ node.layoutTargetY = undefined;
479
+ }
480
+ }
481
+ prepareNodePositionsForCurrentLayout(previousPositions) {
482
+ if (this.isForceLayoutMode()) {
483
+ this.clearLayoutTargets();
484
+ return false;
485
+ }
486
+ const graphCenter = this.getGraphCenter(previousPositions);
487
+ let shouldAnimate = false;
488
+ for (const node of this.data.nodes) {
489
+ const targetX = node.x ?? 0;
490
+ const targetY = node.y ?? 0;
491
+ node.layoutTargetX = targetX;
492
+ node.layoutTargetY = targetY;
493
+ node.fx = undefined;
494
+ node.fy = undefined;
495
+ node.vx = 0;
496
+ node.vy = 0;
497
+ if (previousPositions.size === 0) {
498
+ node.x = targetX;
499
+ node.y = targetY;
500
+ continue;
501
+ }
502
+ const previousPosition = previousPositions.get(node.id)
503
+ ?? this.getConnectedExistingPosition(node.id, previousPositions)
504
+ ?? graphCenter;
505
+ if (!previousPosition) {
506
+ node.x = targetX;
507
+ node.y = targetY;
508
+ continue;
509
+ }
510
+ node.x = previousPosition.x;
511
+ node.y = previousPosition.y;
512
+ if (Math.abs(previousPosition.x - targetX) > 0.5
513
+ || Math.abs(previousPosition.y - targetY) > 0.5) {
514
+ shouldAnimate = true;
515
+ }
516
+ }
517
+ return shouldAnimate;
518
+ }
519
+ setupAnchoredLayoutForces() {
520
+ if (!this.graph)
521
+ return;
522
+ const linkForce = this.graph.d3Force("link");
523
+ if (linkForce) {
524
+ linkForce
525
+ .distance((link) => {
526
+ const sourceSize = link.source.size;
527
+ const targetSize = link.target.size;
528
+ return sourceSize + targetSize + LINK_DISTANCE * 1.6;
529
+ })
530
+ .strength(NON_FORCE_LINK_STRENGTH);
531
+ }
532
+ this.graph.d3Force("collide", d3.forceCollide((node) => node.size + NON_FORCE_COLLIDE_PADDING));
533
+ this.graph.d3Force("centerX", d3.forceX(0).strength(NON_FORCE_CENTER_STRENGTH));
534
+ this.graph.d3Force("centerY", d3.forceY(0).strength(NON_FORCE_CENTER_STRENGTH));
535
+ this.graph.d3Force("layoutTargetX", d3.forceX((node) => node.layoutTargetX ?? node.x ?? 0).strength(NON_FORCE_TARGET_STRENGTH));
536
+ this.graph.d3Force("layoutTargetY", d3.forceY((node) => node.layoutTargetY ?? node.y ?? 0).strength(NON_FORCE_TARGET_STRENGTH));
537
+ const chargeForce = this.graph.d3Force("charge");
538
+ if (chargeForce) {
539
+ chargeForce.strength(NON_FORCE_CHARGE_STRENGTH);
540
+ }
541
+ this.graph.d3VelocityDecay(NON_FORCE_VELOCITY_DECAY);
542
+ this.graph.d3AlphaMin(NON_FORCE_ALPHA_MIN);
543
+ }
544
+ startNonForceSettleAnimation(cooldownTicks) {
545
+ if (!this.graph || this.data.nodes.length === 0 || this.isForceLayoutMode())
546
+ return;
547
+ this.graph.cooldownTicks(cooldownTicks);
548
+ this.updateCanvasSimulationAttribute(true);
549
+ this.graph.d3ReheatSimulation();
550
+ }
551
+ applyLayoutTargets() {
552
+ for (const node of this.data.nodes) {
553
+ if (node.layoutTargetX === undefined || node.layoutTargetY === undefined)
554
+ continue;
555
+ node.x = node.layoutTargetX;
556
+ node.y = node.layoutTargetY;
557
+ node.vx = 0;
558
+ node.vy = 0;
559
+ }
560
+ }
561
+ configureSimulationForCurrentLayout(shouldAnimateNonForceLayout = false) {
562
+ if (!this.graph)
563
+ return;
564
+ if (this.isForceLayoutMode()) {
565
+ this.setupForces();
566
+ const cooldownTicks = this.config.cooldownTicks ?? Infinity;
567
+ this.graph.cooldownTicks(cooldownTicks);
568
+ this.updateCanvasSimulationAttribute(cooldownTicks !== 0 && this.data.nodes.length > 0);
569
+ return;
570
+ }
571
+ this.setupAnchoredLayoutForces();
572
+ if (shouldAnimateNonForceLayout) {
573
+ this.startNonForceSettleAnimation(NON_FORCE_LAYOUT_COOLDOWN_TICKS);
574
+ return;
575
+ }
576
+ this.graph.cooldownTicks(0);
577
+ this.updateCanvasSimulationAttribute(false);
578
+ }
297
579
  triggerRender() {
298
580
  if (!this.graph || this.graph.cooldownTicks() !== 0)
299
581
  return;
@@ -408,6 +690,7 @@ class FalkorDBCanvas extends HTMLElement {
408
690
  if (this.graph && width > 0 && height > 0) {
409
691
  this.log('Container resized to:', width, 'x', height);
410
692
  this.graph.width(width).height(height);
693
+ this.recomputeCullingBoundsIfNeeded();
411
694
  }
412
695
  }
413
696
  });
@@ -420,7 +703,6 @@ class FalkorDBCanvas extends HTMLElement {
420
703
  this.calculateNodeDegree();
421
704
  // Initialize force-graph
422
705
  // 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
706
  this.graph = ForceGraph()(this.container)
425
707
  .width(this.config.width || 800)
426
708
  .height(this.config.height || 600)
@@ -435,7 +717,7 @@ class FalkorDBCanvas extends HTMLElement {
435
717
  .linkCurvature("curve")
436
718
  .linkVisibility("visible")
437
719
  .nodeVisibility("visible")
438
- .cooldownTicks(this.config.cooldownTicks ?? Infinity) // undefined = infinite
720
+ .cooldownTicks(this.isForceLayoutMode() ? (this.config.cooldownTicks ?? Infinity) : 0) // undefined = infinite
439
721
  .cooldownTime(this.config.cooldownTime ?? 2000)
440
722
  .enableNodeDrag(true)
441
723
  .enableZoomInteraction(true)
@@ -464,6 +746,12 @@ class FalkorDBCanvas extends HTMLElement {
464
746
  if (this.config.onNodeHover) {
465
747
  this.config.onNodeHover(node);
466
748
  }
749
+ })
750
+ .onNodeDrag((node) => {
751
+ this.handleNodeDrag(node);
752
+ })
753
+ .onNodeDragEnd((node) => {
754
+ this.handleNodeDragEnd(node);
467
755
  })
468
756
  .onLinkHover((link) => {
469
757
  if (this.config.onLinkHover) {
@@ -481,6 +769,7 @@ class FalkorDBCanvas extends HTMLElement {
481
769
  }
482
770
  })
483
771
  .onZoom((transform) => {
772
+ this.updateCullingBounds(transform);
484
773
  if (this.config.onZoom) {
485
774
  this.config.onZoom(transform);
486
775
  }
@@ -523,8 +812,7 @@ class FalkorDBCanvas extends HTMLElement {
523
812
  this.pointerLink(link, color, ctx);
524
813
  }
525
814
  });
526
- // Setup forces
527
- this.setupForces();
815
+ this.configureSimulationForCurrentLayout();
528
816
  this.log('Force graph initialization complete');
529
817
  }
530
818
  setupForces() {
@@ -534,6 +822,8 @@ class FalkorDBCanvas extends HTMLElement {
534
822
  return;
535
823
  if (!this.graph)
536
824
  return;
825
+ this.graph.d3Force("layoutTargetX", null);
826
+ this.graph.d3Force("layoutTargetY", null);
537
827
  // distance based on node size + constant
538
828
  linkForce
539
829
  .distance((link) => {
@@ -564,11 +854,148 @@ class FalkorDBCanvas extends HTMLElement {
564
854
  }
565
855
  this.log('Force simulation setup complete');
566
856
  }
857
+ /**
858
+ * Recompute the world-space culling bounds from the d3-zoom transform delivered
859
+ * by force-graph's `onZoom` callback.
860
+ *
861
+ * The d3-zoom transform maps world → screen as:
862
+ * screen_x = world_x * k + tx
863
+ * screen_y = world_y * k + ty
864
+ * Inverting for the canvas edges (screen_x ∈ [0, W], screen_y ∈ [0, H]):
865
+ * world_x ∈ [(0 − tx) / k, (W − tx) / k]
866
+ * world_y ∈ [(0 − ty) / k, (H − ty) / k]
867
+ */
868
+ updateCullingBounds(transform) {
869
+ this.lastTransform = transform;
870
+ if (!this.config.largeGraph?.enabled) {
871
+ this.cullingBounds = null;
872
+ return;
873
+ }
874
+ const w = this.graph?.width() ?? 0;
875
+ const h = this.graph?.height() ?? 0;
876
+ const { k, x: tx, y: ty } = transform;
877
+ if (k <= 0 || w <= 0 || h <= 0) {
878
+ this.cullingBounds = null;
879
+ this.cullingZoom = 1;
880
+ return;
881
+ }
882
+ const padding = this.config.largeGraph?.viewportPadding ?? 0;
883
+ this.cullingBounds = {
884
+ minX: -tx / k - padding,
885
+ maxX: (w - tx) / k + padding,
886
+ minY: -ty / k - padding,
887
+ maxY: (h - ty) / k + padding,
888
+ };
889
+ this.cullingZoom = k;
890
+ }
891
+ /** Recompute culling bounds using the last known transform (e.g. after resize). */
892
+ recomputeCullingBoundsIfNeeded() {
893
+ if (!this.config.largeGraph?.enabled)
894
+ return;
895
+ if (this.lastTransform) {
896
+ this.updateCullingBounds(this.lastTransform);
897
+ }
898
+ else if (this.graph) {
899
+ // Seed initial transform from current graph state before first onZoom fires.
900
+ const k = this.graph.zoom() ?? 1;
901
+ const center = this.graph.centerAt() ?? { x: 0, y: 0 };
902
+ const w = this.graph.width() ?? 0;
903
+ const h = this.graph.height() ?? 0;
904
+ if (k > 0 && w > 0 && h > 0) {
905
+ const tx = w / 2 - center.x * k;
906
+ const ty = h / 2 - center.y * k;
907
+ this.updateCullingBounds({ k, x: tx, y: ty });
908
+ }
909
+ }
910
+ }
911
+ /**
912
+ * Returns `true` when the node is (at least partially) inside the current
913
+ * culling viewport, or when culling is disabled / bounds are not yet known.
914
+ */
915
+ isNodeInCullingBounds(node) {
916
+ if (!this.cullingBounds)
917
+ return true;
918
+ const { minX, maxX, minY, maxY } = this.cullingBounds;
919
+ const r = node.size + PADDING;
920
+ const x = node.x ?? 0;
921
+ const y = node.y ?? 0;
922
+ return x + r >= minX && x - r <= maxX && y + r >= minY && y - r <= maxY;
923
+ }
924
+ /**
925
+ * Returns `true` when a link's visual representation overlaps the current
926
+ * culling viewport, or when culling is disabled / bounds are not yet known.
927
+ *
928
+ * For straight / quadratic-bezier links the test uses the convex-hull bounding
929
+ * box of (source, control point, target), which is always a conservative
930
+ * (never-false-negative) bound. For self-loops the test uses a square of
931
+ * side ≈ the loop diameter centred on the node.
932
+ */
933
+ isLinkInCullingBounds(link) {
934
+ if (!this.cullingBounds)
935
+ return true;
936
+ const { minX, maxX, minY, maxY } = this.cullingBounds;
937
+ const sx = link.source.x ?? 0;
938
+ const sy = link.source.y ?? 0;
939
+ const ex = link.target.x ?? 0;
940
+ const ey = link.target.y ?? 0;
941
+ if (link.source.id === link.target.id) {
942
+ // Self-loop: the cubic bezier extends roughly |curve| * nodeSize * factor
943
+ // away from the node centre. Use that as a conservative radius.
944
+ const nodeSize = link.source.size || NODE_SIZE;
945
+ const loopRadius = Math.abs(link.curve || 1) * nodeSize * SELF_LOOP_CURVE_FACTOR;
946
+ return (sx + loopRadius >= minX && sx - loopRadius <= maxX &&
947
+ sy + loopRadius >= minY && sy - loopRadius <= maxY);
948
+ }
949
+ // Compute quadratic-bezier control point (same formula as drawLink).
950
+ const dx = ex - sx;
951
+ const dy = ey - sy;
952
+ const distance = Math.sqrt(dx * dx + dy * dy);
953
+ if (distance === 0) {
954
+ // Co-located nodes: just check the point.
955
+ return sx >= minX && sx <= maxX && sy >= minY && sy <= maxY;
956
+ }
957
+ const curvature = link.curve ?? 0;
958
+ const perpX = dy / distance;
959
+ const perpY = -dx / distance;
960
+ const cx = (sx + ex) / 2 + perpX * curvature * distance;
961
+ const cy = (sy + ey) / 2 + perpY * curvature * distance;
962
+ // Convex-hull AABB of the three control points.
963
+ const lMinX = Math.min(sx, ex, cx);
964
+ const lMaxX = Math.max(sx, ex, cx);
965
+ const lMinY = Math.min(sy, ey, cy);
966
+ const lMaxY = Math.max(sy, ey, cy);
967
+ return lMaxX >= minX && lMinX <= maxX && lMaxY >= minY && lMinY <= maxY;
968
+ }
969
+ handleNodeDrag(node) {
970
+ if (this.isForceLayoutMode())
971
+ return;
972
+ if (node.x === undefined || node.y === undefined)
973
+ return;
974
+ node.layoutTargetX = node.x;
975
+ node.layoutTargetY = node.y;
976
+ this.shouldZoomToFitOnNonForceSettle = false;
977
+ this.startNonForceSettleAnimation(NON_FORCE_DRAG_COOLDOWN_TICKS);
978
+ }
979
+ handleNodeDragEnd(node) {
980
+ if (this.isForceLayoutMode())
981
+ return;
982
+ if (node.x === undefined || node.y === undefined)
983
+ return;
984
+ node.layoutTargetX = node.x;
985
+ node.layoutTargetY = node.y;
986
+ node.fx = undefined;
987
+ node.fy = undefined;
988
+ this.shouldZoomToFitOnNonForceSettle = false;
989
+ this.startNonForceSettleAnimation(NON_FORCE_DRAG_COOLDOWN_TICKS);
990
+ }
567
991
  drawNode(node, ctx) {
568
992
  if (node.x === undefined || node.y === undefined) {
569
993
  node.x = 0;
570
994
  node.y = 0;
571
995
  }
996
+ // Viewport culling: skip nodes that are entirely outside the visible area.
997
+ if (this.config.largeGraph?.enabled && !this.isNodeInCullingBounds(node))
998
+ return;
572
999
  ctx.lineWidth = this.config.isNodeSelected?.(node) ? 1 : 0.5;
573
1000
  ctx.strokeStyle = this.config.foregroundColor;
574
1001
  ctx.fillStyle = node.color;
@@ -579,6 +1006,12 @@ class FalkorDBCanvas extends HTMLElement {
579
1006
  ctx.beginPath();
580
1007
  ctx.arc(node.x, node.y, node.size, 0, 2 * Math.PI, false);
581
1008
  ctx.fill();
1009
+ // Low-zoom optimisation: skip labels when they would be too small to read.
1010
+ const skipLabels = this.config.largeGraph?.enabled &&
1011
+ (this.config.largeGraph?.skipLabelsAtLowZoom ?? true) &&
1012
+ this.cullingZoom < (this.config.largeGraph?.lowZoomThreshold ?? 0.5);
1013
+ if (skipLabels)
1014
+ return;
582
1015
  // Draw text
583
1016
  ctx.fillStyle = getContrastTextColor(node.color);
584
1017
  ctx.textAlign = "center";
@@ -591,32 +1024,46 @@ class FalkorDBCanvas extends HTMLElement {
591
1024
  ctx.font = `400 ${NODE_FONT_SIZE_BASE}px SofiaSans`;
592
1025
  [line1, line2] = wrapTextForCircularNode(ctx, text, textRadius);
593
1026
  let chosenSize = NODE_FONT_SIZE_BASE;
1027
+ // Measure at a large reference size (20px) where canvas metrics are
1028
+ // precise, then compute the exact scale to fill the node.
1029
+ const REF = 20;
1030
+ ctx.font = `400 ${REF}px SofiaSans`;
1031
+ // Switch to "left" for measurement: actualBoundingBoxLeft/Right are
1032
+ // unreliable with textAlign="center" and can double on some engines.
1033
+ ctx.textAlign = "left";
1034
+ const refMetrics = ctx.measureText(line1);
1035
+ // Use the actual visual bounding box (not advance width) so glyphs
1036
+ // with overshoot (e.g. "7") are fully accounted for.
1037
+ const visualWidth = (refMetrics.actualBoundingBoxLeft ?? 0)
1038
+ + (refMetrics.actualBoundingBoxRight ?? 0);
1039
+ let refWidth = Math.max(visualWidth, refMetrics.width);
1040
+ const singleLineHeight = (refMetrics.actualBoundingBoxAscent ?? 0)
1041
+ + (refMetrics.actualBoundingBoxDescent ?? 0);
1042
+ let refHeight;
594
1043
  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
- }
1044
+ refHeight = singleLineHeight;
1045
+ }
1046
+ else {
1047
+ // Two-line: use the wider line and account for the vertical span
1048
+ // of both lines including the 1.5× spacing used by the rendering code.
1049
+ const m2 = ctx.measureText(line2);
1050
+ const vis2 = Math.max((m2.actualBoundingBoxLeft ?? 0) + (m2.actualBoundingBoxRight ?? 0), m2.width);
1051
+ refWidth = Math.max(refWidth, vis2);
1052
+ refHeight = singleLineHeight * 2.5;
1053
+ }
1054
+ ctx.textAlign = "center";
1055
+ // Inscribed-rectangle-in-circle constraint: every corner of the text
1056
+ // bounding box must lie inside the circle, i.e.
1057
+ // sqrt((w/2)² + (h/2)²) r
1058
+ // Solving for the uniform scale factor s:
1059
+ // s = 2·r / sqrt(refWidth² + refHeight²)
1060
+ const r = NODE_TEXT_FILL_RATIO * textRadius;
1061
+ if (refWidth > 0 && refHeight > 0) {
1062
+ const diagonal = Math.sqrt(refWidth * refWidth + refHeight * refHeight);
1063
+ chosenSize = REF * (2 * r / diagonal);
1064
+ }
1065
+ else if (refWidth > 0) {
1066
+ chosenSize = REF * (2 * r / refWidth);
620
1067
  }
621
1068
  ctx.font = `400 ${chosenSize}px SofiaSans`;
622
1069
  node.displayName = [line1, line2];
@@ -650,6 +1097,9 @@ class FalkorDBCanvas extends HTMLElement {
650
1097
  node.y = 0;
651
1098
  }
652
1099
  ;
1100
+ // Viewport culling: skip hit-test painting for offscreen nodes.
1101
+ if (this.config.largeGraph?.enabled && !this.isNodeInCullingBounds(node))
1102
+ return;
653
1103
  const radius = node.size + PADDING;
654
1104
  ctx.fillStyle = color;
655
1105
  ctx.beginPath();
@@ -665,17 +1115,27 @@ class FalkorDBCanvas extends HTMLElement {
665
1115
  end.x = 0;
666
1116
  end.y = 0;
667
1117
  }
1118
+ // Viewport culling: skip links whose visual extent is entirely outside the
1119
+ // visible area. The check is conservative (convex-hull AABB) so it never
1120
+ // produces false negatives.
1121
+ if (this.config.largeGraph?.enabled && !this.isLinkInCullingBounds(link))
1122
+ return;
668
1123
  let textX;
669
1124
  let textY;
670
1125
  let angle;
671
1126
  const isLinkSelected = this.config.isLinkSelected?.(link) ?? false;
672
1127
  const arrowLen = isLinkSelected ? 4 : 2;
1128
+ // Low-zoom flags – evaluated once per link draw.
1129
+ const lowZoomThreshold = this.config.largeGraph?.lowZoomThreshold ?? 0.5;
1130
+ const atLowZoom = this.config.largeGraph?.enabled && this.cullingZoom < lowZoomThreshold;
1131
+ const skipArrows = atLowZoom && (this.config.largeGraph?.skipArrowsAtLowZoom ?? true);
1132
+ const skipLinkLabels = atLowZoom && (this.config.largeGraph?.skipLinkLabelsAtLowZoom ?? true);
673
1133
  // Deferred arrowhead — drawn after the label so it is never covered by
674
1134
  // the label background rect (which happens for short links where the
675
1135
  // bezier midpoint and the arrow tip are at almost the same position).
676
1136
  let pendingArrow = null;
677
1137
  if (start.id === end.id) {
678
- const nodeSize = start.size || 6;
1138
+ const nodeSize = start.size || NODE_SIZE;
679
1139
  const d = (link.curve || 0) * nodeSize * SELF_LOOP_CURVE_FACTOR;
680
1140
  ctx.lineWidth = (isLinkSelected ? 2 : 1) / globalScale;
681
1141
  if (this.config.linkLineDash)
@@ -737,7 +1197,7 @@ class FalkorDBCanvas extends HTMLElement {
737
1197
  // Guard against zero-length tangent vector (e.g. when d ≈ 0) to avoid NaN
738
1198
  // normals and invalid arrowhead geometry. Also skip when d is too small to
739
1199
  // place the arrowhead at the node border (canReachBorder is false).
740
- if (tLen !== 0 && canReachBorder) {
1200
+ if (!skipArrows && tLen !== 0 && canReachBorder) {
741
1201
  const nx = tdx / tLen;
742
1202
  const ny = tdy / tLen;
743
1203
  pendingArrow = { tipX, tipY, nx, ny, arrowLen, arrowHalfWidth };
@@ -786,7 +1246,7 @@ class FalkorDBCanvas extends HTMLElement {
786
1246
  // Draw regular link line and arrowhead
787
1247
  const arrowHalfWidth = arrowLen / ARROW_WH_RATIO / 2;
788
1248
  // Target-side clip: find t where bezier enters target node border + PADDING
789
- const endNodeSize = end.size || 6;
1249
+ const endNodeSize = end.size || NODE_SIZE;
790
1250
  const borderRadius = endNodeSize + (this.config.isNodeSelected?.(end) ? 1 : 0.5) + PADDING;
791
1251
  const borderRadiusSq = borderRadius * borderRadius;
792
1252
  let tArrow;
@@ -815,7 +1275,7 @@ class FalkorDBCanvas extends HTMLElement {
815
1275
  const tipX = uArrow * uArrow * start.x + 2 * uArrow * tArrow * controlX + tArrow * tArrow * end.x;
816
1276
  const tipY = uArrow * uArrow * start.y + 2 * uArrow * tArrow * controlY + tArrow * tArrow * end.y;
817
1277
  // Source-side clip: find t where bezier exits source node border + PADDING
818
- const startNodeSize = start.size || 6;
1278
+ const startNodeSize = start.size || NODE_SIZE;
819
1279
  const srcBorderRadius = startNodeSize + (this.config.isNodeSelected?.(start) ? 1 : 0.5) + PADDING;
820
1280
  const srcBorderRadiusSq = srcBorderRadius * srcBorderRadius;
821
1281
  let tStart = 0;
@@ -863,7 +1323,7 @@ class FalkorDBCanvas extends HTMLElement {
863
1323
  const atx = 2 * uArrow * (controlX - start.x) + 2 * tArrow * (end.x - controlX);
864
1324
  const aty = 2 * uArrow * (controlY - start.y) + 2 * tArrow * (end.y - controlY);
865
1325
  const atLen = Math.sqrt(atx * atx + aty * aty);
866
- if (atLen !== 0) {
1326
+ if (!skipArrows && atLen !== 0) {
867
1327
  const nx = atx / atLen;
868
1328
  const ny = aty / atLen;
869
1329
  pendingArrow = { tipX, tipY, nx, ny, arrowLen, arrowHalfWidth };
@@ -873,40 +1333,42 @@ class FalkorDBCanvas extends HTMLElement {
873
1333
  ctx.textAlign = "center";
874
1334
  // Draw text with alphabetic baseline, positioned so visual center is at y=0
875
1335
  ctx.textBaseline = "alphabetic";
876
- // Separate cache entries per weight so each state is measured with its own
877
- // font, giving equal visual padding regardless of selection state.
878
- const cacheKey = `${link.relationship}_${isLinkSelected ? "700" : "400"}`;
879
- let cached = this.relationshipsTextCache.get(cacheKey);
880
- if (!cached) {
881
- // ctx.font is already set to the correct weight above; measure it directly.
882
- const metrics = ctx.measureText(link.relationship);
883
- // Use actual ink bounds for vertical metrics; fontBoundingBox* is the full
884
- // line-box and adds excessive space for lighter weights.
885
- // Use metrics.width for horizontal extent: actualBoundingBoxLeft/Right are
886
- // unreliable with textAlign="center" and can double the value on some engines.
887
- const inkAscent = metrics.actualBoundingBoxAscent ?? metrics.fontBoundingBoxAscent;
888
- const inkDescent = metrics.actualBoundingBoxDescent ?? metrics.fontBoundingBoxDescent;
889
- const inkWidth = metrics.width;
890
- const bgPadding = 0.3;
891
- cached = {
892
- textWidth: inkWidth + bgPadding * 2,
893
- textHeight: inkAscent + inkDescent + bgPadding * 2,
894
- // Shift baseline up so the ink block is centred inside the bg rect.
895
- textYOffset: (inkAscent - inkDescent) / 2,
896
- };
897
- this.relationshipsTextCache.set(cacheKey, cached);
898
- }
899
- const { textWidth, textHeight, textYOffset } = cached;
900
- ctx.save();
901
- ctx.translate(textX, textY);
902
- ctx.rotate(angle);
903
- // Draw background centered on the link line (y=0)
904
- ctx.fillStyle = this.config.backgroundColor;
905
- // Offset background to match text visual center
906
- ctx.fillRect(-textWidth / 2, -textHeight / 2, textWidth, textHeight);
907
- ctx.fillStyle = getContrastTextColor(this.config.backgroundColor);
908
- ctx.fillText(link.relationship, 0, textYOffset);
909
- ctx.restore();
1336
+ if (!skipLinkLabels) {
1337
+ // Separate cache entries per weight so each state is measured with its own
1338
+ // font, giving equal visual padding regardless of selection state.
1339
+ const cacheKey = `${link.relationship}_${isLinkSelected ? "700" : "400"}`;
1340
+ let cached = this.relationshipsTextCache.get(cacheKey);
1341
+ if (!cached) {
1342
+ // ctx.font is already set to the correct weight above; measure it directly.
1343
+ const metrics = ctx.measureText(link.relationship);
1344
+ // Use actual ink bounds for vertical metrics; fontBoundingBox* is the full
1345
+ // line-box and adds excessive space for lighter weights.
1346
+ // Use metrics.width for horizontal extent: actualBoundingBoxLeft/Right are
1347
+ // unreliable with textAlign="center" and can double the value on some engines.
1348
+ const inkAscent = metrics.actualBoundingBoxAscent ?? metrics.fontBoundingBoxAscent;
1349
+ const inkDescent = metrics.actualBoundingBoxDescent ?? metrics.fontBoundingBoxDescent;
1350
+ const inkWidth = metrics.width;
1351
+ const bgPadding = 0.3;
1352
+ cached = {
1353
+ textWidth: inkWidth + bgPadding * 2,
1354
+ textHeight: inkAscent + inkDescent + bgPadding * 2,
1355
+ // Shift baseline up so the ink block is centred inside the bg rect.
1356
+ textYOffset: (inkAscent - inkDescent) / 2,
1357
+ };
1358
+ this.relationshipsTextCache.set(cacheKey, cached);
1359
+ }
1360
+ const { textWidth, textHeight, textYOffset } = cached;
1361
+ ctx.save();
1362
+ ctx.translate(textX, textY);
1363
+ ctx.rotate(angle);
1364
+ // Draw background centered on the link line (y=0)
1365
+ ctx.fillStyle = this.config.backgroundColor;
1366
+ // Offset background to match text visual center
1367
+ ctx.fillRect(-textWidth / 2, -textHeight / 2, textWidth, textHeight);
1368
+ ctx.fillStyle = getContrastTextColor(this.config.backgroundColor);
1369
+ ctx.fillText(link.relationship, 0, textYOffset);
1370
+ ctx.restore();
1371
+ }
910
1372
  // Draw arrowhead last so it always appears on top of the label background.
911
1373
  if (pendingArrow) {
912
1374
  const { tipX, tipY, nx, ny, arrowLen: aLen, arrowHalfWidth: aHW } = pendingArrow;
@@ -924,6 +1386,9 @@ class FalkorDBCanvas extends HTMLElement {
924
1386
  const end = link.target;
925
1387
  if (start.x == null || start.y == null || end.x == null || end.y == null)
926
1388
  return;
1389
+ // Viewport culling: skip hit-test painting for offscreen links.
1390
+ if (this.config.largeGraph?.enabled && !this.isLinkInCullingBounds(link))
1391
+ return;
927
1392
  ctx.strokeStyle = color;
928
1393
  const basePointerWidth = 10; // Desired on-screen pointer area thickness
929
1394
  const transform = typeof ctx.getTransform === 'function' ? ctx.getTransform() : null;
@@ -939,7 +1404,7 @@ class FalkorDBCanvas extends HTMLElement {
939
1404
  ctx.beginPath();
940
1405
  if (start.id === end.id) {
941
1406
  // Self-loop: replicate exact cubic bezier clip from drawLink
942
- const nodeSize = start.size || 6;
1407
+ const nodeSize = start.size || NODE_SIZE;
943
1408
  const d = (link.curve || 0) * nodeSize * SELF_LOOP_CURVE_FACTOR;
944
1409
  const nodeStrokeWidth = this.config.isNodeSelected?.(start) ? 1 : 0.5;
945
1410
  const borderRadius = nodeSize + nodeStrokeWidth + PADDING;
@@ -984,7 +1449,7 @@ class FalkorDBCanvas extends HTMLElement {
984
1449
  const controlX = (start.x + end.x) / 2 + perpX * curvature * distance;
985
1450
  const controlY = (start.y + end.y) / 2 + perpY * curvature * distance;
986
1451
  // Use the same borderRadius and binary-search clip as drawLink
987
- const endNodeSize = end.size || 6;
1452
+ const endNodeSize = end.size || NODE_SIZE;
988
1453
  const borderRadius = endNodeSize + (this.config.isNodeSelected?.(end) ? 1 : 0.5) + PADDING;
989
1454
  const borderRadiusSq = borderRadius * borderRadius;
990
1455
  let tArrow;
@@ -1013,7 +1478,7 @@ class FalkorDBCanvas extends HTMLElement {
1013
1478
  const tipX = uArrow * uArrow * start.x + 2 * uArrow * tArrow * controlX + tArrow * tArrow * end.x;
1014
1479
  const tipY = uArrow * uArrow * start.y + 2 * uArrow * tArrow * controlY + tArrow * tArrow * end.y;
1015
1480
  // Source-side clip: mirror of drawLink source gap
1016
- const startNodeSize = start.size || 6;
1481
+ const startNodeSize = start.size || NODE_SIZE;
1017
1482
  const srcBorderRadius = startNodeSize + (this.config.isNodeSelected?.(start) ? 1 : 0.5) + PADDING;
1018
1483
  const srcBorderRadiusSq = srcBorderRadius * srcBorderRadius;
1019
1484
  let tStart = 0;
@@ -1068,6 +1533,16 @@ class FalkorDBCanvas extends HTMLElement {
1068
1533
  if (!this.graph)
1069
1534
  return;
1070
1535
  this.log('Engine stopped');
1536
+ if (!this.isForceLayoutMode()) {
1537
+ this.applyLayoutTargets();
1538
+ this.graph.cooldownTicks(0);
1539
+ this.updateCanvasSimulationAttribute(false);
1540
+ if (this.shouldZoomToFitOnNonForceSettle && this.data.nodes.length > 0) {
1541
+ this.zoomToFit(1);
1542
+ }
1543
+ this.shouldZoomToFitOnNonForceSettle = false;
1544
+ return;
1545
+ }
1071
1546
  // If already stopped, just ensure any leftover loading state is cleared and return
1072
1547
  if (this.config.cooldownTicks === 0) {
1073
1548
  if (this.config.isLoading) {
@@ -1136,6 +1611,12 @@ class FalkorDBCanvas extends HTMLElement {
1136
1611
  if (this.config.onNodeHover) {
1137
1612
  this.config.onNodeHover(node);
1138
1613
  }
1614
+ })
1615
+ .onNodeDrag((node) => {
1616
+ this.handleNodeDrag(node);
1617
+ })
1618
+ .onNodeDragEnd((node) => {
1619
+ this.handleNodeDragEnd(node);
1139
1620
  })
1140
1621
  .onLinkHover((link) => {
1141
1622
  if (this.config.onLinkHover) {
@@ -1153,6 +1634,7 @@ class FalkorDBCanvas extends HTMLElement {
1153
1634
  }
1154
1635
  })
1155
1636
  .onZoom((transform) => {
1637
+ this.updateCullingBounds(transform);
1156
1638
  if (this.config.onZoom) {
1157
1639
  this.config.onZoom(transform);
1158
1640
  }
@@ -1185,7 +1667,9 @@ class FalkorDBCanvas extends HTMLElement {
1185
1667
  });
1186
1668
  }
1187
1669
  else {
1188
- this.graph.nodePointerAreaPaint();
1670
+ this.graph.nodePointerAreaPaint((node, color, ctx) => {
1671
+ this.pointerNode(node, color, ctx);
1672
+ });
1189
1673
  }
1190
1674
  if (this.config.link) {
1191
1675
  this.graph.linkPointerAreaPaint((link, color, ctx) => {
@@ -1193,7 +1677,9 @@ class FalkorDBCanvas extends HTMLElement {
1193
1677
  });
1194
1678
  }
1195
1679
  else {
1196
- this.graph.linkPointerAreaPaint();
1680
+ this.graph.linkPointerAreaPaint((link, color, ctx) => {
1681
+ this.pointerLink(link, color, ctx);
1682
+ });
1197
1683
  }
1198
1684
  }
1199
1685
  updateTooltipStyles() {