@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/dist/canvas.js
CHANGED
|
@@ -3,6 +3,16 @@ import ForceGraph from "force-graph";
|
|
|
3
3
|
import * as d3 from "d3";
|
|
4
4
|
import { dataToGraphData, getContrastTextColor, getNodeDisplayText, graphDataToData, LINK_DISTANCE, wrapTextForCircularNode, } from "./canvas-utils.js";
|
|
5
5
|
const PADDING = 2;
|
|
6
|
+
// Arrow geometry constants (shared by self-loop and regular-link drawing paths)
|
|
7
|
+
const ARROW_WH_RATIO = 1.6;
|
|
8
|
+
const ARROW_VLEN_RATIO = 0.2;
|
|
9
|
+
// Multiplier to convert node size → cubic bezier control-point distance for self-loops
|
|
10
|
+
const SELF_LOOP_CURVE_FACTOR = 11.67;
|
|
11
|
+
// Base font size used for the initial measurement and for two-line text.
|
|
12
|
+
const NODE_FONT_SIZE_BASE = 2;
|
|
13
|
+
// Fraction of the chord width that single-line text should fill (0–1).
|
|
14
|
+
// Leaves (1 - ratio)/2 of the radius as horizontal padding on each side.
|
|
15
|
+
const NODE_TEXT_FILL_RATIO = 0.85;
|
|
6
16
|
// Force constants
|
|
7
17
|
const CHARGE_STRENGTH = -400;
|
|
8
18
|
const CENTER_STRENGTH = 0.03;
|
|
@@ -54,10 +64,17 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
54
64
|
captionsKeys: [],
|
|
55
65
|
showPropertyKeyPrefix: false,
|
|
56
66
|
};
|
|
57
|
-
this.nodeMode = '
|
|
58
|
-
this.linkMode = '
|
|
67
|
+
this.nodeMode = 'replace';
|
|
68
|
+
this.linkMode = 'replace';
|
|
59
69
|
this.nodeDegreeMap = new Map();
|
|
70
|
+
// Per-node font size cache: computed once per node, read every frame.
|
|
71
|
+
this.nodeDisplayFontSize = new Map();
|
|
60
72
|
this.relationshipsTextCache = new Map();
|
|
73
|
+
this.onFontsLoadingDone = () => {
|
|
74
|
+
this.relationshipsTextCache.clear();
|
|
75
|
+
this.nodeDisplayFontSize.clear();
|
|
76
|
+
this.triggerRender();
|
|
77
|
+
};
|
|
61
78
|
this.attachShadow({ mode: "open" });
|
|
62
79
|
}
|
|
63
80
|
/**
|
|
@@ -92,9 +109,14 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
92
109
|
}
|
|
93
110
|
this.log('Component connected to DOM');
|
|
94
111
|
this.render();
|
|
112
|
+
// Text measurements taken before the custom font finishes loading use the
|
|
113
|
+
// fallback system font and produce wrong widths that get locked in the cache.
|
|
114
|
+
// Re-measure on every font-load batch (including the initial one).
|
|
115
|
+
document.fonts.addEventListener("loadingdone", this.onFontsLoadingDone);
|
|
95
116
|
}
|
|
96
117
|
disconnectedCallback() {
|
|
97
118
|
this.log('Component disconnected from DOM');
|
|
119
|
+
document.fonts.removeEventListener("loadingdone", this.onFontsLoadingDone);
|
|
98
120
|
if (this.resizeObserver) {
|
|
99
121
|
this.resizeObserver.disconnect();
|
|
100
122
|
this.resizeObserver = null;
|
|
@@ -396,25 +418,12 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
396
418
|
.height(this.config.height || 600)
|
|
397
419
|
.backgroundColor(this.config.backgroundColor)
|
|
398
420
|
.graphData(this.data)
|
|
399
|
-
.nodeRelSize(1)
|
|
400
|
-
.nodeVal((node) => {
|
|
401
|
-
const strokeWidth = this.config.isNodeSelected?.(node) ? 1.5 : 1;
|
|
402
|
-
const radius = node.size + strokeWidth;
|
|
403
|
-
return radius * radius; // Return radius squared since force-graph does sqrt(val * relSize)
|
|
404
|
-
})
|
|
405
421
|
.nodeCanvasObjectMode(() => this.nodeMode)
|
|
406
422
|
.linkCanvasObjectMode(() => this.linkMode)
|
|
407
423
|
.nodeLabel((node) => getNodeDisplayText(node, this.config.captionsKeys, this.config.showPropertyKeyPrefix))
|
|
408
424
|
.linkLabel((link) => link.relationship)
|
|
409
|
-
.
|
|
410
|
-
.
|
|
411
|
-
if (link.source === link.target)
|
|
412
|
-
return 0;
|
|
413
|
-
return this.config.isLinkSelected?.(link) ? 4 : 2;
|
|
414
|
-
})
|
|
415
|
-
.linkDirectionalArrowColor((link) => link.color)
|
|
416
|
-
.linkWidth((link) => this.config.isLinkSelected?.(link) ? 2 : 1)
|
|
417
|
-
.linkLineDash((link) => this.config.linkLineDash?.(link) ?? null)
|
|
425
|
+
.linkDirectionalArrowLength(0)
|
|
426
|
+
.linkWidth(0)
|
|
418
427
|
.linkCurvature("curve")
|
|
419
428
|
.linkVisibility("visible")
|
|
420
429
|
.nodeVisibility("visible")
|
|
@@ -485,20 +494,28 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
485
494
|
})
|
|
486
495
|
.linkCanvasObject((link, ctx, globalScale) => {
|
|
487
496
|
if (this.config.link) {
|
|
488
|
-
this.config.link.linkCanvasObject(link, ctx);
|
|
497
|
+
this.config.link.linkCanvasObject(link, ctx, globalScale);
|
|
489
498
|
}
|
|
490
499
|
else {
|
|
491
500
|
this.drawLink(link, ctx, globalScale);
|
|
492
501
|
}
|
|
502
|
+
})
|
|
503
|
+
.nodePointerAreaPaint((node, color, ctx) => {
|
|
504
|
+
if (this.config.node) {
|
|
505
|
+
this.config.node.nodePointerAreaPaint(node, color, ctx);
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
this.pointerNode(node, color, ctx);
|
|
509
|
+
}
|
|
510
|
+
})
|
|
511
|
+
.linkPointerAreaPaint((link, color, ctx) => {
|
|
512
|
+
if (this.config.link) {
|
|
513
|
+
this.config.link.linkPointerAreaPaint(link, color, ctx);
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
this.pointerLink(link, color, ctx);
|
|
517
|
+
}
|
|
493
518
|
});
|
|
494
|
-
// Only set pointer area paint if custom node/link configs are provided
|
|
495
|
-
if (this.config.node) {
|
|
496
|
-
this.graph?.nodePointerAreaPaint(this.config.node?.nodePointerAreaPaint);
|
|
497
|
-
}
|
|
498
|
-
if (this.config.link) {
|
|
499
|
-
this.graph?.linkPointerAreaPaint(this.config.link?.linkPointerAreaPaint);
|
|
500
|
-
}
|
|
501
|
-
;
|
|
502
519
|
// Setup forces
|
|
503
520
|
this.setupForces();
|
|
504
521
|
this.log('Force graph initialization complete');
|
|
@@ -541,11 +558,11 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
541
558
|
this.log('Force simulation setup complete');
|
|
542
559
|
}
|
|
543
560
|
drawNode(node, ctx) {
|
|
544
|
-
if (
|
|
561
|
+
if (node.x === undefined || node.y === undefined) {
|
|
545
562
|
node.x = 0;
|
|
546
563
|
node.y = 0;
|
|
547
564
|
}
|
|
548
|
-
ctx.lineWidth = this.config.isNodeSelected?.(node) ? 1
|
|
565
|
+
ctx.lineWidth = this.config.isNodeSelected?.(node) ? 1 : 0.5;
|
|
549
566
|
ctx.strokeStyle = this.config.foregroundColor;
|
|
550
567
|
ctx.fillStyle = node.color;
|
|
551
568
|
const radius = node.size + ctx.lineWidth / 2;
|
|
@@ -559,13 +576,30 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
559
576
|
ctx.fillStyle = getContrastTextColor(node.color);
|
|
560
577
|
ctx.textAlign = "center";
|
|
561
578
|
ctx.textBaseline = "middle";
|
|
562
|
-
ctx.font = "400 2px SofiaSans";
|
|
563
579
|
let [line1, line2] = node.displayName;
|
|
580
|
+
const textRadius = node.size - PADDING / 2;
|
|
564
581
|
if (!line1 && !line2) {
|
|
565
582
|
const text = getNodeDisplayText(node, this.config.captionsKeys, this.config.showPropertyKeyPrefix);
|
|
566
|
-
|
|
583
|
+
// Measure at the base (smallest) size — one cheap measurement.
|
|
584
|
+
ctx.font = `400 ${NODE_FONT_SIZE_BASE}px SofiaSans`;
|
|
567
585
|
[line1, line2] = wrapTextForCircularNode(ctx, text, textRadius);
|
|
586
|
+
let chosenSize = NODE_FONT_SIZE_BASE;
|
|
587
|
+
if (!line2) {
|
|
588
|
+
// Single-line: scale up so the text fills NODE_TEXT_FILL_RATIO of the
|
|
589
|
+
// available chord. Font metrics scale linearly so this is exact.
|
|
590
|
+
const measuredWidth = ctx.measureText(line1).width;
|
|
591
|
+
if (measuredWidth > 0) {
|
|
592
|
+
chosenSize = NODE_FONT_SIZE_BASE * (NODE_TEXT_FILL_RATIO * 2 * textRadius / measuredWidth);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
ctx.font = `400 ${chosenSize}px SofiaSans`;
|
|
568
596
|
node.displayName = [line1, line2];
|
|
597
|
+
this.nodeDisplayFontSize.set(node.id, chosenSize);
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
// Cache hit: the font size was stored when displayName was first computed.
|
|
601
|
+
const chosenSize = this.nodeDisplayFontSize.get(node.id) ?? NODE_FONT_SIZE_BASE;
|
|
602
|
+
ctx.font = `400 ${chosenSize}px SofiaSans`;
|
|
569
603
|
}
|
|
570
604
|
const textMetrics = ctx.measureText(line1);
|
|
571
605
|
const textHeight = textMetrics.actualBoundingBoxAscent +
|
|
@@ -578,10 +612,22 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
578
612
|
ctx.fillText(line2, node.x, node.y + halfTextHeight);
|
|
579
613
|
}
|
|
580
614
|
}
|
|
615
|
+
pointerNode(node, color, ctx) {
|
|
616
|
+
if (node.x === undefined || node.y === undefined) {
|
|
617
|
+
node.x = 0;
|
|
618
|
+
node.y = 0;
|
|
619
|
+
}
|
|
620
|
+
;
|
|
621
|
+
const radius = node.size + PADDING;
|
|
622
|
+
ctx.fillStyle = color;
|
|
623
|
+
ctx.beginPath();
|
|
624
|
+
ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI, false);
|
|
625
|
+
ctx.fill();
|
|
626
|
+
}
|
|
581
627
|
drawLink(link, ctx, globalScale) {
|
|
582
628
|
const start = link.source;
|
|
583
629
|
const end = link.target;
|
|
584
|
-
if (
|
|
630
|
+
if (start.x === undefined || start.y === undefined || end.x === undefined || end.y === undefined) {
|
|
585
631
|
start.x = 0;
|
|
586
632
|
start.y = 0;
|
|
587
633
|
end.x = 0;
|
|
@@ -590,21 +636,25 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
590
636
|
let textX;
|
|
591
637
|
let textY;
|
|
592
638
|
let angle;
|
|
639
|
+
const isLinkSelected = this.config.isLinkSelected?.(link) ?? false;
|
|
640
|
+
const arrowLen = isLinkSelected ? 4 : 2;
|
|
641
|
+
// Deferred arrowhead — drawn after the label so it is never covered by
|
|
642
|
+
// the label background rect (which happens for short links where the
|
|
643
|
+
// bezier midpoint and the arrow tip are at almost the same position).
|
|
644
|
+
let pendingArrow = null;
|
|
593
645
|
if (start.id === end.id) {
|
|
594
646
|
const nodeSize = start.size || 6;
|
|
595
|
-
const d = (link.curve || 0) * nodeSize *
|
|
596
|
-
ctx.lineWidth = (
|
|
647
|
+
const d = (link.curve || 0) * nodeSize * SELF_LOOP_CURVE_FACTOR;
|
|
648
|
+
ctx.lineWidth = (isLinkSelected ? 2 : 1) / globalScale;
|
|
649
|
+
ctx.setLineDash(this.config.linkLineDash?.(link) ?? []);
|
|
597
650
|
// The visible outer edge of the node border is nodeSize + strokeWidth
|
|
598
651
|
// (stroke is centered on nodeSize + strokeWidth/2, so outer edge = nodeSize + strokeWidth).
|
|
599
|
-
const nodeStrokeWidth = this.config.isNodeSelected?.(start) ? 1
|
|
600
|
-
const borderRadius = nodeSize + nodeStrokeWidth;
|
|
652
|
+
const nodeStrokeWidth = this.config.isNodeSelected?.(start) ? 1 : 0.5;
|
|
653
|
+
const borderRadius = nodeSize + nodeStrokeWidth + PADDING;
|
|
601
654
|
// Binary search for tArrow near 1.0 where the curve is at distance borderRadius
|
|
602
655
|
// from the node center (i.e. on the outer edge of the node border stroke).
|
|
603
656
|
// Bezier parametric form: Bx(t)=sx+3(1-t)t²d, By(t)=sy-3(1-t)²td
|
|
604
657
|
// dist(t) = 3*(1-t)*t*|d|*sqrt(t² + (1-t)²)
|
|
605
|
-
const ARROW_WH_RATIO = 1.6;
|
|
606
|
-
const ARROW_VLEN_RATIO = 0.2;
|
|
607
|
-
const arrowLen = this.config.isLinkSelected?.(link) ? 4 : 2;
|
|
608
658
|
const arrowHalfWidth = arrowLen / ARROW_WH_RATIO / 2;
|
|
609
659
|
let lo = 0.5, hi = 1.0;
|
|
610
660
|
const absD = Math.abs(d);
|
|
@@ -646,6 +696,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
646
696
|
ctx.bezierCurveTo(start.x, start.y - d, start.x + d, start.y, start.x, start.y);
|
|
647
697
|
}
|
|
648
698
|
ctx.stroke();
|
|
699
|
+
ctx.setLineDash([]);
|
|
649
700
|
// Tangent at tArrow (direction the curve travels toward the node)
|
|
650
701
|
const tdx = 3 * d * tArrow * (2 - 3 * tArrow);
|
|
651
702
|
const tdy = -3 * d * uArrow * (1 - 3 * tArrow);
|
|
@@ -656,13 +707,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
656
707
|
if (tLen !== 0 && canReachBorder) {
|
|
657
708
|
const nx = tdx / tLen;
|
|
658
709
|
const ny = tdy / tLen;
|
|
659
|
-
|
|
660
|
-
ctx.beginPath();
|
|
661
|
-
ctx.moveTo(tipX, tipY);
|
|
662
|
-
ctx.lineTo(tipX - nx * arrowLen + ny * arrowHalfWidth, tipY - ny * arrowLen - nx * arrowHalfWidth);
|
|
663
|
-
ctx.lineTo(tipX - nx * arrowLen * (1 - ARROW_VLEN_RATIO), tipY - ny * arrowLen * (1 - ARROW_VLEN_RATIO));
|
|
664
|
-
ctx.lineTo(tipX - nx * arrowLen - ny * arrowHalfWidth, tipY - ny * arrowLen + nx * arrowHalfWidth);
|
|
665
|
-
ctx.fill();
|
|
710
|
+
pendingArrow = { tipX, tipY, nx, ny, arrowLen, arrowHalfWidth };
|
|
666
711
|
}
|
|
667
712
|
// Midpoint of cubic bezier: P0=(sx,sy), P1=(sx,sy-d), P2=(sx+d,sy), P3=(sx,sy)
|
|
668
713
|
textX = start.x + 0.375 * d;
|
|
@@ -678,6 +723,11 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
678
723
|
const dx = end.x - start.x;
|
|
679
724
|
const dy = end.y - start.y;
|
|
680
725
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
726
|
+
// Guard: skip drawing when source and target are co-located (e.g. during
|
|
727
|
+
// simulation start-up). perpX/perpY would be NaN and propagate through
|
|
728
|
+
// all downstream bezier and arrowhead calculations.
|
|
729
|
+
if (distance === 0)
|
|
730
|
+
return;
|
|
681
731
|
const perpX = dy / distance;
|
|
682
732
|
const perpY = -dx / distance;
|
|
683
733
|
const curvature = link.curve || 0;
|
|
@@ -700,25 +750,118 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
700
750
|
angle = -(Math.PI - angle);
|
|
701
751
|
if (angle < -Math.PI / 2)
|
|
702
752
|
angle = -(-Math.PI - angle);
|
|
753
|
+
// Draw regular link line and arrowhead
|
|
754
|
+
const arrowHalfWidth = arrowLen / ARROW_WH_RATIO / 2;
|
|
755
|
+
// Target-side clip: find t where bezier enters target node border + PADDING
|
|
756
|
+
const endNodeSize = end.size || 6;
|
|
757
|
+
const borderRadius = endNodeSize + (this.config.isNodeSelected?.(end) ? 1 : 0.5) + PADDING;
|
|
758
|
+
const borderRadiusSq = borderRadius * borderRadius;
|
|
759
|
+
let tArrow;
|
|
760
|
+
if (borderRadius / distance < 0.02) {
|
|
761
|
+
tArrow = Math.min(1, Math.max(0, 1 - borderRadius / distance));
|
|
762
|
+
}
|
|
763
|
+
else {
|
|
764
|
+
let lo = 0.5, hi = 1.0;
|
|
765
|
+
for (let i = 0; i < 10; i++) {
|
|
766
|
+
const mid = (lo + hi) / 2;
|
|
767
|
+
const um = 1 - mid;
|
|
768
|
+
const qx = um * um * start.x + 2 * um * mid * controlX + mid * mid * end.x;
|
|
769
|
+
const qy = um * um * start.y + 2 * um * mid * controlY + mid * mid * end.y;
|
|
770
|
+
const dxEnd = qx - end.x;
|
|
771
|
+
const dyEnd = qy - end.y;
|
|
772
|
+
if (dxEnd * dxEnd + dyEnd * dyEnd > borderRadiusSq)
|
|
773
|
+
lo = mid;
|
|
774
|
+
else
|
|
775
|
+
hi = mid;
|
|
776
|
+
if (hi - lo < 1e-3)
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
tArrow = (lo + hi) / 2;
|
|
780
|
+
}
|
|
781
|
+
const uArrow = 1 - tArrow;
|
|
782
|
+
const tipX = uArrow * uArrow * start.x + 2 * uArrow * tArrow * controlX + tArrow * tArrow * end.x;
|
|
783
|
+
const tipY = uArrow * uArrow * start.y + 2 * uArrow * tArrow * controlY + tArrow * tArrow * end.y;
|
|
784
|
+
// Source-side clip: find t where bezier exits source node border + PADDING
|
|
785
|
+
const startNodeSize = start.size || 6;
|
|
786
|
+
const srcBorderRadius = startNodeSize + (this.config.isNodeSelected?.(start) ? 1 : 0.5) + PADDING;
|
|
787
|
+
const srcBorderRadiusSq = srcBorderRadius * srcBorderRadius;
|
|
788
|
+
let tStart = 0;
|
|
789
|
+
if (srcBorderRadius / distance < 0.02) {
|
|
790
|
+
tStart = Math.min(0.5, srcBorderRadius / distance);
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
793
|
+
let lo = 0.0, hi = 0.5;
|
|
794
|
+
for (let i = 0; i < 10; i++) {
|
|
795
|
+
const mid = (lo + hi) / 2;
|
|
796
|
+
const um = 1 - mid;
|
|
797
|
+
const qx = um * um * start.x + 2 * um * mid * controlX + mid * mid * end.x;
|
|
798
|
+
const qy = um * um * start.y + 2 * um * mid * controlY + mid * mid * end.y;
|
|
799
|
+
const dxSrc = qx - start.x;
|
|
800
|
+
const dySrc = qy - start.y;
|
|
801
|
+
if (dxSrc * dxSrc + dySrc * dySrc < srcBorderRadiusSq)
|
|
802
|
+
lo = mid;
|
|
803
|
+
else
|
|
804
|
+
hi = mid;
|
|
805
|
+
if (hi - lo < 1e-3)
|
|
806
|
+
break;
|
|
807
|
+
}
|
|
808
|
+
tStart = (lo + hi) / 2;
|
|
809
|
+
}
|
|
810
|
+
// Gap start point: Q(tStart)
|
|
811
|
+
const uS = 1 - tStart;
|
|
812
|
+
const gapStartX = uS * uS * start.x + 2 * uS * tStart * controlX + tStart * tStart * end.x;
|
|
813
|
+
const gapStartY = uS * uS * start.y + 2 * uS * tStart * controlY + tStart * tStart * end.y;
|
|
814
|
+
// Sub-bezier [tStart, tArrow] control point via De Casteljau:
|
|
815
|
+
// Right sub-bezier at tStart → NewP1 = lerp(control, end, tStart)
|
|
816
|
+
// Left sub-curve at tArrow' = (tArrow-tStart)/(1-tStart) → ctrl = lerp(gapStart, NewP1, tArrow')
|
|
817
|
+
const tArrowPrime = tStart < tArrow ? (tArrow - tStart) / (1 - tStart) : 0;
|
|
818
|
+
const newP1X = (1 - tStart) * controlX + tStart * end.x;
|
|
819
|
+
const newP1Y = (1 - tStart) * controlY + tStart * end.y;
|
|
820
|
+
const subCtrlX = (1 - tArrowPrime) * gapStartX + tArrowPrime * newP1X;
|
|
821
|
+
const subCtrlY = (1 - tArrowPrime) * gapStartY + tArrowPrime * newP1Y;
|
|
822
|
+
ctx.strokeStyle = link.color;
|
|
823
|
+
ctx.lineWidth = (isLinkSelected ? 2 : 1) / globalScale;
|
|
824
|
+
ctx.setLineDash(this.config.linkLineDash?.(link) ?? []);
|
|
825
|
+
ctx.beginPath();
|
|
826
|
+
ctx.moveTo(gapStartX, gapStartY);
|
|
827
|
+
ctx.quadraticCurveTo(subCtrlX, subCtrlY, tipX, tipY);
|
|
828
|
+
ctx.stroke();
|
|
829
|
+
ctx.setLineDash([]);
|
|
830
|
+
const atx = 2 * uArrow * (controlX - start.x) + 2 * tArrow * (end.x - controlX);
|
|
831
|
+
const aty = 2 * uArrow * (controlY - start.y) + 2 * tArrow * (end.y - controlY);
|
|
832
|
+
const atLen = Math.sqrt(atx * atx + aty * aty);
|
|
833
|
+
if (atLen !== 0) {
|
|
834
|
+
const nx = atx / atLen;
|
|
835
|
+
const ny = aty / atLen;
|
|
836
|
+
pendingArrow = { tipX, tipY, nx, ny, arrowLen, arrowHalfWidth };
|
|
837
|
+
}
|
|
703
838
|
}
|
|
704
|
-
ctx.font = "400 2px SofiaSans";
|
|
839
|
+
ctx.font = isLinkSelected ? "700 2px SofiaSans" : "400 2px SofiaSans";
|
|
705
840
|
ctx.textAlign = "center";
|
|
706
841
|
// Draw text with alphabetic baseline, positioned so visual center is at y=0
|
|
707
842
|
ctx.textBaseline = "alphabetic";
|
|
708
|
-
|
|
843
|
+
// Separate cache entries per weight so each state is measured with its own
|
|
844
|
+
// font, giving equal visual padding regardless of selection state.
|
|
845
|
+
const cacheKey = `${link.relationship}_${isLinkSelected ? "700" : "400"}`;
|
|
846
|
+
let cached = this.relationshipsTextCache.get(cacheKey);
|
|
709
847
|
if (!cached) {
|
|
848
|
+
// ctx.font is already set to the correct weight above; measure it directly.
|
|
710
849
|
const metrics = ctx.measureText(link.relationship);
|
|
711
|
-
// Use
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
//
|
|
715
|
-
const
|
|
850
|
+
// Use actual ink bounds for vertical metrics; fontBoundingBox* is the full
|
|
851
|
+
// line-box and adds excessive space for lighter weights.
|
|
852
|
+
// Use metrics.width for horizontal extent: actualBoundingBoxLeft/Right are
|
|
853
|
+
// unreliable with textAlign="center" and can double the value on some engines.
|
|
854
|
+
const inkAscent = metrics.actualBoundingBoxAscent ?? metrics.fontBoundingBoxAscent;
|
|
855
|
+
const inkDescent = metrics.actualBoundingBoxDescent ?? metrics.fontBoundingBoxDescent;
|
|
856
|
+
const inkWidth = metrics.width;
|
|
857
|
+
const bgPadding = 0.3;
|
|
716
858
|
cached = {
|
|
717
|
-
textWidth:
|
|
718
|
-
textHeight:
|
|
719
|
-
|
|
859
|
+
textWidth: inkWidth + bgPadding * 2,
|
|
860
|
+
textHeight: inkAscent + inkDescent + bgPadding * 2,
|
|
861
|
+
// Shift baseline up so the ink block is centred inside the bg rect.
|
|
862
|
+
textYOffset: (inkAscent - inkDescent) / 2,
|
|
720
863
|
};
|
|
721
|
-
this.relationshipsTextCache.set(
|
|
864
|
+
this.relationshipsTextCache.set(cacheKey, cached);
|
|
722
865
|
}
|
|
723
866
|
const { textWidth, textHeight, textYOffset } = cached;
|
|
724
867
|
ctx.save();
|
|
@@ -731,6 +874,150 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
731
874
|
ctx.fillStyle = getContrastTextColor(this.config.backgroundColor);
|
|
732
875
|
ctx.fillText(link.relationship, 0, textYOffset);
|
|
733
876
|
ctx.restore();
|
|
877
|
+
// Draw arrowhead last so it always appears on top of the label background.
|
|
878
|
+
if (pendingArrow) {
|
|
879
|
+
const { tipX, tipY, nx, ny, arrowLen: aLen, arrowHalfWidth: aHW } = pendingArrow;
|
|
880
|
+
ctx.fillStyle = link.color;
|
|
881
|
+
ctx.beginPath();
|
|
882
|
+
ctx.moveTo(tipX, tipY);
|
|
883
|
+
ctx.lineTo(tipX - nx * aLen + ny * aHW, tipY - ny * aLen - nx * aHW);
|
|
884
|
+
ctx.lineTo(tipX - nx * aLen * (1 - ARROW_VLEN_RATIO), tipY - ny * aLen * (1 - ARROW_VLEN_RATIO));
|
|
885
|
+
ctx.lineTo(tipX - nx * aLen - ny * aHW, tipY - ny * aLen + nx * aHW);
|
|
886
|
+
ctx.fill();
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
pointerLink(link, color, ctx) {
|
|
890
|
+
const start = link.source;
|
|
891
|
+
const end = link.target;
|
|
892
|
+
if (start.x == null || start.y == null || end.x == null || end.y == null)
|
|
893
|
+
return;
|
|
894
|
+
ctx.strokeStyle = color;
|
|
895
|
+
const basePointerWidth = 10; // Desired on-screen pointer area thickness
|
|
896
|
+
const transform = typeof ctx.getTransform === 'function' ? ctx.getTransform() : null;
|
|
897
|
+
if (transform) {
|
|
898
|
+
const scaleX = Math.hypot(transform.a, transform.c);
|
|
899
|
+
const scaleY = Math.hypot(transform.b, transform.d);
|
|
900
|
+
const avgScale = (scaleX + scaleY) / 2 || 1;
|
|
901
|
+
ctx.lineWidth = basePointerWidth / avgScale;
|
|
902
|
+
}
|
|
903
|
+
else {
|
|
904
|
+
ctx.lineWidth = basePointerWidth;
|
|
905
|
+
}
|
|
906
|
+
ctx.beginPath();
|
|
907
|
+
if (start.id === end.id) {
|
|
908
|
+
// Self-loop: replicate exact cubic bezier clip from drawLink
|
|
909
|
+
const nodeSize = start.size || 6;
|
|
910
|
+
const d = (link.curve || 0) * nodeSize * SELF_LOOP_CURVE_FACTOR;
|
|
911
|
+
const nodeStrokeWidth = this.config.isNodeSelected?.(start) ? 1 : 0.5;
|
|
912
|
+
const borderRadius = nodeSize + nodeStrokeWidth + PADDING;
|
|
913
|
+
const absD = Math.abs(d);
|
|
914
|
+
const maxReachableDist = 3 * 0.5 * 0.5 * absD * Math.sqrt(0.5);
|
|
915
|
+
const canReachBorder = absD > 0 && maxReachableDist >= borderRadius;
|
|
916
|
+
ctx.moveTo(start.x, start.y);
|
|
917
|
+
if (canReachBorder) {
|
|
918
|
+
let lo = 0.5, hi = 1.0;
|
|
919
|
+
for (let i = 0; i < 20; i++) {
|
|
920
|
+
const mid = (lo + hi) / 2;
|
|
921
|
+
const um = 1 - mid;
|
|
922
|
+
const dist = 3 * um * mid * absD * Math.sqrt(mid * mid + um * um);
|
|
923
|
+
if (dist > borderRadius)
|
|
924
|
+
lo = mid;
|
|
925
|
+
else
|
|
926
|
+
hi = mid;
|
|
927
|
+
}
|
|
928
|
+
const tArrow = (lo + hi) / 2;
|
|
929
|
+
const uArrow = 1 - tArrow;
|
|
930
|
+
const tipX = start.x + 3 * uArrow * tArrow * tArrow * d;
|
|
931
|
+
const tipY = start.y - 3 * uArrow * uArrow * tArrow * d;
|
|
932
|
+
ctx.bezierCurveTo(start.x, start.y - tArrow * d, start.x + tArrow * tArrow * d, start.y - 2 * tArrow * uArrow * d, tipX, tipY);
|
|
933
|
+
}
|
|
934
|
+
else {
|
|
935
|
+
ctx.bezierCurveTo(start.x, start.y - d, start.x + d, start.y, start.x, start.y);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
else {
|
|
939
|
+
// Regular link: replicate exact quadratic bezier clip from drawLink
|
|
940
|
+
const dx = end.x - start.x;
|
|
941
|
+
const dy = end.y - start.y;
|
|
942
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
943
|
+
const curvature = link.curve || 0;
|
|
944
|
+
if (distance === 0) {
|
|
945
|
+
ctx.moveTo(start.x, start.y);
|
|
946
|
+
ctx.lineTo(end.x, end.y);
|
|
947
|
+
}
|
|
948
|
+
else {
|
|
949
|
+
const perpX = dy / distance;
|
|
950
|
+
const perpY = -dx / distance;
|
|
951
|
+
const controlX = (start.x + end.x) / 2 + perpX * curvature * distance;
|
|
952
|
+
const controlY = (start.y + end.y) / 2 + perpY * curvature * distance;
|
|
953
|
+
// Use the same borderRadius and binary-search clip as drawLink
|
|
954
|
+
const endNodeSize = end.size || 6;
|
|
955
|
+
const borderRadius = endNodeSize + (this.config.isNodeSelected?.(end) ? 1 : 0.5) + PADDING;
|
|
956
|
+
const borderRadiusSq = borderRadius * borderRadius;
|
|
957
|
+
let tArrow;
|
|
958
|
+
if (borderRadius / distance < 0.02) {
|
|
959
|
+
tArrow = Math.min(1, Math.max(0, 1 - borderRadius / distance));
|
|
960
|
+
}
|
|
961
|
+
else {
|
|
962
|
+
let lo = 0.5, hi = 1.0;
|
|
963
|
+
for (let i = 0; i < 10; i++) {
|
|
964
|
+
const mid = (lo + hi) / 2;
|
|
965
|
+
const um = 1 - mid;
|
|
966
|
+
const qx = um * um * start.x + 2 * um * mid * controlX + mid * mid * end.x;
|
|
967
|
+
const qy = um * um * start.y + 2 * um * mid * controlY + mid * mid * end.y;
|
|
968
|
+
const dxEnd = qx - end.x;
|
|
969
|
+
const dyEnd = qy - end.y;
|
|
970
|
+
if (dxEnd * dxEnd + dyEnd * dyEnd > borderRadiusSq)
|
|
971
|
+
lo = mid;
|
|
972
|
+
else
|
|
973
|
+
hi = mid;
|
|
974
|
+
if (hi - lo < 1e-3)
|
|
975
|
+
break;
|
|
976
|
+
}
|
|
977
|
+
tArrow = (lo + hi) / 2;
|
|
978
|
+
}
|
|
979
|
+
const uArrow = 1 - tArrow;
|
|
980
|
+
const tipX = uArrow * uArrow * start.x + 2 * uArrow * tArrow * controlX + tArrow * tArrow * end.x;
|
|
981
|
+
const tipY = uArrow * uArrow * start.y + 2 * uArrow * tArrow * controlY + tArrow * tArrow * end.y;
|
|
982
|
+
// Source-side clip: mirror of drawLink source gap
|
|
983
|
+
const startNodeSize = start.size || 6;
|
|
984
|
+
const srcBorderRadius = startNodeSize + (this.config.isNodeSelected?.(start) ? 1 : 0.5) + PADDING;
|
|
985
|
+
const srcBorderRadiusSq = srcBorderRadius * srcBorderRadius;
|
|
986
|
+
let tStart = 0;
|
|
987
|
+
if (srcBorderRadius / distance < 0.02) {
|
|
988
|
+
tStart = Math.min(0.5, srcBorderRadius / distance);
|
|
989
|
+
}
|
|
990
|
+
else {
|
|
991
|
+
let lo = 0.0, hi = 0.5;
|
|
992
|
+
for (let i = 0; i < 10; i++) {
|
|
993
|
+
const mid = (lo + hi) / 2;
|
|
994
|
+
const um = 1 - mid;
|
|
995
|
+
const qx = um * um * start.x + 2 * um * mid * controlX + mid * mid * end.x;
|
|
996
|
+
const qy = um * um * start.y + 2 * um * mid * controlY + mid * mid * end.y;
|
|
997
|
+
const dxSrc = qx - start.x;
|
|
998
|
+
const dySrc = qy - start.y;
|
|
999
|
+
if (dxSrc * dxSrc + dySrc * dySrc < srcBorderRadiusSq)
|
|
1000
|
+
lo = mid;
|
|
1001
|
+
else
|
|
1002
|
+
hi = mid;
|
|
1003
|
+
if (hi - lo < 1e-3)
|
|
1004
|
+
break;
|
|
1005
|
+
}
|
|
1006
|
+
tStart = (lo + hi) / 2;
|
|
1007
|
+
}
|
|
1008
|
+
const uS = 1 - tStart;
|
|
1009
|
+
const gapStartX = uS * uS * start.x + 2 * uS * tStart * controlX + tStart * tStart * end.x;
|
|
1010
|
+
const gapStartY = uS * uS * start.y + 2 * uS * tStart * controlY + tStart * tStart * end.y;
|
|
1011
|
+
const tArrowPrime = tStart < tArrow ? (tArrow - tStart) / (1 - tStart) : 0;
|
|
1012
|
+
const newP1X = (1 - tStart) * controlX + tStart * end.x;
|
|
1013
|
+
const newP1Y = (1 - tStart) * controlY + tStart * end.y;
|
|
1014
|
+
const subCtrlX = (1 - tArrowPrime) * gapStartX + tArrowPrime * newP1X;
|
|
1015
|
+
const subCtrlY = (1 - tArrowPrime) * gapStartY + tArrowPrime * newP1Y;
|
|
1016
|
+
ctx.moveTo(gapStartX, gapStartY);
|
|
1017
|
+
ctx.quadraticCurveTo(subCtrlX, subCtrlY, tipX, tipY);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
ctx.stroke();
|
|
734
1021
|
}
|
|
735
1022
|
updateLoadingState() {
|
|
736
1023
|
if (!this.loadingOverlay)
|
|
@@ -854,7 +1141,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
854
1141
|
})
|
|
855
1142
|
.linkCanvasObject((link, ctx, globalScale) => {
|
|
856
1143
|
if (this.config.link) {
|
|
857
|
-
this.config.link.linkCanvasObject(link, ctx);
|
|
1144
|
+
this.config.link.linkCanvasObject(link, ctx, globalScale);
|
|
858
1145
|
}
|
|
859
1146
|
else {
|
|
860
1147
|
this.drawLink(link, ctx, globalScale);
|