@falkordb/canvas 0.0.45 → 0.0.50
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/README.md +127 -1
- package/dist/canvas-types.d.ts +106 -2
- package/dist/canvas-types.d.ts.map +1 -1
- package/dist/canvas-utils.d.ts +1 -1
- package/dist/canvas-utils.d.ts.map +1 -1
- package/dist/canvas-utils.js +2 -4
- package/dist/canvas-utils.js.map +1 -1
- package/dist/canvas.d.ts +52 -0
- package/dist/canvas.d.ts.map +1 -1
- package/dist/canvas.js +576 -90
- package/dist/canvas.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/layouts.d.ts +4 -0
- package/dist/layouts.d.ts.map +1 -0
- package/dist/layouts.js +822 -0
- package/dist/layouts.js.map +1 -0
- package/package.json +9 -4
- package/src/canvas-types.ts +120 -4
- package/src/canvas-utils.ts +2 -4
- package/src/canvas.ts +662 -99
- package/src/index.ts +32 -1
- package/src/layouts.ts +993 -0
package/dist/canvas.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
/* eslint-disable no-param-reassign */
|
|
2
1
|
import ForceGraph from "force-graph";
|
|
3
2
|
import * as d3 from "d3";
|
|
4
|
-
import { dataToGraphData, getContrastTextColor, getNodeDisplayText, graphDataToData, LINK_DISTANCE, wrapTextForCircularNode, } from "./canvas-utils.js";
|
|
3
|
+
import { dataToGraphData, getContrastTextColor, getNodeDisplayText, graphDataToData, LINK_DISTANCE, NODE_SIZE, wrapTextForCircularNode, } from "./canvas-utils.js";
|
|
4
|
+
import { applyGraphLayout, isForceLayout } from "./layouts.js";
|
|
5
5
|
const PADDING = 2;
|
|
6
6
|
// Arrow geometry constants (shared by self-loop and regular-link drawing paths)
|
|
7
7
|
const ARROW_WH_RATIO = 1.6;
|
|
8
8
|
const ARROW_VLEN_RATIO = 0.2;
|
|
9
9
|
// Multiplier to convert node size → cubic bezier control-point distance for self-loops
|
|
10
10
|
const SELF_LOOP_CURVE_FACTOR = 11.67;
|
|
11
|
-
// Base font size used for the initial measurement
|
|
11
|
+
// Base font size used for the initial wrap-measurement pass.
|
|
12
12
|
const NODE_FONT_SIZE_BASE = 2;
|
|
13
13
|
// Fraction of the chord width that single-line text should fill (0–1).
|
|
14
14
|
// Leaves (1 - ratio)/2 of the radius as horizontal padding on each side.
|
|
@@ -18,6 +18,15 @@ const CHARGE_STRENGTH = -400;
|
|
|
18
18
|
const CENTER_STRENGTH = 0.03;
|
|
19
19
|
const VELOCITY_DECAY = 0.4;
|
|
20
20
|
const ALPHA_MIN = 0.05;
|
|
21
|
+
const NON_FORCE_CHARGE_STRENGTH = -220;
|
|
22
|
+
const NON_FORCE_COLLIDE_PADDING = 18;
|
|
23
|
+
const NON_FORCE_CENTER_STRENGTH = 0.02;
|
|
24
|
+
const NON_FORCE_LINK_STRENGTH = 0.08;
|
|
25
|
+
const NON_FORCE_TARGET_STRENGTH = 0.3;
|
|
26
|
+
const NON_FORCE_VELOCITY_DECAY = 0.5;
|
|
27
|
+
const NON_FORCE_ALPHA_MIN = 0.03;
|
|
28
|
+
const NON_FORCE_LAYOUT_COOLDOWN_TICKS = 120;
|
|
29
|
+
const NON_FORCE_DRAG_COOLDOWN_TICKS = 90;
|
|
21
30
|
// Create styles for the web component
|
|
22
31
|
function createStyles(backgroundColor, foregroundColor) {
|
|
23
32
|
const style = document.createElement("style");
|
|
@@ -63,6 +72,8 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
63
72
|
foregroundColor: '#1A1A1A',
|
|
64
73
|
captionsKeys: [],
|
|
65
74
|
showPropertyKeyPrefix: false,
|
|
75
|
+
layoutMode: "force",
|
|
76
|
+
layoutOptions: {},
|
|
66
77
|
};
|
|
67
78
|
this.nodeMode = 'replace';
|
|
68
79
|
this.linkMode = 'replace';
|
|
@@ -70,11 +81,25 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
70
81
|
// Per-node font size cache: computed once per node, read every frame.
|
|
71
82
|
this.nodeDisplayFontSize = new Map();
|
|
72
83
|
this.relationshipsTextCache = new Map();
|
|
84
|
+
/**
|
|
85
|
+
* Cached world-space axis-aligned bounding box of the currently visible
|
|
86
|
+
* viewport. Updated on every zoom/pan event and on resize.
|
|
87
|
+
* `null` means culling is disabled or not yet computed.
|
|
88
|
+
*/
|
|
89
|
+
this.cullingBounds = null;
|
|
90
|
+
/** Current zoom level, cached alongside cullingBounds. */
|
|
91
|
+
this.cullingZoom = 1;
|
|
92
|
+
/** Last d3-zoom transform, cached so bounds can be recomputed on resize. */
|
|
93
|
+
this.lastTransform = null;
|
|
73
94
|
this.onFontsLoadingDone = () => {
|
|
74
95
|
this.relationshipsTextCache.clear();
|
|
75
96
|
this.nodeDisplayFontSize.clear();
|
|
97
|
+
for (const node of this.data.nodes) {
|
|
98
|
+
node.displayName = ["", ""];
|
|
99
|
+
}
|
|
76
100
|
this.triggerRender();
|
|
77
101
|
};
|
|
102
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
78
103
|
this.attachShadow({ mode: "open" });
|
|
79
104
|
}
|
|
80
105
|
/**
|
|
@@ -122,12 +147,12 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
122
147
|
this.resizeObserver = null;
|
|
123
148
|
}
|
|
124
149
|
if (this.graph) {
|
|
125
|
-
// eslint-disable-next-line no-underscore-dangle
|
|
126
150
|
this.graph._destructor();
|
|
127
151
|
}
|
|
128
152
|
}
|
|
129
153
|
setConfig(config) {
|
|
130
154
|
this.log('Setting config:', config);
|
|
155
|
+
const layoutChanged = config.layoutMode !== undefined || config.layoutOptions !== undefined;
|
|
131
156
|
// If captionsKeys changed, invalidate cached display names and font sizes
|
|
132
157
|
// so text is recomputed with the new keys on the next render.
|
|
133
158
|
if (config.captionsKeys && JSON.stringify(config.captionsKeys) !== JSON.stringify(this.config.captionsKeys)) {
|
|
@@ -136,7 +161,62 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
136
161
|
node.displayName = ["", ""];
|
|
137
162
|
}
|
|
138
163
|
}
|
|
139
|
-
|
|
164
|
+
// Deep-merge largeGraph to avoid wiping sibling fields on partial updates.
|
|
165
|
+
if (config.largeGraph && typeof config.largeGraph === 'object' && this.config.largeGraph) {
|
|
166
|
+
const mergedLargeGraph = { ...this.config.largeGraph, ...config.largeGraph };
|
|
167
|
+
Object.assign(this.config, config, { largeGraph: mergedLargeGraph });
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
Object.assign(this.config, config);
|
|
171
|
+
}
|
|
172
|
+
// Recompute or clear culling bounds when largeGraph config changes.
|
|
173
|
+
if ('largeGraph' in config) {
|
|
174
|
+
if (this.config.largeGraph?.enabled) {
|
|
175
|
+
this.recomputeCullingBoundsIfNeeded();
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
this.cullingBounds = null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (layoutChanged) {
|
|
182
|
+
const previousPositions = this.getNodePositionMap();
|
|
183
|
+
if (this.isForceLayoutMode() && this.config.cooldownTicks === 0 && this.data.nodes.length > 0) {
|
|
184
|
+
this.config.cooldownTicks = undefined;
|
|
185
|
+
}
|
|
186
|
+
this.data = applyGraphLayout(this.data, this.config.layoutMode, this.config.layoutOptions);
|
|
187
|
+
const shouldAnimateNonForceLayout = this.prepareNodePositionsForCurrentLayout(previousPositions);
|
|
188
|
+
if (this.graph) {
|
|
189
|
+
this.calculateNodeDegree();
|
|
190
|
+
this.graph.graphData(this.data);
|
|
191
|
+
this.configureSimulationForCurrentLayout(shouldAnimateNonForceLayout);
|
|
192
|
+
if (this.isForceLayoutMode()) {
|
|
193
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
194
|
+
this.config.isLoading = this.data.nodes.length > 0;
|
|
195
|
+
this.config.onLoadingChange?.(this.config.isLoading);
|
|
196
|
+
this.updateLoadingState();
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
this.config.isLoading = false;
|
|
200
|
+
this.config.onLoadingChange?.(false);
|
|
201
|
+
this.updateLoadingState();
|
|
202
|
+
if (this.data.nodes.length > 0) {
|
|
203
|
+
if (shouldAnimateNonForceLayout) {
|
|
204
|
+
this.shouldZoomToFitOnNonForceSettle = true;
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
208
|
+
this.zoomToFit(1);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
213
|
+
}
|
|
214
|
+
if (!shouldAnimateNonForceLayout) {
|
|
215
|
+
this.triggerRender();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
140
220
|
// Update event handlers if they were provided
|
|
141
221
|
if (config.onNodeClick || config.onLinkClick || config.onNodeRightClick || config.onLinkRightClick ||
|
|
142
222
|
config.onNodeHover || config.onLinkHover || config.onBackgroundClick || config.onBackgroundRightClick || config.onZoom ||
|
|
@@ -152,6 +232,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
152
232
|
this.config.width = width;
|
|
153
233
|
if (this.graph) {
|
|
154
234
|
this.graph.width(width);
|
|
235
|
+
this.recomputeCullingBoundsIfNeeded();
|
|
155
236
|
}
|
|
156
237
|
}
|
|
157
238
|
setHeight(height) {
|
|
@@ -161,6 +242,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
161
242
|
this.config.height = height;
|
|
162
243
|
if (this.graph) {
|
|
163
244
|
this.graph.height(height);
|
|
245
|
+
this.recomputeCullingBoundsIfNeeded();
|
|
164
246
|
}
|
|
165
247
|
}
|
|
166
248
|
setBackgroundColor(color) {
|
|
@@ -197,25 +279,42 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
197
279
|
this.log('Setting cooldown ticks to:', ticks);
|
|
198
280
|
this.config.cooldownTicks = ticks;
|
|
199
281
|
if (this.graph) {
|
|
200
|
-
this.graph.cooldownTicks(ticks ?? Infinity);
|
|
282
|
+
this.graph.cooldownTicks(this.isForceLayoutMode() ? (ticks ?? Infinity) : 0);
|
|
201
283
|
}
|
|
202
|
-
this.updateCanvasSimulationAttribute(ticks !== 0);
|
|
284
|
+
this.updateCanvasSimulationAttribute(this.isForceLayoutMode() && ticks !== 0 && this.data.nodes.length > 0);
|
|
203
285
|
}
|
|
204
286
|
getData() {
|
|
205
287
|
return graphDataToData(this.data);
|
|
206
288
|
}
|
|
207
289
|
setData(data) {
|
|
208
290
|
this.log('setData called with', data.nodes.length, 'nodes and', data.links.length, 'links');
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
291
|
+
const previousPositions = this.getNodePositionMap();
|
|
292
|
+
const oldNodesMap = new Map();
|
|
293
|
+
for (const node of this.data.nodes) {
|
|
294
|
+
oldNodesMap.set(node.id, node);
|
|
295
|
+
}
|
|
296
|
+
// Convert data and preserve positions for existing nodes
|
|
297
|
+
this.data = dataToGraphData(data, undefined, oldNodesMap);
|
|
298
|
+
this.data = applyGraphLayout(this.data, this.config.layoutMode, this.config.layoutOptions);
|
|
299
|
+
const shouldAnimateNonForceLayout = this.prepareNodePositionsForCurrentLayout(previousPositions);
|
|
300
|
+
if (this.isForceLayoutMode()) {
|
|
301
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
302
|
+
this.config.cooldownTicks = this.data.nodes.length > 0 ? undefined : 0;
|
|
303
|
+
this.config.isLoading = this.data.nodes.length > 0;
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
this.config.cooldownTicks = 0;
|
|
307
|
+
this.config.isLoading = false;
|
|
308
|
+
}
|
|
213
309
|
this.log('Loading state:', this.config.isLoading);
|
|
214
310
|
this.config.onLoadingChange?.(this.config.isLoading);
|
|
215
311
|
// Update simulation state
|
|
216
|
-
if (this.data.nodes.length > 0) {
|
|
312
|
+
if (this.data.nodes.length > 0 && this.isForceLayoutMode()) {
|
|
217
313
|
this.updateCanvasSimulationAttribute(true);
|
|
218
314
|
}
|
|
315
|
+
else {
|
|
316
|
+
this.updateCanvasSimulationAttribute(false);
|
|
317
|
+
}
|
|
219
318
|
// Initialize graph if it hasn't been initialized yet
|
|
220
319
|
if (!this.graph && this.container) {
|
|
221
320
|
this.log('Initializing graph');
|
|
@@ -225,11 +324,23 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
225
324
|
return;
|
|
226
325
|
this.log('Calculating node degrees and setting up forces');
|
|
227
326
|
this.calculateNodeDegree();
|
|
228
|
-
this.setupForces();
|
|
229
327
|
// Update graph data and properties
|
|
230
328
|
this.graph
|
|
231
|
-
.graphData(this.data)
|
|
232
|
-
|
|
329
|
+
.graphData(this.data);
|
|
330
|
+
this.configureSimulationForCurrentLayout(shouldAnimateNonForceLayout);
|
|
331
|
+
if (!this.isForceLayoutMode() && this.data.nodes.length > 0) {
|
|
332
|
+
if (shouldAnimateNonForceLayout) {
|
|
333
|
+
this.shouldZoomToFitOnNonForceSettle = true;
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
337
|
+
this.zoomToFit(1);
|
|
338
|
+
this.triggerRender();
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
343
|
+
}
|
|
233
344
|
this.updateLoadingState();
|
|
234
345
|
}
|
|
235
346
|
getViewport() {
|
|
@@ -253,13 +364,23 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
253
364
|
}
|
|
254
365
|
setGraphData(data) {
|
|
255
366
|
this.log('setGraphData called with', data.nodes.length, 'nodes and', data.links.length, 'links');
|
|
256
|
-
this.data = data;
|
|
367
|
+
this.data = applyGraphLayout(data, this.config.layoutMode, this.config.layoutOptions);
|
|
368
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
257
369
|
if (!this.graph)
|
|
258
370
|
return;
|
|
259
371
|
this.calculateNodeDegree();
|
|
260
|
-
this.setupForces();
|
|
261
372
|
this.graph
|
|
262
373
|
.graphData(this.data);
|
|
374
|
+
// setGraphData restores pre-positioned data — freeze simulation, just render.
|
|
375
|
+
this.config.cooldownTicks = 0;
|
|
376
|
+
this.graph.cooldownTicks(0);
|
|
377
|
+
this.updateCanvasSimulationAttribute(false);
|
|
378
|
+
if (this.data.nodes.length > 0) {
|
|
379
|
+
this.triggerRender();
|
|
380
|
+
}
|
|
381
|
+
this.config.isLoading = false;
|
|
382
|
+
this.config.onLoadingChange?.(false);
|
|
383
|
+
this.updateLoadingState();
|
|
263
384
|
if (this.viewport) {
|
|
264
385
|
this.log('Applying viewport:', this.viewport);
|
|
265
386
|
this.graph.zoom(this.viewport.zoom, 0);
|
|
@@ -294,6 +415,167 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
294
415
|
// Use the force-graph's built-in zoomToFit method
|
|
295
416
|
this.graph.zoomToFit(500, padding * paddingMultiplier, filter);
|
|
296
417
|
}
|
|
418
|
+
isForceLayoutMode() {
|
|
419
|
+
return isForceLayout(this.config.layoutMode);
|
|
420
|
+
}
|
|
421
|
+
getNodePositionMap() {
|
|
422
|
+
const positions = new Map();
|
|
423
|
+
for (const node of this.data.nodes) {
|
|
424
|
+
if (node.x === undefined || node.y === undefined)
|
|
425
|
+
continue;
|
|
426
|
+
positions.set(node.id, { x: node.x, y: node.y });
|
|
427
|
+
}
|
|
428
|
+
return positions;
|
|
429
|
+
}
|
|
430
|
+
getGraphCenter(positions) {
|
|
431
|
+
if (positions.size === 0)
|
|
432
|
+
return undefined;
|
|
433
|
+
let sumX = 0;
|
|
434
|
+
let sumY = 0;
|
|
435
|
+
for (const position of positions.values()) {
|
|
436
|
+
sumX += position.x;
|
|
437
|
+
sumY += position.y;
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
x: sumX / positions.size,
|
|
441
|
+
y: sumY / positions.size,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
getConnectedExistingPosition(nodeId, previousPositions) {
|
|
445
|
+
let sumX = 0;
|
|
446
|
+
let sumY = 0;
|
|
447
|
+
let count = 0;
|
|
448
|
+
for (const link of this.data.links) {
|
|
449
|
+
const sourceId = link.source.id;
|
|
450
|
+
const targetId = link.target.id;
|
|
451
|
+
if (sourceId === nodeId) {
|
|
452
|
+
const existingPosition = previousPositions.get(targetId);
|
|
453
|
+
if (!existingPosition)
|
|
454
|
+
continue;
|
|
455
|
+
sumX += existingPosition.x;
|
|
456
|
+
sumY += existingPosition.y;
|
|
457
|
+
count += 1;
|
|
458
|
+
}
|
|
459
|
+
else if (targetId === nodeId) {
|
|
460
|
+
const existingPosition = previousPositions.get(sourceId);
|
|
461
|
+
if (!existingPosition)
|
|
462
|
+
continue;
|
|
463
|
+
sumX += existingPosition.x;
|
|
464
|
+
sumY += existingPosition.y;
|
|
465
|
+
count += 1;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (count === 0)
|
|
469
|
+
return undefined;
|
|
470
|
+
return {
|
|
471
|
+
x: sumX / count,
|
|
472
|
+
y: sumY / count,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
clearLayoutTargets() {
|
|
476
|
+
for (const node of this.data.nodes) {
|
|
477
|
+
node.layoutTargetX = undefined;
|
|
478
|
+
node.layoutTargetY = undefined;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
prepareNodePositionsForCurrentLayout(previousPositions) {
|
|
482
|
+
if (this.isForceLayoutMode()) {
|
|
483
|
+
this.clearLayoutTargets();
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
const graphCenter = this.getGraphCenter(previousPositions);
|
|
487
|
+
let shouldAnimate = false;
|
|
488
|
+
for (const node of this.data.nodes) {
|
|
489
|
+
const targetX = node.x ?? 0;
|
|
490
|
+
const targetY = node.y ?? 0;
|
|
491
|
+
node.layoutTargetX = targetX;
|
|
492
|
+
node.layoutTargetY = targetY;
|
|
493
|
+
node.fx = undefined;
|
|
494
|
+
node.fy = undefined;
|
|
495
|
+
node.vx = 0;
|
|
496
|
+
node.vy = 0;
|
|
497
|
+
if (previousPositions.size === 0) {
|
|
498
|
+
node.x = targetX;
|
|
499
|
+
node.y = targetY;
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
const previousPosition = previousPositions.get(node.id)
|
|
503
|
+
?? this.getConnectedExistingPosition(node.id, previousPositions)
|
|
504
|
+
?? graphCenter;
|
|
505
|
+
if (!previousPosition) {
|
|
506
|
+
node.x = targetX;
|
|
507
|
+
node.y = targetY;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
node.x = previousPosition.x;
|
|
511
|
+
node.y = previousPosition.y;
|
|
512
|
+
if (Math.abs(previousPosition.x - targetX) > 0.5
|
|
513
|
+
|| Math.abs(previousPosition.y - targetY) > 0.5) {
|
|
514
|
+
shouldAnimate = true;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return shouldAnimate;
|
|
518
|
+
}
|
|
519
|
+
setupAnchoredLayoutForces() {
|
|
520
|
+
if (!this.graph)
|
|
521
|
+
return;
|
|
522
|
+
const linkForce = this.graph.d3Force("link");
|
|
523
|
+
if (linkForce) {
|
|
524
|
+
linkForce
|
|
525
|
+
.distance((link) => {
|
|
526
|
+
const sourceSize = link.source.size;
|
|
527
|
+
const targetSize = link.target.size;
|
|
528
|
+
return sourceSize + targetSize + LINK_DISTANCE * 1.6;
|
|
529
|
+
})
|
|
530
|
+
.strength(NON_FORCE_LINK_STRENGTH);
|
|
531
|
+
}
|
|
532
|
+
this.graph.d3Force("collide", d3.forceCollide((node) => node.size + NON_FORCE_COLLIDE_PADDING));
|
|
533
|
+
this.graph.d3Force("centerX", d3.forceX(0).strength(NON_FORCE_CENTER_STRENGTH));
|
|
534
|
+
this.graph.d3Force("centerY", d3.forceY(0).strength(NON_FORCE_CENTER_STRENGTH));
|
|
535
|
+
this.graph.d3Force("layoutTargetX", d3.forceX((node) => node.layoutTargetX ?? node.x ?? 0).strength(NON_FORCE_TARGET_STRENGTH));
|
|
536
|
+
this.graph.d3Force("layoutTargetY", d3.forceY((node) => node.layoutTargetY ?? node.y ?? 0).strength(NON_FORCE_TARGET_STRENGTH));
|
|
537
|
+
const chargeForce = this.graph.d3Force("charge");
|
|
538
|
+
if (chargeForce) {
|
|
539
|
+
chargeForce.strength(NON_FORCE_CHARGE_STRENGTH);
|
|
540
|
+
}
|
|
541
|
+
this.graph.d3VelocityDecay(NON_FORCE_VELOCITY_DECAY);
|
|
542
|
+
this.graph.d3AlphaMin(NON_FORCE_ALPHA_MIN);
|
|
543
|
+
}
|
|
544
|
+
startNonForceSettleAnimation(cooldownTicks) {
|
|
545
|
+
if (!this.graph || this.data.nodes.length === 0 || this.isForceLayoutMode())
|
|
546
|
+
return;
|
|
547
|
+
this.graph.cooldownTicks(cooldownTicks);
|
|
548
|
+
this.updateCanvasSimulationAttribute(true);
|
|
549
|
+
this.graph.d3ReheatSimulation();
|
|
550
|
+
}
|
|
551
|
+
applyLayoutTargets() {
|
|
552
|
+
for (const node of this.data.nodes) {
|
|
553
|
+
if (node.layoutTargetX === undefined || node.layoutTargetY === undefined)
|
|
554
|
+
continue;
|
|
555
|
+
node.x = node.layoutTargetX;
|
|
556
|
+
node.y = node.layoutTargetY;
|
|
557
|
+
node.vx = 0;
|
|
558
|
+
node.vy = 0;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
configureSimulationForCurrentLayout(shouldAnimateNonForceLayout = false) {
|
|
562
|
+
if (!this.graph)
|
|
563
|
+
return;
|
|
564
|
+
if (this.isForceLayoutMode()) {
|
|
565
|
+
this.setupForces();
|
|
566
|
+
const cooldownTicks = this.config.cooldownTicks ?? Infinity;
|
|
567
|
+
this.graph.cooldownTicks(cooldownTicks);
|
|
568
|
+
this.updateCanvasSimulationAttribute(cooldownTicks !== 0 && this.data.nodes.length > 0);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
this.setupAnchoredLayoutForces();
|
|
572
|
+
if (shouldAnimateNonForceLayout) {
|
|
573
|
+
this.startNonForceSettleAnimation(NON_FORCE_LAYOUT_COOLDOWN_TICKS);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
this.graph.cooldownTicks(0);
|
|
577
|
+
this.updateCanvasSimulationAttribute(false);
|
|
578
|
+
}
|
|
297
579
|
triggerRender() {
|
|
298
580
|
if (!this.graph || this.graph.cooldownTicks() !== 0)
|
|
299
581
|
return;
|
|
@@ -408,6 +690,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
408
690
|
if (this.graph && width > 0 && height > 0) {
|
|
409
691
|
this.log('Container resized to:', width, 'x', height);
|
|
410
692
|
this.graph.width(width).height(height);
|
|
693
|
+
this.recomputeCullingBoundsIfNeeded();
|
|
411
694
|
}
|
|
412
695
|
}
|
|
413
696
|
});
|
|
@@ -420,7 +703,6 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
420
703
|
this.calculateNodeDegree();
|
|
421
704
|
// Initialize force-graph
|
|
422
705
|
// Cast to any for the factory call pattern, result is properly typed as ForceGraphInstance
|
|
423
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
424
706
|
this.graph = ForceGraph()(this.container)
|
|
425
707
|
.width(this.config.width || 800)
|
|
426
708
|
.height(this.config.height || 600)
|
|
@@ -435,7 +717,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
435
717
|
.linkCurvature("curve")
|
|
436
718
|
.linkVisibility("visible")
|
|
437
719
|
.nodeVisibility("visible")
|
|
438
|
-
.cooldownTicks(this.config.cooldownTicks ?? Infinity) // undefined = infinite
|
|
720
|
+
.cooldownTicks(this.isForceLayoutMode() ? (this.config.cooldownTicks ?? Infinity) : 0) // undefined = infinite
|
|
439
721
|
.cooldownTime(this.config.cooldownTime ?? 2000)
|
|
440
722
|
.enableNodeDrag(true)
|
|
441
723
|
.enableZoomInteraction(true)
|
|
@@ -464,6 +746,12 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
464
746
|
if (this.config.onNodeHover) {
|
|
465
747
|
this.config.onNodeHover(node);
|
|
466
748
|
}
|
|
749
|
+
})
|
|
750
|
+
.onNodeDrag((node) => {
|
|
751
|
+
this.handleNodeDrag(node);
|
|
752
|
+
})
|
|
753
|
+
.onNodeDragEnd((node) => {
|
|
754
|
+
this.handleNodeDragEnd(node);
|
|
467
755
|
})
|
|
468
756
|
.onLinkHover((link) => {
|
|
469
757
|
if (this.config.onLinkHover) {
|
|
@@ -481,6 +769,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
481
769
|
}
|
|
482
770
|
})
|
|
483
771
|
.onZoom((transform) => {
|
|
772
|
+
this.updateCullingBounds(transform);
|
|
484
773
|
if (this.config.onZoom) {
|
|
485
774
|
this.config.onZoom(transform);
|
|
486
775
|
}
|
|
@@ -523,8 +812,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
523
812
|
this.pointerLink(link, color, ctx);
|
|
524
813
|
}
|
|
525
814
|
});
|
|
526
|
-
|
|
527
|
-
this.setupForces();
|
|
815
|
+
this.configureSimulationForCurrentLayout();
|
|
528
816
|
this.log('Force graph initialization complete');
|
|
529
817
|
}
|
|
530
818
|
setupForces() {
|
|
@@ -534,6 +822,8 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
534
822
|
return;
|
|
535
823
|
if (!this.graph)
|
|
536
824
|
return;
|
|
825
|
+
this.graph.d3Force("layoutTargetX", null);
|
|
826
|
+
this.graph.d3Force("layoutTargetY", null);
|
|
537
827
|
// distance based on node size + constant
|
|
538
828
|
linkForce
|
|
539
829
|
.distance((link) => {
|
|
@@ -564,11 +854,148 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
564
854
|
}
|
|
565
855
|
this.log('Force simulation setup complete');
|
|
566
856
|
}
|
|
857
|
+
/**
|
|
858
|
+
* Recompute the world-space culling bounds from the d3-zoom transform delivered
|
|
859
|
+
* by force-graph's `onZoom` callback.
|
|
860
|
+
*
|
|
861
|
+
* The d3-zoom transform maps world → screen as:
|
|
862
|
+
* screen_x = world_x * k + tx
|
|
863
|
+
* screen_y = world_y * k + ty
|
|
864
|
+
* Inverting for the canvas edges (screen_x ∈ [0, W], screen_y ∈ [0, H]):
|
|
865
|
+
* world_x ∈ [(0 − tx) / k, (W − tx) / k]
|
|
866
|
+
* world_y ∈ [(0 − ty) / k, (H − ty) / k]
|
|
867
|
+
*/
|
|
868
|
+
updateCullingBounds(transform) {
|
|
869
|
+
this.lastTransform = transform;
|
|
870
|
+
if (!this.config.largeGraph?.enabled) {
|
|
871
|
+
this.cullingBounds = null;
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
const w = this.graph?.width() ?? 0;
|
|
875
|
+
const h = this.graph?.height() ?? 0;
|
|
876
|
+
const { k, x: tx, y: ty } = transform;
|
|
877
|
+
if (k <= 0 || w <= 0 || h <= 0) {
|
|
878
|
+
this.cullingBounds = null;
|
|
879
|
+
this.cullingZoom = 1;
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
const padding = this.config.largeGraph?.viewportPadding ?? 0;
|
|
883
|
+
this.cullingBounds = {
|
|
884
|
+
minX: -tx / k - padding,
|
|
885
|
+
maxX: (w - tx) / k + padding,
|
|
886
|
+
minY: -ty / k - padding,
|
|
887
|
+
maxY: (h - ty) / k + padding,
|
|
888
|
+
};
|
|
889
|
+
this.cullingZoom = k;
|
|
890
|
+
}
|
|
891
|
+
/** Recompute culling bounds using the last known transform (e.g. after resize). */
|
|
892
|
+
recomputeCullingBoundsIfNeeded() {
|
|
893
|
+
if (!this.config.largeGraph?.enabled)
|
|
894
|
+
return;
|
|
895
|
+
if (this.lastTransform) {
|
|
896
|
+
this.updateCullingBounds(this.lastTransform);
|
|
897
|
+
}
|
|
898
|
+
else if (this.graph) {
|
|
899
|
+
// Seed initial transform from current graph state before first onZoom fires.
|
|
900
|
+
const k = this.graph.zoom() ?? 1;
|
|
901
|
+
const center = this.graph.centerAt() ?? { x: 0, y: 0 };
|
|
902
|
+
const w = this.graph.width() ?? 0;
|
|
903
|
+
const h = this.graph.height() ?? 0;
|
|
904
|
+
if (k > 0 && w > 0 && h > 0) {
|
|
905
|
+
const tx = w / 2 - center.x * k;
|
|
906
|
+
const ty = h / 2 - center.y * k;
|
|
907
|
+
this.updateCullingBounds({ k, x: tx, y: ty });
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Returns `true` when the node is (at least partially) inside the current
|
|
913
|
+
* culling viewport, or when culling is disabled / bounds are not yet known.
|
|
914
|
+
*/
|
|
915
|
+
isNodeInCullingBounds(node) {
|
|
916
|
+
if (!this.cullingBounds)
|
|
917
|
+
return true;
|
|
918
|
+
const { minX, maxX, minY, maxY } = this.cullingBounds;
|
|
919
|
+
const r = node.size + PADDING;
|
|
920
|
+
const x = node.x ?? 0;
|
|
921
|
+
const y = node.y ?? 0;
|
|
922
|
+
return x + r >= minX && x - r <= maxX && y + r >= minY && y - r <= maxY;
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Returns `true` when a link's visual representation overlaps the current
|
|
926
|
+
* culling viewport, or when culling is disabled / bounds are not yet known.
|
|
927
|
+
*
|
|
928
|
+
* For straight / quadratic-bezier links the test uses the convex-hull bounding
|
|
929
|
+
* box of (source, control point, target), which is always a conservative
|
|
930
|
+
* (never-false-negative) bound. For self-loops the test uses a square of
|
|
931
|
+
* side ≈ the loop diameter centred on the node.
|
|
932
|
+
*/
|
|
933
|
+
isLinkInCullingBounds(link) {
|
|
934
|
+
if (!this.cullingBounds)
|
|
935
|
+
return true;
|
|
936
|
+
const { minX, maxX, minY, maxY } = this.cullingBounds;
|
|
937
|
+
const sx = link.source.x ?? 0;
|
|
938
|
+
const sy = link.source.y ?? 0;
|
|
939
|
+
const ex = link.target.x ?? 0;
|
|
940
|
+
const ey = link.target.y ?? 0;
|
|
941
|
+
if (link.source.id === link.target.id) {
|
|
942
|
+
// Self-loop: the cubic bezier extends roughly |curve| * nodeSize * factor
|
|
943
|
+
// away from the node centre. Use that as a conservative radius.
|
|
944
|
+
const nodeSize = link.source.size || NODE_SIZE;
|
|
945
|
+
const loopRadius = Math.abs(link.curve || 1) * nodeSize * SELF_LOOP_CURVE_FACTOR;
|
|
946
|
+
return (sx + loopRadius >= minX && sx - loopRadius <= maxX &&
|
|
947
|
+
sy + loopRadius >= minY && sy - loopRadius <= maxY);
|
|
948
|
+
}
|
|
949
|
+
// Compute quadratic-bezier control point (same formula as drawLink).
|
|
950
|
+
const dx = ex - sx;
|
|
951
|
+
const dy = ey - sy;
|
|
952
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
953
|
+
if (distance === 0) {
|
|
954
|
+
// Co-located nodes: just check the point.
|
|
955
|
+
return sx >= minX && sx <= maxX && sy >= minY && sy <= maxY;
|
|
956
|
+
}
|
|
957
|
+
const curvature = link.curve ?? 0;
|
|
958
|
+
const perpX = dy / distance;
|
|
959
|
+
const perpY = -dx / distance;
|
|
960
|
+
const cx = (sx + ex) / 2 + perpX * curvature * distance;
|
|
961
|
+
const cy = (sy + ey) / 2 + perpY * curvature * distance;
|
|
962
|
+
// Convex-hull AABB of the three control points.
|
|
963
|
+
const lMinX = Math.min(sx, ex, cx);
|
|
964
|
+
const lMaxX = Math.max(sx, ex, cx);
|
|
965
|
+
const lMinY = Math.min(sy, ey, cy);
|
|
966
|
+
const lMaxY = Math.max(sy, ey, cy);
|
|
967
|
+
return lMaxX >= minX && lMinX <= maxX && lMaxY >= minY && lMinY <= maxY;
|
|
968
|
+
}
|
|
969
|
+
handleNodeDrag(node) {
|
|
970
|
+
if (this.isForceLayoutMode())
|
|
971
|
+
return;
|
|
972
|
+
if (node.x === undefined || node.y === undefined)
|
|
973
|
+
return;
|
|
974
|
+
node.layoutTargetX = node.x;
|
|
975
|
+
node.layoutTargetY = node.y;
|
|
976
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
977
|
+
this.startNonForceSettleAnimation(NON_FORCE_DRAG_COOLDOWN_TICKS);
|
|
978
|
+
}
|
|
979
|
+
handleNodeDragEnd(node) {
|
|
980
|
+
if (this.isForceLayoutMode())
|
|
981
|
+
return;
|
|
982
|
+
if (node.x === undefined || node.y === undefined)
|
|
983
|
+
return;
|
|
984
|
+
node.layoutTargetX = node.x;
|
|
985
|
+
node.layoutTargetY = node.y;
|
|
986
|
+
node.fx = undefined;
|
|
987
|
+
node.fy = undefined;
|
|
988
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
989
|
+
this.startNonForceSettleAnimation(NON_FORCE_DRAG_COOLDOWN_TICKS);
|
|
990
|
+
}
|
|
567
991
|
drawNode(node, ctx) {
|
|
568
992
|
if (node.x === undefined || node.y === undefined) {
|
|
569
993
|
node.x = 0;
|
|
570
994
|
node.y = 0;
|
|
571
995
|
}
|
|
996
|
+
// Viewport culling: skip nodes that are entirely outside the visible area.
|
|
997
|
+
if (this.config.largeGraph?.enabled && !this.isNodeInCullingBounds(node))
|
|
998
|
+
return;
|
|
572
999
|
ctx.lineWidth = this.config.isNodeSelected?.(node) ? 1 : 0.5;
|
|
573
1000
|
ctx.strokeStyle = this.config.foregroundColor;
|
|
574
1001
|
ctx.fillStyle = node.color;
|
|
@@ -579,6 +1006,12 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
579
1006
|
ctx.beginPath();
|
|
580
1007
|
ctx.arc(node.x, node.y, node.size, 0, 2 * Math.PI, false);
|
|
581
1008
|
ctx.fill();
|
|
1009
|
+
// Low-zoom optimisation: skip labels when they would be too small to read.
|
|
1010
|
+
const skipLabels = this.config.largeGraph?.enabled &&
|
|
1011
|
+
(this.config.largeGraph?.skipLabelsAtLowZoom ?? true) &&
|
|
1012
|
+
this.cullingZoom < (this.config.largeGraph?.lowZoomThreshold ?? 0.5);
|
|
1013
|
+
if (skipLabels)
|
|
1014
|
+
return;
|
|
582
1015
|
// Draw text
|
|
583
1016
|
ctx.fillStyle = getContrastTextColor(node.color);
|
|
584
1017
|
ctx.textAlign = "center";
|
|
@@ -591,32 +1024,46 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
591
1024
|
ctx.font = `400 ${NODE_FONT_SIZE_BASE}px SofiaSans`;
|
|
592
1025
|
[line1, line2] = wrapTextForCircularNode(ctx, text, textRadius);
|
|
593
1026
|
let chosenSize = NODE_FONT_SIZE_BASE;
|
|
1027
|
+
// Measure at a large reference size (20px) where canvas metrics are
|
|
1028
|
+
// precise, then compute the exact scale to fill the node.
|
|
1029
|
+
const REF = 20;
|
|
1030
|
+
ctx.font = `400 ${REF}px SofiaSans`;
|
|
1031
|
+
// Switch to "left" for measurement: actualBoundingBoxLeft/Right are
|
|
1032
|
+
// unreliable with textAlign="center" and can double on some engines.
|
|
1033
|
+
ctx.textAlign = "left";
|
|
1034
|
+
const refMetrics = ctx.measureText(line1);
|
|
1035
|
+
// Use the actual visual bounding box (not advance width) so glyphs
|
|
1036
|
+
// with overshoot (e.g. "7") are fully accounted for.
|
|
1037
|
+
const visualWidth = (refMetrics.actualBoundingBoxLeft ?? 0)
|
|
1038
|
+
+ (refMetrics.actualBoundingBoxRight ?? 0);
|
|
1039
|
+
let refWidth = Math.max(visualWidth, refMetrics.width);
|
|
1040
|
+
const singleLineHeight = (refMetrics.actualBoundingBoxAscent ?? 0)
|
|
1041
|
+
+ (refMetrics.actualBoundingBoxDescent ?? 0);
|
|
1042
|
+
let refHeight;
|
|
594
1043
|
if (!line2) {
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
chosenSize = REF * (2 * r / refWidth);
|
|
619
|
-
}
|
|
1044
|
+
refHeight = singleLineHeight;
|
|
1045
|
+
}
|
|
1046
|
+
else {
|
|
1047
|
+
// Two-line: use the wider line and account for the vertical span
|
|
1048
|
+
// of both lines including the 1.5× spacing used by the rendering code.
|
|
1049
|
+
const m2 = ctx.measureText(line2);
|
|
1050
|
+
const vis2 = Math.max((m2.actualBoundingBoxLeft ?? 0) + (m2.actualBoundingBoxRight ?? 0), m2.width);
|
|
1051
|
+
refWidth = Math.max(refWidth, vis2);
|
|
1052
|
+
refHeight = singleLineHeight * 2.5;
|
|
1053
|
+
}
|
|
1054
|
+
ctx.textAlign = "center";
|
|
1055
|
+
// Inscribed-rectangle-in-circle constraint: every corner of the text
|
|
1056
|
+
// bounding box must lie inside the circle, i.e.
|
|
1057
|
+
// sqrt((w/2)² + (h/2)²) ≤ r
|
|
1058
|
+
// Solving for the uniform scale factor s:
|
|
1059
|
+
// s = 2·r / sqrt(refWidth² + refHeight²)
|
|
1060
|
+
const r = NODE_TEXT_FILL_RATIO * textRadius;
|
|
1061
|
+
if (refWidth > 0 && refHeight > 0) {
|
|
1062
|
+
const diagonal = Math.sqrt(refWidth * refWidth + refHeight * refHeight);
|
|
1063
|
+
chosenSize = REF * (2 * r / diagonal);
|
|
1064
|
+
}
|
|
1065
|
+
else if (refWidth > 0) {
|
|
1066
|
+
chosenSize = REF * (2 * r / refWidth);
|
|
620
1067
|
}
|
|
621
1068
|
ctx.font = `400 ${chosenSize}px SofiaSans`;
|
|
622
1069
|
node.displayName = [line1, line2];
|
|
@@ -650,6 +1097,9 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
650
1097
|
node.y = 0;
|
|
651
1098
|
}
|
|
652
1099
|
;
|
|
1100
|
+
// Viewport culling: skip hit-test painting for offscreen nodes.
|
|
1101
|
+
if (this.config.largeGraph?.enabled && !this.isNodeInCullingBounds(node))
|
|
1102
|
+
return;
|
|
653
1103
|
const radius = node.size + PADDING;
|
|
654
1104
|
ctx.fillStyle = color;
|
|
655
1105
|
ctx.beginPath();
|
|
@@ -665,17 +1115,27 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
665
1115
|
end.x = 0;
|
|
666
1116
|
end.y = 0;
|
|
667
1117
|
}
|
|
1118
|
+
// Viewport culling: skip links whose visual extent is entirely outside the
|
|
1119
|
+
// visible area. The check is conservative (convex-hull AABB) so it never
|
|
1120
|
+
// produces false negatives.
|
|
1121
|
+
if (this.config.largeGraph?.enabled && !this.isLinkInCullingBounds(link))
|
|
1122
|
+
return;
|
|
668
1123
|
let textX;
|
|
669
1124
|
let textY;
|
|
670
1125
|
let angle;
|
|
671
1126
|
const isLinkSelected = this.config.isLinkSelected?.(link) ?? false;
|
|
672
1127
|
const arrowLen = isLinkSelected ? 4 : 2;
|
|
1128
|
+
// Low-zoom flags – evaluated once per link draw.
|
|
1129
|
+
const lowZoomThreshold = this.config.largeGraph?.lowZoomThreshold ?? 0.5;
|
|
1130
|
+
const atLowZoom = this.config.largeGraph?.enabled && this.cullingZoom < lowZoomThreshold;
|
|
1131
|
+
const skipArrows = atLowZoom && (this.config.largeGraph?.skipArrowsAtLowZoom ?? true);
|
|
1132
|
+
const skipLinkLabels = atLowZoom && (this.config.largeGraph?.skipLinkLabelsAtLowZoom ?? true);
|
|
673
1133
|
// Deferred arrowhead — drawn after the label so it is never covered by
|
|
674
1134
|
// the label background rect (which happens for short links where the
|
|
675
1135
|
// bezier midpoint and the arrow tip are at almost the same position).
|
|
676
1136
|
let pendingArrow = null;
|
|
677
1137
|
if (start.id === end.id) {
|
|
678
|
-
const nodeSize = start.size ||
|
|
1138
|
+
const nodeSize = start.size || NODE_SIZE;
|
|
679
1139
|
const d = (link.curve || 0) * nodeSize * SELF_LOOP_CURVE_FACTOR;
|
|
680
1140
|
ctx.lineWidth = (isLinkSelected ? 2 : 1) / globalScale;
|
|
681
1141
|
if (this.config.linkLineDash)
|
|
@@ -737,7 +1197,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
737
1197
|
// Guard against zero-length tangent vector (e.g. when d ≈ 0) to avoid NaN
|
|
738
1198
|
// normals and invalid arrowhead geometry. Also skip when d is too small to
|
|
739
1199
|
// place the arrowhead at the node border (canReachBorder is false).
|
|
740
|
-
if (tLen !== 0 && canReachBorder) {
|
|
1200
|
+
if (!skipArrows && tLen !== 0 && canReachBorder) {
|
|
741
1201
|
const nx = tdx / tLen;
|
|
742
1202
|
const ny = tdy / tLen;
|
|
743
1203
|
pendingArrow = { tipX, tipY, nx, ny, arrowLen, arrowHalfWidth };
|
|
@@ -786,7 +1246,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
786
1246
|
// Draw regular link line and arrowhead
|
|
787
1247
|
const arrowHalfWidth = arrowLen / ARROW_WH_RATIO / 2;
|
|
788
1248
|
// Target-side clip: find t where bezier enters target node border + PADDING
|
|
789
|
-
const endNodeSize = end.size ||
|
|
1249
|
+
const endNodeSize = end.size || NODE_SIZE;
|
|
790
1250
|
const borderRadius = endNodeSize + (this.config.isNodeSelected?.(end) ? 1 : 0.5) + PADDING;
|
|
791
1251
|
const borderRadiusSq = borderRadius * borderRadius;
|
|
792
1252
|
let tArrow;
|
|
@@ -815,7 +1275,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
815
1275
|
const tipX = uArrow * uArrow * start.x + 2 * uArrow * tArrow * controlX + tArrow * tArrow * end.x;
|
|
816
1276
|
const tipY = uArrow * uArrow * start.y + 2 * uArrow * tArrow * controlY + tArrow * tArrow * end.y;
|
|
817
1277
|
// Source-side clip: find t where bezier exits source node border + PADDING
|
|
818
|
-
const startNodeSize = start.size ||
|
|
1278
|
+
const startNodeSize = start.size || NODE_SIZE;
|
|
819
1279
|
const srcBorderRadius = startNodeSize + (this.config.isNodeSelected?.(start) ? 1 : 0.5) + PADDING;
|
|
820
1280
|
const srcBorderRadiusSq = srcBorderRadius * srcBorderRadius;
|
|
821
1281
|
let tStart = 0;
|
|
@@ -863,7 +1323,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
863
1323
|
const atx = 2 * uArrow * (controlX - start.x) + 2 * tArrow * (end.x - controlX);
|
|
864
1324
|
const aty = 2 * uArrow * (controlY - start.y) + 2 * tArrow * (end.y - controlY);
|
|
865
1325
|
const atLen = Math.sqrt(atx * atx + aty * aty);
|
|
866
|
-
if (atLen !== 0) {
|
|
1326
|
+
if (!skipArrows && atLen !== 0) {
|
|
867
1327
|
const nx = atx / atLen;
|
|
868
1328
|
const ny = aty / atLen;
|
|
869
1329
|
pendingArrow = { tipX, tipY, nx, ny, arrowLen, arrowHalfWidth };
|
|
@@ -873,40 +1333,42 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
873
1333
|
ctx.textAlign = "center";
|
|
874
1334
|
// Draw text with alphabetic baseline, positioned so visual center is at y=0
|
|
875
1335
|
ctx.textBaseline = "alphabetic";
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1336
|
+
if (!skipLinkLabels) {
|
|
1337
|
+
// Separate cache entries per weight so each state is measured with its own
|
|
1338
|
+
// font, giving equal visual padding regardless of selection state.
|
|
1339
|
+
const cacheKey = `${link.relationship}_${isLinkSelected ? "700" : "400"}`;
|
|
1340
|
+
let cached = this.relationshipsTextCache.get(cacheKey);
|
|
1341
|
+
if (!cached) {
|
|
1342
|
+
// ctx.font is already set to the correct weight above; measure it directly.
|
|
1343
|
+
const metrics = ctx.measureText(link.relationship);
|
|
1344
|
+
// Use actual ink bounds for vertical metrics; fontBoundingBox* is the full
|
|
1345
|
+
// line-box and adds excessive space for lighter weights.
|
|
1346
|
+
// Use metrics.width for horizontal extent: actualBoundingBoxLeft/Right are
|
|
1347
|
+
// unreliable with textAlign="center" and can double the value on some engines.
|
|
1348
|
+
const inkAscent = metrics.actualBoundingBoxAscent ?? metrics.fontBoundingBoxAscent;
|
|
1349
|
+
const inkDescent = metrics.actualBoundingBoxDescent ?? metrics.fontBoundingBoxDescent;
|
|
1350
|
+
const inkWidth = metrics.width;
|
|
1351
|
+
const bgPadding = 0.3;
|
|
1352
|
+
cached = {
|
|
1353
|
+
textWidth: inkWidth + bgPadding * 2,
|
|
1354
|
+
textHeight: inkAscent + inkDescent + bgPadding * 2,
|
|
1355
|
+
// Shift baseline up so the ink block is centred inside the bg rect.
|
|
1356
|
+
textYOffset: (inkAscent - inkDescent) / 2,
|
|
1357
|
+
};
|
|
1358
|
+
this.relationshipsTextCache.set(cacheKey, cached);
|
|
1359
|
+
}
|
|
1360
|
+
const { textWidth, textHeight, textYOffset } = cached;
|
|
1361
|
+
ctx.save();
|
|
1362
|
+
ctx.translate(textX, textY);
|
|
1363
|
+
ctx.rotate(angle);
|
|
1364
|
+
// Draw background centered on the link line (y=0)
|
|
1365
|
+
ctx.fillStyle = this.config.backgroundColor;
|
|
1366
|
+
// Offset background to match text visual center
|
|
1367
|
+
ctx.fillRect(-textWidth / 2, -textHeight / 2, textWidth, textHeight);
|
|
1368
|
+
ctx.fillStyle = getContrastTextColor(this.config.backgroundColor);
|
|
1369
|
+
ctx.fillText(link.relationship, 0, textYOffset);
|
|
1370
|
+
ctx.restore();
|
|
1371
|
+
}
|
|
910
1372
|
// Draw arrowhead last so it always appears on top of the label background.
|
|
911
1373
|
if (pendingArrow) {
|
|
912
1374
|
const { tipX, tipY, nx, ny, arrowLen: aLen, arrowHalfWidth: aHW } = pendingArrow;
|
|
@@ -924,6 +1386,9 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
924
1386
|
const end = link.target;
|
|
925
1387
|
if (start.x == null || start.y == null || end.x == null || end.y == null)
|
|
926
1388
|
return;
|
|
1389
|
+
// Viewport culling: skip hit-test painting for offscreen links.
|
|
1390
|
+
if (this.config.largeGraph?.enabled && !this.isLinkInCullingBounds(link))
|
|
1391
|
+
return;
|
|
927
1392
|
ctx.strokeStyle = color;
|
|
928
1393
|
const basePointerWidth = 10; // Desired on-screen pointer area thickness
|
|
929
1394
|
const transform = typeof ctx.getTransform === 'function' ? ctx.getTransform() : null;
|
|
@@ -939,7 +1404,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
939
1404
|
ctx.beginPath();
|
|
940
1405
|
if (start.id === end.id) {
|
|
941
1406
|
// Self-loop: replicate exact cubic bezier clip from drawLink
|
|
942
|
-
const nodeSize = start.size ||
|
|
1407
|
+
const nodeSize = start.size || NODE_SIZE;
|
|
943
1408
|
const d = (link.curve || 0) * nodeSize * SELF_LOOP_CURVE_FACTOR;
|
|
944
1409
|
const nodeStrokeWidth = this.config.isNodeSelected?.(start) ? 1 : 0.5;
|
|
945
1410
|
const borderRadius = nodeSize + nodeStrokeWidth + PADDING;
|
|
@@ -984,7 +1449,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
984
1449
|
const controlX = (start.x + end.x) / 2 + perpX * curvature * distance;
|
|
985
1450
|
const controlY = (start.y + end.y) / 2 + perpY * curvature * distance;
|
|
986
1451
|
// Use the same borderRadius and binary-search clip as drawLink
|
|
987
|
-
const endNodeSize = end.size ||
|
|
1452
|
+
const endNodeSize = end.size || NODE_SIZE;
|
|
988
1453
|
const borderRadius = endNodeSize + (this.config.isNodeSelected?.(end) ? 1 : 0.5) + PADDING;
|
|
989
1454
|
const borderRadiusSq = borderRadius * borderRadius;
|
|
990
1455
|
let tArrow;
|
|
@@ -1013,7 +1478,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
1013
1478
|
const tipX = uArrow * uArrow * start.x + 2 * uArrow * tArrow * controlX + tArrow * tArrow * end.x;
|
|
1014
1479
|
const tipY = uArrow * uArrow * start.y + 2 * uArrow * tArrow * controlY + tArrow * tArrow * end.y;
|
|
1015
1480
|
// Source-side clip: mirror of drawLink source gap
|
|
1016
|
-
const startNodeSize = start.size ||
|
|
1481
|
+
const startNodeSize = start.size || NODE_SIZE;
|
|
1017
1482
|
const srcBorderRadius = startNodeSize + (this.config.isNodeSelected?.(start) ? 1 : 0.5) + PADDING;
|
|
1018
1483
|
const srcBorderRadiusSq = srcBorderRadius * srcBorderRadius;
|
|
1019
1484
|
let tStart = 0;
|
|
@@ -1068,6 +1533,16 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
1068
1533
|
if (!this.graph)
|
|
1069
1534
|
return;
|
|
1070
1535
|
this.log('Engine stopped');
|
|
1536
|
+
if (!this.isForceLayoutMode()) {
|
|
1537
|
+
this.applyLayoutTargets();
|
|
1538
|
+
this.graph.cooldownTicks(0);
|
|
1539
|
+
this.updateCanvasSimulationAttribute(false);
|
|
1540
|
+
if (this.shouldZoomToFitOnNonForceSettle && this.data.nodes.length > 0) {
|
|
1541
|
+
this.zoomToFit(1);
|
|
1542
|
+
}
|
|
1543
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1071
1546
|
// If already stopped, just ensure any leftover loading state is cleared and return
|
|
1072
1547
|
if (this.config.cooldownTicks === 0) {
|
|
1073
1548
|
if (this.config.isLoading) {
|
|
@@ -1136,6 +1611,12 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
1136
1611
|
if (this.config.onNodeHover) {
|
|
1137
1612
|
this.config.onNodeHover(node);
|
|
1138
1613
|
}
|
|
1614
|
+
})
|
|
1615
|
+
.onNodeDrag((node) => {
|
|
1616
|
+
this.handleNodeDrag(node);
|
|
1617
|
+
})
|
|
1618
|
+
.onNodeDragEnd((node) => {
|
|
1619
|
+
this.handleNodeDragEnd(node);
|
|
1139
1620
|
})
|
|
1140
1621
|
.onLinkHover((link) => {
|
|
1141
1622
|
if (this.config.onLinkHover) {
|
|
@@ -1153,6 +1634,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
1153
1634
|
}
|
|
1154
1635
|
})
|
|
1155
1636
|
.onZoom((transform) => {
|
|
1637
|
+
this.updateCullingBounds(transform);
|
|
1156
1638
|
if (this.config.onZoom) {
|
|
1157
1639
|
this.config.onZoom(transform);
|
|
1158
1640
|
}
|
|
@@ -1185,7 +1667,9 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
1185
1667
|
});
|
|
1186
1668
|
}
|
|
1187
1669
|
else {
|
|
1188
|
-
this.graph.nodePointerAreaPaint()
|
|
1670
|
+
this.graph.nodePointerAreaPaint((node, color, ctx) => {
|
|
1671
|
+
this.pointerNode(node, color, ctx);
|
|
1672
|
+
});
|
|
1189
1673
|
}
|
|
1190
1674
|
if (this.config.link) {
|
|
1191
1675
|
this.graph.linkPointerAreaPaint((link, color, ctx) => {
|
|
@@ -1193,7 +1677,9 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
1193
1677
|
});
|
|
1194
1678
|
}
|
|
1195
1679
|
else {
|
|
1196
|
-
this.graph.linkPointerAreaPaint()
|
|
1680
|
+
this.graph.linkPointerAreaPaint((link, color, ctx) => {
|
|
1681
|
+
this.pointerLink(link, color, ctx);
|
|
1682
|
+
});
|
|
1197
1683
|
}
|
|
1198
1684
|
}
|
|
1199
1685
|
updateTooltipStyles() {
|