• 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

77.63
/src/Node.ts
1
import {
2
    AbstractMesh,
3
} from "@babylonjs/core";
4
import jmespath from "jmespath";
3!
5
import _ from "lodash";
3!
6

7
import {CalculatedValue} from "./CalculatedValue";
8
import {ChangeManager} from "./ChangeManager";
3✔
9
import {AdHocData, NodeStyle, NodeStyleConfig} from "./config";
3✔
10
import type {Graph} from "./Graph";
11
import type {GraphContext} from "./managers/GraphContext";
12
import {NodeMesh} from "./meshes/NodeMesh";
3✔
13
import {RichTextLabel, type RichTextLabelOptions} from "./meshes/RichTextLabel";
3✔
14
import {NodeBehavior, type NodeDragHandler} from "./NodeBehavior";
3✔
15
import {NodeStyleId, Styles} from "./Styles";
3✔
16

17
export type NodeIdType = string | number;
18

19
interface NodeOpts {
20
    pinOnDrag?: boolean;
21
}
22

23
export class Node {
3✔
24
    parentGraph: Graph | GraphContext;
25
    opts: NodeOpts;
26
    id: NodeIdType;
27
    data: AdHocData<string | number>;
28
    algorithmResults: AdHocData;
29
    styleUpdates: AdHocData;
30
    mesh: AbstractMesh;
31
    label?: RichTextLabel;
32
    dragHandler?: NodeDragHandler;
33
    dragging = false;
3✔
34
    styleId: NodeStyleId;
35
    pinOnDrag!: boolean;
36
    size!: number;
37
    changeManager: ChangeManager;
38

39
    /**
40
     * Helper to check if we're using GraphContext
41
     */
42
    private get context(): GraphContext {
3✔
43
        // Check if parentGraph has GraphContext methods
44
        if ("getStyles" in this.parentGraph) {
3✔
45
            return this.parentGraph;
3✔
46
        }
3!
47

48
        // Otherwise, it's a Graph instance which implements GraphContext
49
        return this.parentGraph;
1✔
50
    }
3✔
51

52
    constructor(graph: Graph | GraphContext, nodeId: NodeIdType, styleId: NodeStyleId, data: AdHocData<string | number>, opts: NodeOpts = {}) {
3✔
53
        this.parentGraph = graph;
4,830✔
54
        this.id = nodeId;
4,830✔
55
        this.opts = opts;
4,830✔
56
        this.changeManager = new ChangeManager();
4,830✔
57
        this.data = this.changeManager.watch("data", data);
4,830✔
58
        this.algorithmResults = this.changeManager.watch("algorithmResults", {} as unknown as AdHocData);
4,830✔
59
        this.styleUpdates = this.changeManager.addData("style", {} as unknown as AdHocData, NodeStyle);
4,830✔
60
        this.changeManager.loadCalculatedValues(this.context.getStyleManager().getStyles().getCalculatedStylesForNode(data));
4,830✔
61

62
        // copy nodeMeshOpts
63
        this.styleId = styleId;
4,830✔
64

65
        // create graph node
66
        // TODO: Node is added to layout engine by DataManager, not here
67

68
        // create mesh
69
        const o = Styles.getStyleForNodeStyleId(styleId);
4,830✔
70
        this.size = o.shape?.size ?? 0;
4,830!
71

72
        this.mesh = NodeMesh.create(
4,830✔
73
            this.context.getMeshCache(),
4,830✔
74
            {styleId: String(styleId), is2D: this.context.is2D(), size: this.size},
4,830✔
75
            {shape: o.shape, texture: o.texture, effect: o.effect},
4,830✔
76
            this.context.getScene(),
4,830✔
77
        );
4,830✔
78

79
        // Parent to graph-root for XR gesture support
80
        // This allows gestures to transform the entire graph by manipulating the root
81
        const graphRoot = this.context.getScene().getTransformNodeByName("graph-root");
4,830✔
82
        if (graphRoot) {
4,830✔
83
            this.mesh.parent = graphRoot;
4,809✔
84
        }
4,809✔
85

86
        // Add metadata for XR controller raycasting
87
        // IMPORTANT: For InstancedMesh, we must set metadata on the INSTANCE, not spread from source
88
        this.mesh.metadata = {
4,830✔
89
            graphNode: this,
4,830✔
90
            styleId: String(styleId),
4,830✔
91
            nodeId: this.id,
4,830✔
92
        };
4,830✔
93

94
        // create label
95
        if (o.label?.enabled) {
4,830✔
96
            this.label = this.createLabel(o);
607✔
97
        }
607✔
98

99
        NodeBehavior.addDefaultBehaviors(this, this.opts);
4,830✔
100
    }
4,830✔
101

102
    addCalculatedStyle(cv: CalculatedValue): void {
3✔
UNCOV
103
        this.changeManager.addCalculatedValue(cv);
×
UNCOV
104
    }
×
105

106
    update(): void {
3✔
107
        this.context.getStatsManager().startMeasurement("Node.update");
90,849✔
108

109
        const newStyleKeys = Object.keys(this.styleUpdates);
90,849✔
110
        if (newStyleKeys.length > 0) {
90,849✔
111
            let style = Styles.getStyleForNodeStyleId(this.styleId);
467✔
112
            // Convert styleUpdates Proxy to plain object for proper merging
113
            // (styleUpdates is wrapped by on-change library's Proxy)
114
            const plainStyleUpdates = _.cloneDeep(this.styleUpdates);
467✔
115
            style = _.defaultsDeep(plainStyleUpdates, style);
467✔
116
            const styleId = Styles.getNodeIdForStyle(style);
467✔
117
            this.updateStyle(styleId);
467✔
118
            for (const key of newStyleKeys) {
467✔
119
                // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
120
                delete this.styleUpdates[key];
567✔
121
            }
567✔
122
        }
467✔
123

124
        if (this.dragging) {
90,849!
UNCOV
125
            this.context.getStatsManager().endMeasurement("Node.update");
×
UNCOV
126
            return;
×
UNCOV
127
        }
×
128

129
        const pos = this.context.getLayoutManager().layoutEngine?.getNodePosition(this);
90,849✔
130
        if (pos) {
90,849✔
131
            this.mesh.position.x = pos.x;
90,849✔
132
            this.mesh.position.y = pos.y;
90,849✔
133
            this.mesh.position.z = pos.z ?? 0;
90,849✔
134
        }
90,849✔
135

136
        this.context.getStatsManager().endMeasurement("Node.update");
90,849✔
137
    }
90,849✔
138

139
    updateStyle(styleId: NodeStyleId): void {
3✔
140
        this.context.getStatsManager().startMeasurement("Node.updateMesh");
2,980✔
141

142
        // Only skip update if styleId is the same AND mesh is not disposed
143
        // (mesh can be disposed when switching 2D/3D modes via meshCache.clear())
144
        if (styleId === this.styleId && !this.mesh.isDisposed()) {
2,980✔
145
            this.context.getStatsManager().endMeasurement("Node.updateMesh");
2,087✔
146
            return;
2,087✔
147
        }
2,087✔
148

149
        this.styleId = styleId;
893✔
150
        // Only dispose if not already disposed
151
        if (!this.mesh.isDisposed()) {
2,943✔
152
            this.mesh.dispose();
636✔
153
        }
636✔
154

155
        const o = Styles.getStyleForNodeStyleId(styleId);
893✔
156
        this.size = o.shape?.size ?? 0;
2,980!
157

158
        this.mesh = NodeMesh.create(
2,980✔
159
            this.context.getMeshCache(),
2,980✔
160
            {styleId: String(styleId), is2D: this.context.is2D(), size: this.size},
2,980✔
161
            {shape: o.shape, texture: o.texture, effect: o.effect},
2,980✔
162
            this.context.getScene(),
2,980✔
163
        );
2,980✔
164

165
        // Parent to graph-root for XR gesture support
166
        const graphRoot = this.context.getScene().getTransformNodeByName("graph-root");
2,980✔
167
        if (graphRoot) {
2,980✔
168
            this.mesh.parent = graphRoot;
893✔
169
        }
893✔
170

171
        // Add metadata for XR controller raycasting
172
        // IMPORTANT: For InstancedMesh, we must set metadata on the INSTANCE, not spread from source
173
        this.mesh.metadata = {
893✔
174
            graphNode: this,
893✔
175
            styleId: String(styleId),
893✔
176
            nodeId: this.id,
893✔
177
        };
893✔
178

179
        // Debug: console.log("🔍 [Node updateStyle] Metadata set:", {
180
        //     nodeId: this.id,
181
        //     meshName: this.mesh.name,
182
        //     meshClass: this.mesh.getClassName(),
183
        //     isInstancedMesh: this.mesh.getClassName() === "InstancedMesh",
184
        //     metadata: this.mesh.metadata,
185
        //     isPickable: this.mesh.isPickable,
186
        // });
187

188
        // recreate label if needed
189
        if (o.label?.enabled) {
2,980!
UNCOV
190
            this.label?.dispose();
×
UNCOV
191
            this.label = this.createLabel(o);
×
192
        } else if (this.label) {
2,574!
UNCOV
193
            this.label.dispose();
×
UNCOV
194
            this.label = undefined;
×
UNCOV
195
        }
×
196

197
        NodeBehavior.addDefaultBehaviors(this, this.opts);
893✔
198

199
        this.context.getStatsManager().endMeasurement("Node.updateMesh");
893✔
200
    }
2,980✔
201

202
    pin(): void {
3✔
203
        this.context.getLayoutManager().layoutEngine?.pin(this);
9✔
204
    }
9✔
205

206
    unpin(): void {
3✔
UNCOV
207
        this.context.getLayoutManager().layoutEngine?.unpin(this);
×
UNCOV
208
    }
×
209

210
    private createLabel(styleConfig: NodeStyleConfig): RichTextLabel {
3✔
211
        const labelText = this.extractLabelText(styleConfig.label);
607✔
212
        const labelOptions = this.createLabelOptions(labelText, styleConfig);
607✔
213
        return new RichTextLabel(this.mesh.getScene(), labelOptions);
607✔
214
    }
607✔
215

216
    private extractLabelText(labelConfig?: Record<string, unknown>): string {
3✔
217
        if (!labelConfig) {
607!
218
            return this.id.toString();
×
UNCOV
219
        }
×
220

221
        // Check if text is directly provided
222
        if (labelConfig.text !== undefined && labelConfig.text !== null) {
607✔
223
            // Only convert to string if it's a primitive type
224
            if (typeof labelConfig.text === "string" || typeof labelConfig.text === "number" || typeof labelConfig.text === "boolean") {
184!
225
                return String(labelConfig.text);
184✔
226
            }
184✔
227
        } else if (labelConfig.textPath && typeof labelConfig.textPath === "string") {
607✔
228
            try {
403✔
229
                const result = jmespath.search(this.data, labelConfig.textPath);
403✔
230
                if (result !== null && result !== undefined) {
403✔
231
                    return String(result);
403✔
232
                }
403✔
233
            } catch {
403!
234
                // Ignore jmespath errors
UNCOV
235
            }
×
236
        }
403✔
237

238
        return this.id.toString();
20✔
239
    }
607✔
240

241
    private createLabelOptions(labelText: string, styleConfig: NodeStyleConfig): RichTextLabelOptions {
3✔
242
        const labelStyle = styleConfig.label ?? {};
607!
243

244
        // Get attach position and offset
245
        const attachPosition = this.getAttachPosition(labelStyle.location ?? "top");
607✔
246
        const attachOffset = labelStyle.attachOffset ?? this.getDefaultAttachOffset(labelStyle.location ?? "top");
607✔
247

248
        // Transform backgroundColor to string if it's an advanced color style
249
        let backgroundColor: string | undefined = undefined;
607✔
250
        if (labelStyle.backgroundColor) {
607✔
251
            if (typeof labelStyle.backgroundColor === "string") {
224✔
252
                ({backgroundColor} = labelStyle);
224✔
253
            } else if (labelStyle.backgroundColor.colorType === "solid") {
224!
UNCOV
254
                ({value: backgroundColor} = labelStyle.backgroundColor);
×
UNCOV
255
            } else if (labelStyle.backgroundColor.colorType === "gradient") {
×
256
                // For gradients, use the first color as a fallback
UNCOV
257
                [backgroundColor] = labelStyle.backgroundColor.colors;
×
UNCOV
258
            }
×
259
        }
224✔
260

261
        // Filter out undefined values from backgroundGradientColors
262
        let backgroundGradientColors: string[] | undefined = undefined;
607✔
263
        if (labelStyle.backgroundGradientColors) {
607✔
264
            backgroundGradientColors = labelStyle.backgroundGradientColors.filter((color): color is string => color !== undefined);
20✔
265
            if (backgroundGradientColors.length === 0) {
20!
266
                backgroundGradientColors = undefined;
×
UNCOV
267
            }
×
268
        }
20✔
269

270
        // Transform borders to ensure colors are strings
271
        let borders: {width: number, color: string, spacing: number}[] | undefined = undefined;
607✔
272
        if (labelStyle.borders && labelStyle.borders.length > 0) {
607!
UNCOV
273
            const validBorders = labelStyle.borders
×
UNCOV
274
                .filter((border): border is typeof border & {color: string} => border.color !== undefined)
×
UNCOV
275
                .map((border) => ({
×
UNCOV
276
                    width: border.width,
×
UNCOV
277
                    color: border.color,
×
UNCOV
278
                    spacing: border.spacing,
×
279
                }));
×
280
            // Only set borders if we have valid borders, otherwise leave it undefined
281
            // so the default empty array is used
UNCOV
282
            if (validBorders.length > 0) {
×
UNCOV
283
                borders = validBorders;
×
UNCOV
284
            }
×
UNCOV
285
        }
×
286

287
        // Create label options by spreading the entire labelStyle object
288
        const labelOptions: RichTextLabelOptions = {
607✔
289
            ... labelStyle,
607✔
290
            // Override with computed values
291
            text: labelText,
607✔
292
            attachTo: this.mesh,
607✔
293
            attachPosition,
607✔
294
            attachOffset,
607✔
295
            backgroundColor,
607✔
296
            backgroundGradientColors,
607✔
297
            ... (borders !== undefined && {borders}),
607!
298
        };
607✔
299

300
        // Handle special case for transparent background
301
        if (labelOptions.backgroundColor === "transparent") {
607!
302
            labelOptions.backgroundColor = undefined;
×
UNCOV
303
        }
×
304

305
        // Remove properties that shouldn't be passed to RichTextLabel
306
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
307
        const {location, textPath, enabled, ... finalLabelOptions} = labelOptions as RichTextLabelOptions & {location?: string, textPath?: string, enabled?: boolean};
607✔
308

309
        return finalLabelOptions;
607✔
310
    }
607✔
311

312
    private getAttachPosition(location: string): "top" | "top-left" | "top-right" | "left" | "center" | "right" | "bottom" | "bottom-left" | "bottom-right" {
3✔
313
        switch (location) {
607✔
314
            case "floating":
607!
315
            case "automatic":
607!
UNCOV
316
                return "top";
×
317
            case "top":
607✔
318
            case "top-left":
607✔
319
            case "top-right":
607✔
320
            case "left":
607✔
321
            case "center":
607✔
322
            case "right":
607✔
323
            case "bottom":
607✔
324
            case "bottom-left":
607✔
325
            case "bottom-right":
607✔
326
                return location;
607✔
327
            default:
607!
UNCOV
328
                return "top";
×
329
        }
607✔
330
    }
607✔
331

332
    private getDefaultAttachOffset(location: string): number {
3✔
333
        // Return larger offsets for left/right positions to prevent overlap
334
        switch (location) {
543✔
335
            case "left":
543!
336
            case "right":
543!
UNCOV
337
                return 1.0; // Larger offset for horizontal positions
×
338
            case "center":
543!
UNCOV
339
                return 0; // No offset for center
×
340
            default:
543✔
341
                return 0.5; // Standard offset for top/bottom positions
543✔
342
        }
543✔
343
    }
543✔
344

345
    // Test helper methods
346
    getPosition(): {x: number, y: number, z: number} {
3✔
347
        return {
215✔
348
            x: this.mesh.position.x,
215✔
349
            y: this.mesh.position.y,
215✔
350
            z: this.mesh.position.z,
215✔
351
        };
215✔
352
    }
215✔
353

354
    isPinned(): boolean {
3✔
355
        // For now, nodes are not pinned unless drag behavior is disabled
UNCOV
356
        return false;
×
UNCOV
357
    }
×
358

359
    isSelected(): boolean {
3✔
360
        // Check if node is in selection - this is a simplified version
361
        // In reality, selection state would be managed by a selection manager
UNCOV
362
        return this.mesh.isPickable && this.mesh.metadata?.selected === true;
×
UNCOV
363
    }
×
364
}
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