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

graphty-org / graphty-element / 20514590651

26 Dec 2025 02:37AM UTC coverage: 70.559% (-0.3%) from 70.836%
20514590651

push

github

apowers313
ci: fix npm ci

9591 of 13363 branches covered (71.77%)

Branch coverage included in aggregate %.

25136 of 35854 relevant lines covered (70.11%)

6233.71 hits per line

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

80.19
/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(data["y:PolyLineEdge"] as YFilesPolyLineEdge);
13✔
241
                                Object.assign(parsedEdge, yFilesProps);
13✔
242
                            } else {
449✔
243
                                // Standard GraphML data element
244
                                const value = data["#text"] ?? data;
436!
245
                                parsedEdge[keyDef.name] = this.parseValue(value, keyDef.type);
436✔
246
                            }
436✔
247
                        }
449✔
248
                    }
449✔
249
                }
449✔
250

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

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

264
        return edges;
14✔
265
    }
28✔
266

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

400
        return properties;
16✔
401
    }
16✔
402

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

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

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

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

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

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

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

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

443
        return properties;
13✔
444
    }
13✔
445
}
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

© 2025 Coveralls, Inc