@falkordb/canvas 0.0.40 → 0.0.42

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
@@ -3,6 +3,16 @@ import ForceGraph from "force-graph";
3
3
  import * as d3 from "d3";
4
4
  import { dataToGraphData, getContrastTextColor, getNodeDisplayText, graphDataToData, LINK_DISTANCE, wrapTextForCircularNode, } from "./canvas-utils.js";
5
5
  const PADDING = 2;
6
+ // Arrow geometry constants (shared by self-loop and regular-link drawing paths)
7
+ const ARROW_WH_RATIO = 1.6;
8
+ const ARROW_VLEN_RATIO = 0.2;
9
+ // Multiplier to convert node size → cubic bezier control-point distance for self-loops
10
+ const SELF_LOOP_CURVE_FACTOR = 11.67;
11
+ // Base font size used for the initial measurement and for two-line text.
12
+ const NODE_FONT_SIZE_BASE = 2;
13
+ // Fraction of the chord width that single-line text should fill (0–1).
14
+ // Leaves (1 - ratio)/2 of the radius as horizontal padding on each side.
15
+ const NODE_TEXT_FILL_RATIO = 0.85;
6
16
  // Force constants
7
17
  const CHARGE_STRENGTH = -400;
8
18
  const CENTER_STRENGTH = 0.03;
@@ -54,10 +64,17 @@ class FalkorDBCanvas extends HTMLElement {
54
64
  captionsKeys: [],
55
65
  showPropertyKeyPrefix: false,
56
66
  };
57
- this.nodeMode = 'after';
58
- this.linkMode = 'after';
67
+ this.nodeMode = 'replace';
68
+ this.linkMode = 'replace';
59
69
  this.nodeDegreeMap = new Map();
70
+ // Per-node font size cache: computed once per node, read every frame.
71
+ this.nodeDisplayFontSize = new Map();
60
72
  this.relationshipsTextCache = new Map();
73
+ this.onFontsLoadingDone = () => {
74
+ this.relationshipsTextCache.clear();
75
+ this.nodeDisplayFontSize.clear();
76
+ this.triggerRender();
77
+ };
61
78
  this.attachShadow({ mode: "open" });
62
79
  }
