@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/dist/canvas-types.d.ts +1 -1
- package/dist/canvas-types.d.ts.map +1 -1
- package/dist/canvas-utils.d.ts +1 -0
- package/dist/canvas-utils.d.ts.map +1 -1
- package/dist/canvas-utils.js +1 -1
- package/dist/canvas-utils.js.map +1 -1
- package/dist/canvas.d.ts +2 -0
- package/dist/canvas.d.ts.map +1 -1
- package/dist/canvas.js +244 -90
- package/dist/canvas.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/canvas-types.ts +1 -1
- package/src/canvas-utils.ts +1 -1
- package/src/canvas.ts +258 -102
- package/src/index.ts +1 -0
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 = (
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
914
|
-
|
|
915
|
-
const
|
|
916
|
-
const
|
|
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 = (
|
|
981
|
+
ctx.lineWidth = (isLinkSelected ? 2 : 1) / globalScale;
|
|
982
|
+
|
|
920
983
|
ctx.setLineDash(this.config.linkLineDash?.(link) ?? []);
|
|
921
984
|
ctx.beginPath();
|
|
922
|
-
ctx.moveTo(
|
|
923
|
-
ctx.quadraticCurveTo(
|
|
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
|
-
|
|
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
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
//
|
|
962
|
-
const
|
|
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:
|
|
965
|
-
textHeight:
|
|
966
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
1035
|
-
|
|
1036
|
-
const
|
|
1037
|
-
const
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
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() {
|