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

graphty-org / graphty-element / 19792929756

30 Nov 2025 02:57AM UTC coverage: 86.308% (+3.9%) from 82.377%
19792929756

push

github

apowers313
docs: fix stories for chromatic

3676 of 4303 branches covered (85.43%)

Branch coverage included in aggregate %.

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

1093 existing lines in 30 files now uncovered.

17371 of 20083 relevant lines covered (86.5%)

7075.46 hits per line

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

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

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

18
export type NodeIdType = string | number;
19

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

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

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

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

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

63
        // copy nodeMeshOpts
64
        this.styleId = styleId;
4,654✔
65

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

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

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

80
        // create label
81
        if (o.label?.enabled) {
4,654✔
82
            this.label = this.createLabel(o);
607✔
83
        }
607✔
84

85
        NodeBehavior.addDefaultBehaviors(this, this.opts);
4,654✔
86
    }
4,654✔
87

88
    addCalculatedStyle(cv: CalculatedValue): void {
3✔
89
        this.changeManager.addCalculatedValue(cv);
×
90
    }
×
91

92
    update(): void {
3✔
93
        this.context.getStatsManager().startMeasurement("Node.update");
46,760✔
94

95
        const newStyleKeys = Object.keys(this.styleUpdates);
46,760✔
96
        if (newStyleKeys.length > 0) {
46,760✔
97
            let style = Styles.getStyleForNodeStyleId(this.styleId);
507✔
98
            // Convert styleUpdates Proxy to plain object for proper merging
99
            // (styleUpdates is wrapped by on-change library's Proxy)
100
            const plainStyleUpdates = _.cloneDeep(this.styleUpdates);
507✔
101
            style = _.defaultsDeep(plainStyleUpdates, style);
507✔
102
            const styleId = Styles.getNodeIdForStyle(style);
507✔
103
            this.updateStyle(styleId);
507✔
104
            for (const key of newStyleKeys) {
507✔
105
                // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
106
                delete this.styleUpdates[key];
607✔
107
            }
607✔
108
        }
507✔
109

110
        if (this.dragging) {
46,760!
UNCOV
111
            this.context.getStatsManager().endMeasurement("Node.update");
×
UNCOV
112
            return;
×
UNCOV
113
        }
×
114

115
        const pos = this.context.getLayoutManager().layoutEngine?.getNodePosition(this);
46,760✔
116
        if (pos) {
46,760✔
117
            this.mesh.position.x = pos.x;
46,760✔
118
            this.mesh.position.y = pos.y;
46,760✔
119
            this.mesh.position.z = pos.z ?? 0;
46,760✔
120
        }
46,760✔
121

122
        this.context.getStatsManager().endMeasurement("Node.update");
46,760✔
123
    }
46,760✔
124

125
    updateStyle(styleId: NodeStyleId): void {
3✔
126
        this.context.getStatsManager().startMeasurement("Node.updateMesh");
2,791✔
127

128
        // Only skip update if styleId is the same AND mesh is not disposed
129
        // (mesh can be disposed when switching 2D/3D modes via meshCache.clear())
130
        if (styleId === this.styleId && !this.mesh.isDisposed()) {
2,791✔
131
            this.context.getStatsManager().endMeasurement("Node.updateMesh");
1,987✔
132
            return;
1,987✔
133
        }
1,987✔
134

135
        this.styleId = styleId;
804✔
136
        // Only dispose if not already disposed
137
        if (!this.mesh.isDisposed()) {
2,754✔
138
            this.mesh.dispose();
682✔
139
        }
682✔
140

141
        const o = Styles.getStyleForNodeStyleId(styleId);
804✔
142
        this.size = o.shape?.size ?? 0;
2,791!
143

144
        this.mesh = NodeMesh.create(
2,791✔
145
            this.context.getMeshCache(),
2,791✔
146
            {styleId: String(styleId), is2D: this.context.is2D(), size: this.size},
2,791✔
147
            {shape: o.shape, texture: o.texture, effect: o.effect},
2,791✔
148
            this.context.getScene(),
2,791✔
149
        );
2,791✔
150

151
        // recreate label if needed
152
        if (o.label?.enabled) {
2,791!
153
            this.label?.dispose();
×
UNCOV
154
            this.label = this.createLabel(o);
×
155
        } else if (this.label) {
2,535!
UNCOV
156
            this.label.dispose();
×
UNCOV
157
            this.label = undefined;
×
UNCOV
158
        }
×
159

160
        NodeBehavior.addDefaultBehaviors(this, this.opts);
804✔
161

162
        this.context.getStatsManager().endMeasurement("Node.updateMesh");
804✔
163
    }
2,791✔
164

165
    pin(): void {
3✔
UNCOV
166
        this.context.getLayoutManager().layoutEngine?.pin(this);
×
UNCOV
167
    }
×
168

169
    unpin(): void {
3✔
UNCOV
170
        this.context.getLayoutManager().layoutEngine?.unpin(this);
×
UNCOV
171
    }
×
172

173
    private createLabel(styleConfig: NodeStyleConfig): RichTextLabel {
3✔
174
        const labelText = this.extractLabelText(styleConfig.label);
607✔
175
        const labelOptions = this.createLabelOptions(labelText, styleConfig);
607✔
176
        return new RichTextLabel(this.mesh.getScene(), labelOptions);
607✔
177
    }
607✔
178

