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

graphty-org / graphty-element / 16289845933

15 Jul 2025 09:43AM UTC coverage: 69.908% (-0.3%) from 70.161%
16289845933

push

github

apowers313
fix: fix semantic release, refactor edge, node, and richtextlabel to use their config objects

736 of 935 branches covered (78.72%)

Branch coverage included in aggregate %.

2 of 126 new or added lines in 3 files covered. (1.59%)

5 existing lines in 3 files now uncovered.

4742 of 6901 relevant lines covered (68.71%)

293.48 hits per line

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

54.61
/src/Edge.ts
1
import {
2✔
2
    AbstractMesh,
3
    Ray,
2✔
4
    Vector3,
2✔
5
} from "@babylonjs/core";
2✔
6
import * as jmespath from "jmespath";
2!
7

8
import type {AdHocData, EdgeStyleConfig} from "./config";
9
import type {Graph} from "./Graph";
10
import type {GraphContext} from "./managers/GraphContext";
11
import {EdgeMesh} from "./meshes/EdgeMesh";
2✔
12
import {type AttachPosition, RichTextLabel, type RichTextLabelOptions} from "./meshes/RichTextLabel";
2✔
13
import {Node, NodeIdType} from "./Node";
14
import {EdgeStyleId, Styles} from "./Styles";
2✔
15

16
interface InterceptPoint {
17
    srcPoint: Vector3 | null;
18
    dstPoint: Vector3 | null;
19
    newEndPoint: Vector3 | null;
20
}
21

22
interface EdgeLine {
23
    srcPoint: Vector3 | null;
24
    dstPoint: Vector3 | null;
25

26
}
27

28
interface EdgeOpts {
29
    metadata?: object;
30
}
31

32
// Extend Ray type to include position property
33
interface RayWithPosition extends Ray {
34
    position: Vector3;
35
}
36

