@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/src/canvas.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
/* eslint-disable no-param-reassign */
|
|
2
|
-
|
|
3
1
|
import ForceGraph from "force-graph";
|
|
4
2
|
import * as d3 from "d3";
|
|
5
3
|
import {
|
|
@@ -20,8 +18,10 @@ import {
|
|
|
20
18
|
getNodeDisplayText,
|
|
21
19
|
graphDataToData,
|
|
22
20
|
LINK_DISTANCE,
|
|
21
|
+
NODE_SIZE,
|
|
23
22
|
wrapTextForCircularNode,
|
|
24
23
|
} from "./canvas-utils.js";
|
|
24
|
+
import { applyGraphLayout, isForceLayout } from "./layouts.js";
|
|
25
25
|
|
|
26
26
|
const PADDING = 2;
|
|
27
27
|
// Arrow geometry constants (shared by self-loop and regular-link drawing paths)
|
|
@@ -29,7 +29,7 @@ const ARROW_WH_RATIO = 1.6;
|
|
|
29
29
|
const ARROW_VLEN_RATIO = 0.2;
|
|
30
30
|
// Multiplier to convert node size → cubic bezier control-point distance for self-loops
|
|
31
31
|
const SELF_LOOP_CURVE_FACTOR = 11.67;
|
|
32
|
-
// Base font size used for the initial measurement
|
|
32
|
+
// Base font size used for the initial wrap-measurement pass.
|
|
33
33
|
const NODE_FONT_SIZE_BASE = 2;
|
|
34
34
|
// Fraction of the chord width that single-line text should fill (0–1).
|
|
35
35
|
// Leaves (1 - ratio)/2 of the radius as horizontal padding on each side.
|
|
@@ -40,6 +40,25 @@ const CHARGE_STRENGTH = -400;
|
|
|
40
40
|
const CENTER_STRENGTH = 0.03;
|
|
41
41
|
const VELOCITY_DECAY = 0.4;
|
|
42
42
|
const ALPHA_MIN = 0.05;
|
|
43
|
+
const NON_FORCE_CHARGE_STRENGTH = -220;
|
|
44
|
+
const NON_FORCE_COLLIDE_PADDING = 18;
|
|
45
|
+
const NON_FORCE_CENTER_STRENGTH = 0.02;
|
|
46
|
+
const NON_FORCE_LINK_STRENGTH = 0.08;
|
|
47
|
+
const NON_FORCE_TARGET_STRENGTH = 0.3;
|
|
48
|
+
const NON_FORCE_VELOCITY_DECAY = 0.5;
|
|
49
|
+
const NON_FORCE_ALPHA_MIN = 0.03;
|
|
50
|
+
const NON_FORCE_LAYOUT_COOLDOWN_TICKS = 120;
|
|
51
|
+
const NON_FORCE_DRAG_COOLDOWN_TICKS = 90;
|
|
52
|
+
|
|
53
|
+
type NodePosition = { x: number; y: number };
|
|
54
|
+
|
|
55
|
+
/** Axis-aligned bounding box in world-space coordinates. */
|
|
56
|
+
type WorldBounds = {
|
|
57
|
+
minX: number;
|
|
58
|
+
maxX: number;
|
|
59
|
+
minY: number;
|
|
60
|
+
maxY: number;
|
|
61
|
+
};
|
|
43
62
|
|
|
44
63
|
// Create styles for the web component
|
|
45
64
|
function createStyles(backgroundColor: string, foregroundColor: string): HTMLStyleElement {
|
|
@@ -92,6 +111,8 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
92
111
|
foregroundColor: '#1A1A1A',
|
|
93
112
|
captionsKeys: [],
|
|
94
113
|
showPropertyKeyPrefix: false,
|
|
114
|
+
layoutMode: "force",
|
|
115
|
+
layoutOptions: {},
|
|
95
116
|
};
|
|
96
117
|
|
|
97
118
|
private nodeMode: CanvasRenderMode = 'replace';
|
|
@@ -112,14 +133,32 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
112
133
|
}
|
|
113
134
|
> = new Map();
|
|
114
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Cached world-space axis-aligned bounding box of the currently visible
|
|
138
|
+
* viewport. Updated on every zoom/pan event and on resize.
|
|
139
|
+
* `null` means culling is disabled or not yet computed.
|
|
140
|
+
*/
|
|
141
|
+
private cullingBounds: WorldBounds | null = null;
|
|
142
|
+
|
|
143
|
+
/** Current zoom level, cached alongside cullingBounds. */
|
|
144
|
+
private cullingZoom: number = 1;
|
|
145
|
+
|
|
146
|
+
/** Last d3-zoom transform, cached so bounds can be recomputed on resize. */
|
|
147
|
+
private lastTransform: Transform | null = null;
|
|
148
|
+
|
|
115
149
|
private onFontsLoadingDone = () => {
|
|
116
150
|
this.relationshipsTextCache.clear();
|
|
117
151
|
this.nodeDisplayFontSize.clear();
|
|
152
|
+
for (const node of this.data.nodes) {
|
|
153
|
+
node.displayName = ["", ""];
|
|
154
|
+
}
|
|
118
155
|
this.triggerRender();
|
|
119
156
|
};
|
|
120
157
|
|
|
121
158
|
private viewport: ViewportState;
|
|
122
159
|
|
|
160
|
+
private shouldZoomToFitOnNonForceSettle: boolean = false;
|
|
161
|
+
|
|
123
162
|
constructor() {
|
|
124
163
|
super();
|
|
125
164
|
this.attachShadow({ mode: "open" });
|
|
@@ -176,13 +215,13 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
176
215
|
this.resizeObserver = null;
|
|
177
216
|
}
|
|
178
217
|
if (this.graph) {
|
|
179
|
-
// eslint-disable-next-line no-underscore-dangle
|
|
180
218
|
this.graph._destructor();
|
|
181
219
|
}
|
|
182
220
|
}
|
|
183
221
|
|
|
184
222
|
setConfig(config: Partial<ForceGraphConfig>) {
|
|
185
223
|
this.log('Setting config:', config);
|
|
224
|
+
const layoutChanged = config.layoutMode !== undefined || config.layoutOptions !== undefined;
|
|
186
225
|
|
|
187
226
|
// If captionsKeys changed, invalidate cached display names and font sizes
|
|
188
227
|
// so text is recomputed with the new keys on the next render.
|
|
@@ -193,7 +232,59 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
193
232
|
}
|
|
194
233
|
}
|
|
195
234
|
|
|
196
|
-
|
|
235
|
+
// Deep-merge largeGraph to avoid wiping sibling fields on partial updates.
|
|
236
|
+
if (config.largeGraph && typeof config.largeGraph === 'object' && this.config.largeGraph) {
|
|
237
|
+
const mergedLargeGraph = { ...this.config.largeGraph, ...config.largeGraph };
|
|
238
|
+
Object.assign(this.config, config, { largeGraph: mergedLargeGraph });
|
|
239
|
+
} else {
|
|
240
|
+
Object.assign(this.config, config);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Recompute or clear culling bounds when largeGraph config changes.
|
|
244
|
+
if ('largeGraph' in config) {
|
|
245
|
+
if (this.config.largeGraph?.enabled) {
|
|
246
|
+
this.recomputeCullingBoundsIfNeeded();
|
|
247
|
+
} else {
|
|
248
|
+
this.cullingBounds = null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (layoutChanged) {
|
|
253
|
+
const previousPositions = this.getNodePositionMap();
|
|
254
|
+
if (this.isForceLayoutMode() && this.config.cooldownTicks === 0 && this.data.nodes.length > 0) {
|
|
255
|
+
this.config.cooldownTicks = undefined;
|
|
256
|
+
}
|
|
257
|
+
this.data = applyGraphLayout(this.data, this.config.layoutMode, this.config.layoutOptions);
|
|
258
|
+
const shouldAnimateNonForceLayout = this.prepareNodePositionsForCurrentLayout(previousPositions);
|
|
259
|
+
if (this.graph) {
|
|
260
|
+
this.calculateNodeDegree();
|
|
261
|
+
this.graph.graphData(this.data);
|
|
262
|
+
this.configureSimulationForCurrentLayout(shouldAnimateNonForceLayout);
|
|
263
|
+
if (this.isForceLayoutMode()) {
|
|
264
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
265
|
+
this.config.isLoading = this.data.nodes.length > 0;
|
|
266
|
+
this.config.onLoadingChange?.(this.config.isLoading);
|
|
267
|
+
this.updateLoadingState();
|
|
268
|
+
} else {
|
|
269
|
+
this.config.isLoading = false;
|
|
270
|
+
this.config.onLoadingChange?.(false);
|
|
271
|
+
this.updateLoadingState();
|
|
272
|
+
if (this.data.nodes.length > 0) {
|
|
273
|
+
if (shouldAnimateNonForceLayout) {
|
|
274
|
+
this.shouldZoomToFitOnNonForceSettle = true;
|
|
275
|
+
} else {
|
|
276
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
277
|
+
this.zoomToFit(1);
|
|
278
|
+
}
|
|
279
|
+
} else {
|
|
280
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
281
|
+
}
|
|
282
|
+
if (!shouldAnimateNonForceLayout) {
|
|
283
|
+
this.triggerRender();
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
197
288
|
|
|
198
289
|
// Update event handlers if they were provided
|
|
199
290
|
if (config.onNodeClick || config.onLinkClick || config.onNodeRightClick || config.onLinkRightClick ||
|
|
@@ -210,6 +301,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
210
301
|
this.config.width = width;
|
|
211
302
|
if (this.graph) {
|
|
212
303
|
this.graph.width(width);
|
|
304
|
+
this.recomputeCullingBoundsIfNeeded();
|
|
213
305
|
}
|
|
214
306
|
}
|
|
215
307
|
|
|
@@ -219,6 +311,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
219
311
|
this.config.height = height;
|
|
220
312
|
if (this.graph) {
|
|
221
313
|
this.graph.height(height);
|
|
314
|
+
this.recomputeCullingBoundsIfNeeded();
|
|
222
315
|
}
|
|
223
316
|
}
|
|
224
317
|
|
|
@@ -255,10 +348,12 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
255
348
|
this.log('Setting cooldown ticks to:', ticks);
|
|
256
349
|
this.config.cooldownTicks = ticks;
|
|
257
350
|
if (this.graph) {
|
|
258
|
-
this.graph.cooldownTicks(ticks ?? Infinity);
|
|
351
|
+
this.graph.cooldownTicks(this.isForceLayoutMode() ? (ticks ?? Infinity) : 0);
|
|
259
352
|
}
|
|
260
353
|
|
|
261
|
-
this.updateCanvasSimulationAttribute(
|
|
354
|
+
this.updateCanvasSimulationAttribute(
|
|
355
|
+
this.isForceLayoutMode() && ticks !== 0 && this.data.nodes.length > 0
|
|
356
|
+
);
|
|
262
357
|
}
|
|
263
358
|
|
|
264
359
|
getData(): Data {
|
|
@@ -267,17 +362,33 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
267
362
|
|
|
268
363
|
setData(data: Data) {
|
|
269
364
|
this.log('setData called with', data.nodes.length, 'nodes and', data.links.length, 'links');
|
|
270
|
-
|
|
271
|
-
|
|
365
|
+
const previousPositions = this.getNodePositionMap();
|
|
366
|
+
const oldNodesMap = new Map<number, GraphNode>();
|
|
367
|
+
for (const node of this.data.nodes) {
|
|
368
|
+
oldNodesMap.set(node.id, node);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Convert data and preserve positions for existing nodes
|
|
372
|
+
this.data = dataToGraphData(data, undefined, oldNodesMap);
|
|
373
|
+
this.data = applyGraphLayout(this.data, this.config.layoutMode, this.config.layoutOptions);
|
|
374
|
+
const shouldAnimateNonForceLayout = this.prepareNodePositionsForCurrentLayout(previousPositions);
|
|
272
375
|
|
|
273
|
-
|
|
274
|
-
|
|
376
|
+
if (this.isForceLayoutMode()) {
|
|
377
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
378
|
+
this.config.cooldownTicks = this.data.nodes.length > 0 ? undefined : 0;
|
|
379
|
+
this.config.isLoading = this.data.nodes.length > 0;
|
|
380
|
+
} else {
|
|
381
|
+
this.config.cooldownTicks = 0;
|
|
382
|
+
this.config.isLoading = false;
|
|
383
|
+
}
|
|
275
384
|
this.log('Loading state:', this.config.isLoading);
|
|
276
385
|
this.config.onLoadingChange?.(this.config.isLoading);
|
|
277
386
|
|
|
278
387
|
// Update simulation state
|
|
279
|
-
if (this.data.nodes.length > 0) {
|
|
388
|
+
if (this.data.nodes.length > 0 && this.isForceLayoutMode()) {
|
|
280
389
|
this.updateCanvasSimulationAttribute(true);
|
|
390
|
+
} else {
|
|
391
|
+
this.updateCanvasSimulationAttribute(false);
|
|
281
392
|
}
|
|
282
393
|
|
|
283
394
|
// Initialize graph if it hasn't been initialized yet
|
|
@@ -290,12 +401,23 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
290
401
|
|
|
291
402
|
this.log('Calculating node degrees and setting up forces');
|
|
292
403
|
this.calculateNodeDegree();
|
|
293
|
-
this.setupForces();
|
|
294
404
|
|
|
295
405
|
// Update graph data and properties
|
|
296
406
|
this.graph
|
|
297
|
-
.graphData(this.data)
|
|
298
|
-
|
|
407
|
+
.graphData(this.data);
|
|
408
|
+
this.configureSimulationForCurrentLayout(shouldAnimateNonForceLayout);
|
|
409
|
+
|
|
410
|
+
if (!this.isForceLayoutMode() && this.data.nodes.length > 0) {
|
|
411
|
+
if (shouldAnimateNonForceLayout) {
|
|
412
|
+
this.shouldZoomToFitOnNonForceSettle = true;
|
|
413
|
+
} else {
|
|
414
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
415
|
+
this.zoomToFit(1);
|
|
416
|
+
this.triggerRender();
|
|
417
|
+
}
|
|
418
|
+
} else {
|
|
419
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
420
|
+
}
|
|
299
421
|
|
|
300
422
|
this.updateLoadingState();
|
|
301
423
|
}
|
|
@@ -325,16 +447,28 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
325
447
|
|
|
326
448
|
setGraphData(data: GraphData) {
|
|
327
449
|
this.log('setGraphData called with', data.nodes.length, 'nodes and', data.links.length, 'links');
|
|
328
|
-
|
|
329
|
-
this.
|
|
450
|
+
this.data = applyGraphLayout(data, this.config.layoutMode, this.config.layoutOptions);
|
|
451
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
330
452
|
|
|
331
453
|
if (!this.graph) return;
|
|
332
454
|
|
|
333
455
|
this.calculateNodeDegree();
|
|
334
|
-
this.setupForces();
|
|
335
456
|
|
|
336
457
|
this.graph
|
|
337
|
-
.graphData(this.data)
|
|
458
|
+
.graphData(this.data);
|
|
459
|
+
|
|
460
|
+
// setGraphData restores pre-positioned data — freeze simulation, just render.
|
|
461
|
+
this.config.cooldownTicks = 0;
|
|
462
|
+
this.graph.cooldownTicks(0);
|
|
463
|
+
this.updateCanvasSimulationAttribute(false);
|
|
464
|
+
|
|
465
|
+
if (this.data.nodes.length > 0) {
|
|
466
|
+
this.triggerRender();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
this.config.isLoading = false;
|
|
470
|
+
this.config.onLoadingChange?.(false);
|
|
471
|
+
this.updateLoadingState();
|
|
338
472
|
|
|
339
473
|
if (this.viewport) {
|
|
340
474
|
this.log('Applying viewport:', this.viewport);
|
|
@@ -377,6 +511,214 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
377
511
|
this.graph.zoomToFit(500, padding * paddingMultiplier, filter);
|
|
378
512
|
}
|
|
379
513
|
|
|
514
|
+
private isForceLayoutMode() {
|
|
515
|
+
return isForceLayout(this.config.layoutMode);
|
|
516
|
+
}
|
|
517
|
+
private getNodePositionMap(): Map<number, NodePosition> {
|
|
518
|
+
const positions = new Map<number, NodePosition>();
|
|
519
|
+
|
|
520
|
+
for (const node of this.data.nodes) {
|
|
521
|
+
if (node.x === undefined || node.y === undefined) continue;
|
|
522
|
+
positions.set(node.id, { x: node.x, y: node.y });
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return positions;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private getGraphCenter(positions: Map<number, NodePosition>): NodePosition | undefined {
|
|
529
|
+
if (positions.size === 0) return undefined;
|
|
530
|
+
|
|
531
|
+
let sumX = 0;
|
|
532
|
+
let sumY = 0;
|
|
533
|
+
|
|
534
|
+
for (const position of positions.values()) {
|
|
535
|
+
sumX += position.x;
|
|
536
|
+
sumY += position.y;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return {
|
|
540
|
+
x: sumX / positions.size,
|
|
541
|
+
y: sumY / positions.size,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private getConnectedExistingPosition(
|
|
546
|
+
nodeId: number,
|
|
547
|
+
previousPositions: Map<number, NodePosition>
|
|
548
|
+
): NodePosition | undefined {
|
|
549
|
+
let sumX = 0;
|
|
550
|
+
let sumY = 0;
|
|
551
|
+
let count = 0;
|
|
552
|
+
|
|
553
|
+
for (const link of this.data.links) {
|
|
554
|
+
const sourceId = link.source.id;
|
|
555
|
+
const targetId = link.target.id;
|
|
556
|
+
|
|
557
|
+
if (sourceId === nodeId) {
|
|
558
|
+
const existingPosition = previousPositions.get(targetId);
|
|
559
|
+
if (!existingPosition) continue;
|
|
560
|
+
sumX += existingPosition.x;
|
|
561
|
+
sumY += existingPosition.y;
|
|
562
|
+
count += 1;
|
|
563
|
+
} else if (targetId === nodeId) {
|
|
564
|
+
const existingPosition = previousPositions.get(sourceId);
|
|
565
|
+
if (!existingPosition) continue;
|
|
566
|
+
sumX += existingPosition.x;
|
|
567
|
+
sumY += existingPosition.y;
|
|
568
|
+
count += 1;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (count === 0) return undefined;
|
|
573
|
+
return {
|
|
574
|
+
x: sumX / count,
|
|
575
|
+
y: sumY / count,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
private clearLayoutTargets() {
|
|
580
|
+
for (const node of this.data.nodes) {
|
|
581
|
+
node.layoutTargetX = undefined;
|
|
582
|
+
node.layoutTargetY = undefined;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
private prepareNodePositionsForCurrentLayout(previousPositions: Map<number, NodePosition>): boolean {
|
|
587
|
+
if (this.isForceLayoutMode()) {
|
|
588
|
+
this.clearLayoutTargets();
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const graphCenter = this.getGraphCenter(previousPositions);
|
|
593
|
+
let shouldAnimate = false;
|
|
594
|
+
|
|
595
|
+
for (const node of this.data.nodes) {
|
|
596
|
+
const targetX = node.x ?? 0;
|
|
597
|
+
const targetY = node.y ?? 0;
|
|
598
|
+
|
|
599
|
+
node.layoutTargetX = targetX;
|
|
600
|
+
node.layoutTargetY = targetY;
|
|
601
|
+
node.fx = undefined;
|
|
602
|
+
node.fy = undefined;
|
|
603
|
+
node.vx = 0;
|
|
604
|
+
node.vy = 0;
|
|
605
|
+
|
|
606
|
+
if (previousPositions.size === 0) {
|
|
607
|
+
node.x = targetX;
|
|
608
|
+
node.y = targetY;
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const previousPosition = previousPositions.get(node.id)
|
|
613
|
+
?? this.getConnectedExistingPosition(node.id, previousPositions)
|
|
614
|
+
?? graphCenter;
|
|
615
|
+
|
|
616
|
+
if (!previousPosition) {
|
|
617
|
+
node.x = targetX;
|
|
618
|
+
node.y = targetY;
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
node.x = previousPosition.x;
|
|
623
|
+
node.y = previousPosition.y;
|
|
624
|
+
|
|
625
|
+
if (
|
|
626
|
+
Math.abs(previousPosition.x - targetX) > 0.5
|
|
627
|
+
|| Math.abs(previousPosition.y - targetY) > 0.5
|
|
628
|
+
) {
|
|
629
|
+
shouldAnimate = true;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
return shouldAnimate;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
private setupAnchoredLayoutForces() {
|
|
637
|
+
if (!this.graph) return;
|
|
638
|
+
|
|
639
|
+
const linkForce = this.graph.d3Force("link");
|
|
640
|
+
if (linkForce) {
|
|
641
|
+
linkForce
|
|
642
|
+
.distance((link: GraphLink) => {
|
|
643
|
+
const sourceSize = link.source.size;
|
|
644
|
+
const targetSize = link.target.size;
|
|
645
|
+
return sourceSize + targetSize + LINK_DISTANCE * 1.6;
|
|
646
|
+
})
|
|
647
|
+
.strength(NON_FORCE_LINK_STRENGTH);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
this.graph.d3Force(
|
|
651
|
+
"collide",
|
|
652
|
+
d3.forceCollide((node: GraphNode) => node.size + NON_FORCE_COLLIDE_PADDING)
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
this.graph.d3Force(
|
|
656
|
+
"centerX",
|
|
657
|
+
d3.forceX(0).strength(NON_FORCE_CENTER_STRENGTH)
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
this.graph.d3Force(
|
|
661
|
+
"centerY",
|
|
662
|
+
d3.forceY(0).strength(NON_FORCE_CENTER_STRENGTH)
|
|
663
|
+
);
|
|
664
|
+
|
|
665
|
+
this.graph.d3Force(
|
|
666
|
+
"layoutTargetX",
|
|
667
|
+
d3.forceX((node: GraphNode) => node.layoutTargetX ?? node.x ?? 0).strength(NON_FORCE_TARGET_STRENGTH)
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
this.graph.d3Force(
|
|
671
|
+
"layoutTargetY",
|
|
672
|
+
d3.forceY((node: GraphNode) => node.layoutTargetY ?? node.y ?? 0).strength(NON_FORCE_TARGET_STRENGTH)
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
const chargeForce = this.graph.d3Force("charge");
|
|
676
|
+
if (chargeForce) {
|
|
677
|
+
chargeForce.strength(NON_FORCE_CHARGE_STRENGTH);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
this.graph.d3VelocityDecay(NON_FORCE_VELOCITY_DECAY);
|
|
681
|
+
this.graph.d3AlphaMin(NON_FORCE_ALPHA_MIN);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
private startNonForceSettleAnimation(cooldownTicks: number) {
|
|
685
|
+
if (!this.graph || this.data.nodes.length === 0 || this.isForceLayoutMode()) return;
|
|
686
|
+
|
|
687
|
+
this.graph.cooldownTicks(cooldownTicks);
|
|
688
|
+
this.updateCanvasSimulationAttribute(true);
|
|
689
|
+
this.graph.d3ReheatSimulation();
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
private applyLayoutTargets() {
|
|
693
|
+
for (const node of this.data.nodes) {
|
|
694
|
+
if (node.layoutTargetX === undefined || node.layoutTargetY === undefined) continue;
|
|
695
|
+
node.x = node.layoutTargetX;
|
|
696
|
+
node.y = node.layoutTargetY;
|
|
697
|
+
node.vx = 0;
|
|
698
|
+
node.vy = 0;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
private configureSimulationForCurrentLayout(shouldAnimateNonForceLayout = false) {
|
|
703
|
+
if (!this.graph) return;
|
|
704
|
+
|
|
705
|
+
if (this.isForceLayoutMode()) {
|
|
706
|
+
this.setupForces();
|
|
707
|
+
const cooldownTicks = this.config.cooldownTicks ?? Infinity;
|
|
708
|
+
this.graph.cooldownTicks(cooldownTicks);
|
|
709
|
+
this.updateCanvasSimulationAttribute(cooldownTicks !== 0 && this.data.nodes.length > 0);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
this.setupAnchoredLayoutForces();
|
|
713
|
+
if (shouldAnimateNonForceLayout) {
|
|
714
|
+
this.startNonForceSettleAnimation(NON_FORCE_LAYOUT_COOLDOWN_TICKS);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
this.graph.cooldownTicks(0);
|
|
719
|
+
this.updateCanvasSimulationAttribute(false);
|
|
720
|
+
}
|
|
721
|
+
|
|
380
722
|
private triggerRender() {
|
|
381
723
|
if (!this.graph || this.graph.cooldownTicks() !== 0) return;
|
|
382
724
|
|
|
@@ -516,6 +858,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
516
858
|
if (this.graph && width > 0 && height > 0) {
|
|
517
859
|
this.log('Container resized to:', width, 'x', height);
|
|
518
860
|
this.graph.width(width).height(height);
|
|
861
|
+
this.recomputeCullingBoundsIfNeeded();
|
|
519
862
|
}
|
|
520
863
|
}
|
|
521
864
|
});
|
|
@@ -531,7 +874,6 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
531
874
|
|
|
532
875
|
// Initialize force-graph
|
|
533
876
|
// Cast to any for the factory call pattern, result is properly typed as ForceGraphInstance
|
|
534
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
535
877
|
this.graph = (ForceGraph as any)()(this.container)
|
|
536
878
|
.width(this.config.width || 800)
|
|
537
879
|
.height(this.config.height || 600)
|
|
@@ -548,7 +890,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
548
890
|
.linkCurvature("curve")
|
|
549
891
|
.linkVisibility("visible")
|
|
550
892
|
.nodeVisibility("visible")
|
|
551
|
-
.cooldownTicks(this.config.cooldownTicks ?? Infinity) // undefined = infinite
|
|
893
|
+
.cooldownTicks(this.isForceLayoutMode() ? (this.config.cooldownTicks ?? Infinity) : 0) // undefined = infinite
|
|
552
894
|
.cooldownTime(this.config.cooldownTime ?? 2000)
|
|
553
895
|
.enableNodeDrag(true)
|
|
554
896
|
.enableZoomInteraction(true)
|
|
@@ -578,6 +920,12 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
578
920
|
this.config.onNodeHover(node);
|
|
579
921
|
}
|
|
580
922
|
})
|
|
923
|
+
.onNodeDrag((node: GraphNode) => {
|
|
924
|
+
this.handleNodeDrag(node);
|
|
925
|
+
})
|
|
926
|
+
.onNodeDragEnd((node: GraphNode) => {
|
|
927
|
+
this.handleNodeDragEnd(node);
|
|
928
|
+
})
|
|
581
929
|
.onLinkHover((link: GraphLink | null) => {
|
|
582
930
|
if (this.config.onLinkHover) {
|
|
583
931
|
this.config.onLinkHover(link);
|
|
@@ -594,6 +942,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
594
942
|
}
|
|
595
943
|
})
|
|
596
944
|
.onZoom((transform: Transform) => {
|
|
945
|
+
this.updateCullingBounds(transform);
|
|
597
946
|
if (this.config.onZoom) {
|
|
598
947
|
this.config.onZoom(transform);
|
|
599
948
|
}
|
|
@@ -633,8 +982,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
633
982
|
}
|
|
634
983
|
});
|
|
635
984
|
|
|
636
|
-
|
|
637
|
-
this.setupForces();
|
|
985
|
+
this.configureSimulationForCurrentLayout();
|
|
638
986
|
this.log('Force graph initialization complete');
|
|
639
987
|
}
|
|
640
988
|
|
|
@@ -645,6 +993,9 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
645
993
|
if (!linkForce) return;
|
|
646
994
|
if (!this.graph) return;
|
|
647
995
|
|
|
996
|
+
this.graph.d3Force("layoutTargetX", null);
|
|
997
|
+
this.graph.d3Force("layoutTargetY", null);
|
|
998
|
+
|
|
648
999
|
// distance based on node size + constant
|
|
649
1000
|
linkForce
|
|
650
1001
|
.distance((link: GraphLink) => {
|
|
@@ -688,6 +1039,152 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
688
1039
|
this.log('Force simulation setup complete');
|
|
689
1040
|
}
|
|
690
1041
|
|
|
1042
|
+
/**
|
|
1043
|
+
* Recompute the world-space culling bounds from the d3-zoom transform delivered
|
|
1044
|
+
* by force-graph's `onZoom` callback.
|
|
1045
|
+
*
|
|
1046
|
+
* The d3-zoom transform maps world → screen as:
|
|
1047
|
+
* screen_x = world_x * k + tx
|
|
1048
|
+
* screen_y = world_y * k + ty
|
|
1049
|
+
* Inverting for the canvas edges (screen_x ∈ [0, W], screen_y ∈ [0, H]):
|
|
1050
|
+
* world_x ∈ [(0 − tx) / k, (W − tx) / k]
|
|
1051
|
+
* world_y ∈ [(0 − ty) / k, (H − ty) / k]
|
|
1052
|
+
*/
|
|
1053
|
+
private updateCullingBounds(transform: Transform) {
|
|
1054
|
+
this.lastTransform = transform;
|
|
1055
|
+
if (!this.config.largeGraph?.enabled) {
|
|
1056
|
+
this.cullingBounds = null;
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
const w = this.graph?.width() ?? 0;
|
|
1061
|
+
const h = this.graph?.height() ?? 0;
|
|
1062
|
+
const { k, x: tx, y: ty } = transform;
|
|
1063
|
+
|
|
1064
|
+
if (k <= 0 || w <= 0 || h <= 0) {
|
|
1065
|
+
this.cullingBounds = null;
|
|
1066
|
+
this.cullingZoom = 1;
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const padding = this.config.largeGraph?.viewportPadding ?? 0;
|
|
1071
|
+
|
|
1072
|
+
this.cullingBounds = {
|
|
1073
|
+
minX: -tx / k - padding,
|
|
1074
|
+
maxX: (w - tx) / k + padding,
|
|
1075
|
+
minY: -ty / k - padding,
|
|
1076
|
+
maxY: (h - ty) / k + padding,
|
|
1077
|
+
};
|
|
1078
|
+
this.cullingZoom = k;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/** Recompute culling bounds using the last known transform (e.g. after resize). */
|
|
1082
|
+
private recomputeCullingBoundsIfNeeded() {
|
|
1083
|
+
if (!this.config.largeGraph?.enabled) return;
|
|
1084
|
+
if (this.lastTransform) {
|
|
1085
|
+
this.updateCullingBounds(this.lastTransform);
|
|
1086
|
+
} else if (this.graph) {
|
|
1087
|
+
// Seed initial transform from current graph state before first onZoom fires.
|
|
1088
|
+
const k = this.graph.zoom() ?? 1;
|
|
1089
|
+
const center = this.graph.centerAt() ?? { x: 0, y: 0 };
|
|
1090
|
+
const w = this.graph.width() ?? 0;
|
|
1091
|
+
const h = this.graph.height() ?? 0;
|
|
1092
|
+
if (k > 0 && w > 0 && h > 0) {
|
|
1093
|
+
const tx = w / 2 - center.x * k;
|
|
1094
|
+
const ty = h / 2 - center.y * k;
|
|
1095
|
+
this.updateCullingBounds({ k, x: tx, y: ty });
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* Returns `true` when the node is (at least partially) inside the current
|
|
1102
|
+
* culling viewport, or when culling is disabled / bounds are not yet known.
|
|
1103
|
+
*/
|
|
1104
|
+
private isNodeInCullingBounds(node: GraphNode): boolean {
|
|
1105
|
+
if (!this.cullingBounds) return true;
|
|
1106
|
+
const { minX, maxX, minY, maxY } = this.cullingBounds;
|
|
1107
|
+
const r = node.size + PADDING;
|
|
1108
|
+
const x = node.x ?? 0;
|
|
1109
|
+
const y = node.y ?? 0;
|
|
1110
|
+
return x + r >= minX && x - r <= maxX && y + r >= minY && y - r <= maxY;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Returns `true` when a link's visual representation overlaps the current
|
|
1115
|
+
* culling viewport, or when culling is disabled / bounds are not yet known.
|
|
1116
|
+
*
|
|
1117
|
+
* For straight / quadratic-bezier links the test uses the convex-hull bounding
|
|
1118
|
+
* box of (source, control point, target), which is always a conservative
|
|
1119
|
+
* (never-false-negative) bound. For self-loops the test uses a square of
|
|
1120
|
+
* side ≈ the loop diameter centred on the node.
|
|
1121
|
+
*/
|
|
1122
|
+
private isLinkInCullingBounds(link: GraphLink): boolean {
|
|
1123
|
+
if (!this.cullingBounds) return true;
|
|
1124
|
+
const { minX, maxX, minY, maxY } = this.cullingBounds;
|
|
1125
|
+
|
|
1126
|
+
const sx = link.source.x ?? 0;
|
|
1127
|
+
const sy = link.source.y ?? 0;
|
|
1128
|
+
const ex = link.target.x ?? 0;
|
|
1129
|
+
const ey = link.target.y ?? 0;
|
|
1130
|
+
|
|
1131
|
+
if (link.source.id === link.target.id) {
|
|
1132
|
+
// Self-loop: the cubic bezier extends roughly |curve| * nodeSize * factor
|
|
1133
|
+
// away from the node centre. Use that as a conservative radius.
|
|
1134
|
+
const nodeSize = link.source.size || NODE_SIZE;
|
|
1135
|
+
const loopRadius = Math.abs(link.curve || 1) * nodeSize * SELF_LOOP_CURVE_FACTOR;
|
|
1136
|
+
return (
|
|
1137
|
+
sx + loopRadius >= minX && sx - loopRadius <= maxX &&
|
|
1138
|
+
sy + loopRadius >= minY && sy - loopRadius <= maxY
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// Compute quadratic-bezier control point (same formula as drawLink).
|
|
1143
|
+
const dx = ex - sx;
|
|
1144
|
+
const dy = ey - sy;
|
|
1145
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
1146
|
+
if (distance === 0) {
|
|
1147
|
+
// Co-located nodes: just check the point.
|
|
1148
|
+
return sx >= minX && sx <= maxX && sy >= minY && sy <= maxY;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
const curvature = link.curve ?? 0;
|
|
1152
|
+
const perpX = dy / distance;
|
|
1153
|
+
const perpY = -dx / distance;
|
|
1154
|
+
const cx = (sx + ex) / 2 + perpX * curvature * distance;
|
|
1155
|
+
const cy = (sy + ey) / 2 + perpY * curvature * distance;
|
|
1156
|
+
|
|
1157
|
+
// Convex-hull AABB of the three control points.
|
|
1158
|
+
const lMinX = Math.min(sx, ex, cx);
|
|
1159
|
+
const lMaxX = Math.max(sx, ex, cx);
|
|
1160
|
+
const lMinY = Math.min(sy, ey, cy);
|
|
1161
|
+
const lMaxY = Math.max(sy, ey, cy);
|
|
1162
|
+
|
|
1163
|
+
return lMaxX >= minX && lMinX <= maxX && lMaxY >= minY && lMinY <= maxY;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
private handleNodeDrag(node: GraphNode) {
|
|
1167
|
+
if (this.isForceLayoutMode()) return;
|
|
1168
|
+
if (node.x === undefined || node.y === undefined) return;
|
|
1169
|
+
|
|
1170
|
+
node.layoutTargetX = node.x;
|
|
1171
|
+
node.layoutTargetY = node.y;
|
|
1172
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
1173
|
+
this.startNonForceSettleAnimation(NON_FORCE_DRAG_COOLDOWN_TICKS);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
private handleNodeDragEnd(node: GraphNode) {
|
|
1177
|
+
if (this.isForceLayoutMode()) return;
|
|
1178
|
+
if (node.x === undefined || node.y === undefined) return;
|
|
1179
|
+
|
|
1180
|
+
node.layoutTargetX = node.x;
|
|
1181
|
+
node.layoutTargetY = node.y;
|
|
1182
|
+
node.fx = undefined;
|
|
1183
|
+
node.fy = undefined;
|
|
1184
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
1185
|
+
this.startNonForceSettleAnimation(NON_FORCE_DRAG_COOLDOWN_TICKS);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
691
1188
|
private drawNode(node: GraphNode, ctx: CanvasRenderingContext2D) {
|
|
692
1189
|
|
|
693
1190
|
if (node.x === undefined || node.y === undefined) {
|
|
@@ -695,6 +1192,9 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
695
1192
|
node.y = 0;
|
|
696
1193
|
}
|
|
697
1194
|
|
|
1195
|
+
// Viewport culling: skip nodes that are entirely outside the visible area.
|
|
1196
|
+
if (this.config.largeGraph?.enabled && !this.isNodeInCullingBounds(node)) return;
|
|
1197
|
+
|
|
698
1198
|
ctx.lineWidth = this.config.isNodeSelected?.(node) ? 1 : 0.5;
|
|
699
1199
|
ctx.strokeStyle = this.config.foregroundColor;
|
|
700
1200
|
ctx.fillStyle = node.color;
|
|
@@ -709,6 +1209,12 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
709
1209
|
ctx.arc(node.x, node.y, node.size, 0, 2 * Math.PI, false);
|
|
710
1210
|
ctx.fill();
|
|
711
1211
|
|
|
1212
|
+
// Low-zoom optimisation: skip labels when they would be too small to read.
|
|
1213
|
+
const skipLabels = this.config.largeGraph?.enabled &&
|
|
1214
|
+
(this.config.largeGraph?.skipLabelsAtLowZoom ?? true) &&
|
|
1215
|
+
this.cullingZoom < (this.config.largeGraph?.lowZoomThreshold ?? 0.5);
|
|
1216
|
+
if (skipLabels) return;
|
|
1217
|
+
|
|
712
1218
|
// Draw text
|
|
713
1219
|
ctx.fillStyle = getContrastTextColor(node.color);
|
|
714
1220
|
ctx.textAlign = "center";
|
|
@@ -725,32 +1231,49 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
725
1231
|
[line1, line2] = wrapTextForCircularNode(ctx, text, textRadius);
|
|
726
1232
|
|
|
727
1233
|
let chosenSize = NODE_FONT_SIZE_BASE;
|
|
1234
|
+
// Measure at a large reference size (20px) where canvas metrics are
|
|
1235
|
+
// precise, then compute the exact scale to fill the node.
|
|
1236
|
+
const REF = 20;
|
|
1237
|
+
ctx.font = `400 ${REF}px SofiaSans`;
|
|
1238
|
+
// Switch to "left" for measurement: actualBoundingBoxLeft/Right are
|
|
1239
|
+
// unreliable with textAlign="center" and can double on some engines.
|
|
1240
|
+
ctx.textAlign = "left";
|
|
1241
|
+
const refMetrics = ctx.measureText(line1);
|
|
1242
|
+
// Use the actual visual bounding box (not advance width) so glyphs
|
|
1243
|
+
// with overshoot (e.g. "7") are fully accounted for.
|
|
1244
|
+
const visualWidth = (refMetrics.actualBoundingBoxLeft ?? 0)
|
|
1245
|
+
+ (refMetrics.actualBoundingBoxRight ?? 0);
|
|
1246
|
+
let refWidth = Math.max(visualWidth, refMetrics.width);
|
|
1247
|
+
const singleLineHeight = (refMetrics.actualBoundingBoxAscent ?? 0)
|
|
1248
|
+
+ (refMetrics.actualBoundingBoxDescent ?? 0);
|
|
1249
|
+
|
|
1250
|
+
let refHeight: number;
|
|
728
1251
|
if (!line2) {
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
const
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
1252
|
+
refHeight = singleLineHeight;
|
|
1253
|
+
} else {
|
|
1254
|
+
// Two-line: use the wider line and account for the vertical span
|
|
1255
|
+
// of both lines including the 1.5× spacing used by the rendering code.
|
|
1256
|
+
const m2 = ctx.measureText(line2);
|
|
1257
|
+
const vis2 = Math.max(
|
|
1258
|
+
(m2.actualBoundingBoxLeft ?? 0) + (m2.actualBoundingBoxRight ?? 0),
|
|
1259
|
+
m2.width,
|
|
1260
|
+
);
|
|
1261
|
+
refWidth = Math.max(refWidth, vis2);
|
|
1262
|
+
refHeight = singleLineHeight * 2.5;
|
|
1263
|
+
}
|
|
1264
|
+
ctx.textAlign = "center";
|
|
1265
|
+
|
|
1266
|
+
// Inscribed-rectangle-in-circle constraint: every corner of the text
|
|
1267
|
+
// bounding box must lie inside the circle, i.e.
|
|
1268
|
+
// sqrt((w/2)² + (h/2)²) ≤ r
|
|
1269
|
+
// Solving for the uniform scale factor s:
|
|
1270
|
+
// s = 2·r / sqrt(refWidth² + refHeight²)
|
|
1271
|
+
const r = NODE_TEXT_FILL_RATIO * textRadius;
|
|
1272
|
+
if (refWidth > 0 && refHeight > 0) {
|
|
1273
|
+
const diagonal = Math.sqrt(refWidth * refWidth + refHeight * refHeight);
|
|
1274
|
+
chosenSize = REF * (2 * r / diagonal);
|
|
1275
|
+
} else if (refWidth > 0) {
|
|
1276
|
+
chosenSize = REF * (2 * r / refWidth);
|
|
754
1277
|
}
|
|
755
1278
|
|
|
756
1279
|
ctx.font = `400 ${chosenSize}px SofiaSans`;
|
|
@@ -788,6 +1311,9 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
788
1311
|
node.y = 0;
|
|
789
1312
|
};
|
|
790
1313
|
|
|
1314
|
+
// Viewport culling: skip hit-test painting for offscreen nodes.
|
|
1315
|
+
if (this.config.largeGraph?.enabled && !this.isNodeInCullingBounds(node)) return;
|
|
1316
|
+
|
|
791
1317
|
const radius = node.size + PADDING;
|
|
792
1318
|
|
|
793
1319
|
ctx.fillStyle = color;
|
|
@@ -807,6 +1333,11 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
807
1333
|
end.y = 0;
|
|
808
1334
|
}
|
|
809
1335
|
|
|
1336
|
+
// Viewport culling: skip links whose visual extent is entirely outside the
|
|
1337
|
+
// visible area. The check is conservative (convex-hull AABB) so it never
|
|
1338
|
+
// produces false negatives.
|
|
1339
|
+
if (this.config.largeGraph?.enabled && !this.isLinkInCullingBounds(link)) return;
|
|
1340
|
+
|
|
810
1341
|
let textX;
|
|
811
1342
|
let textY;
|
|
812
1343
|
let angle;
|
|
@@ -814,13 +1345,19 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
814
1345
|
const isLinkSelected = this.config.isLinkSelected?.(link) ?? false;
|
|
815
1346
|
const arrowLen = isLinkSelected ? 4 : 2;
|
|
816
1347
|
|
|
1348
|
+
// Low-zoom flags – evaluated once per link draw.
|
|
1349
|
+
const lowZoomThreshold = this.config.largeGraph?.lowZoomThreshold ?? 0.5;
|
|
1350
|
+
const atLowZoom = this.config.largeGraph?.enabled && this.cullingZoom < lowZoomThreshold;
|
|
1351
|
+
const skipArrows = atLowZoom && (this.config.largeGraph?.skipArrowsAtLowZoom ?? true);
|
|
1352
|
+
const skipLinkLabels = atLowZoom && (this.config.largeGraph?.skipLinkLabelsAtLowZoom ?? true);
|
|
1353
|
+
|
|
817
1354
|
// Deferred arrowhead — drawn after the label so it is never covered by
|
|
818
1355
|
// the label background rect (which happens for short links where the
|
|
819
1356
|
// bezier midpoint and the arrow tip are at almost the same position).
|
|
820
1357
|
let pendingArrow: { tipX: number; tipY: number; nx: number; ny: number; arrowLen: number; arrowHalfWidth: number } | null = null;
|
|
821
1358
|
|
|
822
1359
|
if (start.id === end.id) {
|
|
823
|
-
const nodeSize = start.size ||
|
|
1360
|
+
const nodeSize = start.size || NODE_SIZE;
|
|
824
1361
|
const d = (link.curve || 0) * nodeSize * SELF_LOOP_CURVE_FACTOR;
|
|
825
1362
|
|
|
826
1363
|
ctx.lineWidth = (isLinkSelected ? 2 : 1) / globalScale;
|
|
@@ -891,7 +1428,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
891
1428
|
// Guard against zero-length tangent vector (e.g. when d ≈ 0) to avoid NaN
|
|
892
1429
|
// normals and invalid arrowhead geometry. Also skip when d is too small to
|
|
893
1430
|
// place the arrowhead at the node border (canReachBorder is false).
|
|
894
|
-
if (tLen !== 0 && canReachBorder) {
|
|
1431
|
+
if (!skipArrows && tLen !== 0 && canReachBorder) {
|
|
895
1432
|
const nx = tdx / tLen;
|
|
896
1433
|
const ny = tdy / tLen;
|
|
897
1434
|
pendingArrow = { tipX, tipY, nx, ny, arrowLen, arrowHalfWidth };
|
|
@@ -947,7 +1484,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
947
1484
|
const arrowHalfWidth = arrowLen / ARROW_WH_RATIO / 2;
|
|
948
1485
|
|
|
949
1486
|
// Target-side clip: find t where bezier enters target node border + PADDING
|
|
950
|
-
const endNodeSize = end.size ||
|
|
1487
|
+
const endNodeSize = end.size || NODE_SIZE;
|
|
951
1488
|
const borderRadius = endNodeSize + (this.config.isNodeSelected?.(end) ? 1 : 0.5) + PADDING;
|
|
952
1489
|
const borderRadiusSq = borderRadius * borderRadius;
|
|
953
1490
|
|
|
@@ -975,7 +1512,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
975
1512
|
const tipY = uArrow * uArrow * start.y + 2 * uArrow * tArrow * controlY + tArrow * tArrow * end.y;
|
|
976
1513
|
|
|
977
1514
|
// Source-side clip: find t where bezier exits source node border + PADDING
|
|
978
|
-
const startNodeSize = start.size ||
|
|
1515
|
+
const startNodeSize = start.size || NODE_SIZE;
|
|
979
1516
|
const srcBorderRadius = startNodeSize + (this.config.isNodeSelected?.(start) ? 1 : 0.5) + PADDING;
|
|
980
1517
|
const srcBorderRadiusSq = srcBorderRadius * srcBorderRadius;
|
|
981
1518
|
|
|
@@ -1026,7 +1563,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
1026
1563
|
const aty = 2 * uArrow * (controlY - start.y) + 2 * tArrow * (end.y - controlY);
|
|
1027
1564
|
const atLen = Math.sqrt(atx * atx + aty * aty);
|
|
1028
1565
|
|
|
1029
|
-
if (atLen !== 0) {
|
|
1566
|
+
if (!skipArrows && atLen !== 0) {
|
|
1030
1567
|
const nx = atx / atLen;
|
|
1031
1568
|
const ny = aty / atLen;
|
|
1032
1569
|
pendingArrow = { tipX, tipY, nx, ny, arrowLen, arrowHalfWidth };
|
|
@@ -1038,52 +1575,54 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
1038
1575
|
// Draw text with alphabetic baseline, positioned so visual center is at y=0
|
|
1039
1576
|
ctx.textBaseline = "alphabetic";
|
|
1040
1577
|
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1578
|
+
if (!skipLinkLabels) {
|
|
1579
|
+
// Separate cache entries per weight so each state is measured with its own
|
|
1580
|
+
// font, giving equal visual padding regardless of selection state.
|
|
1581
|
+
const cacheKey = `${link.relationship}_${isLinkSelected ? "700" : "400"}`;
|
|
1582
|
+
let cached = this.relationshipsTextCache.get(cacheKey);
|
|
1583
|
+
|
|
1584
|
+
if (!cached) {
|
|
1585
|
+
// ctx.font is already set to the correct weight above; measure it directly.
|
|
1586
|
+
const metrics = ctx.measureText(link.relationship);
|
|
1587
|
+
// Use actual ink bounds for vertical metrics; fontBoundingBox* is the full
|
|
1588
|
+
// line-box and adds excessive space for lighter weights.
|
|
1589
|
+
// Use metrics.width for horizontal extent: actualBoundingBoxLeft/Right are
|
|
1590
|
+
// unreliable with textAlign="center" and can double the value on some engines.
|
|
1591
|
+
const inkAscent = metrics.actualBoundingBoxAscent ?? metrics.fontBoundingBoxAscent;
|
|
1592
|
+
const inkDescent = metrics.actualBoundingBoxDescent ?? metrics.fontBoundingBoxDescent;
|
|
1593
|
+
const inkWidth = metrics.width;
|
|
1594
|
+
const bgPadding = 0.3;
|
|
1595
|
+
|
|
1596
|
+
cached = {
|
|
1597
|
+
textWidth: inkWidth + bgPadding * 2,
|
|
1598
|
+
textHeight: inkAscent + inkDescent + bgPadding * 2,
|
|
1599
|
+
// Shift baseline up so the ink block is centred inside the bg rect.
|
|
1600
|
+
textYOffset: (inkAscent - inkDescent) / 2,
|
|
1601
|
+
};
|
|
1602
|
+
this.relationshipsTextCache.set(cacheKey, cached);
|
|
1603
|
+
}
|
|
1066
1604
|
|
|
1067
|
-
|
|
1605
|
+
const { textWidth, textHeight, textYOffset } = cached;
|
|
1068
1606
|
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1607
|
+
ctx.save();
|
|
1608
|
+
ctx.translate(textX, textY);
|
|
1609
|
+
ctx.rotate(angle);
|
|
1072
1610
|
|
|
1073
|
-
|
|
1074
|
-
|
|
1611
|
+
// Draw background centered on the link line (y=0)
|
|
1612
|
+
ctx.fillStyle = this.config.backgroundColor;
|
|
1075
1613
|
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1614
|
+
// Offset background to match text visual center
|
|
1615
|
+
ctx.fillRect(
|
|
1616
|
+
-textWidth / 2,
|
|
1617
|
+
-textHeight / 2,
|
|
1618
|
+
textWidth,
|
|
1619
|
+
textHeight
|
|
1620
|
+
);
|
|
1083
1621
|
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1622
|
+
ctx.fillStyle = getContrastTextColor(this.config.backgroundColor);
|
|
1623
|
+
ctx.fillText(link.relationship, 0, textYOffset);
|
|
1624
|
+
ctx.restore();
|
|
1625
|
+
}
|
|
1087
1626
|
|
|
1088
1627
|
// Draw arrowhead last so it always appears on top of the label background.
|
|
1089
1628
|
if (pendingArrow) {
|
|
@@ -1104,6 +1643,9 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
1104
1643
|
|
|
1105
1644
|
if (start.x == null || start.y == null || end.x == null || end.y == null) return;
|
|
1106
1645
|
|
|
1646
|
+
// Viewport culling: skip hit-test painting for offscreen links.
|
|
1647
|
+
if (this.config.largeGraph?.enabled && !this.isLinkInCullingBounds(link)) return;
|
|
1648
|
+
|
|
1107
1649
|
ctx.strokeStyle = color;
|
|
1108
1650
|
const basePointerWidth = 10; // Desired on-screen pointer area thickness
|
|
1109
1651
|
const transform = typeof ctx.getTransform === 'function' ? ctx.getTransform() : null;
|
|
@@ -1119,7 +1661,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
1119
1661
|
|
|
1120
1662
|
if (start.id === end.id) {
|
|
1121
1663
|
// Self-loop: replicate exact cubic bezier clip from drawLink
|
|
1122
|
-
const nodeSize = start.size ||
|
|
1664
|
+
const nodeSize = start.size || NODE_SIZE;
|
|
1123
1665
|
const d = (link.curve || 0) * nodeSize * SELF_LOOP_CURVE_FACTOR;
|
|
1124
1666
|
|
|
1125
1667
|
const nodeStrokeWidth = this.config.isNodeSelected?.(start) ? 1 : 0.5;
|
|
@@ -1170,7 +1712,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
1170
1712
|
const controlY = (start.y + end.y) / 2 + perpY * curvature * distance;
|
|
1171
1713
|
|
|
1172
1714
|
// Use the same borderRadius and binary-search clip as drawLink
|
|
1173
|
-
const endNodeSize = end.size ||
|
|
1715
|
+
const endNodeSize = end.size || NODE_SIZE;
|
|
1174
1716
|
const borderRadius = endNodeSize + (this.config.isNodeSelected?.(end) ? 1 : 0.5) + PADDING;
|
|
1175
1717
|
const borderRadiusSq = borderRadius * borderRadius;
|
|
1176
1718
|
|
|
@@ -1197,7 +1739,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
1197
1739
|
const tipY = uArrow * uArrow * start.y + 2 * uArrow * tArrow * controlY + tArrow * tArrow * end.y;
|
|
1198
1740
|
|
|
1199
1741
|
// Source-side clip: mirror of drawLink source gap
|
|
1200
|
-
const startNodeSize = start.size ||
|
|
1742
|
+
const startNodeSize = start.size || NODE_SIZE;
|
|
1201
1743
|
const srcBorderRadius = startNodeSize + (this.config.isNodeSelected?.(start) ? 1 : 0.5) + PADDING;
|
|
1202
1744
|
const srcBorderRadiusSq = srcBorderRadius * srcBorderRadius;
|
|
1203
1745
|
|
|
@@ -1254,6 +1796,16 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
1254
1796
|
if (!this.graph) return;
|
|
1255
1797
|
|
|
1256
1798
|
this.log('Engine stopped');
|
|
1799
|
+
if (!this.isForceLayoutMode()) {
|
|
1800
|
+
this.applyLayoutTargets();
|
|
1801
|
+
this.graph.cooldownTicks(0);
|
|
1802
|
+
this.updateCanvasSimulationAttribute(false);
|
|
1803
|
+
if (this.shouldZoomToFitOnNonForceSettle && this.data.nodes.length > 0) {
|
|
1804
|
+
this.zoomToFit(1);
|
|
1805
|
+
}
|
|
1806
|
+
this.shouldZoomToFitOnNonForceSettle = false;
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1257
1809
|
// If already stopped, just ensure any leftover loading state is cleared and return
|
|
1258
1810
|
if (this.config.cooldownTicks === 0) {
|
|
1259
1811
|
if (this.config.isLoading) {
|
|
@@ -1326,6 +1878,12 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
1326
1878
|
this.config.onNodeHover(node);
|
|
1327
1879
|
}
|
|
1328
1880
|
})
|
|
1881
|
+
.onNodeDrag((node: GraphNode) => {
|
|
1882
|
+
this.handleNodeDrag(node);
|
|
1883
|
+
})
|
|
1884
|
+
.onNodeDragEnd((node: GraphNode) => {
|
|
1885
|
+
this.handleNodeDragEnd(node);
|
|
1886
|
+
})
|
|
1329
1887
|
.onLinkHover((link: GraphLink | null) => {
|
|
1330
1888
|
if (this.config.onLinkHover) {
|
|
1331
1889
|
this.config.onLinkHover(link);
|
|
@@ -1342,6 +1900,7 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
1342
1900
|
}
|
|
1343
1901
|
})
|
|
1344
1902
|
.onZoom((transform: Transform) => {
|
|
1903
|
+
this.updateCullingBounds(transform);
|
|
1345
1904
|
if (this.config.onZoom) {
|
|
1346
1905
|
this.config.onZoom(transform);
|
|
1347
1906
|
}
|
|
@@ -1372,7 +1931,9 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
1372
1931
|
this.config.node!.nodePointerAreaPaint(node, color, ctx);
|
|
1373
1932
|
});
|
|
1374
1933
|
} else {
|
|
1375
|
-
this.graph.nodePointerAreaPaint()
|
|
1934
|
+
this.graph.nodePointerAreaPaint((node: GraphNode, color: string, ctx: CanvasRenderingContext2D) => {
|
|
1935
|
+
this.pointerNode(node, color, ctx);
|
|
1936
|
+
});
|
|
1376
1937
|
}
|
|
1377
1938
|
|
|
1378
1939
|
if (this.config.link) {
|
|
@@ -1380,7 +1941,9 @@ class FalkorDBCanvas extends HTMLElement {
|
|
|
1380
1941
|
this.config.link!.linkPointerAreaPaint(link, color, ctx);
|
|
1381
1942
|
});
|
|
1382
1943
|
} else {
|
|
1383
|
-
this.graph.linkPointerAreaPaint()
|
|
1944
|
+
this.graph.linkPointerAreaPaint((link: GraphLink, color: string, ctx: CanvasRenderingContext2D) => {
|
|
1945
|
+
this.pointerLink(link, color, ctx);
|
|
1946
|
+
});
|
|
1384
1947
|
}
|
|
1385
1948
|
}
|
|
1386
1949
|
|