179
    private extractLabelText(labelConfig?: Record<string, unknown>): string {
3✔
180
        if (!labelConfig) {
607!
UNCOV
181
            return this.id.toString();
×
UNCOV
182
        }
×
183

184
        // Check if text is directly provided
185
        if (labelConfig.text !== undefined && labelConfig.text !== null) {
607✔
186
            // Only convert to string if it's a primitive type
187
            if (typeof labelConfig.text === "string" || typeof labelConfig.text === "number" || typeof labelConfig.text === "boolean") {
184!
188
                return String(labelConfig.text);
184✔
189
            }
184✔
190
        } else if (labelConfig.textPath && typeof labelConfig.textPath === "string") {
607✔
191
            try {
403✔
192
                const result = jmespath.search(this.data, labelConfig.textPath);
403✔
193
                if (result !== null && result !== undefined) {
403✔
194
                    return String(result);
403✔
195
                }
403✔
196
            } catch {
403!
197
                // Ignore jmespath errors
UNCOV
198
            }
×
199
        }
403✔
200

201
        return this.id.toString();
20✔
202
    }
607✔
203

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

207
        // Get attach position and offset
208
        const attachPosition = this.getAttachPosition(labelStyle.location ?? "top");
607✔
209
        const attachOffset = labelStyle.attachOffset ?? this.getDefaultAttachOffset(labelStyle.location ?? "top");
607✔
210

211
        // Transform backgroundColor to string if it's an advanced color style
212
        let backgroundColor: string | undefined = undefined;
607✔
213
        if (labelStyle.backgroundColor) {
607✔
214
            if (typeof labelStyle.backgroundColor === "string") {
224✔
215
                ({backgroundColor} = labelStyle);
224✔
216
            } else if (labelStyle.backgroundColor.colorType === "solid") {
224!
UNCOV
217
                ({value: backgroundColor} = labelStyle.backgroundColor);
×
218
            } else if (labelStyle.backgroundColor.colorType === "gradient") {
×
219
                // For gradients, use the first color as a fallback
220
                [backgroundColor] = labelStyle.backgroundColor.colors;
×
221
            }
×
222
        }
224✔
223

224
        // Filter out undefined values from backgroundGradientColors
225
        let backgroundGradientColors: string[] | undefined = undefined;
607✔
226
        if (labelStyle.backgroundGradientColors) {
607✔
227
            backgroundGradientColors = labelStyle.backgroundGradientColors.filter((color): color is string => color !== undefined);
20✔
228
            if (backgroundGradientColors.length === 0) {
20!
229
                backgroundGradientColors = undefined;
×
230
            }
×
231
        }
20✔
232

233
        // Transform borders to ensure colors are strings
234
        let borders: {width: number, color: string, spacing: number}[] | undefined = undefined;
607✔
235
        if (labelStyle.borders && labelStyle.borders.length > 0) {
607!
UNCOV
236
            const validBorders = labelStyle.borders
×
UNCOV
237
                .filter((border): border is typeof border & {color: string} => border.color !== undefined)
×
UNCOV
238
                .map((border) => ({
×
UNCOV
239
                    width: border.width,
×
UNCOV
240
                    color: border.color,
×
UNCOV
241
                    spacing: border.spacing,
×
UNCOV
242
                }));
×
243
            // Only set borders if we have valid borders, otherwise leave it undefined
244
            // so the default empty array is used
UNCOV
245
            if (validBorders.length > 0) {
×
UNCOV
246
                borders = validBorders;
×
247
            }
×
248
        }
×
249

250
        // Create label options by spreading the entire labelStyle object
251
        const labelOptions: RichTextLabelOptions = {
607✔
252
            ... labelStyle,
607✔
253
            // Override with computed values
254
            text: labelText,
607✔
255
            attachTo: this.mesh,
607✔
256
            attachPosition,
607✔
257
            attachOffset,
607✔
258
            backgroundColor,
607✔
259
            backgroundGradientColors,
607✔
260
            ... (borders !== undefined && {borders}),
607!
261
        };
607✔
262

263
        // Handle special case for transparent background
264
        if (labelOptions.backgroundColor === "transparent") {
607!
UNCOV
265
            labelOptions.backgroundColor = undefined;
×
UNCOV
266
        }
×
267

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

272
        return finalLabelOptions;
607✔
273
    }
607✔
274

275
    private getAttachPosition(location: string): "top" | "top-left" | "top-right" | "left" | "center" | "right" | "bottom" | "bottom-left" | "bottom-right" {
3✔
276
        switch (location) {
607✔
277
            case "floating":
607!
278
            case "automatic":
607!
UNCOV
279
                return "top";
×
280
            case "top":
607✔
281
            case "top-left":
607✔
282
            case "top-right":
607✔
283
            case "left":
607✔
284
            case "center":
607✔
285
            case "right":
607✔
286
            case "bottom":
607✔
287
            case "bottom-left":
607✔
288
            case "bottom-right":
607✔
289
                return location;
607✔
290
            default:
607!
UNCOV
291
                return "top";
×
292
        }
607✔
293
    }
607✔
294

295
    private getDefaultAttachOffset(location: string): number {
3✔
296
        // Return larger offsets for left/right positions to prevent overlap
297
        switch (location) {
543✔
298
            case "left":
543!
299
            case "right":
543!
UNCOV
300
                return 1.0; // Larger offset for horizontal positions
×
301
            case "center":
543!
302
                return 0; // No offset for center
×
303
            default:
543✔
304
                return 0.5; // Standard offset for top/bottom positions
543✔
305
        }
543✔
306
    }
543✔
307

308
    // Test helper methods
309
    getPosition(): {x: number, y: number, z: number} {
3✔
310
        return {
19✔
311
            x: this.mesh.position.x,
19✔
312
            y: this.mesh.position.y,
19✔
313
            z: this.mesh.position.z,
19✔
314
        };
19✔
315
    }
19✔
316

317
    isPinned(): boolean {
3✔
318
        // For now, nodes are not pinned unless drag behavior is disabled
UNCOV
319
        return false;
×
UNCOV
320
    }
×
321

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