• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

graphty-org / graphty-element / 20514590651

26 Dec 2025 02:37AM UTC coverage: 70.559% (-0.3%) from 70.836%
20514590651

push

github

apowers313
ci: fix npm ci

9591 of 13363 branches covered (71.77%)

Branch coverage included in aggregate %.

25136 of 35854 relevant lines covered (70.11%)

6233.71 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

80.62
/src/NodeBehavior.ts
1
import {
15✔
2
    ActionManager,
14✔
3
    ExecuteCodeAction,
14✔
4
    Matrix,
14✔
5
    type Observer,
6
    PointerEventTypes,
14✔
7
    type PointerInfoPre,
8
    Ray,
9
    Scene,
10
    Vector3,
14✔
11
} from "@babylonjs/core";
14✔
12

13
import type {Graph} from "./Graph";
14
import type {GraphContext} from "./managers/GraphContext";
15
import type {Node as GraphNode, NodeIdType} from "./Node";
16

17
interface NodeBehaviorOptions {
18
    pinOnDrag?: boolean;
19
}
20

21
// Define drag state interface
22
interface DragState {
23
    dragging: boolean;
24
    dragStartMeshPosition: Vector3 | null;
25
    dragStartWorldPosition: Vector3 | null;
26
    dragPlaneNormal: Vector3 | null;
27
}
28

29
// Click detection state
30
interface ClickState {
31
    pointerDownTime: number;
32
    pointerDownPosition: {x: number, y: number};
33
    hasMoved: boolean;
34
    pointerEvent: PointerEvent | null;
35
}
36

37
// Click detection thresholds
38
const CLICK_MAX_DURATION_MS = 300; // Maximum time for a click (vs long press/drag)
15✔
39
const CLICK_MAX_MOVEMENT_PX = 5; // Maximum pixels of movement for a click
15✔
40

41
/**
42
 * Main drag handler class - unified for both desktop and XR
43
 */
