@falkordb/canvas 0.0.41 → 0.0.43

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
@@ -8,6 +8,11 @@ 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.
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;
11
16
  // Force constants
12
17
  const CHARGE_STRENGTH = -400;
13
18
  const CENTER_STRENGTH = 0.03;
@@ -62,7 +67,14 @@ class FalkorDBCanvas extends HTMLElement {
62
67
  this.nodeMode = 'replace';
63
68
  this.linkMode = 'replace';
64
69
  this.nodeDegreeMap = new Map();
70
+ // Per-node font size cache: computed once per node, read every frame.
71
+ this.nodeDisplayFontSize = new Map();
65
72
  this.relationshipsTextCache = new Map();
73
+ this.onFontsLoadingDone = () => {
74
+ this.relationshipsTextCache.clear();
75
+ this.nodeDisplayFontSize.clear();
76
+ this.triggerRender();
77
+ };
66
78
  this.attachShadow({ mode: "open" });
67
79
  }
68
80
  /**
@@ -97,9 +109,14 @@ class FalkorDBCanvas extends HTMLElement {
97
109
  }
98
110
  this.log('Component connected to DOM');
99
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);
100
116
  }
101
117
  disconnectedCallback() {
102
118
  this.log('Component disconnected from DOM');
119
+ document.fonts.removeEventListener("loadingdone", this.onFontsLoadingDone);
103
120
  if (this.resizeObserver) {
104
121
  this.resizeObserver.disconnect();
105
122
  this.resizeObserver = null;
@@ -115,7 +132,7 @@ class FalkorDBCanvas extends HTMLElement {
115
132
  // Update event handlers if they were provided
116
133
  if (config.onNodeClick || config.onLinkClick || config.onNodeRightClick || config.onLinkRightClick ||
117
134
  config.onNodeHover || config.onLinkHover || config.onBackgroundClick || config.onBackgroundRightClick || config.onZoom ||
118
- config.onEngineStop || config.isNodeSelected || config.isLinkSelected || config.linkLineDash || config.node || config.link) {
135
+ config.onEngineStop || config.isNodeSelected || config.isLinkSelected || config.node || config.link) {
119
136
  this.log('Updating event handlers');
120
137
  this.updateEventHandlers();
121
138
  }
@@ -466,7 +483,6 @@ class FalkorDBCanvas extends HTMLElement {
466
483
  this.config.onEngineStop();
467
484
  }
468
485
  })
469
- .linkLineDash((link) => this.config.linkLineDash?.(link) ?? null)
470
486
  .nodeCanvasObject((node, ctx) => {
471
487
  if (this.config.node) {
472
488
  this.config.node.nodeCanvasObject(node, ctx);
@@ -477,7 +493,7 @@ class FalkorDBCanvas extends HTMLElement {
477
493
  })
478
494
  .linkCanvasObject((link, ctx, globalScale) => {
479
495
  if (this.config.link) {
480
- this.config.link.linkCanvasObject(link, ctx);
496
+ this.config.link.linkCanvasObject(link, ctx, globalScale);
481
497
  }
482
498
  else {
483
499
  this.drawLink(link, ctx, globalScale);
@@ -541,8 +557,10 @@ class FalkorDBCanvas extends HTMLElement {
541
557
  this.log('Force simulation setup complete');
542
558
  }
543
559
  drawNode(node, ctx) {
544
- if (node.x == null || node.y == null)
545
- return;
560
+ if (node.x === undefined || node.y === undefined) {
561
+ node.x = 0;
562
+ node.y = 0;
563
+ }
546
564
  ctx.lineWidth = this.config.isNodeSelected?.(node) ? 1 : 0.5;
547
565
  ctx.strokeStyle = this.config.foregroundColor;
548
566
  ctx.fillStyle = node.color;
@@ -557,13 +575,30 @@ class FalkorDBCanvas extends HTMLElement {
557
575
  ctx.fillStyle = getContrastTextColor(node.color);
558
576
  ctx.textAlign = "center";
559
577
  ctx.textBaseline = "middle";
560
- ctx.font = "400 2px SofiaSans";
561
578
  let [line1, line2] = node.displayName;
579
+ const textRadius = node.size - PADDING / 2;
562
580
  if (!line1 && !line2) {
563
581
  const text = getNodeDisplayText(node, this.config.captionsKeys, this.config.showPropertyKeyPrefix);
564
- const textRadius = node.size - PADDING / 2;
582
+ // Measure at the base (smallest) size one cheap measurement.
583
+ ctx.font = `400 ${NODE_FONT_SIZE_BASE}px SofiaSans`;
565
584
  [line1, line2] = wrapTextForCircularNode(ctx, text, textRadius);
585
+ let chosenSize = NODE_FONT_SIZE_BASE;
586
+ if (!line2) {
587
+ // Single-line: scale up so the text fills NODE_TEXT_FILL_RATIO of the
588
+ // available chord. Font metrics scale linearly so this is exact.
589
+ const measuredWidth = ctx.measureText(line1).width;
590
+ if (measuredWidth > 0) {
591
+ chosenSize = NODE_FONT_SIZE_BASE * (NODE_TEXT_FILL_RATIO * 2 * textRadius / measuredWidth);
592
+ }
593
+ }
594
+ ctx.font = `400 ${chosenSize}px SofiaSans`;
566
595
  node.displayName = [line1, line2];
596
+ this.nodeDisplayFontSize.set(node.id, chosenSize);
597
+ }
598
+ else {
599
+ // Cache hit: the font size was stored when displayName was first computed.
600
+ const chosenSize = this.nodeDisplayFontSize.get(node.id) ?? NODE_FONT_SIZE_BASE;
601
+ ctx.font = `400 ${chosenSize}px SofiaSans`;
567
602
  }
568
603
  const textMetrics = ctx.measureText(line1);
569
604
  const textHeight = textMetrics.actualBoundingBoxAscent +
@@ -577,8 +612,11 @@ class FalkorDBCanvas extends HTMLElement {
577
612
  }
578
613
  }
579
614
  pointerNode(node, color, ctx) {
580
- if (node.x == null || node.y == null)
581
- return;
615
+ if (node.x === undefined || node.y === undefined) {
616
+ node.x = 0;
617
+ node.y = 0;
618
+ }
619
+ ;
582
620
  const radius = node.size + PADDING;
583
621
  ctx.fillStyle = color;
584
622
  ctx.beginPath();
@@ -588,25 +626,35 @@ class FalkorDBCanvas extends HTMLElement {
588
626
  drawLink(link, ctx, globalScale) {
589
627
  const start = link.source;
590
628
  const end = link.target;
591
- if (start.x == null || start.y == null || end.x == null || end.y == null)
592
- return;
629
+ if (start.x === undefined || start.y === undefined || end.x === undefined || end.y === undefined) {
630
+ start.x = 0;
631
+ start.y = 0;
632
+ end.x = 0;
633
+ end.y = 0;
634
+ }
593
635
  let textX;
594
636
  let textY;
595
637
  let angle;
638
+ const isLinkSelected = this.config.isLinkSelected?.(link) ?? false;
639
+ const arrowLen = isLinkSelected ? 4 : 2;
640
+ // Deferred arrowhead — drawn after the label so it is never covered by
641
+ // the label background rect (which happens for short links where the
642
+ // bezier midpoint and the arrow tip are at almost the same position).
643
+ let pendingArrow = null;
596
644
  if (start.id === end.id) {
597
645
  const nodeSize = start.size || 6;
598
646
  const d = (link.curve || 0) * nodeSize * SELF_LOOP_CURVE_FACTOR;
599
- ctx.lineWidth = (this.config.isLinkSelected?.(link) ? 2 : 1) / globalScale;
600
- ctx.setLineDash(this.config.linkLineDash?.(link) ?? []);
647
+ ctx.lineWidth = (isLinkSelected ? 2 : 1) / globalScale;
648
+ if (this.config.linkLineDash)
649
+ ctx.setLineDash(this.config.linkLineDash(link));
601
650
  // The visible outer edge of the node border is nodeSize + strokeWidth
602
651
  // (stroke is centered on nodeSize + strokeWidth/2, so outer edge = nodeSize + strokeWidth).
603
652
  const nodeStrokeWidth = this.config.isNodeSelected?.(start) ? 1 : 0.5;
604
- const borderRadius = nodeSize + nodeStrokeWidth;
653
+ const borderRadius = nodeSize + nodeStrokeWidth + PADDING;
605
654
  // Binary search for tArrow near 1.0 where the curve is at distance borderRadius
606
655
  // from the node center (i.e. on the outer edge of the node border stroke).
607
656
  // Bezier parametric form: Bx(t)=sx+3(1-t)t²d, By(t)=sy-3(1-t)²td
608
657
  // dist(t) = 3*(1-t)*t*|d|*sqrt(t² + (1-t)²)
609
- const arrowLen = (this.config.isLinkSelected?.(link) ? 4 : 2) / globalScale;
610
658
  const arrowHalfWidth = arrowLen / ARROW_WH_RATIO / 2;
611
659
  let lo = 0.5, hi = 1.0;
612
660
  const absD = Math.abs(d);
@@ -659,13 +707,7 @@ class FalkorDBCanvas extends HTMLElement {
659
707
  if (tLen !== 0 && canReachBorder) {
660
708
  const nx = tdx / tLen;
661
709
  const ny = tdy / tLen;
662
- ctx.fillStyle = link.color;
663
- ctx.beginPath();
664
- ctx.moveTo(tipX, tipY);
665
- ctx.lineTo(tipX - nx * arrowLen + ny * arrowHalfWidth, tipY - ny * arrowLen - nx * arrowHalfWidth);
666
- ctx.lineTo(tipX - nx * arrowLen * (1 - ARROW_VLEN_RATIO), tipY - ny * arrowLen * (1 - ARROW_VLEN_RATIO));
667
- ctx.lineTo(tipX - nx * arrowLen - ny * arrowHalfWidth, tipY - ny * arrowLen + nx * arrowHalfWidth);
668
- ctx.fill();
710
+ pendingArrow = { tipX, tipY, nx, ny, arrowLen, arrowHalfWidth };
669
711
  }
670
712
  // Midpoint of cubic bezier: P0=(sx,sy), P1=(sx,sy-d), P2=(sx+d,sy), P3=(sx,sy)
671
713
  textX = start.x + 0.375 * d;
@@ -708,22 +750,12 @@ class FalkorDBCanvas extends HTMLElement {
708
750
  angle = -(Math.PI - angle);
709
751
  if (angle < -Math.PI / 2)
710
752
  angle = -(-Math.PI - angle);
711
- // --- Draw the regular link line and arrowhead ---
712
- // Scale arrow geometry by 1/globalScale so arrowheads remain visually
713
- // consistent across zoom levels, matching ctx.lineWidth / globalScale.
714
- const baseArrowLen = this.config.isLinkSelected?.(link) ? 4 : 2;
715
- const arrowLen = baseArrowLen / globalScale;
753
+ // Draw regular link line and arrowhead
716
754
  const arrowHalfWidth = arrowLen / ARROW_WH_RATIO / 2;
717
- // Binary-search for tArrow near 1.0 where the quadratic bezier
718
- // Q(t) = (1-t)²·start + 2(1-t)t·control + t²·end
719
- // is at distance borderRadius from the target node centre.
755
+ // Target-side clip: find t where bezier enters target node border + PADDING
720
756
  const endNodeSize = end.size || 6;
721
- const endNodeStrokeWidth = this.config.isNodeSelected?.(end) ? 1 : 0.5;
722
- const borderRadius = endNodeSize + endNodeStrokeWidth;
757
+ const borderRadius = endNodeSize + (this.config.isNodeSelected?.(end) ? 1 : 0.5) + PADDING;
723
758
  const borderRadiusSq = borderRadius * borderRadius;
724
- // When borderRadius is small relative to the chord length, the bezier and
725
- // chord diverge only near t=1, so a linear approximation is accurate and
726
- // avoids the per-frame search cost on large graphs.
727
759
  let tArrow;
728
760
  if (borderRadius / distance < 0.02) {
729
761
  tArrow = Math.min(1, Math.max(0, 1 - borderRadius / distance));
@@ -747,55 +779,89 @@ class FalkorDBCanvas extends HTMLElement {
747
779
  tArrow = (lo + hi) / 2;
748
780
  }
749
781
  const uArrow = 1 - tArrow;
750
- // Tip = Q(tArrow)
751
782
  const tipX = uArrow * uArrow * start.x + 2 * uArrow * tArrow * controlX + tArrow * tArrow * end.x;
752
783
  const tipY = uArrow * uArrow * start.y + 2 * uArrow * tArrow * controlY + tArrow * tArrow * end.y;
753
- // Clipped quadratic bezier [0, tArrow] via De Casteljau:
754
- // new control = lerp(start, control, tArrow)
755
- const clippedCtrlX = start.x + tArrow * (controlX - start.x);
756
- const clippedCtrlY = start.y + tArrow * (controlY - start.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;
757
822
  ctx.strokeStyle = link.color;
758
- ctx.lineWidth = (this.config.isLinkSelected?.(link) ? 2 : 1) / globalScale;
823
+ ctx.lineWidth = (isLinkSelected ? 2 : 1) / globalScale;
759
824
  ctx.setLineDash(this.config.linkLineDash?.(link) ?? []);
760
825
  ctx.beginPath();
761
- ctx.moveTo(start.x, start.y);
762
- ctx.quadraticCurveTo(clippedCtrlX, clippedCtrlY, tipX, tipY);
826
+ ctx.moveTo(gapStartX, gapStartY);
827
+ ctx.quadraticCurveTo(subCtrlX, subCtrlY, tipX, tipY);
763
828
  ctx.stroke();
764
829
  ctx.setLineDash([]);
765
- // Arrowhead tangent at tArrow: Q'(t) = 2(1-t)(control-start) + 2t(end-control)
766
830
  const atx = 2 * uArrow * (controlX - start.x) + 2 * tArrow * (end.x - controlX);
767
831
  const aty = 2 * uArrow * (controlY - start.y) + 2 * tArrow * (end.y - controlY);
768
832
  const atLen = Math.sqrt(atx * atx + aty * aty);
769
833
  if (atLen !== 0) {
770
834
  const nx = atx / atLen;
771
835
  const ny = aty / atLen;
772
- ctx.fillStyle = link.color;
773
- ctx.beginPath();
774
- ctx.moveTo(tipX, tipY);
775
- ctx.lineTo(tipX - nx * arrowLen + ny * arrowHalfWidth, tipY - ny * arrowLen - nx * arrowHalfWidth);
776
- ctx.lineTo(tipX - nx * arrowLen * (1 - ARROW_VLEN_RATIO), tipY - ny * arrowLen * (1 - ARROW_VLEN_RATIO));
777
- ctx.lineTo(tipX - nx * arrowLen - ny * arrowHalfWidth, tipY - ny * arrowLen + nx * arrowHalfWidth);
778
- ctx.fill();
836
+ pendingArrow = { tipX, tipY, nx, ny, arrowLen, arrowHalfWidth };
779
837
  }
780
838
  }
781
- ctx.font = "400 2px SofiaSans";
839
+ ctx.font = isLinkSelected ? "700 2px SofiaSans" : "400 2px SofiaSans";
782
840
  ctx.textAlign = "center";
783
841
  // Draw text with alphabetic baseline, positioned so visual center is at y=0
784
842
  ctx.textBaseline = "alphabetic";
785
- 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);
786
847
  if (!cached) {
848
+ // ctx.font is already set to the correct weight above; measure it directly.
787
849
  const metrics = ctx.measureText(link.relationship);
788
- // Use font-level metrics for consistent height across all texts
789
- const fontAscent = metrics.fontBoundingBoxAscent ?? metrics.actualBoundingBoxAscent;
790
- const fontDescent = metrics.fontBoundingBoxDescent ?? metrics.actualBoundingBoxDescent;
791
- // Calculate visual center offset from baseline using font-level metrics
792
- 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;
793
858
  cached = {
794
- textWidth: metrics.width,
795
- textHeight: fontAscent + fontDescent,
796
- 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,
797
863
  };
798
- this.relationshipsTextCache.set(link.relationship, cached);
864
+ this.relationshipsTextCache.set(cacheKey, cached);
799
865
  }
800
866
  const { textWidth, textHeight, textYOffset } = cached;
801
867
  ctx.save();
@@ -808,6 +874,17 @@ class FalkorDBCanvas extends HTMLElement {
808
874
  ctx.fillStyle = getContrastTextColor(this.config.backgroundColor);
809
875
  ctx.fillText(link.relationship, 0, textYOffset);
810
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
+ }
811
888
  }
812
889
  pointerLink(link, color, ctx) {
813
890
  const start = link.source;
@@ -828,14 +905,38 @@ class FalkorDBCanvas extends HTMLElement {
828
905
  }
829
906
  ctx.beginPath();
830
907
  if (start.id === end.id) {
831
- // Self-loop: replicate the cubic bezier from drawLink
908
+ // Self-loop: replicate exact cubic bezier clip from drawLink
832
909
  const nodeSize = start.size || 6;
833
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;
834
916
  ctx.moveTo(start.x, start.y);
835
- ctx.bezierCurveTo(start.x, start.y - d, start.x + d, start.y, 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
+ }
836
937
  }
837
938
  else {
838
- // Regular link: replicate the quadratic bezier from drawLink
939
+ // Regular link: replicate exact quadratic bezier clip from drawLink
839
940
  const dx = end.x - start.x;
840
941
  const dy = end.y - start.y;
841
942
  const distance = Math.sqrt(dx * dx + dy * dy);
@@ -849,18 +950,71 @@ class FalkorDBCanvas extends HTMLElement {
849
950
  const perpY = -dx / distance;
850
951
  const controlX = (start.x + end.x) / 2 + perpX * curvature * distance;
851
952
  const controlY = (start.y + end.y) / 2 + perpY * curvature * distance;
852
- // Clip the pointer path to the target node border so hit testing
853
- // doesn't extend underneath the target node, matching what drawLink renders.
854
- const targetRadius = (end.size || 6) + PADDING;
855
- const clampedT = Math.min(1, Math.max(0, 1 - targetRadius / distance));
856
- const ct = clampedT;
857
- const cu = 1 - ct;
858
- const clippedCtrlX = start.x + ct * (controlX - start.x);
859
- const clippedCtrlY = start.y + ct * (controlY - start.y);
860
- const tipX = cu * cu * start.x + 2 * cu * ct * controlX + ct * ct * end.x;
861
- const tipY = cu * cu * start.y + 2 * cu * ct * controlY + ct * ct * end.y;
862
- ctx.moveTo(start.x, start.y);
863
- ctx.quadraticCurveTo(clippedCtrlX, clippedCtrlY, tipX, tipY);
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);
864
1018
  }
865
1019
  }
866
1020
  ctx.stroke();
@@ -976,7 +1130,6 @@ class FalkorDBCanvas extends HTMLElement {
976
1130
  this.config.onEngineStop();
977
1131
  }
978
1132
  })
979
- .linkLineDash((link) => this.config.linkLineDash?.(link) ?? null)
980
1133
  .nodeCanvasObject((node, ctx) => {
981
1134
  if (this.config.node) {
982
1135
  this.config.node.nodeCanvasObject(node, ctx);
@@ -987,28 +1140,28 @@ class FalkorDBCanvas extends HTMLElement {
987
1140
  })
988
1141
  .linkCanvasObject((link, ctx, globalScale) => {
989
1142
  if (this.config.link) {
990
- this.config.link.linkCanvasObject(link, ctx);
1143
+ this.config.link.linkCanvasObject(link, ctx, globalScale);
991
1144
  }
992
1145
  else {
993
1146
  this.drawLink(link, ctx, globalScale);
994
1147
  }
995
- })
996
- .nodePointerAreaPaint((node, color, ctx) => {
997
- if (this.config.node) {
1148
+ });
1149
+ if (this.config.node) {
1150
+ this.graph.nodePointerAreaPaint((node, color, ctx) => {
998
1151
  this.config.node.nodePointerAreaPaint(node, color, ctx);
999
- }
1000
- else {
1001
- this.pointerNode(node, color, ctx);
1002
- }
1003
- })
1004
- .linkPointerAreaPaint((link, color, ctx) => {
1005
- if (this.config.link) {
1152
+ });
1153
+ }
1154
+ else {
1155
+ this.graph.nodePointerAreaPaint();
1156
+ }
1157
+ if (this.config.link) {
1158
+ this.graph.linkPointerAreaPaint((link, color, ctx) => {
1006
1159
  this.config.link.linkPointerAreaPaint(link, color, ctx);
1007
- }
1008
- else {
1009
- this.pointerLink(link, color, ctx);
1010
- }
1011
- });
1160
+ });
1161
+ }
1162
+ else {
1163
+ this.graph.linkPointerAreaPaint();
1164
+ }
1012
1165
  }
1013
1166
  updateTooltipStyles() {
1014
1167
  if (!this.shadowRoot)