@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/src/canvas.ts CHANGED
@@ -24,6 +24,16 @@ import {
24
24
  } from "./canvas-utils.js";
25
25
 
26
26
  const PADDING = 2;
27
+ // Arrow geometry constants (shared by self-loop and regular-link drawing paths)
28
+ const ARROW_WH_RATIO = 1.6;
29
+ const ARROW_VLEN_RATIO = 0.2;
30
+ // Multiplier to convert node size → cubic bezier control-point distance for self-loops
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;
27
37
 
28
38
  // Force constants
29
39
  const CHARGE_STRENGTH = -400;
@@ -84,12 +94,15 @@ class FalkorDBCanvas extends HTMLElement {
84
94
  showPropertyKeyPrefix: false,
85
95
  };
86
96
 
87
- private nodeMode: CanvasRenderMode = 'after';
97
+ private nodeMode: CanvasRenderMode = 'replace';
88
98
 
89
- private linkMode: CanvasRenderMode = 'after';
99
+ private linkMode: CanvasRenderMode = 'replace';
90
100
 
91
101
  private nodeDegreeMap: Map<number, number> = new Map();
92
102
 
103
+ // Per-node font size cache: computed once per node, read every frame.
104
+ private nodeDisplayFontSize: Map<number, number> = new Map();
105
+
93
106
  private relationshipsTextCache: Map<
94
107
  string,