44
export class NodeDragHandler {
15✔
45
    private node: GraphNode;
46
    private dragState: DragState;
47
    private clickState: ClickState | null = null;
15✔
48
    private scene: Scene;
49
    private pointerObserver: Observer<PointerInfoPre> | null = null;
15✔
50
    private hoverObserver: Observer<PointerInfoPre> | null = null;
15✔
51
    private isHovered = false;
15✔
52
    private readonly zAxisAmplification: number;
53
    private readonly enableZAmplificationInDesktop: boolean;
54

55
    /**
56
     * Creates a new drag handler for a node.
57
     * @param node - The graph node to enable dragging on
58
     */
59
    constructor(node: GraphNode) {
15✔
60
        this.node = node;
6,807✔
61
        this.scene = node.mesh.getScene();
6,807✔
62
        this.dragState = {
6,807✔
63
            dragging: false,
6,807✔
64
            dragStartMeshPosition: null,
6,807✔
65
            dragStartWorldPosition: null,
6,807✔
66
            dragPlaneNormal: null,
6,807✔
67
        };
6,807✔
68

69
        // Read config from graph context
70
        const context = this.getContext();
6,807✔
71
        const xrConfig = context.getConfig().xr;
6,807✔
72

73
        this.zAxisAmplification = xrConfig?.input.zAxisAmplification ?? 10.0;
6,807!
74
        this.enableZAmplificationInDesktop = xrConfig?.input.enableZAmplificationInDesktop ?? false;
6,807!
75

76
        // Setup pointer event listeners
77
        this.setupPointerEvents();
6,807✔
78
        this.setupHoverEvents();
6,807✔
79
    }
6,807✔
80

81
    // Public API for both desktop and XR
82
    /**
83
     * Initiates a drag operation for the node.
84
     * @param worldPosition - World space position where drag started
85
     */
86
    public onDragStart(worldPosition: Vector3): void {
15✔
87
        // Debug: console.log("🔍 [Drag] Drag Start:", {
88
        //     nodeId: this.node.id,
89
        //     isXRMode: this.isXRMode(),
90
        // });
91

92
        this.dragState.dragging = true;
29✔
93
        this.dragState.dragStartMeshPosition = this.node.mesh.position.clone();
29✔
94
        this.dragState.dragStartWorldPosition = worldPosition.clone();
29✔
95
        this.node.dragging = true;
29✔
96

97
        // Capture the drag plane orientation at drag start
98
        // This prevents the plane from rotating with camera during drag
99
        const camera = this.scene.activeCamera;
29✔
100
        if (camera) {
29✔
101
            this.dragState.dragPlaneNormal = camera.getForwardRay().direction.clone();
29✔
102

103
            // Disable camera input handler during node drag
104
            // This prevents OrbitInputController from rotating camera while dragging nodes
105
            const cameraManager = this.scene.metadata?.cameraManager;
29✔
106
            if (cameraManager) {
29✔
107
                // Debug: console.log("📷 Disabling camera input during node drag");
108
                cameraManager.temporarilyDisableInput();
29✔
109
            }
29✔
110
        }
29✔
111

112
        // Make sure graph is running
113
        const context = this.getContext();
29✔
114
        context.setRunning(true);
29✔
115

116
        // Emit node-drag-start event
117
        const eventManager = context.getEventManager?.();
29✔
118
        if (eventManager) {
29✔
119
            const pos = this.node.mesh.position;
29✔
120
            eventManager.emitNodeEvent("node-drag-start", {
29✔
121
                node: this.node,
29✔
122
                position: {x: pos.x, y: pos.y, z: pos.z},
29✔
123
            });
29✔
124
        }
29✔
125
    }
29✔
126

127
    /**
128
     * Updates node position during drag operation.
129
     * @param worldPosition - Current world space position of the drag pointer
130
     */
131
    public onDragUpdate(worldPosition: Vector3): void {
15✔
132
        if (!this.dragState.dragging || !this.dragState.dragStartWorldPosition || !this.dragState.dragStartMeshPosition) {
8!
133
            return;
1✔
134
        }
1✔
135

136
        // Calculate delta from drag start
137
        const delta = worldPosition.subtract(this.dragState.dragStartWorldPosition);
7✔
138

139
        // TODO: Add back delta validation with appropriate threshold for XR mode
140
        // The previous MAX_REASONABLE_DELTA of 5.0 was too small for 10x amplification
141
        // and was blocking ALL movement in XR
142

143
        // Apply movement amplification in XR mode
144
        // In VR, all controller movements are physically constrained (not just Z-axis)
145
        // so we amplify all axes to make node manipulation practical
146
        const shouldAmplify = this.isXRMode() || this.enableZAmplificationInDesktop;
7✔
147

148
        if (shouldAmplify) {
8!
149
            delta.x *= this.zAxisAmplification;
×
150
            delta.y *= this.zAxisAmplification;
×
151
            delta.z *= this.zAxisAmplification;
×
152
        }
✔
153

154
        // Calculate new position
155
        const newPosition = this.dragState.dragStartMeshPosition.add(delta);
7✔
156

157
        // Update mesh position (triggers edge updates automatically)
158
        this.node.mesh.position.copyFrom(newPosition);
7✔
159

160
        // Update layout engine
161
        const context = this.getContext();
7✔
162
        context.getLayoutManager().layoutEngine?.setNodePosition(this.node, {
8✔
163
            x: newPosition.x,
8✔
164
            y: newPosition.y,
8✔
165
            z: newPosition.z,
8✔
166
        });
8✔
167
    }
8✔
168

169
    /**
170
     * Completes a drag operation and updates node state.
171
     */
172
    public onDragEnd(): void {
15✔
173
        if (!this.dragState.dragging) {
28!
174
            return;
×
175
        }
×
176

177
        // Debug: console.log("🏁 NodeDragHandler.onDragEnd called", {
178
        //     nodeId: this.node.id,
179
        //     finalPosition: this.node.mesh.position.asArray(),
180
        // });
181

182
        // Make sure graph is running
183
        const context = this.getContext();
28✔
184
        context.setRunning(true);
28✔
185

186
        // Pin after dragging if configured
187
        if (this.node.pinOnDrag) {
28✔
188
            this.node.pin();
27✔
189
        }
27✔
190

191
        // Re-enable camera input handler after node drag
192
        const cameraManager = this.scene.metadata?.cameraManager;
28✔
193
        if (cameraManager) {
28✔
194
            // Debug: console.log("📷 Re-enabling camera input after node drag");
195
            cameraManager.temporarilyEnableInput();
28✔
196
        }
28✔
197

198
        // Emit node-drag-end event before resetting drag state
199
        const eventManager = context.getEventManager?.();
28✔
200
        if (eventManager) {
28✔
201
            const pos = this.node.mesh.position;
28✔
202
            eventManager.emitNodeEvent("node-drag-end", {
28✔
203
                node: this.node,
28✔
204
                position: {x: pos.x, y: pos.y, z: pos.z},
28✔
205
            });
28✔
206
        }
28✔
207

208
        // Reset drag state
209
        this.node.dragging = false;
28✔
210
        this.dragState.dragging = false;
28✔
211
        this.dragState.dragStartMeshPosition = null;
28✔
212
        this.dragState.dragStartWorldPosition = null;
28✔
213
        this.dragState.dragPlaneNormal = null;
28✔
214
    }
28✔
215

216
    /**
217
     * Set node position directly (for XR mode).
218
     * XRInputHandler calculates the position with pivot transform and amplification,
219
     * then calls this method to update the node and layout engine.
220
     *
221
     * This bypasses the delta calculation in onDragUpdate() which doesn't account
222
     * for pivot rotation changes during drag.
223
     * @param newPosition - New position to set for the node
224
     */
225
    public setPositionDirect(newPosition: Vector3): void {
15✔
226
        if (!this.dragState.dragging) {
×
227
            return;
×
228
        }
×
229

230
        // Update mesh position
231
        this.node.mesh.position.copyFrom(newPosition);
×
232

233
        // Update layout engine
234
        const context = this.getContext();
×
235
        context.getLayoutManager().layoutEngine?.setNodePosition(this.node, {
×
236
            x: newPosition.x,
×
237
            y: newPosition.y,
×
238
            z: newPosition.z,
×
239
        });
×
240
    }
×
241

242
    /**
243
     * Get the node being dragged.
244
     * Used by XRInputHandler to access the node's mesh for position calculations.
245
     * @returns The graph node associated with this drag handler
246
     */
247
    public getNode(): GraphNode {
15✔
248
        return this.node;
×
249
    }
×
250

251
    // Internal methods
252
    private setupPointerEvents(): void {
15✔
253
        // Listen to pointer events for node dragging and clicking
254
        this.pointerObserver = this.scene.onPrePointerObservable.add((pointerInfo) => {
6,807✔
255
            // Skip desktop pointer handling in XR mode - XRInputHandler handles it
256
            // This prevents conflicts where XR generates pointer events that the
257
            // desktop handler would misinterpret with wrong world position calculations
258
            if (this.isXRMode()) {
174✔
259
                return;
×
260
            }
×
261

262
            switch (pointerInfo.type) {
174✔
263
                case PointerEventTypes.POINTERDOWN: {
174✔
264
                    // Check if we clicked on this node
265
                    const pickInfo = this.scene.pick(
75✔
266
                        this.scene.pointerX,
75✔
267
                        this.scene.pointerY,
75✔
268
                    );
75✔
269

270
                    // Use nodeId from mesh metadata for comparison
271
                    // This works with both regular and instanced meshes
272
                    const pickedNodeId = pickInfo.pickedMesh?.metadata?.nodeId;
75✔
273

274
                    if (pickedNodeId === this.node.id) {
75✔
275
                        // Initialize click tracking
276
                        this.clickState = {
16✔
277
                            pointerDownTime: Date.now(),
16✔
278
                            pointerDownPosition: {
16✔
279
                                x: this.scene.pointerX,
16✔
280
                                y: this.scene.pointerY,
16✔
281
                            },
16✔
282
                            hasMoved: false,
16✔
283
                            pointerEvent: pointerInfo.event as PointerEvent,
16✔
284
                        };
16✔
285

286
                        // Get world position from pointer
287
                        const ray = this.scene.createPickingRay(
16✔
288
                            this.scene.pointerX,
16✔
289
                            this.scene.pointerY,
16✔
290
                            Matrix.Identity(),
16✔
291
                            this.scene.activeCamera,
16✔
292
                        );
16✔
293
                        const worldPosition = this.getWorldPositionFromRay(ray);
16✔
294
                        this.onDragStart(worldPosition);
16✔
295
                    }
16✔
296

297
                    break;
75✔
298
                }
75✔
299

300
                case PointerEventTypes.POINTERMOVE:
174✔
301
                    if (this.dragState.dragging) {
15✔
302
                        // Track movement for click detection
303
                        if (this.clickState && !this.clickState.hasMoved) {
×
304
                            const dx = this.scene.pointerX - this.clickState.pointerDownPosition.x;
×
305
                            const dy = this.scene.pointerY - this.clickState.pointerDownPosition.y;
×
306
                            const distance = Math.sqrt((dx * dx) + (dy * dy));
×
307
                            if (distance > CLICK_MAX_MOVEMENT_PX) {
×
308
                                this.clickState.hasMoved = true;
×
309
                            }
×
310
                        }
×
311

312
                        const ray = this.scene.createPickingRay(
×
313
                            this.scene.pointerX,
×
314
                            this.scene.pointerY,
×
315
                            Matrix.Identity(),
×
316
                            this.scene.activeCamera,
×
317
                        );
×
318
                        const worldPosition = this.getWorldPositionFromRay(ray);
×
319
                        this.onDragUpdate(worldPosition);
×
320
                    }
×
321

322
                    break;
15✔
323

324
                case PointerEventTypes.POINTERUP:
174✔
325
                    if (this.dragState.dragging) {
72✔
326
                        // Check if this was a click (short duration, minimal movement)
327
                        const wasClick = this.isClick();
16✔
328

329
                        this.onDragEnd();
16✔
330

331
                        // If it was a click, select this node
332
                        if (wasClick) {
16✔
333
                            this.handleClick();
16✔
334
                        }
16✔
335
                    }
16✔
336

337
                    // Reset click state
338
                    this.clickState = null;
72✔
339
                    break;
72✔
340
                default:
174✔
341
                    // Ignore other pointer events
342
                    break;
12✔
343
            }
174✔
344
        });
6,807✔
345
    }
6,807✔
346

347
    /**
348
     * Setup hover detection for emitting node-hover events.
349
     */
350
    private setupHoverEvents(): void {
15✔
351
        this.hoverObserver = this.scene.onPrePointerObservable.add((pointerInfo) => {
6,807✔
352
            // Skip in XR mode
353
            if (this.isXRMode()) {
174✔
354
                return;
×
355
            }
×
356

357
            // Only process move events for hover detection
358
            if (pointerInfo.type !== PointerEventTypes.POINTERMOVE) {
174✔
359
                return;
159✔
360
            }
159✔
361

362
            // Skip if we're currently dragging
363
            if (this.dragState.dragging) {
42✔
364
                return;
×
365
            }
✔
366

367
            // Check if we're hovering over this node
368
            const pickInfo = this.scene.pick(
15✔
369
                this.scene.pointerX,
15✔
370
                this.scene.pointerY,
15✔
371
            );
15✔
372
            const pickedNodeId = pickInfo.pickedMesh?.metadata?.nodeId;
42✔
373
            const isOverThisNode = pickedNodeId === this.node.id;
174✔
374

375
            // Emit node-hover when entering the node (not when already hovering)
376
            if (isOverThisNode && !this.isHovered) {
174✔
377
                this.isHovered = true;
×
378
                const context = this.getContext();
×
379
                const eventManager = context.getEventManager?.();
×
380
                if (eventManager) {
×
381
                    eventManager.emitNodeEvent("node-hover", {
×
382
                        node: this.node,
×
383
                        data: this.node.data,
×
384
                    });
×
385
                }
×
386
            } else if (!isOverThisNode && this.isHovered) {
42✔
387
                // Reset hover state when leaving
388
                this.isHovered = false;
×
389
            }
×
390
        });
6,807✔
391
    }
6,807✔
392

393
    /**
394
     * Check if the current pointer interaction qualifies as a click.
395
     * A click is defined as a short duration interaction with minimal movement.
396
     * @returns True if the interaction qualifies as a click
397
     */
398
    private isClick(): boolean {
15✔
399
        if (!this.clickState) {
16!
400
            return false;
×
401
        }
×
402

403
        const duration = Date.now() - this.clickState.pointerDownTime;
16✔
404
        return duration < CLICK_MAX_DURATION_MS && !this.clickState.hasMoved;
16✔
405
    }
16✔
406

407
    /**
408
     * Handle a click on this node - select it and emit node-click event.
409
     */
410
    private handleClick(): void {
15✔
411
        // Get the selection manager from the graph context
412
        const context = this.getContext();
16✔
413
        const selectionManager = context.getSelectionManager?.();
16✔
414
        if (selectionManager) {
16✔
415
            selectionManager.select(this.node);
16✔
416
        }
16✔
417

418
        // Emit node-click event
419
        const eventManager = context.getEventManager?.();
16✔
420
        if (eventManager && this.clickState?.pointerEvent) {
16✔
421
            eventManager.emitNodeEvent("node-click", {
16✔
422
                node: this.node,
16✔
423
                data: this.node.data,
16✔
424
                event: this.clickState.pointerEvent,
16✔
425
            });
16✔
426
        }
16✔
427
    }
16✔
428

429
    private getWorldPositionFromRay(ray: Ray): Vector3 {
15✔
430
        // Strategy: Plane intersection parallel to camera view
431
        // This maintains predictable drag behavior
432
        const camera = this.scene.activeCamera;
16✔
433
        if (!camera) {
16!
434
            return this.node.mesh.position.clone();
×
435
        }
×
436

437
        const nodePosition = this.node.mesh.position;
16✔
438

439
        // Use stored plane normal from drag start if available
440
        // Otherwise fall back to current camera forward (for initial calculation)
441
        const cameraForward = camera.getForwardRay().direction;
16✔
442
        const planeNormal = this.dragState.dragPlaneNormal ?? cameraForward;
16✔
443

444
        // Calculate distance from camera to node along plane normal
445
        const cameraToNode = nodePosition.subtract(camera.position);
16✔
446
        const depth = Vector3.Dot(cameraToNode, planeNormal);
16✔
447

448
        // Create plane at node depth, with orientation from drag start
449
        const planePoint = camera.position.add(planeNormal.scale(depth));
16✔
450

451
        // Ray-plane intersection
452
        const denominator = Vector3.Dot(ray.direction, planeNormal);
16✔
453
        if (Math.abs(denominator) < 0.0001) {
16!
454
            // Ray parallel to plane, return current position
455
            return this.node.mesh.position.clone();
×
456
        }
×
457

458
        const t = Vector3.Dot(
16✔
459
            planePoint.subtract(ray.origin),
16✔
460
            planeNormal,
16✔
461
        ) / denominator;
16✔
462

463
        return ray.origin.add(ray.direction.scale(t));
16✔
464
    }
16✔
465

466
    private isXRMode(): boolean {
15✔
467
        // Check if we're in an XR session
468
        // The scene has an xrSession property when XR is active
469
        const xrHelper = this.scene.metadata?.xrHelper;
355✔
470
        return xrHelper?.baseExperience?.state === 2; // WebXRState.IN_XR
355!
471
    }
355✔
472

473
    private getContext(): GraphContext {
15✔
474
        // Check if parentGraph has GraphContext methods
475
        if ("getStyles" in this.node.parentGraph) {
6,887✔
476
            return this.node.parentGraph;
6,887✔
477
        }
6,887!
478

479
        // Otherwise, it's a Graph instance which implements GraphContext
480
        return this.node.parentGraph;
×
481
    }
6,887✔
482

483
    /**
484
     * Cleans up event observers and releases resources.
485
     */
486
    public dispose(): void {
15✔
487
        if (this.pointerObserver) {
1,263✔
488
            this.scene.onPrePointerObservable.remove(this.pointerObserver);
1,262✔
489
            this.pointerObserver = null;
1,262✔
490
        }
1,262✔
491

492
        if (this.hoverObserver) {
1,263✔
493
            this.scene.onPrePointerObservable.remove(this.hoverObserver);
1,262✔
494
            this.hoverObserver = null;
1,262✔
495
        }
1,262✔
496
    }
1,263✔
497
}
15✔
498

