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

graphty-org / graphty-monorepo / 20720441317

05 Jan 2026 03:11PM UTC coverage: 77.534% (-0.02%) from 77.551%
20720441317

push

github

apowers313
chore: merge master into layer and selection work

13554 of 17995 branches covered (75.32%)

Branch coverage included in aggregate %.

222 of 222 new or added lines in 2 files covered. (100.0%)

1007 existing lines in 14 files now uncovered.

42007 of 53665 relevant lines covered (78.28%)

142106.16 hits per line

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

86.76
/graphty-element/src/layout/D3GraphLayoutEngine.ts
1
import {
14!
2
    Edge as D3Edge,
3
    forceCenter,
14✔
4
    forceLink,
14✔
5
    forceManyBody,
14✔
6
    forceSimulation,
14✔
7
    InputEdge as D3InputEdge,
8
    Node as D3Node,
9
} from "d3-force-3d";
14✔
10
import { z } from "zod/v4";
14✔
11

12
import { defineOptions, type OptionsSchema } from "../config";
14✔
13
import type { Edge } from "../Edge";
14
import type { Node, NodeIdType } from "../Node";
15
import { EdgePosition, LayoutEngine, Position } from "./LayoutEngine";
14✔
16

17
/**
18
 * Zod-based options schema for D3 Force Layout
19
 */
20
export const d3LayoutOptionsSchema = defineOptions({
14✔
21
    alphaMin: {
14✔
22
        schema: z.number().positive().default(0.1),
14✔
23
        meta: {
14✔
24
            label: "Alpha Min",
14✔
25
            description: "Minimum alpha before simulation stops",
14✔
26
            step: 0.01,
14✔
27
            advanced: true,
14✔
28
        },
14✔
29
    },
14✔
30
    alphaTarget: {
14✔
31
        schema: z.number().min(0).default(0),
14✔
32
        meta: {
14✔
33
            label: "Alpha Target",
14✔
34
            description: "Target alpha value",
14✔
35
            step: 0.01,
14✔
36
            advanced: true,
14✔
37
        },
14✔
38
    },
14✔
39
    alphaDecay: {
14✔
40
        schema: z.number().positive().default(0.0228),
14✔
41
        meta: {
14✔
42
            label: "Alpha Decay",
14✔
43
            description: "Rate of alpha decay per tick",
14✔
44
            step: 0.001,
14✔
45
            advanced: true,
14✔
46
        },
14✔
47
    },
14✔
48
    velocityDecay: {
14✔
49
        schema: z.number().positive().default(0.4),
14✔
50
        meta: {
14✔
51
            label: "Velocity Decay",
14✔
52
            description: "Velocity damping factor",
14✔
53
            step: 0.05,
14✔
54
        },
14✔
55
    },
14✔
56
});
14✔
57

58
interface D3InputNode extends Partial<D3Node> {
59
    id: NodeIdType;
60
}
61

62
function isD3Node(n: unknown): n is D3Node {
639✔
63
    if (
639✔
64
        typeof n === "object" &&
639✔
65
        n !== null &&
639✔
66
        "index" in n &&
639✔
67
        typeof n.index === "number" &&
639✔
68
        "x" in n &&
639✔
69
        typeof n.x === "number" &&
639✔
70
        "y" in n &&
639✔
71
        typeof n.y === "number" &&
639✔
72
        "z" in n &&
639✔
73
        typeof n.z === "number" &&
639✔
74
        "vx" in n &&
639✔
75
        typeof n.vx === "number" &&
639✔
76
        "vy" in n &&
639✔
77
        typeof n.vy === "number" &&
639✔
78
        "vz" in n &&
639✔
79
        typeof n.vz === "number"
639✔
80
    ) {
639✔
81
        return true;
639✔
82
    }
639!
83

84
    return false;
×
85
}
×
86

87
export const D3LayoutConfig = z.strictObject({
14✔
88
    alphaMin: z.number().positive().default(0.1),
14✔
89
    alphaTarget: z.number().min(0).default(0),
14✔
90
    alphaDecay: z.number().positive().default(0.0228),
14✔
91
    velocityDecay: z.number().positive().default(0.4),
14✔
92
});
14✔
93