95
108
  {
@@ -99,6 +112,12 @@ class FalkorDBCanvas extends HTMLElement {
99
112
  }
100
113
  > = new Map();
101
114
 
115
+ private onFontsLoadingDone = () => {
116
+ this.relationshipsTextCache.clear();
117
+ this.nodeDisplayFontSize.clear();
118
+ this.triggerRender();
119
+ };
120
+
102
121
  private viewport: ViewportState;
103
122
 
104
123
  constructor() {
@@ -142,10 +161,16 @@ class FalkorDBCanvas extends HTMLElement {
142
161
 
143
162
  this.log('Component connected to DOM');
144
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);
145
169
  }
146
170
 
147
171
  disconnectedCallback() {
148
172
  this.log('Component disconnected from DOM');
173
+ document.fonts.removeEventListener("loadingdone", this.onFontsLoadingDone);
149
174
  if (this.resizeObserver) {
150
175
  this.resizeObserver.disconnect();
151
176
  this.resizeObserver = null;
@@ -501,28 +526,14 @@ class FalkorDBCanvas extends HTMLElement {
501
526
  .height(this.config.height || 600)
502
527
  .backgroundColor(this.config.backgroundColor)
503
528
  .graphData(this.data)
504
- .nodeRelSize(1)
505
- .nodeVal((node: GraphNode) => {
506
- const strokeWidth = this.config.isNodeSelected?.(node) ? 1.5 : 1;
507
- const radius = node.size + strokeWidth;
508
- return radius * radius; // Return radius squared since force-graph does sqrt(val * relSize)
509
- })
510
529
  .nodeCanvasObjectMode(() => this.nodeMode)
511
530
  .linkCanvasObjectMode(() => this.linkMode)
512
531
  .nodeLabel((node: GraphNode) =>
513
532
  getNodeDisplayText(node, this.config.captionsKeys, this.config.showPropertyKeyPrefix)
514
533
  )
515
534
  .linkLabel((link: GraphLink) => link.relationship)
516
- .linkDirectionalArrowRelPos(1)
517
- .linkDirectionalArrowLength((link: GraphLink) => {
518
- if (link.source === link.target) return 0;
519
- return this.config.isLinkSelected?.(link) ? 4 : 2;
520
- })
521
- .linkDirectionalArrowColor((link: GraphLink) => link.color)
522
- .linkWidth((link: GraphLink) =>
523
- this.config.isLinkSelected?.(link) ? 2 : 1
524
- )
525
- .linkLineDash((link: GraphLink) => this.config.linkLineDash?.(link) ?? null)
535
+ .linkDirectionalArrowLength(0)
536
+ .linkWidth(0)
526
537
  .linkCurvature("curve")
527
538
  .linkVisibility("visible")
528
539
  .nodeVisibility("visible")
@@ -592,21 +603,26 @@ class FalkorDBCanvas extends HTMLElement {
592
603
  })
593
604
  .linkCanvasObject((link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) => {
594
605
  if (this.config.link) {
595
- this.config.link.linkCanvasObject(link, ctx);
606
+ this.config.link.linkCanvasObject(link, ctx, globalScale);
596
607
  } else {
597
608
  this.drawLink(link, ctx, globalScale);
598
609
  }
610
+ })
611
+ .nodePointerAreaPaint((node: GraphNode, color: string, ctx: CanvasRenderingContext2D) => {
612
+ if (this.config.node) {
613
+ this.config.node.nodePointerAreaPaint(node, color, ctx);
614
+ } else {
615
+ this.pointerNode(node, color, ctx);
616
+ }
617
+ })
618
+ .linkPointerAreaPaint((link: GraphLink, color: string, ctx: CanvasRenderingContext2D) => {
619
+ if (this.config.link) {
620
+ this.config.link.linkPointerAreaPaint(link, color, ctx);
621
+ } else {
622
+ this.pointerLink(link, color, ctx);
623
+ }
599
624
  });
600
625
 
601
- // Only set pointer area paint if custom node/link configs are provided
602
- if (this.config.node) {
603
- this.graph?.nodePointerAreaPaint(this.config.node?.nodePointerAreaPaint);
604
- }
605
-
606
- if (this.config.link) {
607
- this.graph?.linkPointerAreaPaint(this.config.link?.linkPointerAreaPaint);
608
- };
609
-
610
626
  // Setup forces
611
627
  this.setupForces();
612
628
  this.log('Force graph initialization complete');
@@ -664,12 +680,12 @@ class FalkorDBCanvas extends HTMLElement {
664
680
 
665
681
  private drawNode(node: GraphNode, ctx: CanvasRenderingContext2D) {
666
682
 
667
- if (!node.x || !node.y) {
683
+ if (node.x === undefined || node.y === undefined) {
668
684
  node.x = 0;
669
685
  node.y = 0;
670
686
  }
671
687
 
672
- ctx.lineWidth = this.config.isNodeSelected?.(node) ? 1.5 : 1;
688
+ ctx.lineWidth = this.config.isNodeSelected?.(node) ? 1 : 0.5;
673
689
  ctx.strokeStyle = this.config.foregroundColor;
674
690
  ctx.fillStyle = node.color;
675
691
 
@@ -687,15 +703,34 @@ class FalkorDBCanvas extends HTMLElement {
687
703
  ctx.fillStyle = getContrastTextColor(node.color);
688
704
  ctx.textAlign = "center";
689
705
  ctx.textBaseline = "middle";
690
- ctx.font = "400 2px SofiaSans";
691
706
 
692
707
  let [line1, line2] = node.displayName;
708
+ const textRadius = node.size - PADDING / 2;
693
709
 
694
710
  if (!line1 && !line2) {
695
711
  const text = getNodeDisplayText(node, this.config.captionsKeys, this.config.showPropertyKeyPrefix);
696
- 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`;
697
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`;
698
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`;
699
734
  }
700
735
 
701
736
  const textMetrics = ctx.measureText(line1);
@@ -712,11 +747,25 @@ class FalkorDBCanvas extends HTMLElement {
712
747
  }
713
748
  }
714
749
 
750
+ private pointerNode(node: GraphNode, color: string, ctx: CanvasRenderingContext2D) {
751
+ if (node.x === undefined || node.y === undefined) {
752
+ node.x = 0;
753
+ node.y = 0;
754
+ };
755
+
756
+ const radius = node.size + PADDING;
757
+
758
+ ctx.fillStyle = color;
759
+ ctx.beginPath();
760
+ ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI, false);
761
+ ctx.fill();
762
+ }
763
+
715
764
  private drawLink(link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) {
716
765
  const start = link.source;
717
766
  const end = link.target;
718
767
 
719
- if (!start.x || !start.y || !end.x || !end.y) {
768
+ if (start.x === undefined || start.y === undefined || end.x === undefined || end.y === undefined) {
720
769
  start.x = 0;
721
770
  start.y = 0;
722
771
  end.x = 0;
@@ -727,24 +776,30 @@ class FalkorDBCanvas extends HTMLElement {
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
- const d = (link.curve || 0) * nodeSize * 11.67;
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;
792
+ ctx.setLineDash(this.config.linkLineDash?.(link) ?? []);
735
793
 
736
794
  // The visible outer edge of the node border is nodeSize + strokeWidth
737
795
  // (stroke is centered on nodeSize + strokeWidth/2, so outer edge = nodeSize + strokeWidth).
738
- const nodeStrokeWidth = this.config.isNodeSelected?.(start) ? 1.5 : 1;
739
- const borderRadius = nodeSize + nodeStrokeWidth;
796
+ const nodeStrokeWidth = this.config.isNodeSelected?.(start) ? 1 : 0.5;
797
+ const borderRadius = nodeSize + nodeStrokeWidth + PADDING;
740
798
 
741
799
  // Binary search for tArrow near 1.0 where the curve is at distance borderRadius
742
800
  // from the node center (i.e. on the outer edge of the node border stroke).
743
801
  // Bezier parametric form: Bx(t)=sx+3(1-t)t²d, By(t)=sy-3(1-t)²td
744
802
  // dist(t) = 3*(1-t)*t*|d|*sqrt(t² + (1-t)²)
745
- const ARROW_WH_RATIO = 1.6;
746
- const ARROW_VLEN_RATIO = 0.2;
747
- const arrowLen = this.config.isLinkSelected?.(link) ? 4 : 2;
748
803
  const arrowHalfWidth = arrowLen / ARROW_WH_RATIO / 2;
749
804
  let lo = 0.5, hi = 1.0;
750
805
  const absD = Math.abs(d);
@@ -791,6 +846,7 @@ class FalkorDBCanvas extends HTMLElement {
791
846
  ctx.bezierCurveTo(start.x, start.y - d, start.x + d, start.y, start.x, start.y);
792
847
  }
793
848
  ctx.stroke();
849
+ ctx.setLineDash([]);
794
850
 
795
851
  // Tangent at tArrow (direction the curve travels toward the node)
796
852
  const tdx = 3 * d * tArrow * (2 - 3 * tArrow);
@@ -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)
@@ -834,6 +874,11 @@ class FalkorDBCanvas extends HTMLElement {
834
874
  const dy = end.y - start.y;
835
875
  const distance = Math.sqrt(dx * dx + dy * dy);
836
876
 
877
+ // Guard: skip drawing when source and target are co-located (e.g. during
878
+ // simulation start-up). perpX/perpY would be NaN and propagate through
879
+ // all downstream bezier and arrowhead calculations.
880
+ if (distance === 0) return;
881
+
837
882
  const perpX = dy / distance;
838
883
  const perpY = -dx / distance;
839
884
 
@@ -862,28 +907,126 @@ class FalkorDBCanvas extends HTMLElement {
862
907
 
863
908
  if (angle > Math.PI / 2) angle = -(Math.PI - angle);
864
909
  if (angle < -Math.PI / 2) angle = -(-Math.PI - angle);
910
+
911
+ // Draw regular link line and arrowhead
912
+ const arrowHalfWidth = arrowLen / ARROW_WH_RATIO / 2;
913
+
914
+ // Target-side clip: find t where bezier enters target node border + PADDING
915
+ const endNodeSize = end.size || 6;
916
+ const borderRadius = endNodeSize + (this.config.isNodeSelected?.(end) ? 1 : 0.5) + PADDING;
917
+ const borderRadiusSq = borderRadius * borderRadius;
918
+
919
+ let tArrow: number;
920
+ if (borderRadius / distance < 0.02) {
921
+ tArrow = Math.min(1, Math.max(0, 1 - borderRadius / distance));
922
+ } else {
923
+ let lo = 0.5, hi = 1.0;
924
+ for (let i = 0; i < 10; i++) {
925
+ const mid = (lo + hi) / 2;
926
+ const um = 1 - mid;
927
+ const qx = um * um * start.x + 2 * um * mid * controlX + mid * mid * end.x;
928
+ const qy = um * um * start.y + 2 * um * mid * controlY + mid * mid * end.y;
929
+ const dxEnd = qx - end.x;
930
+ const dyEnd = qy - end.y;
931
+ if (dxEnd * dxEnd + dyEnd * dyEnd > borderRadiusSq) lo = mid;
932
+ else hi = mid;
933
+ if (hi - lo < 1e-3) break;
934
+ }
935
+ tArrow = (lo + hi) / 2;
936
+ }
937
+ const uArrow = 1 - tArrow;
938
+
939
+ const tipX = uArrow * uArrow * start.x + 2 * uArrow * tArrow * controlX + tArrow * tArrow * end.x;
940
+ const tipY = uArrow * uArrow * start.y + 2 * uArrow * tArrow * controlY + tArrow * tArrow * end.y;
941
+
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;
979
+
980
+ ctx.strokeStyle = link.color;
981
+ ctx.lineWidth = (isLinkSelected ? 2 : 1) / globalScale;
982
+
983
+ ctx.setLineDash(this.config.linkLineDash?.(link) ?? []);
984
+ ctx.beginPath();
985
+ ctx.moveTo(gapStartX, gapStartY);
986
+ ctx.quadraticCurveTo(subCtrlX, subCtrlY, tipX, tipY);
987
+ ctx.stroke();
988
+ ctx.setLineDash([]);
989
+
990
+ const atx = 2 * uArrow * (controlX - start.x) + 2 * tArrow * (end.x - controlX);
991
+ const aty = 2 * uArrow * (controlY - start.y) + 2 * tArrow * (end.y - controlY);
992
+ const atLen = Math.sqrt(atx * atx + aty * aty);
993
+
994
+ if (atLen !== 0) {
995
+ const nx = atx / atLen;
996
+ const ny = aty / atLen;
997
+ pendingArrow = { tipX, tipY, nx, ny, arrowLen, arrowHalfWidth };
998
+ }
865
999
  }
866
1000
 
867
- ctx.font = "400 2px SofiaSans";
1001
+ ctx.font = isLinkSelected ? "700 2px SofiaSans" : "400 2px SofiaSans";
868
1002
  ctx.textAlign = "center";
869
1003
  // Draw text with alphabetic baseline, positioned so visual center is at y=0
870
1004
  ctx.textBaseline = "alphabetic";
871
1005
 
872
- 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);
873
1010
 
874
1011
  if (!cached) {
1012
+ // ctx.font is already set to the correct weight above; measure it directly.
875
1013
  const metrics = ctx.measureText(link.relationship);
876
- // Use font-level metrics for consistent height across all texts
877
- const fontAscent = metrics.fontBoundingBoxAscent ?? metrics.actualBoundingBoxAscent;
878
- const fontDescent = metrics.fontBoundingBoxDescent ?? metrics.actualBoundingBoxDescent;
879
- // Calculate visual center offset from baseline using font-level metrics
880
- 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
+
881
1023
  cached = {
882
- textWidth: metrics.width,
883
- textHeight: fontAscent + fontDescent,
884
- 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,
885
1028
  };
886
- this.relationshipsTextCache.set(link.relationship, cached);
1029
+ this.relationshipsTextCache.set(cacheKey, cached);
887
1030
  }
888
1031
 
889
1032
  const { textWidth, textHeight, textYOffset } = cached;
@@ -906,6 +1049,158 @@ class FalkorDBCanvas extends HTMLElement {
906
1049
  ctx.fillStyle = getContrastTextColor(this.config.backgroundColor);
907
1050
  ctx.fillText(link.relationship, 0, textYOffset);
908
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
+ }
1064
+ }
1065
+
1066
+ private pointerLink(link: GraphLink, color: string, ctx: CanvasRenderingContext2D) {
1067
+ const start = link.source;
1068
+ const end = link.target;
1069
+
1070
+ if (start.x == null || start.y == null || end.x == null || end.y == null) return;
1071
+
1072
+ ctx.strokeStyle = color;
1073
+ const basePointerWidth = 10; // Desired on-screen pointer area thickness
1074
+ const transform = typeof ctx.getTransform === 'function' ? ctx.getTransform() : null;
1075
+ if (transform) {
1076
+ const scaleX = Math.hypot(transform.a, transform.c);
1077
+ const scaleY = Math.hypot(transform.b, transform.d);
1078
+ const avgScale = (scaleX + scaleY) / 2 || 1;
1079
+ ctx.lineWidth = basePointerWidth / avgScale;
1080
+ } else {
1081
+ ctx.lineWidth = basePointerWidth;
1082
+ }
1083
+ ctx.beginPath();
1084
+
1085
+ if (start.id === end.id) {
1086
+ // Self-loop: replicate exact cubic bezier clip from drawLink
1087
+ const nodeSize = start.size || 6;
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
+
1096
+ ctx.moveTo(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
+ }
1121
+ } else {
1122
+ // Regular link: replicate exact quadratic bezier clip from drawLink
1123
+ const dx = end.x - start.x;
1124
+ const dy = end.y - start.y;
1125
+ const distance = Math.sqrt(dx * dx + dy * dy);
1126
+ const curvature = link.curve || 0;
1127
+
1128
+ if (distance === 0) {
1129
+ ctx.moveTo(start.x, start.y);
1130
+ ctx.lineTo(end.x, end.y);
1131
+ } else {
1132
+ const perpX = dy / distance;
1133
+ const perpY = -dx / distance;
1134
+ const controlX = (start.x + end.x) / 2 + perpX * curvature * distance;
1135
+ const controlY = (start.y + end.y) / 2 + perpY * curvature * distance;
1136
+
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);
1200
+ }
1201
+ }
1202
+
1203
+ ctx.stroke();
909
1204
  }
910
1205
 
911
1206
  private updateLoadingState() {
@@ -1032,7 +1327,7 @@ class FalkorDBCanvas extends HTMLElement {
1032
1327
  })
1033
1328
  .linkCanvasObject((link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) => {
1034
1329
  if (this.config.link) {
1035
- this.config.link.linkCanvasObject(link, ctx);
1330
+ this.config.link.linkCanvasObject(link, ctx, globalScale);
1036
1331
  } else {
1037
1332
  this.drawLink(link, ctx, globalScale);
1038
1333
  }
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,