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

graphty-org / graphty-monorepo / 20661584252

02 Jan 2026 03:50PM UTC coverage: 77.924% (+7.3%) from 70.62%
20661584252

push

github

apowers313
ci: fix flakey performance test

13438 of 17822 branches covered (75.4%)

Branch coverage included in aggregate %.

41247 of 52355 relevant lines covered (78.78%)

145534.85 hits per line

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

80.28
/graphty-element/src/data/GraphMLDataSource.ts
1
import { XMLParser } from "fast-xml-parser";
15✔
2

3
import type { AdHocData } from "../config/common.js";
4
import { BaseDataSourceConfig, DataSource, DataSourceChunk } from "./DataSource.js";
15✔
5

6
// GraphML has no additional config currently, so just use the base config
7
export type GraphMLDataSourceConfig = BaseDataSourceConfig;
8

9
interface GraphMLKey {
10
    name: string;
11
    type: string;
12
    for: "node" | "edge" | "graph";
13
    yfilesType?: string;
14
}
15

16
interface YFilesShapeNode {
17
    "y:Geometry"?: {
18
        "@_x"?: string;
19
        "@_y"?: string;
20
        "@_width"?: string;
21
        "@_height"?: string;
22
    };
23
    "y:Fill"?: {
24
        "@_color"?: string;
25
        "@_transparent"?: string;
26
    };
27
    "y:BorderStyle"?: {
28
        "@_color"?: string;
29
        "@_type"?: string;
30
        "@_width"?: string;
31
    };
32
    "y:NodeLabel"?: string | { "#text"?: string };
33
    "y:Shape"?: {
34
        "@_type"?: string;
35
    };
36
}
37

38
interface YFilesPolyLineEdge {
39
    "y:LineStyle"?: {
40
        "@_color"?: string;
41
        "@_type"?: string;
42
        "@_width"?: string;
43
    };
44
    "y:Arrows"?: {
45
        "@_source"?: string;
46
        "@_target"?: string;
47
    };
48
}
49

50
/**
51
 * Data source for loading graph data from GraphML files.
52
 * Supports GraphML format with yFiles extensions for node shapes and edge styles.
53
 */
