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

graphty-org / graphty-element / 20219217725

15 Dec 2025 03:10AM UTC coverage: 83.666% (-2.7%) from 86.405%
20219217725

push

github

apowers313
chore: delint

4072 of 4771 branches covered (85.35%)

Branch coverage included in aggregate %.

15 of 26 new or added lines in 1 file covered. (57.69%)

890 existing lines in 12 files now uncovered.

18814 of 22583 relevant lines covered (83.31%)

8168.81 hits per line

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

64.96
/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
/**
30
 * Main drag handler class - unified for both desktop and XR
31
 */
32
export class NodeDragHandler {
3✔
33
    private node: GraphNode;
34
    private dragState: DragState;
35
    private scene: Scene;
36
    private pointerObserver: Observer<PointerInfoPre> | null = null;
3✔
37
    private readonly zAxisAmplification: number;
38
    private readonly enableZAmplificationInDesktop: boolean;
39

40
    constructor(node: GraphNode) {
3✔
41
        this.node = node;
5,729✔
42
        this.scene = node.mesh.getScene();
5,729✔
43
        this.dragState = {
5,729✔
44
            dragging: false,
5,729✔
45
            dragStartMeshPosition: null,
5,729✔
46
            dragStartWorldPosition: null,
5,729✔
47
            dragPlaneNormal: null,
5,729✔
48
        };
5,729✔
49

50
        // Read config from graph context
51
        const context = this.getContext();
5,729✔
52
        const xrConfig = context.getConfig().xr;
5,729✔
53

54
        this.zAxisAmplification = xrConfig?.input.zAxisAmplification ?? 10.0;
5,729!
55
        this.enableZAmplificationInDesktop = xrConfig?.input.enableZAmplificationInDesktop ?? false;
5,729!
56

57
        // Setup pointer event listeners
58
        this.setupPointerEvents();
5,729✔
59
    }
5,729✔
60

61
    // Public API for both desktop and XR
62
    public onDragStart(worldPosition: Vector3): void {
3✔
63
        // Debug: console.log("🔍 [Drag] Drag Start:", {
64
        //     nodeId: this.node.id,
65
        //     isXRMode: this.isXRMode(),
66
        // });
67

68
        this.dragState.dragging = true;
13✔
69
        this.dragState.dragStartMeshPosition = this.node.mesh.position.clone();
13✔
70
        this.dragState.dragStartWorldPosition = worldPosition.clone();
13✔
71
        this.node.dragging = true;
13✔
72

73
        // Capture the drag plane orientation at drag start
74
        // This prevents the plane from rotating with camera during drag
75
        const camera = this.scene.activeCamera;
13✔
76
        if (camera) {
13✔
77
            this.dragState.dragPlaneNormal = camera.getForwardRay().direction.clone();
13✔
78

79
            // Disable camera input handler during node drag
80
            // This prevents OrbitInputController from rotating camera while dragging nodes
81
            const cameraManager = this.scene.metadata?.cameraManager;
13✔
82
            if (cameraManager) {
13✔
83
                // Debug: console.log("📷 Disabling camera input during node drag");
84
                cameraManager.temporarilyDisableInput();
13✔
85
            }
13✔
86
        }
13✔
87

88
        // Make sure graph is running
89
        const context = this.getContext();
13✔
90
        context.setRunning(true);
13✔
91
    }
13✔
92

93
    public onDragUpdate(worldPosition: Vector3): void {
3✔
94
        if (!this.dragState.dragging || !this.dragState.dragStartWorldPosition || !this.dragState.dragStartMeshPosition) {
8✔
95
            return;
1✔
96
        }
1✔
97

98
        // Calculate delta from drag start
99
        const delta = worldPosition.subtract(this.dragState.dragStartWorldPosition);
7✔
100

101
        // TODO: Add back delta validation with appropriate threshold for XR mode
102
        // The previous MAX_REASONABLE_DELTA of 5.0 was too small for 10x amplification
103
        // and was blocking ALL movement in XR
104

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

110
        if (shouldAmplify) {
8!
UNCOV
111
            delta.x *= this.zAxisAmplification;
×
UNCOV
112
            delta.y *= this.zAxisAmplification;
×
UNCOV
113
            delta.z *= this.zAxisAmplification;
×
UNCOV
114
        }
✔
115

116
        // Calculate new position
117
        const newPosition = this.dragState.dragStartMeshPosition.add(delta);
7✔
118

119
        // Update mesh position (triggers edge updates automatically)
120
        this.node.mesh.position.copyFrom(newPosition);
7✔
121

122
        // Update layout engine
123
        const context = this.getContext();
7✔
124
        context.getLayoutManager().layoutEngine?.setNodePosition(this.node, {
8✔
125
            x: newPosition.x,
8✔
126
            y: newPosition.y,
8✔
127
            z: newPosition.z,
8✔
128
        });
8✔
129
    }
8✔
130

131
    public onDragEnd(): void {
3✔
132
        if (!this.dragState.dragging) {
12!
UNCOV
133
            return;
×
UNCOV
134
        }
×
135

136
        // Debug: console.log("🏁 NodeDragHandler.onDragEnd called", {
137
        //     nodeId: this.node.id,
138
        //     finalPosition: this.node.mesh.position.asArray(),
139
        // });
140

141
        // Make sure graph is running
142
        const context = this.getContext();
12✔
143
        context.setRunning(true);
12✔
144

145
        // Pin after dragging if configured
146
        if (this.node.pinOnDrag) {
12✔
147
            this.node.pin();
11✔
148
        }
11✔
149

150
        // Re-enable camera input handler after node drag
151
        const cameraManager = this.scene.metadata?.cameraManager;
12✔
152
        if (cameraManager) {
12✔
153
            // Debug: console.log("📷 Re-enabling camera input after node drag");
154
            cameraManager.temporarilyEnableInput();
12✔
155
        }
12✔
156

157
        // Reset drag state
158
        this.node.dragging = false;
12✔
159
        this.dragState.dragging = false;
12✔
160
        this.dragState.dragStartMeshPosition = null;
12✔
161
        this.dragState.dragStartWorldPosition = null;
12✔
162
        this.dragState.dragPlaneNormal = null;
12✔
163
    }
12✔
164

165
    /**
166
     * Set node position directly (for XR mode).
167
     * XRInputHandler calculates the position with pivot transform and amplification,
168
     * then calls this method to update the node and layout engine.
169
     *
170
     * This bypasses the delta calculation in onDragUpdate() which doesn't account
171
     * for pivot rotation changes during drag.
172
     */
173
    public setPositionDirect(newPosition: Vector3): void {
3✔
UNCOV
174
        if (!this.dragState.dragging) {
×
UNCOV
175
            return;
×
UNCOV
176
        }
×
177

178
        // Update mesh position
UNCOV
179
        this.node.mesh.position.copyFrom(newPosition);
×
180

181
        // Update layout engine
UNCOV
182
        const context = this.getContext();
×
UNCOV
183
        context.getLayoutManager().layoutEngine?.setNodePosition(this.node, {
×
UNCOV
184
            x: newPosition.x,
×
UNCOV
185
            y: newPosition.y,
×
UNCOV
186
            z: newPosition.z,
×
UNCOV
187
        });
×
UNCOV
188
    }
×
189

190
    /**
191
     * Get the node being dragged.
192
     * Used by XRInputHandler to access the node's mesh for position calculations.
193
     */
194
    public getNode(): GraphNode {
3✔
UNCOV
195
        return this.node;
×
UNCOV
196
    }
×
197

198
    // Internal methods
199
    private setupPointerEvents(): void {
3✔
200
        // Listen to pointer events for node dragging
201
        this.pointerObserver = this.scene.onPrePointerObservable.add((pointerInfo) => {
5,729✔
202
            // Skip desktop pointer handling in XR mode - XRInputHandler handles it
203
            // This prevents conflicts where XR generates pointer events that the
204
            // desktop handler would misinterpret with wrong world position calculations
UNCOV
205
            if (this.isXRMode()) {
×
UNCOV
206
                return;
×
UNCOV
207
            }
×
208

UNCOV
209
            switch (pointerInfo.type) {
×
UNCOV
210
                case PointerEventTypes.POINTERDOWN: {
×
211
                    // Check if we clicked on this node
UNCOV
212
                    const pickInfo = this.scene.pick(
×
UNCOV
213
                        this.scene.pointerX,
×
UNCOV
214
                        this.scene.pointerY,
×
UNCOV
215
                    );
×
216

217
                    // Debug: console.log("🖱️ POINTERDOWN", {
218
                    //     nodeId: this.node.id,
219
                    //     pickedMeshName: pickInfo.pickedMesh?.name,
220
                    //     nodeMeshName: this.node.mesh.name,
221
                    //     meshesMatch: pickInfo.pickedMesh === this.node.mesh,
222
                    //     hit: pickInfo.hit,
223
                    // });
224

UNCOV
225
                    if (pickInfo.pickedMesh === this.node.mesh) {
×
226
                        // Get world position from pointer
UNCOV
227
                        const ray = this.scene.createPickingRay(
×
UNCOV
228
                            this.scene.pointerX,
×
UNCOV
229
                            this.scene.pointerY,
×
UNCOV
230
                            Matrix.Identity(),
×
UNCOV
231
                            this.scene.activeCamera,
×
UNCOV
232
                        );
×
UNCOV
233
                        const worldPosition = this.getWorldPositionFromRay(ray);
×
UNCOV
234
                        this.onDragStart(worldPosition);
×
UNCOV
235
                    }
×
236

UNCOV
237
                    break;
×
UNCOV
238
                }
×
239

UNCOV
240
                case PointerEventTypes.POINTERMOVE:
×
UNCOV
241
                    if (this.dragState.dragging) {
×
UNCOV
242
                        const ray = this.scene.createPickingRay(
×
UNCOV
243
                            this.scene.pointerX,
×
UNCOV
244
                            this.scene.pointerY,
×
UNCOV
245
                            Matrix.Identity(),
×
UNCOV
246
                            this.scene.activeCamera,
×
UNCOV
247
                        );
×
UNCOV
248
                        const worldPosition = this.getWorldPositionFromRay(ray);
×
UNCOV
249
                        this.onDragUpdate(worldPosition);
×
UNCOV
250
                    }
×
251

UNCOV
252
                    break;
×
253

UNCOV
254
                case PointerEventTypes.POINTERUP:
×
UNCOV
255
                    if (this.dragState.dragging) {
×
UNCOV
256
                        this.onDragEnd();
×
UNCOV
257
                    }
×
258

UNCOV
259
                    break;
×
UNCOV
260
                default:
×
261
                    // Ignore other pointer events
UNCOV
262
                    break;
×
UNCOV
263
            }
×
264
        });
5,729✔
265
    }
5,729✔
266

267
    private getWorldPositionFromRay(ray: Ray): Vector3 {
3✔
268
        // Strategy: Plane intersection parallel to camera view
269
        // This maintains predictable drag behavior
UNCOV
270
        const camera = this.scene.activeCamera;
×
UNCOV
271
        if (!camera) {
×
UNCOV
272
            return this.node.mesh.position.clone();
×
UNCOV
273
        }
×
274

UNCOV
275
        const nodePosition = this.node.mesh.position;
×
276

277
        // Use stored plane normal from drag start if available
278
        // Otherwise fall back to current camera forward (for initial calculation)
UNCOV
279
        const cameraForward = camera.getForwardRay().direction;
×
UNCOV
280
        const planeNormal = this.dragState.dragPlaneNormal ?? cameraForward;
×
281

282
        // Calculate distance from camera to node along plane normal
UNCOV
283
        const cameraToNode = nodePosition.subtract(camera.position);
×
UNCOV
284
        const depth = Vector3.Dot(cameraToNode, planeNormal);
×
285

286
        // Create plane at node depth, with orientation from drag start
UNCOV
287
        const planePoint = camera.position.add(planeNormal.scale(depth));
×
288

289
        // Ray-plane intersection
UNCOV
290
        const denominator = Vector3.Dot(ray.direction, planeNormal);
×
UNCOV
291
        if (Math.abs(denominator) < 0.0001) {
×
292
            // Ray parallel to plane, return current position
UNCOV
293
            return this.node.mesh.position.clone();
×
UNCOV
294
        }
×
295

UNCOV
296
        const t = Vector3.Dot(
×
UNCOV
297
            planePoint.subtract(ray.origin),
×
UNCOV
298
            planeNormal,
×
UNCOV
299
        ) / denominator;
×
300

UNCOV
301
        return ray.origin.add(ray.direction.scale(t));
×
UNCOV
302
    }
×
303

304
    private isXRMode(): boolean {
3✔
305
        // Check if we're in an XR session
306
        // The scene has an xrSession property when XR is active
307
        const xrHelper = this.scene.metadata?.xrHelper;
7✔
308
        return xrHelper?.baseExperience?.state === 2; // WebXRState.IN_XR
7!
309
    }
7✔
310

311
    private getContext(): GraphContext {
3✔
312
        // Check if parentGraph has GraphContext methods
313
        if ("getStyles" in this.node.parentGraph) {
5,761✔
314
            return this.node.parentGraph;
5,761✔
315
        }
5,761!
316

317
        // Otherwise, it's a Graph instance which implements GraphContext
UNCOV
318
        return this.node.parentGraph;
×
319
    }
5,761✔
320

321
    public dispose(): void {
3✔
322
        if (this.pointerObserver) {
2✔
323
            this.scene.onPrePointerObservable.remove(this.pointerObserver);
1✔
324
            this.pointerObserver = null;
1✔
325
        }
1✔
326
    }
2✔
327
}
3✔
328

