• 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

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

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

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

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

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

82
    return false;
×
83
}
×
84

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

92
export type D3LayoutOptions = Partial<z.infer<typeof D3LayoutConfig>>;
93

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

107
    return false;
×
108
}
×
109

110
export class D3GraphEngine extends LayoutEngine {
2✔
111
    static type = "d3";
2✔
112
    static maxDimensions = 3;
2✔
113
    static zodOptionsSchema: OptionsSchema = d3LayoutOptionsSchema;
2✔
114
    d3ForceLayout: ReturnType<typeof forceSimulation>;
115
    d3AlphaMin: number;
116
    d3AlphaTarget: number;
117
    d3AlphaDecay: number;
118
    d3VelocityDecay: number;
119
    nodeMapping = new Map<Node, D3Node>();
2✔
120
    edgeMapping = new Map<Edge, D3Edge>();
2✔
121
    newNodeMap = new Map<Node, D3InputNode>();
2✔
122
    newEdgeMap = new Map<Edge, D3InputEdge>();
2✔
123
    reheat = false;
2✔
124

125
    get graphNeedsRefresh(): boolean {
2✔
126
        return !!this.newNodeMap.size || !!this.newEdgeMap.size;
13,386✔
127
    }
13,386✔
128

129
    constructor(anyOpts: D3LayoutOptions = {}) {
2✔
130
        super();
6✔
131

132
        const opts = D3LayoutConfig.parse(anyOpts);
6✔
133
        this.d3AlphaMin = opts.alphaMin;
6✔
134
        this.d3AlphaTarget = opts.alphaTarget;
6✔
135
        this.d3AlphaDecay = opts.alphaDecay;
6✔
136
        this.d3VelocityDecay = opts.velocityDecay;
6✔
137

138
        // https://github.com/vasturiano/d3-force-3d?tab=readme-ov-file#links
139
        const fl = forceLink();
6✔
140
        fl.strength(0.9);
6✔
141
        this.d3ForceLayout = forceSimulation()
6✔
142
            .numDimensions(3)
6✔
143
            .alpha(1)
6✔
144
            .force("link", fl)
6✔
145
            .force("charge", forceManyBody())
6✔
146
            .force("center", forceCenter())
6✔
147
            .force("dagRadial", null)
6✔
148
            .stop();
6✔
149
        this.d3ForceLayout.force("link").id((d) => (d as D3InputNode).id);
6✔
150
    }
6✔
151

152
    // eslint-disable-next-line @typescript-eslint/no-empty-function
153
    async init(): Promise<void> {}
2✔
154

155
    refresh(): void {
2✔
156
        if (this.graphNeedsRefresh || this.reheat) {
12,216✔
157
            // update nodes
158
            let nodeList: (D3Node | D3InputNode)[] = [... this.nodeMapping.values()];
6✔
159
            nodeList = nodeList.concat([... this.newNodeMap.values()]);
6✔
160
            this.d3ForceLayout
6✔
161
                .alpha(1) // re-heat the simulation
6✔
162
                .nodes(nodeList)
6✔
163
                .stop();
6✔
164

165
            // copy over new nodes
166
            for (const entry of this.newNodeMap.entries()) {
6✔
167
                const n = entry[0];
97✔
168
                const d3node = entry[1];
97✔
169
                if (!isD3Node(d3node)) {
97!
170
                    throw new Error("Internal error: Node is not settled as a complete D3 Node");
×
171
                }
×
172

173
                this.nodeMapping.set(n, d3node);
97✔
174
            }
97✔
175
            this.newNodeMap.clear();
6✔
176

177
            // update edges
178
            let linkList: (D3Edge | D3InputEdge)[] = [... this.edgeMapping.values()];
6✔
179
            linkList = linkList.concat([... this.newEdgeMap.values()]);
6✔
180
            this.d3ForceLayout
6✔
181
                .force("link")
6✔
182
                .links(linkList);
6✔
183

184
            // copy over new edges
185
            for (const entry of this.newEdgeMap.entries()) {
6✔
186
                const e = entry[0];
271✔
187
                const d3edge = entry[1];
271✔
188
                if (!isD3Edge(d3edge)) {
271!
189
                    throw new Error("Internal error: Edge is not settled as a complete D3 Edge");
×
190
                }
×
191

192
                this.edgeMapping.set(e, d3edge);
271✔
193
            }
271✔
194
            this.newEdgeMap.clear();
6✔
195
        }
6✔
196
    }
12,216✔
197

198
    step(): void {
2✔
199
        this.refresh();
323✔
200
        this.d3ForceLayout.tick();
323✔
201
    }
323✔
202

203
    get isSettled(): boolean {
2✔
204
        // If there are pending nodes/edges to be processed, we're not settled
205
        if (this.graphNeedsRefresh) {
1,170✔
206
            return false;
6✔
207
        }
6✔
208

209
        return this.d3ForceLayout.alpha() < this.d3AlphaMin;
1,164✔
210
    }
1,170✔
211

212
    addNode(n: Node): void {
2✔
213
        this.newNodeMap.set(n, {id: n.id});
97✔
214
    }
97✔
215

216
    addEdge(e: Edge): void {
2✔
217
        this.newEdgeMap.set(e, {
271✔
218
            source: e.srcId,
271✔
219
            target: e.dstId,
271✔
220
        });
271✔
221
    }
271✔
222

223
    get nodes(): Iterable<Node> {
2✔
224
        return this.nodeMapping.keys();
215✔
225
    }
215✔
226

227
    get edges(): Iterable<Edge> {
2✔
228
        return this.edgeMapping.keys();
432✔
229
    }
432✔
230

231
    getNodePosition(n: Node): Position {
2✔
232
        const d3node = this._getMappedNode(n);
3,295✔
233
        // if (d3node.x === undefined || d3node.y === undefined || d3node.z === undefined) {
234
        //     throw new Error("Internal error: Node not initialized in D3GraphEngine");
235
        // }
236

237
        return {
3,295✔
238
            x: d3node.x,
3,295✔
239
            y: d3node.y,
3,295✔
240
            z: d3node.z,
3,295✔
241
        };
3,295✔
242
    }
3,295✔
243

244
    setNodePosition(n: Node, newPos: Position): void {
2✔
245
        const d3node = this._getMappedNode(n);
×
246
        d3node.x = newPos.x;
×
247
        d3node.y = newPos.y;
×
248
        d3node.z = newPos.z ?? 0;
×
249
        this.reheat = true;
×
250
    }
×
251

252
    getEdgePosition(e: Edge): EdgePosition {
2✔
253
        const d3edge = this._getMappedEdge(e);
8,598✔
254

255
        return {
8,598✔
256
            src: {
8,598✔
257
                x: d3edge.source.x,
8,598✔
258
                y: d3edge.source.y,
8,598✔
259
                z: d3edge.source.z,
8,598✔
260
            },
8,598✔
261
            dst: {
8,598✔
262
                x: d3edge.target.x,
8,598✔
263
                y: d3edge.target.y,
8,598✔
264
                z: d3edge.target.z,
8,598✔
265
            },
8,598✔
266
        };
8,598✔
267
    }
8,598✔
268

269
    pin(n: Node): void {
2✔
270
        const d3node = this._getMappedNode(n);
×
271

272
        d3node.fx = d3node.x;
×
273
        d3node.fy = d3node.y;
×
274
        d3node.fz = d3node.z;
×
275
        this.reheat = true; // TODO: is this necessary?
×
276
    }
×
277

278
    unpin(n: Node): void {
2✔
279
        const d3node = this._getMappedNode(n);
×
280

281
        d3node.fx = undefined;
×
282
        d3node.fy = undefined;
×
283
        d3node.fz = undefined;
×
284
        this.reheat = true; // TODO: is this necessary?
×
285
    }
×
286

287
    private _getMappedNode(n: Node): D3Node {
2✔
288
        this.refresh(); // ensure consistent state
3,295✔
289

290
        const d3node = this.nodeMapping.get(n);
3,295✔
291
        if (!d3node) {
3,295!
292
            throw new Error("Internal error: Node not found in D3GraphEngine");
×
293
        }
×
294

295
        return d3node;
3,295✔
296
    }
3,295✔
297

298
    private _getMappedEdge(e: Edge): D3Edge {
2✔
299
        this.refresh(); // ensure consistent state
8,598✔
300

301
        const d3edge = this.edgeMapping.get(e);
8,598✔
302
        if (!d3edge) {
8,598!
303
            throw new Error("Internal error: Edge not found in D3GraphEngine");
×
304
        }
×
305

306
        return d3edge;
8,598✔
307
    }
8,598✔
308
}
2✔
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