@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/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 +4 -0
- package/dist/canvas.d.ts.map +1 -1
- package/dist/canvas.js +344 -57
- 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 +364 -69
- package/src/index.ts +1 -0
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 = '
|
|
97
|
+
private nodeMode: CanvasRenderMode = 'replace';
|
|
88
98
|
|
|
89
|
-
private linkMode: CanvasRenderMode = '
|
|
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
|
-
.
|
|
517
|
-
.
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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 *
|
|
789
|
+
const d = (link.curve || 0) * nodeSize * SELF_LOOP_CURVE_FACTOR;
|
|
733
790
|
|
|
734
|
-
ctx.lineWidth = (
|
|
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
|
|
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
|
-
|
|
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
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
//
|
|
880
|
-
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
|
+
|
|
881
1023
|
cached = {
|
|
882
|
-
textWidth:
|
|
883
|
-
textHeight:
|
|
884
|
-
|
|
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(
|
|
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
|
}
|