329
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
330
export class NodeBehavior {
3✔
331
    /**
332
     * Add default interaction behaviors to a node
333
     */
334
    static addDefaultBehaviors(node: GraphNode, options: NodeBehaviorOptions = {}): void {
3✔
335
        node.mesh.isPickable = true;
5,729✔
336

337
        // Set pinOnDrag config
338
        node.pinOnDrag = options.pinOnDrag ?? true;
5,729✔
339

340
        // Create unified drag handler (replaces SixDofDragBehavior)
341
        const dragHandler = new NodeDragHandler(node);
5,729✔
342
        node.dragHandler = dragHandler;
5,729✔
343

344
        this.addClickBehavior(node);
5,729✔
345
    }
5,729✔
346

347
    /**
348
     * Add click behavior for node expansion
349
     */
350
    private static addClickBehavior(node: GraphNode): void {
3✔
351
        // click behavior setup
352
        const context = this.getContext(node);
5,729✔
353
        const scene = context.getScene();
5,729✔
354
        node.mesh.actionManager = node.mesh.actionManager ?? new ActionManager(scene);
5,729✔
355

356
        // Available triggers:
357
        // ActionManager.OnDoublePickTrigger
358
        // ActionManager.OnRightPickTrigger
359
        // ActionManager.OnCenterPickTrigger
360
        // ActionManager.OnLongPressTrigger
361

362
        // Only Graph has fetchNodes/fetchEdges, not GraphContext
363
        // For now, check if parentGraph is the full Graph instance
364
        const graph = node.parentGraph as Graph & {fetchNodes?: unknown, fetchEdges?: unknown};
5,729✔
365
        if (graph.fetchNodes && graph.fetchEdges) {
5,729✔
366
            const {fetchNodes, fetchEdges} = graph;
2✔
367

368
            node.mesh.actionManager.registerAction(
2✔
369
                new ExecuteCodeAction(
2✔
370
                    {
2✔
371
                        trigger: ActionManager.OnDoublePickTrigger,
2✔
372
                        // trigger: ActionManager.OnLongPressTrigger,
373
                    },
2✔
374
                    () => {
2✔
375
                        // make sure the graph is running
376
                        context.setRunning(true);
1✔
377

378
                        // fetch all edges for current node
379
                        const edgeSet = fetchEdges(node, graph as unknown as Graph);
1✔
380
                        const edges = Array.from(edgeSet);
1✔
381

382
                        // create set of unique node ids
383
                        const nodeIds = new Set<NodeIdType>();
1✔
384
                        edges.forEach((e) => {
1✔
385
                            nodeIds.add(e.src);
2✔
386
                            nodeIds.add(e.dst);
2✔
387
                        });
1✔
388
                        nodeIds.delete(node.id);
1✔
389

390
                        // fetch all nodes from associated edges
391
                        const nodes = fetchNodes(nodeIds, graph);
1✔
392

393
                        // add all the nodes and edges we collected
394
                        const dataManager = context.getDataManager();
1✔
395
                        dataManager.addNodes([... nodes]);
1✔
396
                        dataManager.addEdges([... edges]);
1✔
397

398
                        // TODO: fetch and add secondary edges
399
                    },
1✔
400
                ),
2✔
401
            );
2✔
402
        }
2✔
403
    }
5,729✔
404

405
    /**
406
     * Helper to get GraphContext from a Node
407
     */
408
    private static getContext(node: GraphNode): GraphContext {
3✔
409
        // Check if parentGraph has GraphContext methods
410
        if ("getStyles" in node.parentGraph) {
5,729✔
411
            return node.parentGraph;
5,729✔
412
        }
5,729!
413

414
        // Otherwise, it's a Graph instance which implements GraphContext
UNCOV
415
        return node.parentGraph;
×
416
    }
5,729✔
417
}
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