@falkordb/canvas 0.0.41 → 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/src/canvas.ts CHANGED
@@ -24,12 +24,16 @@ import {
24
24
  } from "./canvas-utils.js";
25
25
 
26
26
  const PADDING = 2;
27
-
28
27
  // Arrow geometry constants (shared by self-loop and regular-link drawing paths)
29
28
  const ARROW_WH_RATIO = 1.6;
30
29
  const ARROW_VLEN_RATIO = 0.2;
31
30
  // Multiplier to convert node size → cubic bezier control-point distance for self-loops
32
31
  const SELF_LOOP_CURVE_FACTOR = 11.67;
32
+ // Base font size used for the initial measurement and for two-line text.
33
+ const NODE_FONT_SIZE_BASE = 2;
34
+ // Fraction of the chord width that single-line text should fill (0–1).
35
+ // Leaves (1 - ratio)/2 of the radius as horizontal padding on each side.
36
+ const NODE_TEXT_FILL_RATIO = 0.85;
33
37
 
34
38
  // Force constants
35
39
  const CHARGE_STRENGTH = -400;
@@ -96,6 +100,9 @@ class FalkorDBCanvas extends HTMLElement {
96
100
 
97
101
  private nodeDegreeMap: Map<number, number> = new Map();
98
102
 
103
+ // Per-node font size cache: computed once per node, read every frame.
104
+ private nodeDisplayFontSize: Map<number, number> = new Map();
105
+
99
106
  private relationshipsTextCache: Map<
100
107
  string,
101
108
  {
@@ -105,6 +112,12 @@ class FalkorDBCanvas extends HTMLElement {
105
112
  }
106
113
  > = new Map();
107
114
 
115
+ private onFontsLoadingDone = () => {
116
+ this.relationshipsTextCache.clear();
117
+ this.nodeDisplayFontSize.clear();
118
+ this.triggerRender();
119
+ };
120
+
108
121
  private viewport: ViewportState;
109
122
 
110
123
  constructor() {
@@ -148,10 +161,16 @@ class FalkorDBCanvas extends HTMLElement {
148
161
 
149
162
  this.log('Component connected to DOM');
150
163
  this.render();
164
+
165
+ // Text measurements taken before the custom font finishes loading use the
166
+ // fallback system font and produce wrong widths that get locked in the cache.
167
+ // Re-measure on every font-load batch (including the initial one).
168
+ document.fonts.addEventListener("loadingdone", this.onFontsLoadingDone);
151
169
  }
152
170
 
153
171
  disconnectedCallback() {
154
172
  this.log('Component disconnected from DOM');
173
+ document.fonts.removeEventListener("loadingdone", this.onFontsLoadingDone);
155
174
  if (this.resizeObserver) {
156
175
  this.resizeObserver.disconnect();
157
176
  this.resizeObserver = null;
@@ -584,7 +603,7 @@ class FalkorDBCanvas extends HTMLElement {
584
603
  })
585
604
  .linkCanvasObject((link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) => {
586
605
  if (this.config.link) {
587
- this.config.link.linkCanvasObject(link, ctx);
606
+ this.config.link.linkCanvasObject(link, ctx, globalScale);
588
607
  } else {
589
608
  this.drawLink(link, ctx, globalScale);
590
609
  }
@@ -604,7 +623,6 @@ class FalkorDBCanvas extends HTMLElement {
604
623
  }
605
624
  });
606
625
 
607
-
608
626
  // Setup forces
609
627
  this.setupForces();
610
628
  this.log('Force graph initialization complete');
@@ -661,7 +679,11 @@ class FalkorDBCanvas extends HTMLElement {
661
679
  }
662
680
 
663
681
  private drawNode(node: GraphNode, ctx: CanvasRenderingContext2D) {
664
- if (node.x == null || node.y == null) return;
682
+
683
+ if (node.x === undefined || node.y === undefined) {
684
+ node.x = 0;
685
+ node.y = 0;
686
+ }
665
687
 
666
688
  ctx.lineWidth = this.config.isNodeSelected?.(node) ? 1 : 0.5;
667
689
  ctx.strokeStyle = this.config.foregroundColor;
@@ -681,15 +703,34 @@ class FalkorDBCanvas extends HTMLElement {
681
703
  ctx.fillStyle = getContrastTextColor(node.color);
682
704
  ctx.textAlign = "center";
683
705
  ctx.textBaseline = "middle";
684
- ctx.font = "400 2px SofiaSans";
685
706
 
686
707
  let [line1, line2] = node.displayName;
708
+ const textRadius = node.size - PADDING / 2;
687
709
 
688
710
  if (!line1 && !line2) {
689
711
  const text = getNodeDisplayText(node, this.config.captionsKeys, this.config.showPropertyKeyPrefix);
690
- const textRadius = node.size - PADDING / 2;
712
+
713
+ // Measure at the base (smallest) size — one cheap measurement.
714
+ ctx.font = `400 ${NODE_FONT_SIZE_BASE}px SofiaSans`;
691
715
  [line1, line2] = wrapTextForCircularNode(ctx, text, textRadius);
716
+
717
+ let chosenSize = NODE_FONT_SIZE_BASE;
718
+ if (!line2) {
719
+ // Single-line: scale up so the text fills NODE_TEXT_FILL_RATIO of the
720
+ // available chord. Font metrics scale linearly so this is exact.
721
+ const measuredWidth = ctx.measureText(line1).width;
722
+ if (measuredWidth > 0) {
723
+ chosenSize = NODE_FONT_SIZE_BASE * (NODE_TEXT_FILL_RATIO * 2 * textRadius / measuredWidth);
724
+ }
725
+ }
726
+
727
+ ctx.font = `400 ${chosenSize}px SofiaSans`;
692
728
  node.displayName = [line1, line2];
729
+ this.nodeDisplayFontSize.set(node.id, chosenSize);
730
+ } else {
731
+ // Cache hit: the font size was stored when displayName was first computed.
732
+ const chosenSize = this.nodeDisplayFontSize.get(node.id) ?? NODE_FONT_SIZE_BASE;
733
+ ctx.font = `400 ${chosenSize}px SofiaSans`;
693
734
  }
694
735
 
695
736
  const textMetrics = ctx.measureText(line1);
@@ -707,7 +748,10 @@ class FalkorDBCanvas extends HTMLElement {
707
748
  }
708
749
 
709
750
  private pointerNode(node: GraphNode, color: string, ctx: CanvasRenderingContext2D) {
710
- if (node.x == null || node.y == null) return;
751
+ if (node.x === undefined || node.y === undefined) {
752
+ node.x = 0;
753
+ node.y = 0;
754
+ };
711
755
 
712
756
  const radius = node.size + PADDING;
713
757
 
@@ -721,29 +765,41 @@ class FalkorDBCanvas extends HTMLElement {
721
765
  const start = link.source;
722
766
  const end = link.target;
723
767
 
724
- if (start.x == null || start.y == null || end.x == null || end.y == null) return;
768
+ if (start.x === undefined || start.y === undefined || end.x === undefined || end.y === undefined) {
769
+ start.x = 0;
770
+ start.y = 0;
771
+ end.x = 0;
772
+ end.y = 0;
773
+ }
725
774
 
726
775
  let textX;
727
776
  let textY;
728
777
  let angle;
729
778
 
779
+ const isLinkSelected = this.config.isLinkSelected?.(link) ?? false;
780
+ const arrowLen = isLinkSelected ? 4 : 2;
781
+
782
+ // Deferred arrowhead — drawn after the label so it is never covered by
783
+ // the label background rect (which happens for short links where the
784
+ // bezier midpoint and the arrow tip are at almost the same position).
785
+ let pendingArrow: { tipX: number; tipY: number; nx: number; ny: number; arrowLen: number; arrowHalfWidth: number } | null = null;
786
+
730
787
  if (start.id === end.id) {
731
788
  const nodeSize = start.size || 6;
732
789
  const d = (link.curve || 0) * nodeSize * SELF_LOOP_CURVE_FACTOR;
733
790
 
734
- ctx.lineWidth = (this.config.isLinkSelected?.(link) ? 2 : 1) / globalScale;
791
+ ctx.lineWidth = (isLinkSelected ? 2 : 1) / globalScale;
735
792
  ctx.setLineDash(this.config.linkLineDash?.(link) ?? []);
736
793
 
737
794
  // The visible outer edge of the node border is nodeSize + strokeWidth
738
795
  // (stroke is centered on nodeSize + strokeWidth/2, so outer edge = nodeSize + strokeWidth).
739
796
  const nodeStrokeWidth = this.config.isNodeSelected?.(start) ? 1 : 0.5;
740
- const borderRadius = nodeSize + nodeStrokeWidth;
797
+ const borderRadius = nodeSize + nodeStrokeWidth + PADDING;
741
798
 
742
799
  // Binary search for tArrow near 1.0 where the curve is at distance borderRadius
743
800
  // from the node center (i.e. on the outer edge of the node border stroke).
744
801
  // Bezier parametric form: Bx(t)=sx+3(1-t)t²d, By(t)=sy-3(1-t)²td
745
802
  // dist(t) = 3*(1-t)*t*|d|*sqrt(t² + (1-t)²)
746
- const arrowLen = (this.config.isLinkSelected?.(link) ? 4 : 2) / globalScale;
747
803
  const arrowHalfWidth = arrowLen / ARROW_WH_RATIO / 2;
748
804
  let lo = 0.5, hi = 1.0;
749
805
  const absD = Math.abs(d);
@@ -803,23 +859,7 @@ class FalkorDBCanvas extends HTMLElement {
803
859
  if (tLen !== 0 && canReachBorder) {
804
860
  const nx = tdx / tLen;
805
861
  const ny = tdy / tLen;
806
-
807
- ctx.fillStyle = link.color;
808
- ctx.beginPath();
809
- ctx.moveTo(tipX, tipY);
810
- ctx.lineTo(
811
- tipX - nx * arrowLen + ny * arrowHalfWidth,
812
- tipY - ny * arrowLen - nx * arrowHalfWidth,
813
- );
814
- ctx.lineTo(
815
- tipX - nx * arrowLen * (1 - ARROW_VLEN_RATIO),
816
- tipY - ny * arrowLen * (1 - ARROW_VLEN_RATIO),
817
- );
818
- ctx.lineTo(
819
- tipX - nx * arrowLen - ny * arrowHalfWidth,
820
- tipY - ny * arrowLen + nx * arrowHalfWidth,
821
- );
822
- ctx.fill();
862
+ pendingArrow = { tipX, tipY, nx, ny, arrowLen, arrowHalfWidth };
823
863
  }
824
864
 
825
865
  // Midpoint of cubic bezier: P0=(sx,sy), P1=(sx,sy-d), P2=(sx+d,sy), P3=(sx,sy)
@@ -868,24 +908,14 @@ class FalkorDBCanvas extends HTMLElement {
868
908
  if (angle > Math.PI / 2) angle = -(Math.PI - angle);
869
909
  if (angle < -Math.PI / 2) angle = -(-Math.PI - angle);
870
910
 
871
- // --- Draw the regular link line and arrowhead ---
872
- // Scale arrow geometry by 1/globalScale so arrowheads remain visually
873
- // consistent across zoom levels, matching ctx.lineWidth / globalScale.
874
- const baseArrowLen = this.config.isLinkSelected?.(link) ? 4 : 2;
875
- const arrowLen = baseArrowLen / globalScale;
911
+ // Draw regular link line and arrowhead
876
912
  const arrowHalfWidth = arrowLen / ARROW_WH_RATIO / 2;
877
913
 
878
- // Binary-search for tArrow near 1.0 where the quadratic bezier
879
- // Q(t) = (1-t)²·start + 2(1-t)t·control + t²·end
880
- // is at distance borderRadius from the target node centre.
914
+ // Target-side clip: find t where bezier enters target node border + PADDING
881
915
  const endNodeSize = end.size || 6;
882
- const endNodeStrokeWidth = this.config.isNodeSelected?.(end) ? 1 : 0.5;
883
- const borderRadius = endNodeSize + endNodeStrokeWidth;
916
+ const borderRadius = endNodeSize + (this.config.isNodeSelected?.(end) ? 1 : 0.5) + PADDING;
884
917
  const borderRadiusSq = borderRadius * borderRadius;
885
918
 
886
- // When borderRadius is small relative to the chord length, the bezier and
887
- // chord diverge only near t=1, so a linear approximation is accurate and
888
- // avoids the per-frame search cost on large graphs.
889
919
  let tArrow: number;
890
920
  if (borderRadius / distance < 0.02) {
891
921
  tArrow = Math.min(1, Math.max(0, 1 - borderRadius / distance));
@@ -906,25 +936,57 @@ class FalkorDBCanvas extends HTMLElement {
906
936
  }
907
937
  const uArrow = 1 - tArrow;
908
938
 
909
- // Tip = Q(tArrow)
910
939
  const tipX = uArrow * uArrow * start.x + 2 * uArrow * tArrow * controlX + tArrow * tArrow * end.x;
911
940
  const tipY = uArrow * uArrow * start.y + 2 * uArrow * tArrow * controlY + tArrow * tArrow * end.y;
912
941
 
913
- // Clipped quadratic bezier [0, tArrow] via De Casteljau:
914
- // new control = lerp(start, control, tArrow)
915
- const clippedCtrlX = start.x + tArrow * (controlX - start.x);
916
- const clippedCtrlY = start.y + tArrow * (controlY - start.y);
942
+ // Source-side clip: find t where bezier exits source node border + PADDING
943
+ const startNodeSize = start.size || 6;
944
+ const srcBorderRadius = startNodeSize + (this.config.isNodeSelected?.(start) ? 1 : 0.5) + PADDING;
945
+ const srcBorderRadiusSq = srcBorderRadius * srcBorderRadius;
946
+
947
+ let tStart = 0;
948
+ if (srcBorderRadius / distance < 0.02) {
949
+ tStart = Math.min(0.5, srcBorderRadius / distance);
950
+ } else {
951
+ let lo = 0.0, hi = 0.5;
952
+ for (let i = 0; i < 10; i++) {
953
+ const mid = (lo + hi) / 2;
954
+ const um = 1 - mid;
955
+ const qx = um * um * start.x + 2 * um * mid * controlX + mid * mid * end.x;
956
+ const qy = um * um * start.y + 2 * um * mid * controlY + mid * mid * end.y;
957
+ const dxSrc = qx - start.x;
958
+ const dySrc = qy - start.y;
959
+ if (dxSrc * dxSrc + dySrc * dySrc < srcBorderRadiusSq) lo = mid;
960
+ else hi = mid;
961
+ if (hi - lo < 1e-3) break;
962
+ }
963
+ tStart = (lo + hi) / 2;
964
+ }
965
+
966
+ // Gap start point: Q(tStart)
967
+ const uS = 1 - tStart;
968
+ const gapStartX = uS * uS * start.x + 2 * uS * tStart * controlX + tStart * tStart * end.x;
969
+ const gapStartY = uS * uS * start.y + 2 * uS * tStart * controlY + tStart * tStart * end.y;
970
+
971
+ // Sub-bezier [tStart, tArrow] control point via De Casteljau:
972
+ // Right sub-bezier at tStart → NewP1 = lerp(control, end, tStart)
973
+ // Left sub-curve at tArrow' = (tArrow-tStart)/(1-tStart) → ctrl = lerp(gapStart, NewP1, tArrow')
974
+ const tArrowPrime = tStart < tArrow ? (tArrow - tStart) / (1 - tStart) : 0;
975
+ const newP1X = (1 - tStart) * controlX + tStart * end.x;
976
+ const newP1Y = (1 - tStart) * controlY + tStart * end.y;
977
+ const subCtrlX = (1 - tArrowPrime) * gapStartX + tArrowPrime * newP1X;
978
+ const subCtrlY = (1 - tArrowPrime) * gapStartY + tArrowPrime * newP1Y;
917
979
 
918
980
  ctx.strokeStyle = link.color;
919
- ctx.lineWidth = (this.config.isLinkSelected?.(link) ? 2 : 1) / globalScale;
981
+ ctx.lineWidth = (isLinkSelected ? 2 : 1) / globalScale;
982
+
920
983
  ctx.setLineDash(this.config.linkLineDash?.(link) ?? []);
921
984
  ctx.beginPath();
922
- ctx.moveTo(start.x, start.y);
923
- ctx.quadraticCurveTo(clippedCtrlX, clippedCtrlY, tipX, tipY);
985
+ ctx.moveTo(gapStartX, gapStartY);
986
+ ctx.quadraticCurveTo(subCtrlX, subCtrlY, tipX, tipY);
924
987
  ctx.stroke();
925
988
  ctx.setLineDash([]);
926
989
 
927
- // Arrowhead tangent at tArrow: Q'(t) = 2(1-t)(control-start) + 2t(end-control)
928
990
  const atx = 2 * uArrow * (controlX - start.x) + 2 * tArrow * (end.x - controlX);
929
991
  const aty = 2 * uArrow * (controlY - start.y) + 2 * tArrow * (end.y - controlY);
930
992
  const atLen = Math.sqrt(atx * atx + aty * aty);
@@ -932,40 +994,39 @@ class FalkorDBCanvas extends HTMLElement {
932
994
  if (atLen !== 0) {
933
995
  const nx = atx / atLen;
934
996
  const ny = aty / atLen;
935
-
936
- ctx.fillStyle = link.color;
937
- ctx.beginPath();
938
- ctx.moveTo(tipX, tipY);
939
- ctx.lineTo(tipX - nx * arrowLen + ny * arrowHalfWidth, tipY - ny * arrowLen - nx * arrowHalfWidth);
940
- ctx.lineTo(
941
- tipX - nx * arrowLen * (1 - ARROW_VLEN_RATIO),
942
- tipY - ny * arrowLen * (1 - ARROW_VLEN_RATIO),
943
- );
944
- ctx.lineTo(tipX - nx * arrowLen - ny * arrowHalfWidth, tipY - ny * arrowLen + nx * arrowHalfWidth);
945
- ctx.fill();
997
+ pendingArrow = { tipX, tipY, nx, ny, arrowLen, arrowHalfWidth };
946
998
  }
947
999
  }
948
1000
 
949
- ctx.font = "400 2px SofiaSans";
1001
+ ctx.font = isLinkSelected ? "700 2px SofiaSans" : "400 2px SofiaSans";
950
1002
  ctx.textAlign = "center";
951
1003
  // Draw text with alphabetic baseline, positioned so visual center is at y=0
952
1004
  ctx.textBaseline = "alphabetic";
953
1005
 
954
- let cached = this.relationshipsTextCache.get(link.relationship);
1006
+ // Separate cache entries per weight so each state is measured with its own
1007
+ // font, giving equal visual padding regardless of selection state.
1008
+ const cacheKey = `${link.relationship}_${isLinkSelected ? "700" : "400"}`;
1009
+ let cached = this.relationshipsTextCache.get(cacheKey);
955
1010
 
956
1011
  if (!cached) {
1012
+ // ctx.font is already set to the correct weight above; measure it directly.
957
1013
  const metrics = ctx.measureText(link.relationship);
958
- // Use font-level metrics for consistent height across all texts
959
- const fontAscent = metrics.fontBoundingBoxAscent ?? metrics.actualBoundingBoxAscent;
960
- const fontDescent = metrics.fontBoundingBoxDescent ?? metrics.actualBoundingBoxDescent;
961
- // Calculate visual center offset from baseline using font-level metrics
962
- const visualCenter = (fontAscent - fontDescent) / 2;
1014
+ // Use actual ink bounds for vertical metrics; fontBoundingBox* is the full
1015
+ // line-box and adds excessive space for lighter weights.
1016
+ // Use metrics.width for horizontal extent: actualBoundingBoxLeft/Right are
1017
+ // unreliable with textAlign="center" and can double the value on some engines.
1018
+ const inkAscent = metrics.actualBoundingBoxAscent ?? metrics.fontBoundingBoxAscent;
1019
+ const inkDescent = metrics.actualBoundingBoxDescent ?? metrics.fontBoundingBoxDescent;
1020
+ const inkWidth = metrics.width;
1021
+ const bgPadding = 0.3;
1022
+
963
1023
  cached = {
964
- textWidth: metrics.width,
965
- textHeight: fontAscent + fontDescent,
966
- textYOffset: visualCenter,
1024
+ textWidth: inkWidth + bgPadding * 2,
1025
+ textHeight: inkAscent + inkDescent + bgPadding * 2,
1026
+ // Shift baseline up so the ink block is centred inside the bg rect.
1027
+ textYOffset: (inkAscent - inkDescent) / 2,
967
1028
  };
968
- this.relationshipsTextCache.set(link.relationship, cached);
1029
+ this.relationshipsTextCache.set(cacheKey, cached);
969
1030
  }
970
1031
 
971
1032
  const { textWidth, textHeight, textYOffset } = cached;
@@ -988,6 +1049,18 @@ class FalkorDBCanvas extends HTMLElement {
988
1049
  ctx.fillStyle = getContrastTextColor(this.config.backgroundColor);
989
1050
  ctx.fillText(link.relationship, 0, textYOffset);
990
1051
  ctx.restore();
1052
+
1053
+ // Draw arrowhead last so it always appears on top of the label background.
1054
+ if (pendingArrow) {
1055
+ const { tipX, tipY, nx, ny, arrowLen: aLen, arrowHalfWidth: aHW } = pendingArrow;
1056
+ ctx.fillStyle = link.color;
1057
+ ctx.beginPath();
1058
+ ctx.moveTo(tipX, tipY);
1059
+ ctx.lineTo(tipX - nx * aLen + ny * aHW, tipY - ny * aLen - nx * aHW);
1060
+ ctx.lineTo(tipX - nx * aLen * (1 - ARROW_VLEN_RATIO), tipY - ny * aLen * (1 - ARROW_VLEN_RATIO));
1061
+ ctx.lineTo(tipX - nx * aLen - ny * aHW, tipY - ny * aLen + nx * aHW);
1062
+ ctx.fill();
1063
+ }
991
1064
  }
992
1065
 
993
1066
  private pointerLink(link: GraphLink, color: string, ctx: CanvasRenderingContext2D) {
@@ -1010,13 +1083,43 @@ class FalkorDBCanvas extends HTMLElement {
1010
1083
  ctx.beginPath();
1011
1084
 
1012
1085
  if (start.id === end.id) {
1013
- // Self-loop: replicate the cubic bezier from drawLink
1086
+ // Self-loop: replicate exact cubic bezier clip from drawLink
1014
1087
  const nodeSize = start.size || 6;
1015
1088
  const d = (link.curve || 0) * nodeSize * SELF_LOOP_CURVE_FACTOR;
1089
+
1090
+ const nodeStrokeWidth = this.config.isNodeSelected?.(start) ? 1 : 0.5;
1091
+ const borderRadius = nodeSize + nodeStrokeWidth + PADDING;
1092
+ const absD = Math.abs(d);
1093
+ const maxReachableDist = 3 * 0.5 * 0.5 * absD * Math.sqrt(0.5);
1094
+ const canReachBorder = absD > 0 && maxReachableDist >= borderRadius;
1095
+
1016
1096
  ctx.moveTo(start.x, start.y);
1017
- ctx.bezierCurveTo(start.x, start.y - d, start.x + d, start.y, start.x, start.y);
1097
+ if (canReachBorder) {
1098
+ let lo = 0.5, hi = 1.0;
1099
+ for (let i = 0; i < 20; i++) {
1100
+ const mid = (lo + hi) / 2;
1101
+ const um = 1 - mid;
1102
+ const dist = 3 * um * mid * absD * Math.sqrt(mid * mid + um * um);
1103
+ if (dist > borderRadius) lo = mid;
1104
+ else hi = mid;
1105
+ }
1106
+ const tArrow = (lo + hi) / 2;
1107
+ const uArrow = 1 - tArrow;
1108
+ const tipX = start.x + 3 * uArrow * tArrow * tArrow * d;
1109
+ const tipY = start.y - 3 * uArrow * uArrow * tArrow * d;
1110
+ ctx.bezierCurveTo(
1111
+ start.x,
1112
+ start.y - tArrow * d,
1113
+ start.x + tArrow * tArrow * d,
1114
+ start.y - 2 * tArrow * uArrow * d,
1115
+ tipX,
1116
+ tipY,
1117
+ );
1118
+ } else {
1119
+ ctx.bezierCurveTo(start.x, start.y - d, start.x + d, start.y, start.x, start.y);
1120
+ }
1018
1121
  } else {
1019
- // Regular link: replicate the quadratic bezier from drawLink
1122
+ // Regular link: replicate exact quadratic bezier clip from drawLink
1020
1123
  const dx = end.x - start.x;
1021
1124
  const dy = end.y - start.y;
1022
1125
  const distance = Math.sqrt(dx * dx + dy * dy);
@@ -1031,18 +1134,69 @@ class FalkorDBCanvas extends HTMLElement {
1031
1134
  const controlX = (start.x + end.x) / 2 + perpX * curvature * distance;
1032
1135
  const controlY = (start.y + end.y) / 2 + perpY * curvature * distance;
1033
1136
 
1034
- // Clip the pointer path to the target node border so hit testing
1035
- // doesn't extend underneath the target node, matching what drawLink renders.
1036
- const targetRadius = (end.size || 6) + PADDING;
1037
- const clampedT = Math.min(1, Math.max(0, 1 - targetRadius / distance));
1038
- const ct = clampedT;
1039
- const cu = 1 - ct;
1040
- const clippedCtrlX = start.x + ct * (controlX - start.x);
1041
- const clippedCtrlY = start.y + ct * (controlY - start.y);
1042
- const tipX = cu * cu * start.x + 2 * cu * ct * controlX + ct * ct * end.x;
1043
- const tipY = cu * cu * start.y + 2 * cu * ct * controlY + ct * ct * end.y;
1044
- ctx.moveTo(start.x, start.y);
1045
- ctx.quadraticCurveTo(clippedCtrlX, clippedCtrlY, tipX, tipY);
1137
+ // Use the same borderRadius and binary-search clip as drawLink
1138
+ const endNodeSize = end.size || 6;
1139
+ const borderRadius = endNodeSize + (this.config.isNodeSelected?.(end) ? 1 : 0.5) + PADDING;
1140
+ const borderRadiusSq = borderRadius * borderRadius;
1141
+
1142
+ let tArrow: number;
1143
+ if (borderRadius / distance < 0.02) {
1144
+ tArrow = Math.min(1, Math.max(0, 1 - borderRadius / distance));
1145
+ } else {
1146
+ let lo = 0.5, hi = 1.0;
1147
+ for (let i = 0; i < 10; i++) {
1148
+ const mid = (lo + hi) / 2;
1149
+ const um = 1 - mid;
1150
+ const qx = um * um * start.x + 2 * um * mid * controlX + mid * mid * end.x;
1151
+ const qy = um * um * start.y + 2 * um * mid * controlY + mid * mid * end.y;
1152
+ const dxEnd = qx - end.x;
1153
+ const dyEnd = qy - end.y;
1154
+ if (dxEnd * dxEnd + dyEnd * dyEnd > borderRadiusSq) lo = mid;
1155
+ else hi = mid;
1156
+ if (hi - lo < 1e-3) break;
1157
+ }
1158
+ tArrow = (lo + hi) / 2;
1159
+ }
1160
+ const uArrow = 1 - tArrow;
1161
+ const tipX = uArrow * uArrow * start.x + 2 * uArrow * tArrow * controlX + tArrow * tArrow * end.x;
1162
+ const tipY = uArrow * uArrow * start.y + 2 * uArrow * tArrow * controlY + tArrow * tArrow * end.y;
1163
+
1164
+ // Source-side clip: mirror of drawLink source gap
1165
+ const startNodeSize = start.size || 6;
1166
+ const srcBorderRadius = startNodeSize + (this.config.isNodeSelected?.(start) ? 1 : 0.5) + PADDING;
1167
+ const srcBorderRadiusSq = srcBorderRadius * srcBorderRadius;
1168
+
1169
+ let tStart = 0;
1170
+ if (srcBorderRadius / distance < 0.02) {
1171
+ tStart = Math.min(0.5, srcBorderRadius / distance);
1172
+ } else {
1173
+ let lo = 0.0, hi = 0.5;
1174
+ for (let i = 0; i < 10; i++) {
1175
+ const mid = (lo + hi) / 2;
1176
+ const um = 1 - mid;
1177
+ const qx = um * um * start.x + 2 * um * mid * controlX + mid * mid * end.x;
1178
+ const qy = um * um * start.y + 2 * um * mid * controlY + mid * mid * end.y;
1179
+ const dxSrc = qx - start.x;
1180
+ const dySrc = qy - start.y;
1181
+ if (dxSrc * dxSrc + dySrc * dySrc < srcBorderRadiusSq) lo = mid;
1182
+ else hi = mid;
1183
+ if (hi - lo < 1e-3) break;
1184
+ }
1185
+ tStart = (lo + hi) / 2;
1186
+ }
1187
+
1188
+ const uS = 1 - tStart;
1189
+ const gapStartX = uS * uS * start.x + 2 * uS * tStart * controlX + tStart * tStart * end.x;
1190
+ const gapStartY = uS * uS * start.y + 2 * uS * tStart * controlY + tStart * tStart * end.y;
1191
+
1192
+ const tArrowPrime = tStart < tArrow ? (tArrow - tStart) / (1 - tStart) : 0;
1193
+ const newP1X = (1 - tStart) * controlX + tStart * end.x;
1194
+ const newP1Y = (1 - tStart) * controlY + tStart * end.y;
1195
+ const subCtrlX = (1 - tArrowPrime) * gapStartX + tArrowPrime * newP1X;
1196
+ const subCtrlY = (1 - tArrowPrime) * gapStartY + tArrowPrime * newP1Y;
1197
+
1198
+ ctx.moveTo(gapStartX, gapStartY);
1199
+ ctx.quadraticCurveTo(subCtrlX, subCtrlY, tipX, tipY);
1046
1200
  }
1047
1201
  }
1048
1202
 
@@ -1173,25 +1327,27 @@ class FalkorDBCanvas extends HTMLElement {
1173
1327
  })
1174
1328
  .linkCanvasObject((link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) => {
1175
1329
  if (this.config.link) {
1176
- this.config.link.linkCanvasObject(link, ctx);
1330
+ this.config.link.linkCanvasObject(link, ctx, globalScale);
1177
1331
  } else {
1178
1332
  this.drawLink(link, ctx, globalScale);
1179
1333
  }
1180
- })
1181
- .nodePointerAreaPaint((node: GraphNode, color: string, ctx: CanvasRenderingContext2D) => {
1182
- if (this.config.node) {
1183
- this.config.node.nodePointerAreaPaint(node, color, ctx);
1184
- } else {
1185
- this.pointerNode(node, color, ctx);
1186
- }
1187
- })
1188
- .linkPointerAreaPaint((link: GraphLink, color: string, ctx: CanvasRenderingContext2D) => {
1189
- if (this.config.link) {
1190
- this.config.link.linkPointerAreaPaint(link, color, ctx);
1191
- } else {
1192
- this.pointerLink(link, color, ctx);
1193
- }
1194
1334
  });
1335
+
1336
+ if (this.config.node) {
1337
+ this.graph.nodePointerAreaPaint((node: GraphNode, color: string, ctx: CanvasRenderingContext2D) => {
1338
+ this.config.node!.nodePointerAreaPaint(node, color, ctx);
1339
+ });
1340
+ } else {
1341
+ this.graph.nodePointerAreaPaint();
1342
+ }
1343
+
1344
+ if (this.config.link) {
1345
+ this.graph.linkPointerAreaPaint((link: GraphLink, color: string, ctx: CanvasRenderingContext2D) => {
1346
+ this.config.link!.linkPointerAreaPaint(link, color, ctx);
1347
+ });
1348
+ } else {
1349
+ this.graph.linkPointerAreaPaint();
1350
+ }
1195
1351
  }
1196
1352
 
1197
1353
  private updateTooltipStyles() {
package/src/index.ts CHANGED
@@ -43,6 +43,7 @@ export type {
43
43
 
44
44
  // Utils
45
45
  export {
46
+ NODE_SIZE,
46
47
  dataToGraphData,
47
48
  graphDataToData,
48
49
  getContrastTextColor,