499
/**
500
 * Manages node interaction behaviors including dragging and clicking.
501
 * This class uses static methods to provide utility functions for node behavior management.
502
 */
503
// eslint-disable-next-line @typescript-eslint/no-extraneous-class -- Static utility class pattern for node behavior management
504
export class NodeBehavior {
15✔
505
    /**
506
     * Add default interaction behaviors to a node
507
     * @param node - The graph node to add behaviors to
508
     * @param options - Configuration options for node behaviors
509
     */
510
    static addDefaultBehaviors(node: GraphNode, options: NodeBehaviorOptions = {}): void {
15✔
511
        node.mesh.isPickable = true;
6,807✔
512

513
        // Set pinOnDrag config
514
        node.pinOnDrag = options.pinOnDrag ?? true;
6,807✔
515

516
        // Create unified drag handler (replaces SixDofDragBehavior)
517
        const dragHandler = new NodeDragHandler(node);
6,807✔
518
        node.dragHandler = dragHandler;
6,807✔
519

520
        this.addClickBehavior(node);
6,807✔
521
    }
6,807✔
522

523
    /**
524
     * Add click behavior for node expansion
525
     * @param node - The graph node to add click behavior to
526
     */
527
    private static addClickBehavior(node: GraphNode): void {
15✔
528
        // click behavior setup
529
        const context = this.getContext(node);
6,807✔
530
        const scene = context.getScene();
6,807✔
531
        node.mesh.actionManager = node.mesh.actionManager ?? new ActionManager(scene);
6,807✔
532

533
        // Available triggers:
534
        // ActionManager.OnDoublePickTrigger
535
        // ActionManager.OnRightPickTrigger
536
        // ActionManager.OnCenterPickTrigger
537
        // ActionManager.OnLongPressTrigger
538

539
        // Only Graph has fetchNodes/fetchEdges, not GraphContext
540
        // For now, check if parentGraph is the full Graph instance
541
        const graph = node.parentGraph as Graph & {fetchNodes?: unknown, fetchEdges?: unknown};
6,807✔
542
        if (graph.fetchNodes && graph.fetchEdges) {
6,807!
543
            const {fetchNodes, fetchEdges} = graph;
2✔
544

545
            node.mesh.actionManager.registerAction(
2✔
546
                new ExecuteCodeAction(
2✔
547
                    {
2✔
548
                        trigger: ActionManager.OnDoublePickTrigger,
2✔
549
                        // trigger: ActionManager.OnLongPressTrigger,
550
                    },
2✔
551
                    () => {
2✔
552
                        // make sure the graph is running
553
                        context.setRunning(true);
1✔
554

555
                        // fetch all edges for current node
556
                        const edgeSet = fetchEdges(node, graph as unknown as Graph);
1✔
557
                        const edges = Array.from(edgeSet);
1✔
558

559
                        // create set of unique node ids
560
                        const nodeIds = new Set<NodeIdType>();
1✔
561
                        edges.forEach((e) => {
1✔
562
                            nodeIds.add(e.src);
2✔
563
                            nodeIds.add(e.dst);
2✔
564
                        });
1✔
565
                        nodeIds.delete(node.id);
1✔
566

567
                        // fetch all nodes from associated edges
568
                        const nodes = fetchNodes(nodeIds, graph);
1✔
569

570
                        // add all the nodes and edges we collected
571
                        const dataManager = context.getDataManager();
1✔
572
                        dataManager.addNodes([... nodes]);
1✔
573
                        dataManager.addEdges([... edges]);
1✔
574

575
                        // TODO: fetch and add secondary edges
576
                    },
1✔
577
                ),
2✔
578
            );
2✔
579
        }
2✔
580
    }
6,807✔
581

582
    /**
583
     * Helper to get GraphContext from a Node
584
     * @param node - The graph node to get context from
585
     * @returns The graph context for the node
586
     */
587
    private static getContext(node: GraphNode): GraphContext {
15✔
588
        // Check if parentGraph has GraphContext methods
589
        if ("getStyles" in node.parentGraph) {
6,807✔
590
            return node.parentGraph;
6,807✔
591
        }
6,807!
592

593
        // Otherwise, it's a Graph instance which implements GraphContext
594
        return node.parentGraph;
×
595
    }
6,807✔
596
}
15✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc