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

graphty-org / graphty-element / 20390753610

20 Dec 2025 06:53AM UTC coverage: 82.423% (-1.2%) from 83.666%
20390753610

push

github

apowers313
Merge branch 'master' of https://github.com/graphty-org/graphty-element

5162 of 6088 branches covered (84.79%)

Branch coverage included in aggregate %.

24775 of 30233 relevant lines covered (81.95%)

6480.4 hits per line

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

58.98
/src/NodeBehavior.ts
1
import {
3✔
2
    ActionManager,
2✔
3
    ExecuteCodeAction,
2✔
4
    Matrix,
2✔
5
    type Observer,
6
    PointerEventTypes,
2✔
7
    type PointerInfoPre,
8
    Ray,
9
    Scene,
10
    Vector3,
2✔
11
} from "@babylonjs/core";
2✔
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
}
35

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

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

52
    constructor(node: GraphNode) {
3✔
53
        this.node = node;
6,754✔
54
        this.scene = node.mesh.getScene();
6,754✔
55
        this.dragState = {
6,754✔
56
            dragging: false,
6,754✔
57
            dragStartMeshPosition: null,
6,754✔
58
            dragStartWorldPosition: null,
6,754✔
59
            dragPlaneNormal: null,
6,754✔
60
        };
6,754✔
61

62
        // Read config from graph context
63
        const context = this.getContext();
6,754✔
64
        const xrConfig = context.getConfig().xr;
6,754✔
65

66
        this.zAxisAmplification = xrConfig?.input.zAxisAmplification ?? 10.0;
6,754✔
67
        this.enableZAmplificationInDesktop = xrConfig?.input.enableZAmplificationInDesktop ?? false;
6,754✔
68

69
        // Setup pointer event listeners
70
        this.setupPointerEvents();
6,754✔
71
    }
6,754✔
72

73
    // Public API for both desktop and XR
74
    public onDragStart(worldPosition: Vector3): void {
3✔
75
        // Debug: console.log("🔍 [Drag] Drag Start:", {
76
        //     nodeId: this.node.id,
77
        //     isXRMode: this.isXRMode(),
78
        // });
79

80
        this.dragState.dragging = true;
13✔
81
        this.dragState.dragStartMeshPosition = this.node.mesh.position.clone();
13✔
82
        this.dragState.dragStartWorldPosition = worldPosition.clone();
13✔
83
        this.node.dragging = true;
13✔
84

85
        // Capture the drag plane orientation at drag start
86
        // This prevents the plane from rotating with camera during drag
87
        const camera = this.scene.activeCamera;
13✔
88
        if (camera) {
13✔
89
            this.dragState.dragPlaneNormal = camera.getForwardRay().direction.clone();
13✔
90

91
            // Disable camera input handler during node drag
92
            // This prevents OrbitInputController from rotating camera while dragging nodes
93
            const cameraManager = this.scene.metadata?.cameraManager;
13✔
94
            if (cameraManager) {
13✔
95
                // Debug: console.log("📷 Disabling camera input during node drag");
96
                cameraManager.temporarilyDisableInput();
13✔
97
            }
13✔
98
        }
13✔
99

100
        // Make sure graph is running
101
        const context = this.getContext();
13✔
102
        context.setRunning(true);
13✔
103
    }
13✔
104

105
    public onDragUpdate(worldPosition: Vector3): void {
3✔
106
        if (!this.dragState.dragging || !this.dragState.dragStartWorldPosition || !this.dragState.dragStartMeshPosition) {
8✔
107
            return;
1✔
108
        }
1✔
109

110
        // Calculate delta from drag start
111
        const delta = worldPosition.subtract(this.dragState.dragStartWorldPosition);
7✔
112

113
        // TODO: Add back delta validation with appropriate threshold for XR mode
114
        // The previous MAX_REASONABLE_DELTA of 5.0 was too small for 10x amplification
115
        // and was blocking ALL movement in XR
116

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

122
        if (shouldAmplify) {
8!
123
            delta.x *= this.zAxisAmplification;
×
124
            delta.y *= this.zAxisAmplification;
×
125
            delta.z *= this.zAxisAmplification;
×
126
        }
✔
127

128
        // Calculate new position
129
        const newPosition = this.dragState.dragStartMeshPosition.add(delta);
7✔
130

131
        // Update mesh position (triggers edge updates automatically)
132
        this.node.mesh.position.copyFrom(newPosition);
7✔
133

134
        // Update layout engine
135
        const context = this.getContext();
7✔
136
        context.getLayoutManager().layoutEngine?.setNodePosition(this.node, {
8✔
137
            x: newPosition.x,
8✔
138
            y: newPosition.y,
8✔
139
            z: newPosition.z,
8✔
140
        });
8✔
141
    }
8✔
142

143
    public onDragEnd(): void {
3✔
144
        if (!this.dragState.dragging) {
12!
145
            return;
×
146
        }
×
147

148
        // Debug: console.log("🏁 NodeDragHandler.onDragEnd called", {
149
        //     nodeId: this.node.id,
150
        //     finalPosition: this.node.mesh.position.asArray(),
151
        // });
152

153
        // Make sure graph is running
154
        const context = this.getContext();
12✔
155
        context.setRunning(true);
12✔
156

157
        // Pin after dragging if configured
158
        if (this.node.pinOnDrag) {
12✔
159
            this.node.pin();
11✔
160
        }
11✔
161

162
        // Re-enable camera input handler after node drag
163
        const cameraManager = this.scene.metadata?.cameraManager;
12✔
164
        if (cameraManager) {
12✔
165
            // Debug: console.log("📷 Re-enabling camera input after node drag");
166
            cameraManager.temporarilyEnableInput();
12✔
167
        }
12✔
168

169
        // Reset drag state
170
        this.node.dragging = false;
12✔
171
        this.dragState.dragging = false;
12✔
172
        this.dragState.dragStartMeshPosition = null;
12✔
173
        this.dragState.dragStartWorldPosition = null;
12✔
174
        this.dragState.dragPlaneNormal = null;
12✔
175
    }
12✔
176

177
    /**
178
     * Set node position directly (for XR mode).
179
     * XRInputHandler calculates the position with pivot transform and amplification,
180
     * then calls this method to update the node and layout engine.
181
     *
182
     * This bypasses the delta calculation in onDragUpdate() which doesn't account
183
     * for pivot rotation changes during drag.
184
     */
185
    public setPositionDirect(newPosition: Vector3): void {
3✔
186
        if (!this.dragState.dragging) {
×
187
            return;
×
188
        }
×
189

190
        // Update mesh position
191
        this.node.mesh.position.copyFrom(newPosition);
×
192

193
        // Update layout engine
194
        const context = this.getContext();
×
195
        context.getLayoutManager().layoutEngine?.setNodePosition(this.node, {
×
196
            x: newPosition.x,
×
197
            y: newPosition.y,
×
198
            z: newPosition.z,
×
199
        });
×
200
    }
×
201

202
    /**
203
     * Get the node being dragged.
204
     * Used by XRInputHandler to access the node's mesh for position calculations.
205
     */
206
    public getNode(): GraphNode {
3✔
207
        return this.node;
×
208
    }
×
209

210
    // Internal methods
211
    private setupPointerEvents(): void {
3✔
212
        // Listen to pointer events for node dragging and clicking
213
        this.pointerObserver = this.scene.onPrePointerObservable.add((pointerInfo) => {
6,754✔
214
            // Skip desktop pointer handling in XR mode - XRInputHandler handles it
215
            // This prevents conflicts where XR generates pointer events that the
216
            // desktop handler would misinterpret with wrong world position calculations
217
            if (this.isXRMode()) {
×
218
                return;
×
219
            }
×
220

221
            switch (pointerInfo.type) {
×
222
                case PointerEventTypes.POINTERDOWN: {
×
223
                    // Check if we clicked on this node
224
                    const pickInfo = this.scene.pick(
×
225
                        this.scene.pointerX,
×
226
                        this.scene.pointerY,
×
227
                    );
×
228

229
                    // Use nodeId from mesh metadata for comparison
230
                    // This works with both regular and instanced meshes
231
                    const pickedNodeId = pickInfo.pickedMesh?.metadata?.nodeId;
×
232

233
                    if (pickedNodeId === this.node.id) {
×
234
                        // Initialize click tracking
235
                        this.clickState = {
×
236
                            pointerDownTime: Date.now(),
×
237
                            pointerDownPosition: {
×
238
                                x: this.scene.pointerX,
×
239
                                y: this.scene.pointerY,
×
240
                            },
×
241
                            hasMoved: false,
×
242
                        };
×
243

244
                        // Get world position from pointer
245
                        const ray = this.scene.createPickingRay(
×
246
                            this.scene.pointerX,
×
247
                            this.scene.pointerY,
×
248
                            Matrix.Identity(),
×
249
                            this.scene.activeCamera,
×
250
                        );
×
251
                        const worldPosition = this.getWorldPositionFromRay(ray);
×
252
                        this.onDragStart(worldPosition);
×
253
                    }
×
254

255
                    break;
×
256
                }
×
257

258
                case PointerEventTypes.POINTERMOVE:
×
259
                    if (this.dragState.dragging) {
×
260
                        // Track movement for click detection
261
                        if (this.clickState && !this.clickState.hasMoved) {
×
262
                            const dx = this.scene.pointerX - this.clickState.pointerDownPosition.x;
×
263
                            const dy = this.scene.pointerY - this.clickState.pointerDownPosition.y;
×
264
                            const distance = Math.sqrt((dx * dx) + (dy * dy));
×
265
                            if (distance > CLICK_MAX_MOVEMENT_PX) {
×
266
                                this.clickState.hasMoved = true;
×
267
                            }
×
268
                        }
×
269

270
                        const ray = this.scene.createPickingRay(
×
271
                            this.scene.pointerX,
×
272
                            this.scene.pointerY,
×
273
                            Matrix.Identity(),
×
274
                            this.scene.activeCamera,
×
275
                        );
×
276
                        const worldPosition = this.getWorldPositionFromRay(ray);
×
277
                        this.onDragUpdate(worldPosition);
×
278
                    }
×
279

280
                    break;
×
281

282
                case PointerEventTypes.POINTERUP:
×
283
                    if (this.dragState.dragging) {
×
284
                        // Check if this was a click (short duration, minimal movement)
285
                        const wasClick = this.isClick();
×
286

287
                        this.onDragEnd();
×
288

289
                        // If it was a click, select this node
290
                        if (wasClick) {
×
291
                            this.handleClick();
×
292
                        }
×
293
                    }
×
294

295
                    // Reset click state
296
                    this.clickState = null;
×
297
                    break;
×
298
                default:
×
299
                    // Ignore other pointer events
300
                    break;
×
301
            }
×
302
        });
6,754✔
303
    }