37
export class Edge {
2✔
38
    parentGraph: Graph | GraphContext;
39
    opts: EdgeOpts;
40
    srcId: NodeIdType;
41
    dstId: NodeIdType;
42
    id: string;
43
    dstNode: Node;
44
    srcNode: Node;
45
    data: AdHocData;
46
    mesh: AbstractMesh;
47
    arrowMesh: AbstractMesh | null = null;
2✔
48
    styleId: EdgeStyleId;
49
    // XXX: performance impact when not needed?
50
    ray: Ray;
51
    label: RichTextLabel | null = null;
2✔
52

53
    /**
54
     * Helper to check if we're using GraphContext
55
     */
56
    private get context(): GraphContext {
2✔
57
        // Check if parentGraph has GraphContext methods
58
        if ("getStyles" in this.parentGraph) {
2✔
59
            return this.parentGraph;
2✔
60
        }
2!
61

62
        // Otherwise, it's a Graph instance which implements GraphContext
63
        return this.parentGraph;
×
64
    }
2✔
65

66
    constructor(graph: Graph | GraphContext, srcNodeId: NodeIdType, dstNodeId: NodeIdType, styleId: EdgeStyleId, data: AdHocData, opts: EdgeOpts = {}) {
2✔
67
        this.parentGraph = graph;
3,712✔
68
        this.srcId = srcNodeId;
3,712✔
69
        this.dstId = dstNodeId;
3,712✔
70
        this.id = `${srcNodeId}:${dstNodeId}`;
3,712✔
71
        this.data = data;
3,712✔
72
        this.opts = opts;
3,712✔
73

74
        // make sure both srcNode and dstNode already exist
75
        const srcNode = this.context.getDataManager().nodeCache.get(srcNodeId);
3,712✔
76
        if (!srcNode) {
3,712✔
77
            throw new Error(`Attempting to create edge '${srcNodeId}->${dstNodeId}', Node '${srcNodeId}' hasn't been created yet.`);
1✔
78
        }
1✔
79

80
        const dstNode = this.context.getDataManager().nodeCache.get(dstNodeId);
3,711✔
81
        if (!dstNode) {
3,712✔
82
            throw new Error(`Attempting to create edge '${srcNodeId}->${dstNodeId}', Node '${dstNodeId}' hasn't been created yet.`);
1✔
83
        }
1✔
84

85
        this.srcNode = srcNode;
3,710✔
86
        this.dstNode = dstNode;
3,710✔
87

88
        // create ray for direction / intercept finding
89
        this.ray = new Ray(this.srcNode.mesh.position, this.dstNode.mesh.position);
3,710✔
90

91
        // copy edgeMeshConfig
92
        this.styleId = styleId;
3,710✔
93

94
        // create ngraph link
95
        // Note: Edge is added to layout engine by DataManager, not here
96

97
        // create mesh
98
        const style = Styles.getStyleForEdgeStyleId(this.styleId);
3,710✔
99

100
        // create arrow mesh if needed
101
        this.arrowMesh = EdgeMesh.createArrowHead(
3,710✔
102
            this.context.getMeshCache(),
3,710✔
103
            String(this.styleId),
3,710✔
104
            {
3,710✔
105
                type: style.arrowHead?.type ?? "none",
3,712!
106
                width: style.line?.width ?? 0.25,
3,712!
107
                color: style.line?.color ?? "#FFFFFF",
3,712!
108
            },
3,712✔
109
            this.context.getScene(),
3,712✔
110
        );
3,712✔
111

112
        // create edge line mesh
113
        this.mesh = EdgeMesh.create(
3,712✔
114
            this.context.getMeshCache(),
3,712✔
115
            {
3,712✔
116
                styleId: String(this.styleId),
3,712✔
117
                width: style.line?.width ?? 0.25,
3,712!
118
                color: style.line?.color ?? "#FFFFFF",
3,712!
119
            },
3,712✔
120
            style,
3,712✔
121
            this.context.getScene(),
3,712✔
122
        );
3,712✔
123

124
        this.mesh.isPickable = false;
3,712✔
125
        this.mesh.metadata = {};
3,712✔
126
        this.mesh.metadata.parentEdge = this;
3,712✔
127

128
        // create label if configured
129
        if (style.label?.enabled) {
3,712!
130
            this.label = this.createLabel(style);
×
131
        }
×
132
    }
3,712✔
133

134
    update(): void {
2✔
135
        const lnk = this.context.getLayoutManager().layoutEngine?.getEdgePosition(this);
8,127✔
136
        if (!lnk) {
8,127!
137
            return;
×
138
        }
×
139

140
        const {srcPoint, dstPoint} = this.transformArrowCap();
8,127✔
141

142
        if (srcPoint && dstPoint) {
8,127✔
143
            this.transformEdgeMesh(
8,127✔
144
                srcPoint,
8,127✔
145
                dstPoint,
8,127✔
146
            );
8,127✔
147
        } else {
8,127!
148
            this.transformEdgeMesh(
×
149
                new Vector3(lnk.src.x, lnk.src.y, lnk.src.z),
×
150
                new Vector3(lnk.dst.x, lnk.dst.y, lnk.dst.z),
×
151
            );
×
152
        }
×
153

154
        // Update label position if exists
155
        if (this.label) {
8,127!
156
            const midPoint = new Vector3(
×
157
                (lnk.src.x + lnk.dst.x) / 2,
×
158
                (lnk.src.y + lnk.dst.y) / 2,
×
159
                ((lnk.src.z ?? 0) + (lnk.dst.z ?? 0)) / 2,
×
160
            );
×
161
            this.label.attachTo(midPoint, "center", 0);
×
162
        }
×
163
    }
8,127✔
164

165
    updateStyle(styleId: EdgeStyleId): void {
2✔
166
        if (styleId === this.styleId) {
×
167
            return;
×
168
        }
×
169

170
        this.styleId = styleId;
×
171
        this.mesh.dispose();
×
172

173
        const style = Styles.getStyleForEdgeStyleId(styleId);
×
174

175
        // recreate arrow mesh if needed
176
        if (this.arrowMesh) {
×
177
            this.arrowMesh.dispose();
×
178
        }
×
179

180
        this.arrowMesh = EdgeMesh.createArrowHead(
×
181
            this.context.getMeshCache(),
×
182
            String(styleId),
×
183
            {
×
184
                type: style.arrowHead?.type ?? "none",
×
185
                width: style.line?.width ?? 0.25,
×
186
                color: style.line?.color ?? "#FFFFFF",
×
187
            },
×
188
            this.context.getScene(),
×
189
        );
×
190

191
        // recreate edge line mesh
192
        this.mesh = EdgeMesh.create(
×
193
            this.context.getMeshCache(),
×
194
            {
×
195
                styleId: String(styleId),
×
196
                width: style.line?.width ?? 0.25,
×
197
                color: style.line?.color ?? "#FFFFFF",
×
198
            },
×
199
            style,
×
200
            this.context.getScene(),
×
201
        );
×
202

203
        this.mesh.isPickable = false;
×
204
        this.mesh.metadata = {};
×
205
        this.mesh.metadata.parentEdge = this;
×
206

207
        // Update label if needed
208
        if (style.label?.enabled) {
×
209
            if (this.label) {
×
210
                this.label.dispose();
×
211
            }
×
212

213
            this.label = this.createLabel(style);
×
214
        } else if (this.label) {
×
215
            this.label.dispose();
×
216
            this.label = null;
×
217
        }
×
218
    }
×
219

220
    static updateRays(g: Graph | GraphContext): void {
2✔
221
        const context = "getStyles" in g ? g : g;
72!
222

223
        if (!context.needsRayUpdate()) {
72!
224
            return;
×
225
        }
×
226

227
        const {layoutEngine} = context.getLayoutManager();
72✔
228
        if (!layoutEngine) {
72!
229
            return;
×
230
        }
×
231

232
        for (const e of layoutEngine.edges) {
72✔
233
            const srcMesh = e.srcNode.mesh;
8,127✔
234
            const dstMesh = e.dstNode.mesh;
8,127✔
235

236
            const style = Styles.getStyleForEdgeStyleId(e.styleId);
8,127✔
237
            if (style.arrowHead?.type === undefined || style.arrowHead.type === "none") {
8,127!
238
                // TODO: this could be faster
239
                continue;
×
240
            }
×
241

242
            // RayHelper.CreateAndShow(ray, e.parentGraph.scene, Color3.Red());
243

244
            // XXX: position is missing from Ray TypeScript definition
245
            (e.ray as RayWithPosition).position = dstMesh.position;
8,127✔
246
            e.ray.direction = dstMesh.position.subtract(srcMesh.position);
8,127✔
247
        }
8,127✔
248

249
        // this sucks for performance, but we have to do a full render pass
250
        // to update rays and intersections
251
        context.getScene().render();
72✔
252
    }
72✔
253

254
    transformEdgeMesh(srcPoint: Vector3, dstPoint: Vector3): void {
2✔
255
        EdgeMesh.transformMesh(this.mesh, srcPoint, dstPoint);
8,127✔
256
    }
8,127✔
257

258
    transformArrowCap(): EdgeLine {
2✔
259
        if (this.arrowMesh) {
8,127✔
260
            const {srcPoint, dstPoint, newEndPoint} = this.getInterceptPoints();
8,127✔
261

262
            // If we can't find intercept points, fall back to approximate positions
263
            if (!srcPoint || !dstPoint || !newEndPoint) {
8,127✔
264
                const fallbackSrc = this.srcNode.mesh.position;
254✔
265
                const fallbackDst = this.dstNode.mesh.position;
254✔
266

267
                // Hide arrow if nodes are too close or at same position
268
                if (fallbackSrc.equalsWithEpsilon(fallbackDst, 0.01)) {
254!
269
                    this.arrowMesh.setEnabled(false);
×
270
                    return {
×
271
                        srcPoint: fallbackSrc,
×
272
                        dstPoint: fallbackDst,
×
273
                    };
×
274
                }
×
275

276
                // Calculate approximate positions based on node sizes
277
                const direction = fallbackDst.subtract(fallbackSrc).normalize();
254✔
278
                const style = Styles.getStyleForEdgeStyleId(this.styleId);
254✔
279
                const arrowLen = EdgeMesh.calculateArrowLength(style.line?.width ?? 0.25);
254!
280

281
                // Estimate node radius (assuming spherical nodes)
282
                const dstNodeRadius = this.dstNode.size || 1;
254!
283
                const srcNodeRadius = this.srcNode.size || 1;
254!
284

285
                // Position arrow at edge of destination node
286
                const approxDstPoint = fallbackDst.subtract(direction.scale(dstNodeRadius));
254✔
287
                const approxSrcPoint = fallbackSrc.add(direction.scale(srcNodeRadius));
254✔
288
                const approxNewEndPoint = approxDstPoint.subtract(direction.scale(arrowLen));
254✔
289

290
                this.arrowMesh.setEnabled(true);
254✔
291
                this.arrowMesh.position = approxDstPoint;
254✔
292
                this.arrowMesh.lookAt(this.dstNode.mesh.position);
254✔
293

294
                return {
254✔
295
                    srcPoint: approxSrcPoint,
254✔
296
                    dstPoint: approxNewEndPoint,
254✔
297
                };
254✔
298
            }
254✔
299

300
            this.arrowMesh.setEnabled(true);
7,873✔
301
            this.arrowMesh.position = dstPoint;
7,873✔
302
            this.arrowMesh.lookAt(this.dstNode.mesh.position);
7,873✔
303

304
            return {
7,873✔
305
                srcPoint,
7,873✔
306
                dstPoint: newEndPoint,
7,873✔
307
                // dstPoint,
308
            };
7,873✔
309
        }
7,873!
310

311
        return {
×
312
            srcPoint: null,
×
313
            dstPoint: null,
×
314
        };
×
315
    }
8,127✔
316

317
    getInterceptPoints(): InterceptPoint {
2✔
318
        const srcMesh = this.srcNode.mesh;
8,127✔
319
        const dstMesh = this.dstNode.mesh;
8,127✔
320

321
        // ray is updated in updateRays to ensure intersections
322
        const dstHitInfo = this.ray.intersectsMeshes([dstMesh]);
8,127✔
323
        const srcHitInfo = this.ray.intersectsMeshes([srcMesh]);
8,127✔
324

325
        let srcPoint: Vector3 | null = null;
8,127✔
326
        let dstPoint: Vector3 | null = null;
8,127✔
327
        let newEndPoint: Vector3 | null = null;
8,127✔
328
        if (dstHitInfo.length && srcHitInfo.length) {
8,127✔
329
            const style = Styles.getStyleForEdgeStyleId(this.styleId);
7,873✔
330
            const hasArrowHead = style.arrowHead?.type && style.arrowHead.type !== "none";
7,873✔
331

332
            dstPoint = dstHitInfo[0].pickedPoint;
7,873✔
333
            srcPoint = srcHitInfo[0].pickedPoint;
7,873✔
334
            if (!srcPoint || !dstPoint) {
7,873!
335
                throw new TypeError("error picking points");
×
336
            }
×
337

338
            // Only adjust endpoint if we have an arrow head
339
            if (hasArrowHead) {
7,873✔
340
                const len = EdgeMesh.calculateArrowLength(style.line?.width ?? 0.25);
7,873!
341
                const distance = srcPoint.subtract(dstPoint).length();
7,873✔
342
                const adjDistance = distance - len;
7,873✔
343
                const {x: x1, y: y1, z: z1} = srcPoint;
7,873✔
344
                const {x: x2, y: y2, z: z2} = dstPoint;
7,873✔
345
                // calculate new line endpoint along line between midpoints of meshes
346
                const x3 = x1 + ((adjDistance / distance) * (x2 - x1));
7,873✔
347
                const y3 = y1 + ((adjDistance / distance) * (y2 - y1));
7,873✔
348
                const z3 = z1 + ((adjDistance / distance) * (z2 - z1));
7,873✔
349
                newEndPoint = new Vector3(x3, y3, z3);
7,873✔
350
            } else {
7,873!
351
                // No arrow head, edge goes all the way to the node surface
352
                newEndPoint = dstPoint;
×
353
            }
×
354
        }
7,873✔
355

356
        return {
8,127✔
357
            srcPoint,
8,127✔
358
            dstPoint,
8,127✔
359
            newEndPoint,
8,127✔
360
        };
8,127✔
361
    }
8,127✔
362

363
    private createLabel(styleConfig: EdgeStyleConfig): RichTextLabel {
2✔
364
        const labelText = this.extractLabelText(styleConfig.label);
×
365
        const labelOptions = this.createLabelOptions(labelText, styleConfig);
×
366
        return new RichTextLabel(this.context.getScene(), labelOptions);
×
367
    }
×
368

369
    private extractLabelText(labelConfig?: Record<string, unknown>): string {
2✔
370
        if (!labelConfig) {
×
371
            return this.id;
×
372
        }
×
373

374
        // Check if text is directly provided
375
        if (labelConfig.text !== undefined && labelConfig.text !== null) {
×
376
            // Only convert to string if it's a primitive type
377
            if (typeof labelConfig.text === "string" || typeof labelConfig.text === "number" || typeof labelConfig.text === "boolean") {
×
378
                return String(labelConfig.text);
×
379
            }
×
380
        } else if (labelConfig.textPath && typeof labelConfig.textPath === "string") {
×
381
            try {
×
382
                const result = jmespath.search(this.data, labelConfig.textPath);
×
383
                if (result !== null && result !== undefined) {
×
384
                    return String(result);
×
385
                }
×
386
            } catch {
×
387
                // Ignore jmespath errors
388
            }
×
389
        }
×
390

391
        return this.id;
×
392
    }
×
393

394
    private createLabelOptions(labelText: string, styleConfig: EdgeStyleConfig): RichTextLabelOptions {
2✔
NEW
395
        const {label} = styleConfig;
×
NEW
396
        if (!label) {
×
NEW
397
            return {
×
NEW
398
                text: labelText,
×
NEW
399
                attachPosition: "center",
×
NEW
400
                attachOffset: 0,
×
NEW
401
            };
×
NEW
402
        }
×
403

NEW
404
        const labelLocation = label.location ?? "center";
×
NEW
405
        const attachPosition = labelLocation === "automatic" ? "center" : labelLocation;
×
406

407
        // Transform backgroundColor to string if it's an advanced color style
NEW
408
        let backgroundColor: string | undefined = undefined;
×
NEW
409
        if (label.backgroundColor) {
×
NEW
410
            if (typeof label.backgroundColor === "string") {
×
NEW
411
                ({backgroundColor} = label);
×
NEW
412
            } else if (label.backgroundColor.colorType === "solid") {
×
NEW
413
                ({value: backgroundColor} = label.backgroundColor);
×
NEW
414
            } else if (label.backgroundColor.colorType === "gradient") {
×
415
                // For gradients, use the first color as a fallback
NEW
416
                [backgroundColor] = label.backgroundColor.colors;
×
NEW
417
            }
×
NEW
418
        }
×
419

420
        // Filter out undefined values from backgroundGradientColors
NEW
421
        let backgroundGradientColors: string[] | undefined = undefined;
×
NEW
422
        if (label.backgroundGradientColors) {
×
NEW
423
            backgroundGradientColors = label.backgroundGradientColors.filter((color): color is string => color !== undefined);
×
NEW
424
            if (backgroundGradientColors.length === 0) {
×
NEW
425
                backgroundGradientColors = undefined;
×
NEW
426
            }
×
NEW
427
        }
×
428

429
        // Transform borders to ensure colors are strings
NEW
430
        let borders: {width: number, color: string, spacing: number}[] | undefined = undefined;
×
NEW
431
        if (label.borders && label.borders.length > 0) {
×
NEW
432
            const validBorders = label.borders
×
NEW
433
                .filter((border): border is typeof border & {color: string} => border.color !== undefined)
×
NEW
434
                .map((border) => ({
×
NEW
435
                    width: border.width,
×
NEW
436
                    color: border.color,
×
NEW
437
                    spacing: border.spacing,
×
NEW
438
                }));
×
439
            // Only set borders if we have valid borders, otherwise leave it undefined
440
            // so the default empty array is used
NEW
441
            if (validBorders.length > 0) {
×
NEW
442
                borders = validBorders;
×
NEW
443
            }
×
NEW
444
        }
×
445

446
        // Create label options by spreading the entire label object
447
        const labelOptions: RichTextLabelOptions = {
×
NEW
448
            ... label,
×
449
            // Override with computed values
450
            text: labelText,
×
NEW
451
            attachPosition: attachPosition as AttachPosition,
×
NEW
452
            attachOffset: label.attachOffset ?? 0,
×
NEW
453
            backgroundColor,
×
NEW
454
            backgroundGradientColors,
×
NEW
455
            ... (borders !== undefined && {borders}),
×
UNCOV
456
        };
×
457

458
        // Handle special case for transparent background
NEW
459
        if (labelOptions.backgroundColor === "transparent") {
×
NEW
460
            labelOptions.backgroundColor = undefined;
×
UNCOV
461
        }
×
462

463
        // Remove properties that shouldn't be passed to RichTextLabel
464
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
NEW
465
        const {location, textPath, enabled, ... finalLabelOptions} = labelOptions as RichTextLabelOptions & {location?: string, textPath?: string, enabled?: boolean};
×
466

NEW
467
        return finalLabelOptions;
×
468
    }
×
469
}
2✔
470