54
export class GraphMLDataSource extends DataSource {
15✔
55
    static readonly type = "graphml";
19✔
56

57
    private config: GraphMLDataSourceConfig;
58

59
    /**
60
     * Creates a new GraphMLDataSource instance.
61
     * @param config - Configuration options for GraphML parsing and data loading
62
     */
63
    constructor(config: GraphMLDataSourceConfig) {
19✔
64
        super(config.errorLimit ?? 100, config.chunkSize);
35✔
65
        this.config = config;
35✔
66
    }
35✔
67

68
    protected getConfig(): BaseDataSourceConfig {
19✔
69
        return this.config;
35✔
70
    }
35✔
71

72
    /**
73
     * Fetches and parses GraphML format data into graph chunks.
74
     * @yields DataSourceChunk objects containing parsed nodes and edges
75
     */
76
    async *sourceFetchData(): AsyncGenerator<DataSourceChunk, void, unknown> {
19✔
77
        // Get XML content
78
        const xmlContent = await this.getContent();
35✔
79

80
        // Parse XML
81
        const parser = new XMLParser({
32✔
82
            ignoreAttributes: false,
32✔
83
            attributeNamePrefix: "@_",
32✔
84
            parseAttributeValue: false, // Keep as strings, we'll parse by type
32✔
85
            trimValues: true,
32✔
86
        });
32✔
87

88
        let parsed;
32✔
89
        try {
32✔
90
            parsed = parser.parse(xmlContent);
32✔
91
        } catch (error) {
32!
92
            throw new Error(`Failed to parse GraphML XML: ${error instanceof Error ? error.message : String(error)}`);
×
93
        }
×
94

95
        const { graphml } = parsed;
32✔
96
        if (!graphml) {
32!
97
            throw new Error("Invalid GraphML: missing <graphml> root element");
2✔
98
        }
2✔
99

100
        // Parse key definitions
101
        const keys = this.parseKeyDefinitions(graphml.key);
30✔
102

103
        // Get graph element
104
        const { graph } = graphml;
30✔
105
        if (!graph) {
32!
106
            // Empty graphml file - return empty data
107
            return;
2✔
108
        }
2✔
109

110
        // Parse and yield nodes in chunks
111
        const nodes = this.parseNodes(graph.node, keys);
28✔
112
        const edges = this.parseEdges(graph.edge, keys);
28✔
113

114
        // Use shared chunking helper
115
        yield* this.chunkData(nodes as AdHocData[], edges as AdHocData[]);
28✔
116
    }
35✔
117

118
    private parseKeyDefinitions(keyData: unknown): Map<string, GraphMLKey> {
19✔
119
        const keys = new Map<string, GraphMLKey>();
30✔
120

121
        if (!keyData) {
30!
122
            return keys;
17✔
123
        }
17!
124

125
        const keyArray = Array.isArray(keyData) ? keyData : [keyData];
30!
126

127
        for (const key of keyArray) {
30✔
128
            const id = key["@_id"];
28✔
129
            const name = key["@_attr.name"] ?? key["@_name"] ?? id;
28✔
130
            const type = key["@_attr.type"] ?? key["@_type"] ?? "string";
28✔
131
            const forElement = key["@_for"] ?? "node";
28!
132
            const yfilesType = key["@_yfiles.type"];
28✔
133

134
            keys.set(id, {
28✔
135
                name,
28✔
136
                type,
28✔
137
                for: forElement as "node" | "edge" | "graph",
28✔
138
                yfilesType,
28✔
139
            });
28✔
140
        }
28✔
141

142
        return keys;
13✔
143
    }
30✔
144

145
    private parseNodes(nodeData: unknown, keys: Map<string, GraphMLKey>): unknown[] {
19✔
146
        if (!nodeData) {
28!
147
            return [];
×
148
        }
×
149

150
        const nodeArray = Array.isArray(nodeData) ? nodeData : [nodeData];
28!
151
        const nodes: unknown[] = [];
28✔
152

153
        for (const node of nodeArray) {
28✔
154
            try {
5,192✔
155
                const id = node["@_id"];
5,192✔
156
                if (!id) {
5,192!
157
                    this.errorAggregator.addError({
1✔
158
                        message: "Node missing id attribute",
1✔
159
                        category: "missing-value",
1✔
160
                        field: "id",
1✔
161
                    });
1✔
162
                    continue;
1✔
163
                }
1✔
164

165
                const parsedNode: Record<string, unknown> = { id };
5,191✔
166

167
                // Parse data elements
168
                if (node.data) {
5,192!
169
                    const dataElements = Array.isArray(node.data) ? node.data : [node.data];
165!
170

171
                    for (const data of dataElements) {
165✔
172
                        const keyId = data["@_key"];
208✔
173
                        const keyDef = keys.get(keyId);
208✔
174

175
                        if (keyDef?.for === "node") {
208✔
176
                            // Check if this is yFiles node graphics data
177
                            if (keyDef.yfilesType === "nodegraphics" && data["y:ShapeNode"]) {
208✔
178
                                const yFilesProps = this.parseYFilesShapeNode(data["y:ShapeNode"] as YFilesShapeNode);
16✔
179
                                Object.assign(parsedNode, yFilesProps);
16✔
180
                            } else {
202✔
181
                                // Standard GraphML data element
182
                                const value = data["#text"] ?? data;
192!
183
                                parsedNode[keyDef.name] = this.parseValue(value, keyDef.type);
192✔
184
                            }
192✔
185
                        }
208✔
186
                    }
208✔
187
                }
165✔
188

189
                nodes.push(parsedNode);
5,191✔
190
            } catch (error) {
5,192!
191
                const canContinue = this.errorAggregator.addError({
×
192
                    message: `Failed to parse node: ${error instanceof Error ? error.message : String(error)}`,
×
193
                    category: "parse-error",
×
194
                });
×
195

196
                if (!canContinue) {
×
197
                    throw new Error(`Too many errors (${this.errorAggregator.getErrorCount()}), aborting parse`);
×
198
                }
×
199
            }
×
200
        }
5,192✔
201

202
        return nodes;
28✔
203
    }
28✔
204

205
    private parseEdges(edgeData: unknown, keys: Map<string, GraphMLKey>): unknown[] {
19✔
206
        if (!edgeData) {
28!
207
            return [];
14✔
208
        }
14!
209

210
        const edgeArray = Array.isArray(edgeData) ? edgeData : [edgeData];
28!
211
        const edges: unknown[] = [];
28✔
212

213
        for (const edge of edgeArray) {
28!
214
            try {
457✔
215
                const src = edge["@_source"];
457✔
216
                const dst = edge["@_target"];
457✔
217

218
                if (!src || !dst) {
457!
219
                    this.errorAggregator.addError({
1✔
220
                        message: "Edge missing source or target attribute",
1✔
221
                        category: "missing-value",
1✔
222
                        field: !src ? "source" : "target",
1!
223
                    });
1✔
224
                    continue;
1✔
225
                }
1✔
226

227
                const parsedEdge: Record<string, unknown> = { src, dst };
456✔
228

229
                // Parse data elements
230
                if (edge.data) {
457✔
231
                    const dataElements = Array.isArray(edge.data) ? edge.data : [edge.data];
449!
232

233
                    for (const data of dataElements) {
449✔
234
                        const keyId = data["@_key"];
449✔
235
                        const keyDef = keys.get(keyId);
449✔
236

237
                        if (keyDef?.for === "edge") {
449✔
238
                            // Check if this is yFiles edge graphics data
239
                            if (keyDef.yfilesType === "edgegraphics" && data["y:PolyLineEdge"]) {
449✔
240
                                const yFilesProps = this.parseYFilesPolyLineEdge(
13✔
241
                                    data["y:PolyLineEdge"] as YFilesPolyLineEdge,
13✔
242
                                );
13✔
243
                                Object.assign(parsedEdge, yFilesProps);
13✔
244
                            } else {
449✔
245
                                // Standard GraphML data element
246
                                const value = data["#text"] ?? data;
436!
247
                                parsedEdge[keyDef.name] = this.parseValue(value, keyDef.type);
436✔
248
                            }
436✔
249
                        }
449✔
250
                    }
449✔
251
                }
449✔
252

253
                edges.push(parsedEdge);
456✔
254
            } catch (error) {
457!
255
                const canContinue = this.errorAggregator.addError({
×
256
                    message: `Failed to parse edge: ${error instanceof Error ? error.message : String(error)}`,
×
257
                    category: "parse-error",
×
258
                });
×
259

260
                if (!canContinue) {
×
261
                    throw new Error(`Too many errors (${this.errorAggregator.getErrorCount()}), aborting parse`);
×
262
                }
×
263
            }
×
264
        }
457✔
265

266
        return edges;
14✔
267
    }
28✔
268

269
    private parseValue(value: string | object, type: string): unknown {
19✔
270
        // Handle case where value is an object (like #text wrapper)
271
        const stringValue = typeof value === "string" ? value : JSON.stringify(value);
628✔
272

273
        switch (type) {
628✔
274
            case "int":
628!
275
            case "long":
628!
276
                return Number.parseInt(stringValue, 10);
1✔
277
            case "float":
628!
278
            case "double":
628✔
279
                return Number.parseFloat(stringValue);
477✔
280
            case "boolean":
628!
281
                return stringValue.toLowerCase() === "true" || stringValue === "1";
1!
282
            default:
628✔
283
                return stringValue;
149✔
284
        }
628✔
285
    }
628✔
286

287
    /**
288
     * Maps yFiles shape types to Graphty shape types
289
     * @param yfilesShape - yFiles shape type identifier
290
     * @returns Corresponding Graphty shape type
291
     */
292
    private mapYFilesShape(yfilesShape: string): string {
19✔
293
        const shapeMap: Record<string, string> = {
14✔
294
            rectangle: "box",
14✔
295
            roundrectangle: "box",
14✔
296
            ellipse: "sphere",
14✔
297
            circle: "sphere",
14✔
298
            diamond: "box",
14✔
299
            parallelogram: "box",
14✔
300
            hexagon: "box",
14✔
301
            octagon: "box",
14✔
302
            triangle: "box",
14✔
303
        };
14✔
304

305
        return shapeMap[yfilesShape.toLowerCase()] ?? "box";
14!
306
    }
14✔
307

308
    /**
309
     * Normalizes color to standard hex format (#RRGGBB)
310
     * @param color - Color value in any format
311
     * @returns Normalized hex color string
312
     */
313
    private normalizeColor(color: string): string {
19✔
314
        const hexPattern = /^#[0-9A-Fa-f]{6}$/;
37✔
315
        const shortHexPattern = /^#[0-9A-Fa-f]{3}$/;
37✔
316

317
        // Already in hex format
318
        if (hexPattern.exec(color)) {
37✔
319
            return color.toUpperCase();
37✔
320
        }
37!
321

322
        // Short hex format
323
        if (shortHexPattern.exec(color)) {
×
324
            const r = color[1];
×
325
            const g = color[2];
×
326
            const b = color[3];
×
327

328
            return `#${r}${r}${g}${g}${b}${b}`.toUpperCase();
×
329
        }
×
330

331
        // Return as-is if not recognized (could add more formats later)
332
        return color;
×
333
    }
37✔
334

335
    /**
336
     * Parses yFiles ShapeNode data and extracts visual properties
337
     * @param shapeNode - yFiles ShapeNode data structure
338
     * @returns Object containing extracted node properties
339
     */
340
    private parseYFilesShapeNode(shapeNode: YFilesShapeNode): Record<string, unknown> {
19✔
341
        const properties: Record<string, unknown> = {};
16✔
342

343
        // Extract geometry
344
        if (shapeNode["y:Geometry"]) {
16✔
345
            const geom = shapeNode["y:Geometry"];
16✔
346
            const position: { x?: number; y?: number; z?: number } = {};
16✔
347

348
            if (geom["@_x"]) {
16✔
349
                position.x = Number.parseFloat(geom["@_x"]);
16✔
350
            }
16✔
351

352
            if (geom["@_y"]) {
16✔
353
                position.y = Number.parseFloat(geom["@_y"]);
16✔
354
            }
16✔
355

356
            // yFiles uses 2D coordinates, set z to 0 for fixed layout
357
            position.z = 0;
16✔
358

359
            // Only add position if we have at least x or y
360
            if (position.x !== undefined || position.y !== undefined) {
16!
361
                properties.position = position;
16✔
362
            }
16✔
363

364
            if (geom["@_width"]) {
16✔
365
                properties.width = Number.parseFloat(geom["@_width"]);
16✔
366
            }
16✔
367

368
            if (geom["@_height"]) {
16✔
369
                properties.height = Number.parseFloat(geom["@_height"]);
16✔
370
            }
16✔
371
        }
16✔
372

373
        // Extract fill color
374
        if (shapeNode["y:Fill"]?.["@_color"]) {
16✔
375
            properties.color = this.normalizeColor(shapeNode["y:Fill"]["@_color"]);
15✔
376
        }
15✔
377

378
        // Extract border style
379
        if (shapeNode["y:BorderStyle"]) {
16✔
380
            const border = shapeNode["y:BorderStyle"];
11✔
381

382
            if (border["@_color"]) {
11✔
383
                properties.borderColor = this.normalizeColor(border["@_color"]);
11✔
384
            }
11✔
385

386
            if (border["@_width"]) {
11✔
387
                properties.borderWidth = Number.parseFloat(border["@_width"]);
11✔
388
            }
11✔
389
        }
11✔
390

391
        // Extract node label
392
        if (shapeNode["y:NodeLabel"]) {
16✔
393
            const label = shapeNode["y:NodeLabel"];
12✔
394
            properties.label = typeof label === "string" ? label : (label["#text"] ?? "");
12!
395
        }
12✔
396

397
        // Extract and map shape
398
        if (shapeNode["y:Shape"]?.["@_type"]) {
16✔
399
            properties.shape = this.mapYFilesShape(shapeNode["y:Shape"]["@_type"]);
14✔
400
        }
14✔
401

402
        return properties;
16✔
403
    }
16✔
404

405
    /**
406
     * Parses yFiles PolyLineEdge data and extracts visual properties
407
     * @param polyLineEdge - yFiles PolyLineEdge data structure
408
     * @returns Object containing extracted edge properties
409
     */
410
    private parseYFilesPolyLineEdge(polyLineEdge: YFilesPolyLineEdge): Record<string, unknown> {
19✔
411
        const properties: Record<string, unknown> = {};
13✔
412

413
        // Extract line style
414
        if (polyLineEdge["y:LineStyle"]) {
13✔
415
            const lineStyle = polyLineEdge["y:LineStyle"];
11✔
416

417
            if (lineStyle["@_color"]) {
11✔
418
                properties.color = this.normalizeColor(lineStyle["@_color"]);
11✔
419
            }
11✔
420

421
            if (lineStyle["@_width"]) {
11✔
422
                properties.width = Number.parseFloat(lineStyle["@_width"]);
11✔
423
            }
11✔
424
        }
11✔
425

426
        // Extract arrows (determines if directed and arrow type)
427
        if (polyLineEdge["y:Arrows"]) {
13✔
428
            const arrows = polyLineEdge["y:Arrows"];
13✔
429
            const targetArrow = arrows["@_target"];
13✔
430
            const sourceArrow = arrows["@_source"];
13✔
431

432
            // Edge is directed if it has a target arrow (and source is none)
433
            properties.directed = targetArrow !== "none" && targetArrow !== undefined;
13✔
434

435
            // Extract arrow types
436
            if (targetArrow && targetArrow !== "none") {
13✔
437
                properties.targetArrow = targetArrow;
12✔
438
            }
12✔
439

440
            if (sourceArrow && sourceArrow !== "none") {
13!
441
                properties.sourceArrow = sourceArrow;
×
442
            }
×
443
        }
13✔
444

445
        return properties;
13✔
446
    }
13✔
447
}
19✔
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