6,754✔
304

305
    /**
306
     * Check if the current pointer interaction qualifies as a click.
307
     * A click is defined as a short duration interaction with minimal movement.
308
     */
309
    private isClick(): boolean {
3✔
310
        if (!this.clickState) {
×
311
            return false;
×
312
        }
×
313

314
        const duration = Date.now() - this.clickState.pointerDownTime;
×
315
        return duration < CLICK_MAX_DURATION_MS && !this.clickState.hasMoved;
×
316
    }
×
317

318
    /**
319
     * Handle a click on this node - select it.
320
     */
321
    private handleClick(): void {
3✔
322
        // Get the selection manager from the graph context
323
        const context = this.getContext();
×
324
        const selectionManager = context.getSelectionManager?.();
×
325
        if (selectionManager) {
×
326
            selectionManager.select(this.node);
×
327
        }
×
328
    }
×
329

330
    private getWorldPositionFromRay(ray: Ray): Vector3 {
3✔
331
        // Strategy: Plane intersection parallel to camera view
332
        // This maintains predictable drag behavior
333
        const camera = this.scene.activeCamera;
×
334
        if (!camera) {
×
335
            return this.node.mesh.position.clone();
×
336
        }
×
337

338
        const nodePosition = this.node.mesh.position;
×
339

340
        // Use stored plane normal from drag start if available
341
        // Otherwise fall back to current camera forward (for initial calculation)
342
        const cameraForward = camera.getForwardRay().direction;
×
343
        const planeNormal = this.dragState.dragPlaneNormal ?? cameraForward;
×
344

345
        // Calculate distance from camera to node along plane normal
346
        const cameraToNode = nodePosition.subtract(camera.position);
×
347
        const depth = Vector3.Dot(cameraToNode, planeNormal);
×
348

349
        // Create plane at node depth, with orientation from drag start
350
        const planePoint = camera.position.add(planeNormal.scale(depth));
×
351

352
        // Ray-plane intersection
353
        const denominator = Vector3.Dot(ray.direction, planeNormal);
×
354
        if (Math.abs(denominator) < 0.0001) {
×
355
            // Ray parallel to plane, return current position
356
            return this.node.mesh.position.clone();
×
357
        }
×
358

359
        const t = Vector3.Dot(
×
360
            planePoint.subtract(ray.origin),
×
361
            planeNormal,
×
362
        ) / denominator;
×
363

364
        return ray.origin.add(ray.direction.scale(t));
×
365
    }
×
366

367
    private isXRMode(): boolean {
3✔
368
        // Check if we're in an XR session
369
        // The scene has an xrSession property when XR is active
370
        const xrHelper = this.scene.metadata?.xrHelper;
7✔
371
        return xrHelper?.baseExperience?.state === 2; // WebXRState.IN_XR
7!
372
    }