63
80
  /**
@@ -92,9 +109,14 @@ class FalkorDBCanvas extends HTMLElement {
92
109
  }
93
110
  this.log('Component connected to DOM');
94
111
  this.render();
112
+ // Text measurements taken before the custom font finishes loading use the
113
+ // fallback system font and produce wrong widths that get locked in the cache.
114
+ // Re-measure on every font-load batch (including the initial one).
115
+ document.fonts.addEventListener("loadingdone", this.onFontsLoadingDone);
95
116
  }
96
117
  disconnectedCallback() {
97
118
  this.log('Component disconnected from DOM');
119
+ document.fonts.removeEventListener("loadingdone", this.onFontsLoadingDone);
98
120
  if (this.resizeObserver) {
99
121
  this.resizeObserver.disconnect();
100
122
  this.resizeObserver = null;
@@ -396,25 +418,12 @@ class FalkorDBCanvas extends HTMLElement {
396
418
  .height(this.config.height || 600)
397
419
  .backgroundColor(this.config.backgroundColor)
398
420
  .graphData(this.data)
399
- .nodeRelSize(1)
400
- .nodeVal((node) => {
401
- const strokeWidth = this.config.isNodeSelected?.(node) ? 1.5 : 1;
402
- const radius = node.size + strokeWidth;
403
- return radius * radius; // Return radius squared since force-graph does sqrt(val * relSize)
404
- })
405
421
  .nodeCanvasObjectMode(() => this.nodeMode)
406
422
  .linkCanvasObjectMode(() => this.linkMode)
407
423
  .nodeLabel((node) => getNodeDisplayText(node, this.config.captionsKeys, this.config.showPropertyKeyPrefix))
408
424
  .linkLabel((link) => link.relationship)
409
- .linkDirectionalArrowRelPos(1)
410
- .linkDirectionalArrowLength((link) => {
411
- if (link.source === link.target)
412
- return 0;
413
- return this.config.isLinkSelected?.(link) ? 4 : 2;
414
- })
415
- .linkDirectionalArrowColor((link) => link.color)
416
- .linkWidth((link) => this.config.isLinkSelected?.(link) ? 2 : 1)
417
- .linkLineDash((link) => this.config.linkLineDash?.(link) ?? null)
425
+ .linkDirectionalArrowLength(0)
426
+ .linkWidth(0)
418
427
  .linkCurvature("curve")
419
428
  .linkVisibility("visible")
420
429
  .nodeVisibility("visible")
@@ -485,20 +494,28 @@ class FalkorDBCanvas extends HTMLElement {
485
494
  })
486
495
  .linkCanvasObject((link, ctx, globalScale) => {
487
496
  if (this.config.link) {
488
- this.config.link.linkCanvasObject(link, ctx);
497
+ this.config.link.linkCanvasObject(link, ctx, globalScale);
489
498
  }
490
499
  else {
491
500
  this.drawLink(link, ctx, globalScale);
492
501
  }
502
+ })
503
+ .nodePointerAreaPaint((node, color, ctx) => {
504
+ if (this.config.node) {
505
+ this.config.node.nodePointerAreaPaint(node, color, ctx);
506
+ }
507
+ else {
508
+ this.pointerNode(node, color, ctx);
509
+ }
510
+ })
511
+ .linkPointerAreaPaint((link, color, ctx) => {
512
+ if (this.config.link) {
513
+ this.config.link.linkPointerAreaPaint(link, color, ctx);
514
+ }
515
+ else {
516
+ this.pointerLink(link, color, ctx);
517
+ }
493
518
  });
494
- // Only set pointer area paint if custom node/link configs are provided
495
- if (this.config.node) {
496
- this.graph?.nodePointerAreaPaint(this.config.node?.nodePointerAreaPaint);
497
- }
498
- if (this.config.link) {
499
- this.graph?.linkPointerAreaPaint(this.config.link?.linkPointerAreaPaint);
500
- }
501
- ;
502
519
  // Setup forces
503
520
  this.setupForces();
504
521
  this.log('Force graph initialization complete');
@@ -541,11 +558,11 @@ class FalkorDBCanvas extends HTMLElement {
541
558
  this.log('Force simulation setup complete');
542
559
  }
543
560
  drawNode(node, ctx) {
544
- if (!node.x || !node.y) {
561
+ if (node.x === undefined || node.y === undefined) {
545
562
  node.x = 0;
546
563
  node.y = 0;
547
564
  }
548
- ctx.lineWidth = this.config.isNodeSelected?.(node) ? 1.5 : 1;
565
+ ctx.lineWidth = this.config.isNodeSelected?.(node) ? 1 : 0.5;
549
566
  ctx.strokeStyle = this.config.foregroundColor;
550
567
  ctx.fillStyle = node.color;
551
568
  const radius = node.size + ctx.lineWidth / 2;
@@ -559,13 +576,30 @@ class FalkorDBCanvas extends HTMLElement {
559
576
  ctx.fillStyle = getContrastTextColor(node.color);
560
577
  ctx.textAlign = "center";
561
578
  ctx.textBaseline = "middle";
562
- ctx.font = "400 2px SofiaSans";
563
579
  let [line1, line2] = node.displayName;
580
+ const textRadius = node.size - PADDING / 2;
564
581
  if (!line1 && !line2) {
565
582
  const text = getNodeDisplayText(node, this.config.captionsKeys, this.config.showPropertyKeyPrefix);
566
- const textRadius = node.size - PADDING / 2;
583
+ // Measure at the base (smallest) size one cheap measurement.
584
+ ctx.font = `400 ${NODE_FONT_SIZE_BASE}px SofiaSans`;
567
585
  [line1, line2] = wrapTextForCircularNode(ctx, text, textRadius);
586
+ let chosenSize = NODE_FONT_SIZE_BASE;
587
+ if (!line2) {
588
+ // Single-line: scale up so the text fills NODE_TEXT_FILL_RATIO of the
589
+ // available chord. Font metrics scale linearly so this is exact.
590
+ const measuredWidth = ctx.measureText(line1).width;
591
+ if (measuredWidth > 0) {
592
+ chosenSize = NODE_FONT_SIZE_BASE * (NODE_TEXT_FILL_RATIO * 2 * textRadius / measuredWidth);
593
+ }
594
+ }
595
+ ctx.font = `400 ${chosenSize}px SofiaSans`;
568
596
  node.displayName = [line1, line2];
597
+ this.nodeDisplayFontSize.set(node.id, chosenSize);
598
+ }
599
+ else {
600
+ // Cache hit: the font size was stored when displayName was first computed.
601
+ const chosenSize = this.nodeDisplayFontSize.get(node.id) ?? NODE_FONT_SIZE_BASE;
602
+ ctx.font = `400 ${chosenSize}px SofiaSans`;
569
603
  }
570
604
  const textMetrics = ctx.measureText(line1);
571
605
  const textHeight = textMetrics.actualBoundingBoxAscent +
@@ -578,10 +612,22 @@ class FalkorDBCanvas extends HTMLElement {
578
612
  ctx.fillText(line2, node.x, node.y + halfTextHeight);
579
613
  }
580
614
  }
615
+ pointerNode(node, color, ctx) {
616
+ if (node.x === undefined || node.y === undefined) {
617
+ node.x = 0;
618
+ node.y = 0;
619
+ }
620
+ ;
621
+ const radius = node.size + PADDING;
622
+ ctx.fillStyle = color;
623
+ ctx.beginPath();
624
+ ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI, false);
625
+ ctx.fill();
626
+ }
581
627
  drawLink(link, ctx, globalScale) {
582
628
  const start = link.source;
583
629
  const end = link.target;
584
- if (!start.x || !start.y || !end.x || !end.y) {
630
+ if (start.x === undefined || start.y === undefined || end.x === undefined || end.y === undefined) {
585
631
  start.x = 0;
586
632
  start.y = 0;
587
633
  end.x = 0;
@@ -590,21 +636,25 @@ class FalkorDBCanvas extends HTMLElement {
590
636
  let textX;
591
637
  let textY;
592
638
  let angle;
639
+ const isLinkSelected = this.config.isLinkSelected?.(link) ?? false;
640
+ const arrowLen = isLinkSelected ? 4 : 2;
641
+ // Deferred arrowhead — drawn after the label so it is never covered by
642
+ // the label background rect (which happens for short links where the
643
+ // bezier midpoint and the arrow tip are at almost the same position).
644
+ let pendingArrow = null;
593
645
  if (start.id === end.id) {
594
646
  const nodeSize = start.size || 6;
595
- const d = (link.curve || 0) * nodeSize * 11.67;
596
- ctx.lineWidth = (this.config.isLinkSelected?.(link) ? 2 : 1) / globalScale;
647
+ const d = (link.curve || 0) * nodeSize * SELF_LOOP_CURVE_FACTOR;
648
+ ctx.lineWidth = (isLinkSelected ? 2 : 1) / globalScale;
649
+ ctx.setLineDash(this.config.linkLineDash?.(link) ?? []);
597
650
  // The visible outer edge of the node border is nodeSize + strokeWidth
598
651
  // (stroke is centered on nodeSize + strokeWidth/2, so outer edge = nodeSize + strokeWidth).
599
- const nodeStrokeWidth = this.config.isNodeSelected?.(start) ? 1.5 : 1;
600
- const borderRadius = nodeSize + nodeStrokeWidth;
652
+ const nodeStrokeWidth = this.config.isNodeSelected?.(start) ? 1 : 0.5;
653
+ const borderRadius = nodeSize + nodeStrokeWidth + PADDING;
601
654
  // Binary search for tArrow near 1.0 where the curve is at distance borderRadius
602
655
  // from the node center (i.e. on the outer edge of the node border stroke).
603
656
  // Bezier parametric form: Bx(t)=sx+3(1-t)t²d, By(t)=sy-3(1-t)²td
604
657
  // dist(t) = 3*(1-t)*t*|d|*sqrt(t² + (1-t)²)
605
- const ARROW_WH_RATIO = 1.6;
606
- const ARROW_VLEN_RATIO = 0.2;
607
- const arrowLen = this.config.isLinkSelected?.(link) ? 4 : 2;
608
658
  const arrowHalfWidth = arrowLen / ARROW_WH_RATIO / 2;
609
659
  let lo = 0.5, hi = 1.0;
610
660
  const absD = Math.abs(d);
@@ -646,6 +696,7 @@ class FalkorDBCanvas extends HTMLElement {
646
696
  ctx.bezierCurveTo(start.x, start.y - d, start.x + d, start.y, start.x, start.y);
647
697
  }
648
698
  ctx.stroke();
699
+ ctx.setLineDash([]);
649
700
  // Tangent at tArrow (direction the curve travels toward the node)
650
701
  const tdx = 3 * d * tArrow * (2 - 3 * tArrow);
651
702
  const tdy = -3 * d * uArrow * (1 - 3 * tArrow);
@@ -656,13 +707,7 @@ class FalkorDBCanvas extends HTMLElement {
656
707
  if (tLen !== 0 && canReachBorder) {
657
708
  const nx = tdx / tLen;
658
709
  const ny = tdy / tLen;
659
- ctx.fillStyle = link.color;
660
- ctx.beginPath();
661
- ctx.moveTo(tipX, tipY);
662
- ctx.lineTo(tipX - nx * arrowLen + ny * arrowHalfWidth, tipY - ny * arrowLen - nx * arrowHalfWidth);
663
- ctx.lineTo(tipX - nx * arrowLen * (1 - ARROW_VLEN_RATIO), tipY - ny * arrowLen * (1 - ARROW_VLEN_RATIO));
664
- ctx.lineTo(tipX - nx * arrowLen - ny * arrowHalfWidth, tipY - ny * arrowLen + nx * arrowHalfWidth);
665
- ctx.fill();
710
+ pendingArrow = { tipX, tipY, nx, ny, arrowLen, arrowHalfWidth };
666
711
  }
667
712
  // Midpoint of cubic bezier: P0=(sx,sy), P1=(sx,sy-d), P2=(sx+d,sy), P3=(sx,sy)
668
713
  textX = start.x + 0.375 * d;
@@ -678,6 +723,11 @@ class FalkorDBCanvas extends HTMLElement {
678
723
  const dx = end.x - start.x;
679
724
  const dy = end.y - start.y;
680
725
  const distance = Math.sqrt(dx * dx + dy * dy);
726
+ // Guard: skip drawing when source and target are co-located (e.g. during
727
+ // simulation start-up). perpX/perpY would be NaN and propagate through
728
+ // all downstream bezier and arrowhead calculations.
729
+ if (distance === 0)
730
+ return;
681
731
  const perpX = dy / distance;
682
732
  const perpY = -dx / distance;
683
733
  const curvature = link.curve || 0;
@@ -700,25 +750,118 @@ class FalkorDBCanvas extends HTMLElement {
700
750
  angle = -(Math.PI - angle);
701
751
  if (angle < -Math.PI / 2)
702
752
  angle = -(-Math.PI - angle);
753
+ // Draw regular link line and arrowhead
754
+ const arrowHalfWidth = arrowLen / ARROW_WH_RATIO / 2;
755
+ // Target-side clip: find t where bezier enters target node border + PADDING
756
+ const endNodeSize = end.size || 6;
757
+ const borderRadius = endNodeSize + (this.config.isNodeSelected?.(end) ? 1 : 0.5) + PADDING;
758
+ const borderRadiusSq = borderRadius * borderRadius;
759
+ let tArrow;
760
+ if (borderRadius / distance < 0.02) {
761
+ tArrow = Math.min(1, Math.max(0, 1 - borderRadius / distance));
762
+ }
763
+ else {
764
+ let lo = 0.5, hi = 1.0;
765
+ for (let i = 0; i < 10; i++) {
766
+ const mid = (lo + hi) / 2;
767
+ const um = 1 - mid;
768
+ const qx = um * um * start.x + 2 * um * mid * controlX + mid * mid * end.x;
769
+ const qy = um * um * start.y + 2 * um * mid * controlY + mid * mid * end.y;
770
+ const dxEnd = qx - end.x;
771
+ const dyEnd = qy - end.y;
772
+ if (dxEnd * dxEnd + dyEnd * dyEnd > borderRadiusSq)
773
+ lo = mid;
774
+ else
775
+ hi = mid;
776
+ if (hi - lo < 1e-3)
777
+ break;
778
+ }
779
+ tArrow = (lo + hi) / 2;
780
+ }
781
+ const uArrow = 1 - tArrow;
782
+ const tipX = uArrow * uArrow * start.x + 2 * uArrow * tArrow * controlX + tArrow * tArrow * end.x;
783
+ const tipY = uArrow * uArrow * start.y + 2 * uArrow * tArrow * controlY + tArrow * tArrow * end.y;
784
+ // Source-side clip: find t where bezier exits source node border + PADDING
785
+ const startNodeSize = start.size || 6;
786
+ const srcBorderRadius = startNodeSize + (this.config.isNodeSelected?.(start) ? 1 : 0.5) + PADDING;
787
+ const srcBorderRadiusSq = srcBorderRadius * srcBorderRadius;
788
+ let tStart = 0;
789
+ if (srcBorderRadius / distance < 0.02) {
790
+ tStart = Math.min(0.5, srcBorderRadius / distance);
791
+ }
792
+ else {
793
+ let lo = 0.0, hi = 0.5;
794
+ for (let i = 0; i < 10; i++) {
795
+ const mid = (lo + hi) / 2;
796
+ const um = 1 - mid;
797
+ const qx = um * um * start.x + 2 * um * mid * controlX + mid * mid * end.x;
798
+ const qy = um * um * start.y + 2 * um * mid * controlY + mid * mid * end.y;
799
+ const dxSrc = qx - start.x;
800
+ const dySrc = qy - start.y;
801
+ if (dxSrc * dxSrc + dySrc * dySrc < srcBorderRadiusSq)
802
+ lo = mid;
803
+ else
804
+ hi = mid;
805
+ if (hi - lo < 1e-3)
806
+ break;
807
+ }
808
+ tStart = (lo + hi) / 2;
809
+ }
810
+ // Gap start point: Q(tStart)
811
+ const uS = 1 - tStart;
812
+ const gapStartX = uS * uS * start.x + 2 * uS * tStart * controlX + tStart * tStart * end.x;
813
+ const gapStartY = uS * uS * start.y + 2 * uS * tStart * controlY + tStart * tStart * end.y;
814
+ // Sub-bezier [tStart, tArrow] control point via De Casteljau:
815
+ // Right sub-bezier at tStart → NewP1 = lerp(control, end, tStart)
816
+ // Left sub-curve at tArrow' = (tArrow-tStart)/(1-tStart) → ctrl = lerp(gapStart, NewP1, tArrow')
817
+ const tArrowPrime = tStart < tArrow ? (tArrow - tStart) / (1 - tStart) : 0;
818
+ const newP1X = (1 - tStart) * controlX + tStart * end.x;
819
+ const newP1Y = (1 - tStart) * controlY + tStart * end.y;
820
+ const subCtrlX = (1 - tArrowPrime) * gapStartX + tArrowPrime * newP1X;
821
+ const subCtrlY = (1 - tArrowPrime) * gapStartY + tArrowPrime * newP1Y;
822
+ ctx.strokeStyle = link.color;
823
+ ctx.lineWidth = (isLinkSelected ? 2 : 1) / globalScale;
824
+ ctx.setLineDash(this.config.linkLineDash?.(link) ?? []);
825
+ ctx.beginPath();
826
+ ctx.moveTo(gapStartX, gapStartY);
827
+ ctx.quadraticCurveTo(subCtrlX, subCtrlY, tipX, tipY);
828
+ ctx.stroke();
829
+ ctx.setLineDash([]);
830
+ const atx = 2 * uArrow * (controlX - start.x) + 2 * tArrow * (end.x - controlX);
831
+ const aty = 2 * uArrow * (controlY - start.y) + 2 * tArrow * (end.y - controlY);
832
+ const atLen = Math.sqrt(atx * atx + aty * aty);
833
+ if (atLen !== 0) {
834
+ const nx = atx / atLen;
835
+ const ny = aty / atLen;
836
+ pendingArrow = { tipX, tipY, nx, ny, arrowLen, arrowHalfWidth };
837
+ }
703
838
  }
704
- ctx.font = "400 2px SofiaSans";
839
+ ctx.font = isLinkSelected ? "700 2px SofiaSans" : "400 2px SofiaSans";
705
840
  ctx.textAlign = "center";
706
841
  // Draw text with alphabetic baseline, positioned so visual center is at y=0
707
842
  ctx.textBaseline = "alphabetic";
708
- let cached = this.relationshipsTextCache.get(link.relationship);
843
+ // Separate cache entries per weight so each state is measured with its own
844
+ // font, giving equal visual padding regardless of selection state.
845
+ const cacheKey = `${link.relationship}_${isLinkSelected ? "700" : "400"}`;
846
+ let cached = this.relationshipsTextCache.get(cacheKey);
709
847
  if (!cached) {
848
+ // ctx.font is already set to the correct weight above; measure it directly.
710
849
  const metrics = ctx.measureText(link.relationship);
711
- // Use font-level metrics for consistent height across all texts
712
- const fontAscent = metrics.fontBoundingBoxAscent ?? metrics.actualBoundingBoxAscent;
713
- const fontDescent = metrics.fontBoundingBoxDescent ?? metrics.actualBoundingBoxDescent;
714
- // Calculate visual center offset from baseline using font-level metrics
715
- const visualCenter = (fontAscent - fontDescent) / 2;
850
+ // Use actual ink bounds for vertical metrics; fontBoundingBox* is the full
851
+ // line-box and adds excessive space for lighter weights.
852
+ // Use metrics.width for horizontal extent: actualBoundingBoxLeft/Right are
853
+ // unreliable with textAlign="center" and can double the value on some engines.
854
+ const inkAscent = metrics.actualBoundingBoxAscent ?? metrics.fontBoundingBoxAscent;
855
+ const inkDescent = metrics.actualBoundingBoxDescent ?? metrics.fontBoundingBoxDescent;
856
+ const inkWidth = metrics.width;
857
+ const bgPadding = 0.3;
716
858
  cached = {
717
- textWidth: metrics.width,
718
- textHeight: fontAscent + fontDescent,
719
- textYOffset: visualCenter,
859
+ textWidth: inkWidth + bgPadding * 2,
860
+ textHeight: inkAscent + inkDescent + bgPadding * 2,
861
+ // Shift baseline up so the ink block is centred inside the bg rect.
862
+ textYOffset: (inkAscent - inkDescent) / 2,
720
863
  };
721
- this.relationshipsTextCache.set(link.relationship, cached);
864
+ this.relationshipsTextCache.set(cacheKey, cached);
722
865
  }
723
866
  const { textWidth, textHeight, textYOffset } = cached;
724
867
  ctx.save();
@@ -731,6 +874,150 @@ class FalkorDBCanvas extends HTMLElement {
731
874
  ctx.fillStyle = getContrastTextColor(this.config.backgroundColor);
732
875
  ctx.fillText(link.relationship, 0, textYOffset);
733
876
  ctx.restore();
877
+ // Draw arrowhead last so it always appears on top of the label background.
878
+ if (pendingArrow) {
879
+ const { tipX, tipY, nx, ny, arrowLen: aLen, arrowHalfWidth: aHW } = pendingArrow;
880
+ ctx.fillStyle = link.color;
881
+ ctx.beginPath();
882
+ ctx.moveTo(tipX, tipY);
883
+ ctx.lineTo(tipX - nx * aLen + ny * aHW, tipY - ny * aLen - nx * aHW);
884
+ ctx.lineTo(tipX - nx * aLen * (1 - ARROW_VLEN_RATIO), tipY - ny * aLen * (1 - ARROW_VLEN_RATIO));
885
+ ctx.lineTo(tipX - nx * aLen - ny * aHW, tipY - ny * aLen + nx * aHW);
886
+ ctx.fill();
887
+ }
888
+ }
889
+ pointerLink(link, color, ctx) {
890
+ const start = link.source;
891
+ const end = link.target;
892
+ if (start.x == null || start.y == null || end.x == null || end.y == null)
893
+ return;
894
+ ctx.strokeStyle = color;
895
+ const basePointerWidth = 10; // Desired on-screen pointer area thickness
896
+ const transform = typeof ctx.getTransform === 'function' ? ctx.getTransform() : null;
897
+ if (transform) {
898
+ const scaleX = Math.hypot(transform.a, transform.c);
899
+ const scaleY = Math.hypot(transform.b, transform.d);
900
+ const avgScale = (scaleX + scaleY) / 2 || 1;
901
+ ctx.lineWidth = basePointerWidth / avgScale;
902
+ }
903
+ else {
904
+ ctx.lineWidth = basePointerWidth;
905
+ }
906
+ ctx.beginPath();
907
+ if (start.id === end.id) {
908
+ // Self-loop: replicate exact cubic bezier clip from drawLink
909
+ const nodeSize = start.size || 6;
910
+ const d = (link.curve || 0) * nodeSize * SELF_LOOP_CURVE_FACTOR;
911
+ const nodeStrokeWidth = this.config.isNodeSelected?.(start) ? 1 : 0.5;
912
+ const borderRadius = nodeSize + nodeStrokeWidth + PADDING;
913
+ const absD = Math.abs(d);
914
+ const maxReachableDist = 3 * 0.5 * 0.5 * absD * Math.sqrt(0.5);
915
+ const canReachBorder = absD > 0 && maxReachableDist >= borderRadius;
916
+ ctx.moveTo(start.x, start.y);
917
+ if (canReachBorder) {
918
+ let lo = 0.5, hi = 1.0;
919
+ for (let i = 0; i < 20; i++) {
920
+ const mid = (lo + hi) / 2;
921
+ const um = 1 - mid;
922
+ const dist = 3 * um * mid * absD * Math.sqrt(mid * mid + um * um);
923
+ if (dist > borderRadius)
924
+ lo = mid;
925
+ else
926
+ hi = mid;
927
+ }
928
+ const tArrow = (lo + hi) / 2;
929
+ const uArrow = 1 - tArrow;
930
+ const tipX = start.x + 3 * uArrow * tArrow * tArrow * d;
931
+ const tipY = start.y - 3 * uArrow * uArrow * tArrow * d;
932
+ ctx.bezierCurveTo(start.x, start.y - tArrow * d, start.x + tArrow * tArrow * d, start.y - 2 * tArrow * uArrow * d, tipX, tipY);
933
+ }
934
+ else {
935
+ ctx.bezierCurveTo(start.x, start.y - d, start.x + d, start.y, start.x, start.y);
936
+ }
937
+ }
938
+ else {
939
+ // Regular link: replicate exact quadratic bezier clip from drawLink
940
+ const dx = end.x - start.x;
941
+ const dy = end.y - start.y;
942
+ const distance = Math.sqrt(dx * dx + dy * dy);
943
+ const curvature = link.curve || 0;
944
+ if (distance === 0) {
945
+ ctx.moveTo(start.x, start.y);
946
+ ctx.lineTo(end.x, end.y);
947
+ }
948
+ else {
949
+ const perpX = dy / distance;
950
+ const perpY = -dx / distance;
951
+ const controlX = (start.x + end.x) / 2 + perpX * curvature * distance;
952
+ const controlY = (start.y + end.y) / 2 + perpY * curvature * distance;
953
+ // Use the same borderRadius and binary-search clip as drawLink
954
+ const endNodeSize = end.size || 6;
955
+ const borderRadius = endNodeSize + (this.config.isNodeSelected?.(end) ? 1 : 0.5) + PADDING;
956
+ const borderRadiusSq = borderRadius * borderRadius;
957
+ let tArrow;
958
+ if (borderRadius / distance < 0.02) {
959
+ tArrow = Math.min(1, Math.max(0, 1 - borderRadius / distance));
960
+ }
961
+ else {
962
+ let lo = 0.5, hi = 1.0;
963
+ for (let i = 0; i < 10; i++) {
964
+ const mid = (lo + hi) / 2;
965
+ const um = 1 - mid;
966
+ const qx = um * um * start.x + 2 * um * mid * controlX + mid * mid * end.x;
967
+ const qy = um * um * start.y + 2 * um * mid * controlY + mid * mid * end.y;
968
+ const dxEnd = qx - end.x;
969
+ const dyEnd = qy - end.y;
970
+ if (dxEnd * dxEnd + dyEnd * dyEnd > borderRadiusSq)
971
+ lo = mid;
972
+ else
973
+ hi = mid;
974
+ if (hi - lo < 1e-3)
975
+ break;
976
+ }
977
+ tArrow = (lo + hi) / 2;
978
+ }
979
+ const uArrow = 1 - tArrow;
980
+ const tipX = uArrow * uArrow * start.x + 2 * uArrow * tArrow * controlX + tArrow * tArrow * end.x;
981
+ const tipY = uArrow * uArrow * start.y + 2 * uArrow * tArrow * controlY + tArrow * tArrow * end.y;
982
+ // Source-side clip: mirror of drawLink source gap
983
+ const startNodeSize = start.size || 6;
984
+ const srcBorderRadius = startNodeSize + (this.config.isNodeSelected?.(start) ? 1 : 0.5) + PADDING;
985
+ const srcBorderRadiusSq = srcBorderRadius * srcBorderRadius;
986
+ let tStart = 0;
987
+ if (srcBorderRadius / distance < 0.02) {
988
+ tStart = Math.min(0.5, srcBorderRadius / distance);
989
+ }
990
+ else {
991
+ let lo = 0.0, hi = 0.5;
992
+ for (let i = 0; i < 10; i++) {
993
+ const mid = (lo + hi) / 2;
994
+ const um = 1 - mid;
995
+ const qx = um * um * start.x + 2 * um * mid * controlX + mid * mid * end.x;
996
+ const qy = um * um * start.y + 2 * um * mid * controlY + mid * mid * end.y;
997
+ const dxSrc = qx - start.x;
998
+ const dySrc = qy - start.y;
999
+ if (dxSrc * dxSrc + dySrc * dySrc < srcBorderRadiusSq)
1000
+ lo = mid;
1001
+ else
1002
+ hi = mid;
1003
+ if (hi - lo < 1e-3)
1004
+ break;
1005
+ }
1006
+ tStart = (lo + hi) / 2;
1007
+ }
1008
+ const uS = 1 - tStart;
1009
+ const gapStartX = uS * uS * start.x + 2 * uS * tStart * controlX + tStart * tStart * end.x;
1010
+ const gapStartY = uS * uS * start.y + 2 * uS * tStart * controlY + tStart * tStart * end.y;
1011
+ const tArrowPrime = tStart < tArrow ? (tArrow - tStart) / (1 - tStart) : 0;
1012
+ const newP1X = (1 - tStart) * controlX + tStart * end.x;
1013
+ const newP1Y = (1 - tStart) * controlY + tStart * end.y;
1014
+ const subCtrlX = (1 - tArrowPrime) * gapStartX + tArrowPrime * newP1X;
1015
+ const subCtrlY = (1 - tArrowPrime) * gapStartY + tArrowPrime * newP1Y;
1016
+ ctx.moveTo(gapStartX, gapStartY);
1017
+ ctx.quadraticCurveTo(subCtrlX, subCtrlY, tipX, tipY);
1018
+ }
1019
+ }
1020
+ ctx.stroke();
734
1021
  }
735
1022
  updateLoadingState() {
736
1023
  if (!this.loadingOverlay)
@@ -854,7 +1141,7 @@ class FalkorDBCanvas extends HTMLElement {
854
1141
  })
855
1142
  .linkCanvasObject((link, ctx, globalScale) => {
856
1143
  if (this.config.link) {
857
- this.config.link.linkCanvasObject(link, ctx);
1144
+ this.config.link.linkCanvasObject(link, ctx, globalScale);
858
1145
  }
859
1146
  else {
860
1147
  this.drawLink(link, ctx, globalScale);