471
export class EdgeMap {
2✔
472
    map = new Map<NodeIdType, Map<NodeIdType, Edge>>();
77✔
473

474
    has(srcId: NodeIdType, dstId: NodeIdType): boolean {
2✔
475
        const dstMap = this.map.get(srcId);
×
476
        if (!dstMap) {
×
477
            return false;
×
478
        }
×
479

480
        return dstMap.has(dstId);
×
481
    }
×
482

483
    set(srcId: NodeIdType, dstId: NodeIdType, e: Edge): void {
2✔
484
        let dstMap = this.map.get(srcId);
3,710✔
485
        if (!dstMap) {
3,710✔
486
            dstMap = new Map();
1,086✔
487
            this.map.set(srcId, dstMap);
1,086✔
488
        }
1,086✔
489

490
        if (dstMap.has(dstId)) {
3,710!
491
            throw new Error("Attempting to create duplicate Edge");
×
492
        }
×
493

494
        dstMap.set(dstId, e);
3,710✔
495
    }
3,710✔
496

497
    get(srcId: NodeIdType, dstId: NodeIdType): Edge | undefined {
2✔
498
        const dstMap = this.map.get(srcId);
3,713✔
499
        if (!dstMap) {
3,713✔
500
            return undefined;
1,088✔
501
        }
1,088✔
502

503
        return dstMap.get(dstId);
2,625✔
504
    }
3,713✔
505

506
    get size(): number {
2✔
507
        let sz = 0;
72✔
508
        for (const dstMap of this.map.values()) {
72✔
509
            sz += dstMap.size;
2,370✔
510
        }
2,370✔
511

512
        return sz;
72✔
513
    }
72✔
514

515
    delete(srcId: NodeIdType, dstId: NodeIdType): boolean {
2✔
516
        const dstMap = this.map.get(srcId);
2✔
517
        if (!dstMap) {
2!
518
            return false;
×
519
        }
×
520

521
        const result = dstMap.delete(dstId);
2✔
522

523
        // Clean up empty maps
524
        if (dstMap.size === 0) {
2✔
525
            this.map.delete(srcId);
2✔
526
        }
2✔
527

528
        return result;
2✔
529
    }
2✔
530

531
    clear(): void {
2✔
532
        this.map.clear();
73✔
533
    }
73✔
534
}
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