7✔
373

374
    private getContext(): GraphContext {
3✔
375
        // Check if parentGraph has GraphContext methods
376
        if ("getStyles" in this.node.parentGraph) {
6,786✔
377
            return this.node.parentGraph;
6,786✔
378
        }
6,786!
379

380
        // Otherwise, it's a Graph instance which implements GraphContext
381
        return this.node.parentGraph;
×
382
    }
6,786✔
383

384
    public dispose(): void {
3✔
385
        if (this.pointerObserver) {
1,232✔
386
            this.scene.onPrePointerObservable.remove(this.pointerObserver);
1,231✔
387
            this.pointerObserver = null;
1,231✔
388
        }
1,231✔
389
    }
1,232✔
390
}
3✔
391

392
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
393
export class NodeBehavior {
3✔
394
    /**
395
     * Add default interaction behaviors to a node
396
     */
397
    static addDefaultBehaviors(node: GraphNode, options: NodeBehaviorOptions = {}): void {
3✔
398
        node.mesh.isPickable = true;
6,754✔
399

400
        // Set pinOnDrag config
401
        node.pinOnDrag = options.pinOnDrag ?? true;
6,754✔
402

403
        // Create unified drag handler (replaces SixDofDragBehavior)
404
        const dragHandler = new NodeDragHandler(node);
6,754✔
405
        node.dragHandler = dragHandler;
6,754✔
406

407
        this.addClickBehavior(node);
6,754✔
408
    }
6,754✔
409

410
    /**
411
     * Add click behavior for node expansion
412
     */
413
    private static addClickBehavior(node: GraphNode): void {
3✔
414
        // click behavior setup
415
        const context = this.getContext(node);
6,754✔
416
        const scene = context.getScene();
6,754✔
417
        node.mesh.actionManager = node.mesh.actionManager ?? new ActionManager(scene);
6,754✔
418

419
        // Available triggers:
420
        // ActionManager.OnDoublePickTrigger
421
        // ActionManager.OnRightPickTrigger
422
        // ActionManager.OnCenterPickTrigger
423
        // ActionManager.OnLongPressTrigger
424

425
        // Only Graph has fetchNodes/fetchEdges, not GraphContext
426
        // For now, check if parentGraph is the full Graph instance
427
        const graph = node.parentGraph as Graph & {fetchNodes?: unknown, fetchEdges?: unknown};
6,754✔
428
        if (graph.fetchNodes && graph.fetchEdges) {
6,754✔
429
            const {fetchNodes, fetchEdges} = graph;
2✔
430

431
            node.mesh.actionManager.registerAction(
2✔
432
                new ExecuteCodeAction(
2✔
433
                    {
2✔
434
                        trigger: ActionManager.OnDoublePickTrigger,
2✔
435
                        // trigger: ActionManager.OnLongPressTrigger,
436
                    },
2✔
437
                    () => {
2✔
438
                        // make sure the graph is running
439
                        context.setRunning(true);
1✔
440

441
                        // fetch all edges for current node
442
                        const edgeSet = fetchEdges(node, graph as unknown as Graph);
1✔
443
                        const edges = Array.from(edgeSet);
1✔
444

445
                        // create set of unique node ids
446
                        const nodeIds = new Set<NodeIdType>();
1✔
447
                        edges.forEach((e) => {
1✔
448
                            nodeIds.add(e.src);
2✔
449
                            nodeIds.add(e.dst);
2✔
450
                        });
1✔
451
                        nodeIds.delete(node.id);
1✔
452

453
                        // fetch all nodes from associated edges
454
                        const nodes = fetchNodes(nodeIds, graph);
1✔
455

456
                        // add all the nodes and edges we collected
457
                        const dataManager = context.getDataManager();
1✔
458
                        dataManager.addNodes([... nodes]);
1✔
459
                        dataManager.addEdges([... edges]);
1✔
460

461
                        // TODO: fetch and add secondary edges
462
                    },
1✔
463
                ),
2✔
464
            );
2✔
465
        }
2✔
466
    }
6,754✔
467

468
    /**
469
     * Helper to get GraphContext from a Node
470
     */
471
    private static getContext(node: GraphNode): GraphContext {
3✔
472
        // Check if parentGraph has GraphContext methods
473
        if ("getStyles" in node.parentGraph) {
6,754✔
474
            return node.parentGraph;
6,754✔
475
        }
6,754!
476

477
        // Otherwise, it's a Graph instance which implements GraphContext
478
        return node.parentGraph;
×
479
    }
6,754✔
480
}
3✔
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