@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/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;
@@ -169,7 +188,7 @@ class FalkorDBCanvas extends HTMLElement {
169
188
  // Update event handlers if they were provided
170
189
  if (config.onNodeClick || config.onLinkClick || config.onNodeRightClick || config.onLinkRightClick ||
171
190
  config.onNodeHover || config.onLinkHover || config.onBackgroundClick || config.onBackgroundRightClick || config.onZoom ||
172
- config.onEngineStop || config.isNodeSelected || config.isLinkSelected || config.linkLineDash || config.node || config.link) {
191
+ config.onEngineStop || config.isNodeSelected || config.isLinkSelected || config.node || config.link) {
173
192
  this.log('Updating event handlers');
174
193
  this.updateEventHandlers();
175
194
  }
@@ -574,7 +593,6 @@ class FalkorDBCanvas extends HTMLElement {
574
593
  this.config.onEngineStop();
575
594
  }
576
595
  })
577
- .linkLineDash((link: GraphLink) => this.config.linkLineDash?.(link) ?? null)
578
596
  .nodeCanvasObject((node: GraphNode, ctx: CanvasRenderingContext2D) => {
579
597
  if (this.config.node) {
580
598
  this.config.node.nodeCanvasObject(node, ctx);
@@ -584,7 +602,7 @@ class FalkorDBCanvas extends HTMLElement {
584
602
  })
585
603
  .linkCanvasObject((link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) => {
586
604
  if (this.config.link) {
587
- this.config.link.linkCanvasObject(link, ctx);
605
+ this.config.link.linkCanvasObject(link, ctx, globalScale);
588
606
  } else {
589
607
  this.drawLink(link, ctx, globalScale);
590
608
  }
@@ -604,7 +622,6 @@ class FalkorDBCanvas extends HTMLElement {
604
622
  }
605
623
  });
606
624
 
607
-
608
625
  // Setup forces
609
626
  this.setupForces();
610
627
  this.log('Force graph initialization complete');
@@ -661,7 +678,11 @@ class FalkorDBCanvas extends HTMLElement {
661
678
  }
662
679
 
663
680
  private drawNode(node: GraphNode, ctx: CanvasRenderingContext2D) {
664
- if (node.x == null || node.y == null) return;
681
+
682
+ if (node.x === undefined || node.y === undefined) {
683
+ node.x = 0;
684
+ node.y = 0;
685
+ }
665
686
 
666
687
  ctx.lineWidth = this.config.isNodeSelected?.(node) ? 1 : 0.5;
667
688
  ctx.strokeStyle = this.config.foregroundColor;
@@ -681,15 +702,34 @@ class FalkorDBCanvas extends HTMLElement {
681
702
  ctx.fillStyle = getContrastTextColor(node.color);
682
703
  ctx.textAlign = "center";
683
704
  ctx.textBaseline = "middle";
684
- ctx.font = "400 2px SofiaSans";
685
705
 
686
706
  let [line1, line2] = node.displayName;
707
+ const textRadius = node.size - PADDING / 2;
687
708
 
688
709
  if (!line1 && !line2) {
689
710
  const text = getNodeDisplayText(node, this.config.captionsKeys, this.config.showPropertyKeyPrefix);
690
- const textRadius = node.size - PADDING / 2;
711
+
712
+ // Measure at the base (smallest) size — one cheap measurement.
713
+ ctx.font = `400 ${NODE_FONT_SIZE_BASE}px SofiaSans`;
691
714
  [line1, line2] = wrapTextForCircularNode(ctx, text, textRadius);
715
+
716
+ let chosenSize = NODE_FONT_SIZE_BASE;
717
+ if (!line2) {
718
+ // Single-line: scale up so the text fills NODE_TEXT_FILL_RATIO of the
719
+ // available chord. Font metrics scale linearly so this is exact.
720
+ const measuredWidth = ctx.measureText(line1).width;
721
+ if (measuredWidth > 0) {
722
+ chosenSize = NODE_FONT_SIZE_BASE * (NODE_TEXT_FILL_RATIO * 2 * textRadius / measuredWidth);
723
+ }
724
+ }
725
+
726
+ ctx.font = `400 ${chosenSize}px SofiaSans`;
692
727
  node.displayName = [line1, line2];
728
+ this.nodeDisplayFontSize.set(node.id, chosenSize);
729
+ } else {
730
+ // Cache hit: the font size was stored when displayName was first computed.
731
+ const chosenSize = this.nodeDisplayFontSize.get(node.id) ?? NODE_FONT_SIZE_BASE;
732
+ ctx.font = `400 ${chosenSize}px SofiaSans`;
693
733
  }
694
734
 
695
735
  const textMetrics = ctx.measureText(line1);
@@ -707,7 +747,10 @@ class FalkorDBCanvas extends HTMLElement {
707
747
  }
708
748
 
709
749
  private pointerNode(node: GraphNode, color: string, ctx: CanvasRenderingContext2D) {
710
- if (node.x == null || node.y == null) return;
750
+ if (node.x === undefined || node.y === undefined) {
751
+ node.x = 0;
752
+ node.y = 0;
753
+ };
711
754
 
712
755
  const radius = node.size + PADDING;
713
756
 
@@ -721,29 +764,41 @@ class FalkorDBCanvas extends HTMLElement {
721
764
  const start = link.source;
722
765
  const end = link.target;
723
766
 
724
- if (start.x == null || start.y == null || end.x == null || end.y == null) return;
767
+ if (start.x === undefined || start.y === undefined || end.x === undefined || end.y === undefined) {
768
+ start.x = 0;
769
+ start.y = 0;
770
+ end.x = 0;
771
+ end.y = 0;
772
+ }
725
773
 
726
774
  let textX;
727
775
  let textY;
728
776
  let angle;
729
777
 
778
+ const isLinkSelected = this.config.isLinkSelected?.(link) ?? false;
779
+ const arrowLen = isLinkSelected ? 4 : 2;
780
+
781
+ // Deferred arrowhead — drawn after the label so it is never covered by
782
+ // the label background rect (which happens for short links where the
783
+ // bezier midpoint and the arrow tip are at almost the same position).
784
+ let pendingArrow: { tipX: number; tipY: number; nx: number; ny: number; arrowLen: number; arrowHalfWidth: number } | null = null;
785
+
730
786
  if (start.id === end.id) {
731
787
  const nodeSize = start.size || 6;
732
788
  const d = (link.curve || 0) * nodeSize * SELF_LOOP_CURVE_FACTOR;
733
789
 
734
- ctx.lineWidth = (this.config.isLinkSelected?.(link) ? 2 : 1) / globalScale;
735
- ctx.setLineDash(this.config.linkLineDash?.(link) ?? []);
790
+ ctx.lineWidth = (isLinkSelected ? 2 : 1) / globalScale;
791
+ if (this.config.linkLineDash) ctx.setLineDash(this.config.linkLineDash(link));
736
792
 
737
793
  // The visible outer edge of the node border is nodeSize + strokeWidth
738
794
  // (stroke is centered on nodeSize + strokeWidth/2, so outer edge = nodeSize + strokeWidth).
739
795
  const nodeStrokeWidth = this.config.isNodeSelected?.(start) ? 1 : 0.5;
740
- const borderRadius = nodeSize + nodeStrokeWidth;
796
+ const borderRadius = nodeSize + nodeStrokeWidth + PADDING;
741
797
 
742
798
  // Binary search for tArrow near 1.0 where the curve is at distance borderRadius
743
799
  // from the node center (i.e. on the outer edge of the node border stroke).
744
800
  // Bezier parametric form: Bx(t)=sx+3(1-t)t²d, By(t)=sy-3(1-t)²td
745
801
  // dist(t) = 3*(1-t)*t*|d|*sqrt(t² + (1-t)²)
746
- const arrowLen = (this.config.isLinkSelected?.(link) ? 4 : 2) / globalScale;
747
802
  const arrowHalfWidth = arrowLen / ARROW_WH_RATIO / 2;
748
803
  let lo = 0.5, hi = 1.0;
749
804
  const absD = Math.abs(d);
@@ -803,23 +858,7 @@ class FalkorDBCanvas extends HTMLElement {
803
858
  if (tLen !== 0 && canReachBorder) {
804
859
  const nx = tdx / tLen;
805
860
  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();
861
+ pendingArrow = { tipX, tipY, nx, ny, arrowLen, arrowHalfWidth };
823
862
  }
824
863
 
825
864
  // Midpoint of cubic bezier: P0=(sx,sy), P1=(sx,sy-d), P2=(sx+d,sy), P3=(sx,sy)
@@ -868,24 +907,14 @@ class FalkorDBCanvas extends HTMLElement {
868
907
  if (angle > Math.PI / 2) angle = -(Math.PI - angle);
869
908
  if (angle < -Math.PI / 2) angle = -(-Math.PI - angle);
870
909
 
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;
910
+ // Draw regular link line and arrowhead
876
911
  const arrowHalfWidth = arrowLen / ARROW_WH_RATIO / 2;
877
912
 
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.
913
+ // Target-side clip: find t where bezier enters target node border + PADDING
881
914
  const endNodeSize = end.size || 6;
882
- const endNodeStrokeWidth = this.config.isNodeSelected?.(end) ? 1 : 0.5;
883
- const borderRadius = endNodeSize + endNodeStrokeWidth;
915
+ const borderRadius = endNodeSize + (this.config.isNodeSelected?.(end) ? 1 : 0.5) + PADDING;
884
916
  const borderRadiusSq = borderRadius * borderRadius;
885
917
 
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
918
  let tArrow: number;
890
919
  if (borderRadius / distance < 0.02) {
891
920
  tArrow = Math.min(1, Math.max(0, 1 - borderRadius / distance));
@@ -906,25 +935,57 @@ class FalkorDBCanvas extends HTMLElement {
906
935
  }
907
936
  const uArrow = 1 - tArrow;
908
937
 
909
- // Tip = Q(tArrow)
910
938
  const tipX = uArrow * uArrow * start.x + 2 * uArrow * tArrow * controlX + tArrow * tArrow * end.x;
911
939
  const tipY = uArrow * uArrow * start.y + 2 * uArrow * tArrow * controlY + tArrow * tArrow * end.y;
912
940
 
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);
941
+ // Source-side clip: find t where bezier exits source node border + PADDING
942
+ const startNodeSize = start.size || 6;
943
+ const srcBorderRadius = startNodeSize + (this.config.isNodeSelected?.(start) ? 1 : 0.5) + PADDING;
944
+ const srcBorderRadiusSq = srcBorderRadius * srcBorderRadius;
945
+
946
+ let tStart = 0;
947
+ if (srcBorderRadius / distance < 0.02) {
948
+ tStart = Math.min(0.5, srcBorderRadius / distance);
949
+ } else {
950
+ let lo = 0.0, hi = 0.5;
951
+ for (let i = 0; i < 10; i++) {
952
+ const mid = (lo + hi) / 2;
953
+ const um = 1 - mid;
954
+ const qx = um * um * start.x + 2 * um * mid * controlX + mid * mid * end.x;
955
+ const qy = um * um * start.y + 2 * um * mid * controlY + mid * mid * end.y;
956
+ const dxSrc = qx - start.x;
957
+ const dySrc = qy - start.y;
958
+ if (dxSrc * dxSrc + dySrc * dySrc < srcBorderRadiusSq) lo = mid;
959
+ else hi = mid;
960
+ if (hi - lo < 1e-3) break;
961
+ }
962
+ tStart = (lo + hi) / 2;
963
+ }
964
+
965
+ // Gap start point: Q(tStart)
966
+ const uS = 1 - tStart;
967
+ const gapStartX = uS * uS * start.x + 2 * uS * tStart * controlX + tStart * tStart * end.x;
968
+ const gapStartY = uS * uS * start.y + 2 * uS * tStart * controlY + tStart * tStart * end.y;
969
+
970
+ // Sub-bezier [tStart, tArrow] control point via De Casteljau:
971
+ // Right sub-bezier at tStart → NewP1 = lerp(control, end, tStart)
972
+ // Left sub-curve at tArrow' = (tArrow-tStart)/(1-tStart) → ctrl = lerp(gapStart, NewP1, tArrow')
973
+ const tArrowPrime = tStart < tArrow ? (tArrow - tStart) / (1 - tStart) : 0;
974
+ const newP1X = (1 - tStart) * controlX + tStart * end.x;
975
+ const newP1Y = (1 - tStart) * controlY + tStart * end.y;
976
+ const subCtrlX = (1 - tArrowPrime) * gapStartX + tArrowPrime * newP1X;
977
+ const subCtrlY = (1 - tArrowPrime) * gapStartY + tArrowPrime * newP1Y;
917
978
 
918
979
  ctx.strokeStyle = link.color;
919
- ctx.lineWidth = (this.config.isLinkSelected?.(link) ? 2 : 1) / globalScale;
980
+ ctx.lineWidth = (isLinkSelected ? 2 : 1) / globalScale;
981
+
920
982
  ctx.setLineDash(this.config.linkLineDash?.(link) ?? []);
921
983
  ctx.beginPath();
922
- ctx.moveTo(start.x, start.y);
923
- ctx.quadraticCurveTo(clippedCtrlX, clippedCtrlY, tipX, tipY);
984
+ ctx.moveTo(gapStartX, gapStartY);
985
+ ctx.quadraticCurveTo(subCtrlX, subCtrlY, tipX, tipY);
924
986
  ctx.stroke();
925
987
  ctx.setLineDash([]);
926
988
 
927
- // Arrowhead tangent at tArrow: Q'(t) = 2(1-t)(control-start) + 2t(end-control)
928
989
  const atx = 2 * uArrow * (controlX - start.x) + 2 * tArrow * (end.x - controlX);
929
990
  const aty = 2 * uArrow * (controlY - start.y) + 2 * tArrow * (end.y - controlY);
930
991
  const atLen = Math.sqrt(atx * atx + aty * aty);
@@ -932,40 +993,39 @@ class FalkorDBCanvas extends HTMLElement {
932
993
  if (atLen !== 0) {
933
994
  const nx = atx / atLen;
934
995
  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();
996
+ pendingArrow = { tipX, tipY, nx, ny, arrowLen, arrowHalfWidth };
946
997
  }
947
998
  }
948
999
 
949
- ctx.font = "400 2px SofiaSans";
1000
+ ctx.font = isLinkSelected ? "700 2px SofiaSans" : "400 2px SofiaSans";
950
1001
  ctx.textAlign = "center";
951
1002
  // Draw text with alphabetic baseline, positioned so visual center is at y=0
952
1003
  ctx.textBaseline = "alphabetic";
953
1004
 
954
- let cached = this.relationshipsTextCache.get(link.relationship);
1005
+ // Separate cache entries per weight so each state is measured with its own
1006
+ // font, giving equal visual padding regardless of selection state.
1007
+ const cacheKey = `${link.relationship}_${isLinkSelected ? "700" : "400"}`;
1008
+ let cached = this.relationshipsTextCache.get(cacheKey);
955
1009
 
956
1010
  if (!cached) {
1011
+ // ctx.font is already set to the correct weight above; measure it directly.
957
1012
  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;
1013
+ // Use actual ink bounds for vertical metrics; fontBoundingBox* is the full
1014
+ // line-box and adds excessive space for lighter weights.
1015
+ // Use metrics.width for horizontal extent: actualBoundingBoxLeft/Right are
1016
+ // unreliable with textAlign="center" and can double the value on some engines.
1017
+ const inkAscent = metrics.actualBoundingBoxAscent ?? metrics.fontBoundingBoxAscent;
1018
+ const inkDescent = metrics.actualBoundingBoxDescent ?? metrics.fontBoundingBoxDescent;
1019
+ const inkWidth = metrics.width;
1020
+ const bgPadding = 0.3;
1021
+
963
1022
  cached = {
964
- textWidth: metrics.width,
965
- textHeight: fontAscent + fontDescent,
966
- textYOffset: visualCenter,
1023
+ textWidth: inkWidth + bgPadding * 2,
1024
+ textHeight: inkAscent + inkDescent + bgPadding * 2,
1025
+ // Shift baseline up so the ink block is centred inside the bg rect.
1026
+ textYOffset: (inkAscent - inkDescent) / 2,
967
1027
  };
968
- this.relationshipsTextCache.set(link.relationship, cached);
1028
+ this.relationshipsTextCache.set(cacheKey, cached);
969
1029
  }
970
1030
 
971
1031
  const { textWidth, textHeight, textYOffset } = cached;
@@ -988,6 +1048,18 @@ class FalkorDBCanvas extends HTMLElement {
988
1048
  ctx.fillStyle = getContrastTextColor(this.config.backgroundColor);
989
1049
  ctx.fillText(link.relationship, 0, textYOffset);
990
1050
  ctx.restore();
1051
+
1052
+ // Draw arrowhead last so it always appears on top of the label background.
1053
+ if (pendingArrow) {
1054
+ const { tipX, tipY, nx, ny, arrowLen: aLen, arrowHalfWidth: aHW } = pendingArrow;
1055
+ ctx.fillStyle = link.color;
1056
+ ctx.beginPath();
1057
+ ctx.moveTo(tipX, tipY);
1058
+ ctx.lineTo(tipX - nx * aLen + ny * aHW, tipY - ny * aLen - nx * aHW);
1059
+ ctx.lineTo(tipX - nx * aLen * (1 - ARROW_VLEN_RATIO), tipY - ny * aLen * (1 - ARROW_VLEN_RATIO));
1060
+ ctx.lineTo(tipX - nx * aLen - ny * aHW, tipY - ny * aLen + nx * aHW);
1061
+ ctx.fill();
1062
+ }
991
1063
  }
992
1064
 
993
1065
  private pointerLink(link: GraphLink, color: string, ctx: CanvasRenderingContext2D) {
@@ -1010,13 +1082,43 @@ class FalkorDBCanvas extends HTMLElement {
1010
1082
  ctx.beginPath();
1011
1083
 
1012
1084
  if (start.id === end.id) {
1013
- // Self-loop: replicate the cubic bezier from drawLink
1085
+ // Self-loop: replicate exact cubic bezier clip from drawLink
1014
1086
  const nodeSize = start.size || 6;
1015
1087
  const d = (link.curve || 0) * nodeSize * SELF_LOOP_CURVE_FACTOR;
1088
+
1089
+ const nodeStrokeWidth = this.config.isNodeSelected?.(start) ? 1 : 0.5;
1090
+ const borderRadius = nodeSize + nodeStrokeWidth + PADDING;
1091
+ const absD = Math.abs(d);
1092
+ const maxReachableDist = 3 * 0.5 * 0.5 * absD * Math.sqrt(0.5);
1093
+ const canReachBorder = absD > 0 && maxReachableDist >= borderRadius;
1094
+
1016
1095
  ctx.moveTo(start.x, start.y);
1017
- ctx.bezierCurveTo(start.x, start.y - d, start.x + d, start.y, start.x, start.y);
1096
+ if (canReachBorder) {
1097
+ let lo = 0.5, hi = 1.0;
1098
+ for (let i = 0; i < 20; i++) {
1099
+ const mid = (lo + hi) / 2;
1100
+ const um = 1 - mid;
1101
+ const dist = 3 * um * mid * absD * Math.sqrt(mid * mid + um * um);
1102
+ if (dist > borderRadius) lo = mid;
1103
+ else hi = mid;
1104
+ }
1105
+ const tArrow = (lo + hi) / 2;
1106
+ const uArrow = 1 - tArrow;
1107
+ const tipX = start.x + 3 * uArrow * tArrow * tArrow * d;
1108
+ const tipY = start.y - 3 * uArrow * uArrow * tArrow * d;
1109
+ ctx.bezierCurveTo(
1110
+ start.x,
1111
+ start.y - tArrow * d,
1112
+ start.x + tArrow * tArrow * d,
1113
+ start.y - 2 * tArrow * uArrow * d,
1114
+ tipX,
1115
+ tipY,
1116
+ );
1117
+ } else {
1118
+ ctx.bezierCurveTo(start.x, start.y - d, start.x + d, start.y, start.x, start.y);
1119
+ }
1018
1120
  } else {
1019
- // Regular link: replicate the quadratic bezier from drawLink
1121
+ // Regular link: replicate exact quadratic bezier clip from drawLink
1020
1122
  const dx = end.x - start.x;
1021
1123
  const dy = end.y - start.y;
1022
1124
  const distance = Math.sqrt(dx * dx + dy * dy);
@@ -1031,18 +1133,69 @@ class FalkorDBCanvas extends HTMLElement {
1031
1133
  const controlX = (start.x + end.x) / 2 + perpX * curvature * distance;
1032
1134
  const controlY = (start.y + end.y) / 2 + perpY * curvature * distance;
1033
1135
 
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);
1136
+ // Use the same borderRadius and binary-search clip as drawLink
1137
+ const endNodeSize = end.size || 6;
1138
+ const borderRadius = endNodeSize + (this.config.isNodeSelected?.(end) ? 1 : 0.5) + PADDING;
1139
+ const borderRadiusSq = borderRadius * borderRadius;
1140
+
1141
+ let tArrow: number;
1142
+ if (borderRadius / distance < 0.02) {
1143
+ tArrow = Math.min(1, Math.max(0, 1 - borderRadius / distance));
1144
+ } else {
1145
+ let lo = 0.5, hi = 1.0;
1146
+ for (let i = 0; i < 10; i++) {
1147
+ const mid = (lo + hi) / 2;
1148
+ const um = 1 - mid;
1149
+ const qx = um * um * start.x + 2 * um * mid * controlX + mid * mid * end.x;
1150
+ const qy = um * um * start.y + 2 * um * mid * controlY + mid * mid * end.y;
1151
+ const dxEnd = qx - end.x;
1152
+ const dyEnd = qy - end.y;
1153
+ if (dxEnd * dxEnd + dyEnd * dyEnd > borderRadiusSq) lo = mid;
1154
+ else hi = mid;
1155
+ if (hi - lo < 1e-3) break;
1156
+ }
1157
+ tArrow = (lo + hi) / 2;
1158
+ }
1159
+ const uArrow = 1 - tArrow;
1160
+ const tipX = uArrow * uArrow * start.x + 2 * uArrow * tArrow * controlX + tArrow * tArrow * end.x;
1161
+ const tipY = uArrow * uArrow * start.y + 2 * uArrow * tArrow * controlY + tArrow * tArrow * end.y;
1162
+
1163
+ // Source-side clip: mirror of drawLink source gap
1164
+ const startNodeSize = start.size || 6;
1165
+ const srcBorderRadius = startNodeSize + (this.config.isNodeSelected?.(start) ? 1 : 0.5) + PADDING;
1166
+ const srcBorderRadiusSq = srcBorderRadius * srcBorderRadius;
1167
+
1168
+ let tStart = 0;
1169
+ if (srcBorderRadius / distance < 0.02) {
1170
+ tStart = Math.min(0.5, srcBorderRadius / distance);
1171
+ } else {
1172
+ let lo = 0.0, hi = 0.5;
1173
+ for (let i = 0; i < 10; i++) {
1174
+ const mid = (lo + hi) / 2;
1175
+ const um = 1 - mid;
1176
+ const qx = um * um * start.x + 2 * um * mid * controlX + mid * mid * end.x;
1177
+ const qy = um * um * start.y + 2 * um * mid * controlY + mid * mid * end.y;
1178
+ const dxSrc = qx - start.x;
1179
+ const dySrc = qy - start.y;
1180
+ if (dxSrc * dxSrc + dySrc * dySrc < srcBorderRadiusSq) lo = mid;
1181
+ else hi = mid;
1182
+ if (hi - lo < 1e-3) break;
1183
+ }
1184
+ tStart = (lo + hi) / 2;
1185
+ }
1186
+
1187
+ const uS = 1 - tStart;
1188
+ const gapStartX = uS * uS * start.x + 2 * uS * tStart * controlX + tStart * tStart * end.x;
1189
+ const gapStartY = uS * uS * start.y + 2 * uS * tStart * controlY + tStart * tStart * end.y;
1190
+
1191
+ const tArrowPrime = tStart < tArrow ? (tArrow - tStart) / (1 - tStart) : 0;
1192
+ const newP1X = (1 - tStart) * controlX + tStart * end.x;
1193
+ const newP1Y = (1 - tStart) * controlY + tStart * end.y;
1194
+ const subCtrlX = (1 - tArrowPrime) * gapStartX + tArrowPrime * newP1X;
1195
+ const subCtrlY = (1 - tArrowPrime) * gapStartY + tArrowPrime * newP1Y;
1196
+
1197
+ ctx.moveTo(gapStartX, gapStartY);
1198
+ ctx.quadraticCurveTo(subCtrlX, subCtrlY, tipX, tipY);
1046
1199
  }
1047
1200
  }
1048
1201
 
@@ -1163,7 +1316,6 @@ class FalkorDBCanvas extends HTMLElement {
1163
1316
  this.config.onEngineStop();
1164
1317
  }
1165
1318
  })
1166
- .linkLineDash((link: GraphLink) => this.config.linkLineDash?.(link) ?? null)
1167
1319
  .nodeCanvasObject((node: GraphNode, ctx: CanvasRenderingContext2D) => {
1168
1320
  if (this.config.node) {
1169
1321
  this.config.node.nodeCanvasObject(node, ctx);
@@ -1173,25 +1325,27 @@ class FalkorDBCanvas extends HTMLElement {
1173
1325
  })
1174
1326
  .linkCanvasObject((link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) => {
1175
1327
  if (this.config.link) {
1176
- this.config.link.linkCanvasObject(link, ctx);
1328
+ this.config.link.linkCanvasObject(link, ctx, globalScale);
1177
1329
  } else {
1178
1330
  this.drawLink(link, ctx, globalScale);
1179
1331
  }
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
1332
  });
1333
+
1334
+ if (this.config.node) {
1335
+ this.graph.nodePointerAreaPaint((node: GraphNode, color: string, ctx: CanvasRenderingContext2D) => {
1336
+ this.config.node!.nodePointerAreaPaint(node, color, ctx);
1337
+ });
1338
+ } else {
1339
+ this.graph.nodePointerAreaPaint();
1340
+ }
1341
+
1342
+ if (this.config.link) {
1343
+ this.graph.linkPointerAreaPaint((link: GraphLink, color: string, ctx: CanvasRenderingContext2D) => {
1344
+ this.config.link!.linkPointerAreaPaint(link, color, ctx);
1345
+ });
1346
+ } else {
1347
+ this.graph.linkPointerAreaPaint();
1348
+ }
1195
1349
  }
1196
1350
 
1197
1351
  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,