94
export type D3LayoutOptions = Partial<z.infer<typeof D3LayoutConfig>>;
95

96
function isD3Edge(e: unknown): e is D3Edge {
271✔
97
    if (
271✔
98
        typeof e === "object" &&
271✔
99
        e !== null &&
271✔
100
        Object.hasOwn(e, "index") &&
271✔
101
        "index" in e &&
271✔
102
        typeof e.index === "number" &&
271✔
103
        "source" in e &&
271✔
104
        isD3Node(e.source) &&
271✔
105
        "target" in e &&
271✔
106
        isD3Node(e.target)
271✔
107
    ) {
271✔
108
        return true;
271✔
109
    }
271!
110

111
    return false;
×
112
}
×
113

114
/**
115
 * D3 force-directed layout engine using d3-force-3d simulation
116
 */
117
export class D3GraphEngine extends LayoutEngine {
14✔
118
    static type = "d3";
14✔
119
    static maxDimensions = 3;
14✔
120
    static zodOptionsSchema: OptionsSchema = d3LayoutOptionsSchema;
14✔
121
    d3ForceLayout: ReturnType<typeof forceSimulation>;
122
    d3AlphaMin: number;
123
    d3AlphaTarget: number;
124
    d3AlphaDecay: number;
125
    d3VelocityDecay: number;
126
    nodeMapping = new Map<Node, D3Node>();
14✔
127
    edgeMapping = new Map<Edge, D3Edge>();
14✔
128
    newNodeMap = new Map<Node, D3InputNode>();
14✔
129
    newEdgeMap = new Map<Edge, D3InputEdge>();
14✔
130
    reheat = false;
14✔
131

132
    /**
133
     * Check if there are pending nodes or edges to be processed
134
     * @returns True if the graph needs to be refreshed
135
     */
136
    get graphNeedsRefresh(): boolean {
14✔
137
        return !!this.newNodeMap.size || !!this.newEdgeMap.size;
23,116✔
138
    }
23,116✔
139

140
    /**
141
     * Create a D3 force-directed layout engine
142
     * @param anyOpts - Configuration options for the D3 simulation
143
     */
144
    constructor(anyOpts: D3LayoutOptions = {}) {
14✔
145
        super();
6✔
146

147
        const opts = D3LayoutConfig.parse(anyOpts);
6✔
148
        this.d3AlphaMin = opts.alphaMin;
6✔
149
        this.d3AlphaTarget = opts.alphaTarget;
6✔
150
        this.d3AlphaDecay = opts.alphaDecay;
6✔
151
        this.d3VelocityDecay = opts.velocityDecay;
6✔
152

153
        // https://github.com/vasturiano/d3-force-3d?tab=readme-ov-file#links
154
        const fl = forceLink();
6✔
155
        fl.strength(0.9);
6✔
156
        // Type assertions needed due to d3-force-3d type definition issues with strict mode
157
        /* eslint-disable @typescript-eslint/no-explicit-any -- d3-force-3d types are incompatible */
158
        this.d3ForceLayout = forceSimulation()
6✔
159
            .numDimensions(3)
6✔
160
            .alpha(1)
6✔
161
            .force("link", fl as any)
6✔
162
            .force("charge", forceManyBody() as any)
6✔
163
            .force("center", forceCenter() as any)
6✔
164
            .force("dagRadial", null)
6✔
165
            .stop();
6✔
166
        /* eslint-enable @typescript-eslint/no-explicit-any */
167
        // eslint-disable-next-line @typescript-eslint/no-explicit-any -- d3-force-3d types are incompatible
168
        (this.d3ForceLayout.force("link") as any).id((d: D3Node) => (d as D3InputNode).id);
6✔
169
    }
6✔
170

171
    /**
172
     * Initialize the layout engine
173
     *
174
     * D3 force simulation is initialized in the constructor and doesn't require
175
     * additional async initialization.
176
     */
177
    async init(): Promise<void> {
14✔
178
        // No-op - D3 simulation is ready after construction
179
    }
6✔
180

181
    /**
182
     * Refresh the D3 simulation with pending nodes and edges
183
     */
184
    refresh(): void {
14✔
185
        if (this.graphNeedsRefresh || this.reheat) {
21,721✔
186
            // update nodes
187
            let nodeList: (D3Node | D3InputNode)[] = [...this.nodeMapping.values()];
6✔
188
            nodeList = nodeList.concat([...this.newNodeMap.values()]);
6✔
189
            this.d3ForceLayout
6✔
190
                .alpha(1) // re-heat the simulation
6✔
191
                // eslint-disable-next-line @typescript-eslint/no-explicit-any -- d3-force-3d types are incompatible
192
                .nodes(nodeList as any)
6✔
193
                .stop();
6✔
194

195
            // copy over new nodes
196
            for (const entry of this.newNodeMap.entries()) {
6✔
197
                const n = entry[0];
97✔
198
                const d3node = entry[1];
97✔
199
                if (!isD3Node(d3node)) {
97!
200
                    throw new Error("Internal error: Node is not settled as a complete D3 Node");
×
201
                }
×
202

203
                this.nodeMapping.set(n, d3node);
97✔
204
            }
97✔
205
            this.newNodeMap.clear();
6✔
206

207
            // update edges
208
            let linkList: (D3Edge | D3InputEdge)[] = [...this.edgeMapping.values()];
6✔
209
            linkList = linkList.concat([...this.newEdgeMap.values()]);
6✔
210
            // eslint-disable-next-line @typescript-eslint/no-explicit-any -- d3-force-3d types are incompatible
211
            (this.d3ForceLayout.force("link") as any).links(linkList);
6✔
212

213
            // copy over new edges
214
            for (const entry of this.newEdgeMap.entries()) {
6✔
215
                const e = entry[0];
271✔
216
                const d3edge = entry[1];
271✔
217
                if (!isD3Edge(d3edge)) {
271!
218
                    throw new Error("Internal error: Edge is not settled as a complete D3 Edge");
×
219
                }
×
220

221
                this.edgeMapping.set(e, d3edge);
271✔
222
            }
271✔
223
            this.newEdgeMap.clear();
6✔
224

225
            // Clear reheat flag so we don't keep reheating every tick
226
            this.reheat = false;
6✔
227
        }
6✔
228
    }
21,721✔
229

230
    /**
231
     * Advance the D3 simulation by one tick
232
     */
233
    step(): void {
14✔
234
        this.refresh();
367✔
235
        this.d3ForceLayout.tick();
367✔
236
    }
367✔
237

238
    /**
239
     * Check if the simulation has settled below alpha minimum
240
     * @returns True if the simulation has settled
241
     */
242
    get isSettled(): boolean {
14✔
243
        // If there are pending nodes/edges to be processed, we're not settled
244
        if (this.graphNeedsRefresh) {
1,395✔
245
            return false;
6✔
246
        }
6✔
247

248
        return this.d3ForceLayout.alpha() < this.d3AlphaMin;
1,389✔
249
    }
1,395✔
250

251
    /**
252
     * Add a node to the D3 simulation
253
     * @param n - The node to add
254
     */
255
    addNode(n: Node): void {
14✔
256
        this.newNodeMap.set(n, { id: n.id });
97✔
257
    }
97✔
258

259
    /**
260
     * Add an edge to the D3 simulation
261
     * @param e - The edge to add
262
     */
263
    addEdge(e: Edge): void {
14✔
264
        this.newEdgeMap.set(e, {
271✔
265
            source: e.srcId,
271✔
266
            target: e.dstId,
271✔
267
        });
271✔
268
    }
271✔
269

270
    /**
271
     * Get all nodes in the simulation
272
     * @returns Iterable of nodes
273
     */
274
    get nodes(): Iterable<Node> {
14✔
275
        return this.nodeMapping.keys();
260✔
276
    }
260✔
277

278
    /**
279
     * Get all edges in the simulation
280
     * @returns Iterable of edges
281
     */
282
    get edges(): Iterable<Edge> {
14✔
283
        return this.edgeMapping.keys();
544✔
284
    }
544✔
285

286
    /**
287
     * Get the current position of a node in the simulation
288
     * @param n - The node to get position for
289
     * @returns The node's position coordinates
290
     */
291
    getNodePosition(n: Node): Position {
14✔
292
        const d3node = this._getMappedNode(n);
5,534✔
293
        // if (d3node.x === undefined || d3node.y === undefined || d3node.z === undefined) {
294
        //     throw new Error("Internal error: Node not initialized in D3GraphEngine");
295
        // }
296

297
        return {
5,534✔
298
            x: d3node.x,
5,534✔
299
            y: d3node.y,
5,534✔
300
            z: d3node.z,
5,534✔
301
        };
5,534✔
302
    }
5,534✔
303

304
    /**
305
     * Set a node's position in the simulation
306
     * @param n - The node to set position for
307
     * @param newPos - The new position coordinates
308
     */
309
    setNodePosition(n: Node, newPos: Position): void {
14✔
310
        const d3node = this._getMappedNode(n);
×
311
        d3node.x = newPos.x;
×
312
        d3node.y = newPos.y;
×
UNCOV
313
        d3node.z = newPos.z ?? 0;
×
UNCOV
314
        this.reheat = true;
×
UNCOV
315
    }
×
316

317
    /**
318
     * Get the position of an edge based on its endpoint positions
319
     * @param e - The edge to get position for
320
     * @returns The edge's source and destination positions
321
     */
322
    getEdgePosition(e: Edge): EdgePosition {
14✔
323
        const d3edge = this._getMappedEdge(e);
15,820✔
324

325
        return {
15,820✔
326
            src: {
15,820✔
327
                x: d3edge.source.x,
15,820✔
328
                y: d3edge.source.y,
15,820✔
329
                z: d3edge.source.z,
15,820✔
330
            },
15,820✔
331
            dst: {
15,820✔
332
                x: d3edge.target.x,
15,820✔
333
                y: d3edge.target.y,
15,820✔
334
                z: d3edge.target.z,
15,820✔
335
            },
15,820✔
336
        };
15,820✔
337
    }
15,820✔
338

339
    /**
340
     * Pin a node to its current position
341
     * @param n - The node to pin
342
     */
343
    pin(n: Node): void {
14✔
344
        const d3node = this._getMappedNode(n);
×
345

346
        d3node.fx = d3node.x;
×
347
        d3node.fy = d3node.y;
×
UNCOV
348
        d3node.fz = d3node.z;
×
349
        // Note: We intentionally do NOT reheat the simulation when pinning.
350
        // Pinning just locks the node's current position - it shouldn't restart
351
        // the simulation. Reheating here would cause the layout to never settle.
UNCOV
352
    }
×
353

354
    /**
355
     * Unpin a node to allow it to move freely
356
     * @param n - The node to unpin
357
     */
358
    unpin(n: Node): void {
14✔
359
        const d3node = this._getMappedNode(n);
×
360

UNCOV
361
        d3node.fx = undefined;
×
UNCOV
362
        d3node.fy = undefined;
×
UNCOV
363
        d3node.fz = undefined;
×
UNCOV
364
        this.reheat = true; // TODO: is this necessary?
×
UNCOV
365
    }
×
366

367
    private _getMappedNode(n: Node): D3Node {
14✔
368
        this.refresh(); // ensure consistent state
5,534✔
369

370
        const d3node = this.nodeMapping.get(n);
5,534✔
371
        if (!d3node) {
5,534!
UNCOV
372
            throw new Error("Internal error: Node not found in D3GraphEngine");
×
UNCOV
373
        }
×
374

375
        return d3node;
5,534✔
376
    }
5,534✔
377

378
    private _getMappedEdge(e: Edge): D3Edge {
14✔
379
        this.refresh(); // ensure consistent state
15,820✔
380

381
        const d3edge = this.edgeMapping.get(e);
15,820✔
382
        if (!d3edge) {
15,820!
UNCOV
383
            throw new Error("Internal error: Edge not found in D3GraphEngine");
×
UNCOV
384
        }
×
385

386
        return d3edge;
15,820✔
387
    }
15,820✔
388
}
14✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc