@loaders.gl/graphs 5.0.0-alpha.9 → 5.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +24 -0
- package/README.md +123 -4
- package/THIRD-PARTY-NOTICES.md +17 -0
- package/dist/dot-loader-types.cjs +59 -0
- package/dist/dot-loader-types.cjs.map +7 -0
- package/dist/dot-loader-types.d.ts +25 -8
- package/dist/dot-loader-types.d.ts.map +1 -1
- package/dist/dot-loader-types.js +2 -2
- package/dist/dot-loader-types.js.map +1 -1
- package/dist/dot-loader.cjs +638 -47
- package/dist/dot-loader.cjs.map +3 -3
- package/dist/dot-loader.d.ts +10 -6
- package/dist/dot-loader.d.ts.map +1 -1
- package/dist/dot-loader.js +3 -3
- package/dist/dot-loader.js.map +1 -1
- package/dist/dot-types.d.ts +6 -1
- package/dist/dot-types.d.ts.map +1 -1
- package/dist/gexf-loader-types.cjs +59 -0
- package/dist/gexf-loader-types.cjs.map +7 -0
- package/dist/gexf-loader-types.d.ts +53 -0
- package/dist/gexf-loader-types.d.ts.map +1 -0
- package/dist/gexf-loader-types.js +26 -0
- package/dist/gexf-loader-types.js.map +1 -0
- package/dist/gexf-loader.cjs +2940 -0
- package/dist/gexf-loader.cjs.map +6 -0
- package/dist/gexf-loader.d.ts +24 -0
- package/dist/gexf-loader.d.ts.map +1 -0
- package/dist/gexf-loader.js +13 -0
- package/dist/gexf-loader.js.map +1 -0
- package/dist/gexf-types.d.ts +16 -0
- package/dist/gexf-types.d.ts.map +1 -0
- package/dist/gexf-types.js +5 -0
- package/dist/gexf-types.js.map +1 -0
- package/dist/graph-types.d.ts +28 -0
- package/dist/graph-types.d.ts.map +1 -1
- package/dist/graphml-loader-types.cjs +59 -0
- package/dist/graphml-loader-types.cjs.map +7 -0
- package/dist/graphml-loader-types.d.ts +24 -8
- package/dist/graphml-loader-types.d.ts.map +1 -1
- package/dist/graphml-loader-types.js +2 -2
- package/dist/graphml-loader-types.js.map +1 -1
- package/dist/graphml-loader.cjs +2303 -69
- package/dist/graphml-loader.cjs.map +3 -3
- package/dist/graphml-loader.d.ts +10 -6
- package/dist/graphml-loader.d.ts.map +1 -1
- package/dist/graphml-loader.js +3 -3
- package/dist/graphml-loader.js.map +1 -1
- package/dist/index.cjs +56 -1085
- package/dist/index.cjs.map +4 -3
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/build-graph-tables.d.ts +4 -0
- package/dist/lib/build-graph-tables.d.ts.map +1 -0
- package/dist/lib/build-graph-tables.js +143 -0
- package/dist/lib/build-graph-tables.js.map +1 -0
- package/dist/lib/parse-dot.d.ts +3 -1
- package/dist/lib/parse-dot.d.ts.map +1 -1
- package/dist/lib/parse-dot.js +38 -21
- package/dist/lib/parse-dot.js.map +1 -1
- package/dist/lib/parse-gexf.d.ts +5 -0
- package/dist/lib/parse-gexf.d.ts.map +1 -0
- package/dist/lib/parse-gexf.js +382 -0
- package/dist/lib/parse-gexf.js.map +1 -0
- package/dist/lib/parse-graphml.d.ts +2 -1
- package/dist/lib/parse-graphml.d.ts.map +1 -1
- package/dist/lib/parse-graphml.js +54 -31
- package/dist/lib/parse-graphml.js.map +1 -1
- package/package.json +18 -8
- package/src/dot-loader-types.ts +15 -5
- package/src/dot-loader.ts +7 -4
- package/src/dot-types.ts +7 -1
- package/src/gexf-loader-types.ts +41 -0
- package/src/gexf-loader.ts +17 -0
- package/src/gexf-types.ts +21 -0
- package/src/graph-types.ts +35 -0
- package/src/graphml-loader-types.ts +14 -5
- package/src/graphml-loader.ts +7 -4
- package/src/index.ts +9 -0
- package/src/lib/build-graph-tables.ts +208 -0
- package/src/lib/parse-dot.ts +44 -21
- package/src/lib/parse-gexf.ts +390 -0
- package/src/lib/parse-graphml.ts +92 -40
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
// loaders.gl
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// Copyright (c) vis.gl contributors
|
|
4
|
+
|
|
5
|
+
import {parseXMLSync, validateXML} from '@loaders.gl/documents/xml-parser';
|
|
6
|
+
import type {GraphNode, GraphEdge, GraphShape, GraphAttributeType} from '../graph-types';
|
|
7
|
+
import type {GEXFOutput} from '../gexf-types';
|
|
8
|
+
import {buildGraphTables} from './build-graph-tables';
|
|
9
|
+
|
|
10
|
+
/** XML element and attribute representation. */
|
|
11
|
+
type XMLElement = Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
/** A scoped application attribute declaration. */
|
|
14
|
+
type AttributeDefinition = {
|
|
15
|
+
/** Application attribute title. */
|
|
16
|
+
name: string;
|
|
17
|
+
/** Arrow-compatible scalar or list type. */
|
|
18
|
+
type: GraphAttributeType;
|
|
19
|
+
/** Default value, when supplied. */
|
|
20
|
+
defaultValue?: unknown;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const XML_OPTIONS = {
|
|
24
|
+
ignoreAttributes: false,
|
|
25
|
+
parseAttributeValue: false,
|
|
26
|
+
parseTagValue: false,
|
|
27
|
+
trimValues: false,
|
|
28
|
+
attributeNamePrefix: '@_',
|
|
29
|
+
textNodeName: '#text'
|
|
30
|
+
};
|
|
31
|
+
const GEXF_NAMESPACE = /^https?:\/\/(?:www\.)?gexf.net\/1\.(?:2(?:draft)?|3)(\/viz)?$/;
|
|
32
|
+
const TEMPORAL_ATTRIBUTES = [
|
|
33
|
+
'start',
|
|
34
|
+
'end',
|
|
35
|
+
'startopen',
|
|
36
|
+
'endopen',
|
|
37
|
+
'timestamp',
|
|
38
|
+
'timestamps',
|
|
39
|
+
'intervals'
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/** Parses a static GEXF document directly into the selected graph representation. */
|
|
43
|
+
export function parseGEXF(
|
|
44
|
+
input: string | ArrayBuffer | Uint8Array,
|
|
45
|
+
shape: GraphShape = 'arrow-table'
|
|
46
|
+
): GEXFOutput {
|
|
47
|
+
const text = typeof input === 'string' ? input : new TextDecoder().decode(input);
|
|
48
|
+
const validation = validateXML(text);
|
|
49
|
+
if (validation !== true) throw new Error(`Invalid GEXF XML: ${validation.message}`);
|
|
50
|
+
// External and custom entity declarations are outside the supported format subset.
|
|
51
|
+
if (/<!DOCTYPE\s/i.test(text)) throw new Error('GEXF document types are unsupported.');
|
|
52
|
+
const document = normalizeNamespaces(parseXMLSync(text, XML_OPTIONS) as XMLElement, {});
|
|
53
|
+
const root = getElement(document.gexf, 'gexf');
|
|
54
|
+
const version = root['@_version'] as string | undefined;
|
|
55
|
+
if (version && !['1.2', '1.2draft', '1.3'].includes(version)) {
|
|
56
|
+
throw new Error(`Unsupported GEXF version: ${version}`);
|
|
57
|
+
}
|
|
58
|
+
const graph = getElement(root.graph, 'graph');
|
|
59
|
+
const directed = parseDirection(graph['@_defaultedgetype'] ?? 'undirected');
|
|
60
|
+
const definitions = {
|
|
61
|
+
nodes: new Map<string, AttributeDefinition>(),
|
|
62
|
+
edges: new Map<string, AttributeDefinition>()
|
|
63
|
+
};
|
|
64
|
+
for (const attributes of getElements(graph.attributes)) {
|
|
65
|
+
const domain = attributes['@_class'];
|
|
66
|
+
if (domain !== 'node' && domain !== 'edge')
|
|
67
|
+
throw new Error('GEXF attributes require a node or edge class.');
|
|
68
|
+
const scoped = domain === 'node' ? definitions.nodes : definitions.edges;
|
|
69
|
+
const names = new Set(Array.from(scoped.values(), definition => definition.name));
|
|
70
|
+
for (const attribute of getElements(attributes.attribute)) {
|
|
71
|
+
const identifier = requireAttribute(attribute, 'id');
|
|
72
|
+
const name = requireAttribute(attribute, 'title');
|
|
73
|
+
if (name === 'gexf' || names.has(name) || scoped.has(identifier)) {
|
|
74
|
+
throw new Error(`Duplicate or reserved GEXF attribute: ${name}`);
|
|
75
|
+
}
|
|
76
|
+
names.add(name);
|
|
77
|
+
const type = parseAttributeType(requireAttribute(attribute, 'type'));
|
|
78
|
+
scoped.set(identifier, {
|
|
79
|
+
name,
|
|
80
|
+
type,
|
|
81
|
+
defaultValue:
|
|
82
|
+
attribute.default === undefined
|
|
83
|
+
? undefined
|
|
84
|
+
: castValue(readTextContent(attribute.default), type)
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const nodeElements = getElements(getContainer(graph.nodes).node);
|
|
89
|
+
const edgeElements = getElements(getContainer(graph.edges).edge);
|
|
90
|
+
const nodeIdentifiers = new Set<string>();
|
|
91
|
+
for (const node of nodeElements) {
|
|
92
|
+
const identifier = requireAttribute(node, 'id');
|
|
93
|
+
if (nodeIdentifiers.has(identifier)) throw new Error(`Duplicate GEXF node ID: ${identifier}`);
|
|
94
|
+
nodeIdentifiers.add(identifier);
|
|
95
|
+
}
|
|
96
|
+
const edgeIdentifiers = new Set(
|
|
97
|
+
edgeElements
|
|
98
|
+
.map(edge => edge['@_id'])
|
|
99
|
+
.filter((identifier): identifier is string => typeof identifier === 'string')
|
|
100
|
+
);
|
|
101
|
+
const seenEdges = new Set<string>();
|
|
102
|
+
/** Emits node records directly to the table builder. */
|
|
103
|
+
function* iterateNodes(): Iterable<GraphNode> {
|
|
104
|
+
for (const node of nodeElements) yield parseRecord(node, definitions.nodes);
|
|
105
|
+
}
|
|
106
|
+
/** Emits edge records without materializing a plain graph. */
|
|
107
|
+
function* iterateEdges(): Iterable<GraphEdge> {
|
|
108
|
+
for (const [index, edge] of edgeElements.entries()) {
|
|
109
|
+
const sourceId = requireAttribute(edge, 'source');
|
|
110
|
+
const targetId = requireAttribute(edge, 'target');
|
|
111
|
+
if (!nodeIdentifiers.has(sourceId) || !nodeIdentifiers.has(targetId)) {
|
|
112
|
+
throw new Error('GEXF edge references an undeclared node.');
|
|
113
|
+
}
|
|
114
|
+
let identifier = edge['@_id'] === undefined ? undefined : requireAttribute(edge, 'id');
|
|
115
|
+
if (identifier === undefined) {
|
|
116
|
+
identifier = `edge-${index}`;
|
|
117
|
+
while (edgeIdentifiers.has(identifier)) identifier = `_${identifier}`;
|
|
118
|
+
edgeIdentifiers.add(identifier);
|
|
119
|
+
}
|
|
120
|
+
if (seenEdges.has(identifier)) throw new Error(`Duplicate GEXF edge ID: ${identifier}`);
|
|
121
|
+
seenEdges.add(identifier);
|
|
122
|
+
yield {
|
|
123
|
+
...parseRecord(edge, definitions.edges, identifier),
|
|
124
|
+
sourceId,
|
|
125
|
+
targetId,
|
|
126
|
+
directed: edge['@_type'] === undefined ? directed : parseDirection(edge['@_type'])
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const metadataAttributes: Record<string, string> = Object.create(null);
|
|
131
|
+
const meta = getContainer(root.meta);
|
|
132
|
+
for (const name of ['creator', 'description', 'keywords']) {
|
|
133
|
+
if (meta[name] !== undefined) metadataAttributes[name] = readTextContent(meta[name]);
|
|
134
|
+
}
|
|
135
|
+
if (typeof meta['@_lastmodifieddate'] === 'string')
|
|
136
|
+
metadataAttributes.lastmodifieddate = meta['@_lastmodifieddate'];
|
|
137
|
+
const declarations = {
|
|
138
|
+
nodes: new Map(
|
|
139
|
+
Array.from(definitions.nodes.values(), definition => [definition.name, definition.type])
|
|
140
|
+
),
|
|
141
|
+
edges: new Map(
|
|
142
|
+
Array.from(definitions.edges.values(), definition => [definition.name, definition.type])
|
|
143
|
+
)
|
|
144
|
+
};
|
|
145
|
+
return {
|
|
146
|
+
...buildGraphTables(iterateNodes(), iterateEdges(), shape, declarations),
|
|
147
|
+
metadata: {version, directed, attributes: metadataAttributes}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Resolves inherited namespace bindings without treating foreign elements as GEXF. */
|
|
152
|
+
function normalizeNamespaces(element: XMLElement, inherited: Record<string, string>): XMLElement {
|
|
153
|
+
const namespaces: Record<string, string> = Object.assign(Object.create(null), inherited);
|
|
154
|
+
for (const [name, value] of Object.entries(element)) {
|
|
155
|
+
if (name === '@_xmlns') namespaces[''] = String(value);
|
|
156
|
+
else if (name.startsWith('@_xmlns:')) namespaces[name.slice(8)] = String(value);
|
|
157
|
+
}
|
|
158
|
+
const normalized: XMLElement = Object.create(null);
|
|
159
|
+
for (const [name, value] of Object.entries(element)) {
|
|
160
|
+
if (name.startsWith('@_') || name === '#text' || name.startsWith('?')) {
|
|
161
|
+
normalized[name] = value;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const separator = name.indexOf(':');
|
|
165
|
+
const prefix = separator < 0 ? '' : name.slice(0, separator);
|
|
166
|
+
const localName = name.slice(separator + 1);
|
|
167
|
+
// Namespace declarations on this child also scope the child's own name.
|
|
168
|
+
const children = Array.isArray(value) ? value : [value];
|
|
169
|
+
for (const child of children) {
|
|
170
|
+
const object = typeof child === 'object' && child !== null ? (child as XMLElement) : {};
|
|
171
|
+
const namespace = object[prefix ? `@_xmlns:${prefix}` : '@_xmlns'] ?? namespaces[prefix];
|
|
172
|
+
const match = typeof namespace === 'string' ? GEXF_NAMESPACE.exec(namespace) : null;
|
|
173
|
+
const key = match
|
|
174
|
+
? `${match[1] ? 'viz:' : ''}${localName}`
|
|
175
|
+
: namespace || prefix
|
|
176
|
+
? `foreign:${name}`
|
|
177
|
+
: localName;
|
|
178
|
+
const childValue =
|
|
179
|
+
typeof child === 'object' && child !== null
|
|
180
|
+
? match || (!namespace && !prefix)
|
|
181
|
+
? normalizeNamespaces(object, namespaces)
|
|
182
|
+
: object
|
|
183
|
+
: child;
|
|
184
|
+
if (normalized[key] === undefined) normalized[key] = childValue;
|
|
185
|
+
else
|
|
186
|
+
normalized[key] = [
|
|
187
|
+
...(Array.isArray(normalized[key]) ? (normalized[key] as unknown[]) : [normalized[key]]),
|
|
188
|
+
childValue
|
|
189
|
+
];
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
assertStaticElement(normalized);
|
|
193
|
+
return normalized;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Rejects temporal and hierarchical constructs that flat static tables cannot represent. */
|
|
197
|
+
function assertStaticElement(element: XMLElement): void {
|
|
198
|
+
if (
|
|
199
|
+
(element['@_mode'] !== undefined && element['@_mode'] !== 'static') ||
|
|
200
|
+
TEMPORAL_ATTRIBUTES.some(name => element[`@_${name}`] !== undefined) ||
|
|
201
|
+
element.spells !== undefined
|
|
202
|
+
) {
|
|
203
|
+
throw new Error('Dynamic GEXF graphs and temporal values are unsupported.');
|
|
204
|
+
}
|
|
205
|
+
if (
|
|
206
|
+
element['@_pid'] !== undefined ||
|
|
207
|
+
element.parents !== undefined ||
|
|
208
|
+
(element['@_id'] !== undefined && (element.nodes !== undefined || element.edges !== undefined))
|
|
209
|
+
) {
|
|
210
|
+
throw new Error('Hierarchical GEXF graphs are unsupported.');
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Reads a singleton element, including an empty element. */
|
|
215
|
+
function getElement(value: unknown, name: string): XMLElement {
|
|
216
|
+
if (value === '') return {};
|
|
217
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) return value as XMLElement;
|
|
218
|
+
throw new Error(`GEXF requires one <${name}> element.`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Reads scalar text from elements that also declare local namespace bindings. */
|
|
222
|
+
function readTextContent(value: unknown): string {
|
|
223
|
+
if (typeof value === 'string') return value;
|
|
224
|
+
const text = getElement(value, 'text')['#text'];
|
|
225
|
+
return typeof text === 'string' ? text : '';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Reads an optional container while rejecting repeated containers. */
|
|
229
|
+
function getContainer(value: unknown): XMLElement {
|
|
230
|
+
return value === undefined ? {} : getElement(value, 'container');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Reads repeated or singleton element records. */
|
|
234
|
+
function getElements(value: unknown): XMLElement[] {
|
|
235
|
+
if (value === undefined) return [];
|
|
236
|
+
return (Array.isArray(value) ? value : [value]).map(entry => getElement(entry, 'record'));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Reads a required XML attribute without changing identifier or label whitespace. */
|
|
240
|
+
function requireAttribute(element: XMLElement, name: string): string {
|
|
241
|
+
const value = element[`@_${name}`];
|
|
242
|
+
if (typeof value !== 'string' || value === '')
|
|
243
|
+
throw new Error(`GEXF requires a ${name} attribute.`);
|
|
244
|
+
return value;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Reads a direction while rejecting bidirectional mutual edges. */
|
|
248
|
+
function parseDirection(value: unknown): boolean {
|
|
249
|
+
if (value !== 'directed' && value !== 'undirected')
|
|
250
|
+
throw new Error(`Unsupported GEXF edge type: ${String(value)}`);
|
|
251
|
+
return value === 'directed';
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Maps supported GEXF scalar and list types to graph column declarations. */
|
|
255
|
+
function parseAttributeType(value: string): GraphAttributeType {
|
|
256
|
+
if (value.startsWith('list') && !value.slice(4).startsWith('list')) {
|
|
257
|
+
return {list: parseAttributeType(value.slice(4))};
|
|
258
|
+
}
|
|
259
|
+
if (value === 'integer' || value === 'byte' || value === 'short') return 'int';
|
|
260
|
+
if (value === 'boolean' || value === 'long' || value === 'float' || value === 'double')
|
|
261
|
+
return value;
|
|
262
|
+
if (['string', 'anyURI', 'date', 'char', 'bigdecimal', 'biginteger'].includes(value))
|
|
263
|
+
return 'string';
|
|
264
|
+
throw new Error(`Unsupported GEXF attribute type: ${value}`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Casts exact declared values; invalid scalar text survives Arrow promotion to Utf8. */
|
|
268
|
+
function castValue(text: string, type: GraphAttributeType): unknown {
|
|
269
|
+
if (typeof type === 'object') {
|
|
270
|
+
return parseListValues(text).map(value => castValue(value, type.list));
|
|
271
|
+
}
|
|
272
|
+
if (type === 'string') return text;
|
|
273
|
+
const trimmed = text.trim();
|
|
274
|
+
if (type === 'boolean') {
|
|
275
|
+
if (trimmed === 'true' || trimmed === '1') return true;
|
|
276
|
+
if (trimmed === 'false' || trimmed === '0') return false;
|
|
277
|
+
return text;
|
|
278
|
+
}
|
|
279
|
+
if (type === 'int' || type === 'long') {
|
|
280
|
+
if (!/^[+-]?\d+$/.test(trimmed)) return text;
|
|
281
|
+
return type === 'long'
|
|
282
|
+
? BigInt(trimmed)
|
|
283
|
+
: Number.isSafeInteger(Number(trimmed))
|
|
284
|
+
? Number(trimmed)
|
|
285
|
+
: text;
|
|
286
|
+
}
|
|
287
|
+
if (trimmed === 'INF') return Infinity;
|
|
288
|
+
if (trimmed === '-INF') return -Infinity;
|
|
289
|
+
if (trimmed === 'NaN') return NaN;
|
|
290
|
+
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(trimmed)) return text;
|
|
291
|
+
const number = Number(trimmed);
|
|
292
|
+
const represented = type === 'float' ? Math.fround(number) : number;
|
|
293
|
+
const nonzero = /[1-9]/.test(trimmed.split(/[eE]/)[0]);
|
|
294
|
+
return Number.isFinite(represented) && (represented !== 0 || !nonzero) ? number : text;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Reads bracketed and legacy lists, preserving quoted delimiters and string whitespace. */
|
|
298
|
+
function parseListValues(text: string): string[] {
|
|
299
|
+
const trimmed = text.trim();
|
|
300
|
+
const bracketed = trimmed.startsWith('[');
|
|
301
|
+
if (bracketed !== trimmed.endsWith(']')) throw new Error('Invalid GEXF list brackets.');
|
|
302
|
+
const contents = bracketed ? trimmed.slice(1, -1) : trimmed;
|
|
303
|
+
if (!contents.trim()) return [];
|
|
304
|
+
const values: string[] = [];
|
|
305
|
+
let value = '';
|
|
306
|
+
let quote = '';
|
|
307
|
+
let quoted = false;
|
|
308
|
+
let closed = false;
|
|
309
|
+
for (let index = 0; index < contents.length; index++) {
|
|
310
|
+
const character = contents[index];
|
|
311
|
+
if (quote) {
|
|
312
|
+
if (character === '\\' && (contents[index + 1] === quote || contents[index + 1] === '\\')) {
|
|
313
|
+
value += contents[++index];
|
|
314
|
+
} else if (character === quote) {
|
|
315
|
+
quote = '';
|
|
316
|
+
closed = true;
|
|
317
|
+
} else value += character;
|
|
318
|
+
} else if (character === ',' || (!bracketed && (character === '|' || character === ';'))) {
|
|
319
|
+
values.push(quoted ? value : value.trim());
|
|
320
|
+
value = '';
|
|
321
|
+
quoted = false;
|
|
322
|
+
closed = false;
|
|
323
|
+
} else if (closed) {
|
|
324
|
+
if (character.trim()) throw new Error('Invalid GEXF list text after a quote.');
|
|
325
|
+
} else if ((character === "'" || character === '"') && !value.trim()) {
|
|
326
|
+
quote = character;
|
|
327
|
+
quoted = true;
|
|
328
|
+
value = '';
|
|
329
|
+
} else value += character;
|
|
330
|
+
}
|
|
331
|
+
if (quote) throw new Error('Unterminated GEXF list quote.');
|
|
332
|
+
values.push(quoted ? value : value.trim());
|
|
333
|
+
return values;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Converts application attributes and native GEXF properties to a collision-free record. */
|
|
337
|
+
function parseRecord(
|
|
338
|
+
element: XMLElement,
|
|
339
|
+
definitions: Map<string, AttributeDefinition>,
|
|
340
|
+
identifier?: string
|
|
341
|
+
): GraphNode {
|
|
342
|
+
const attributes: Record<string, unknown> = Object.create(null);
|
|
343
|
+
for (const definition of definitions.values()) {
|
|
344
|
+
if (definition.defaultValue !== undefined)
|
|
345
|
+
attributes[definition.name] = Array.isArray(definition.defaultValue)
|
|
346
|
+
? [...definition.defaultValue]
|
|
347
|
+
: definition.defaultValue;
|
|
348
|
+
}
|
|
349
|
+
for (const attvalue of getElements(getContainer(element.attvalues).attvalue)) {
|
|
350
|
+
const key = requireAttribute(attvalue, 'for');
|
|
351
|
+
const definition = definitions.get(key);
|
|
352
|
+
if (!definition) throw new Error(`Undeclared GEXF attribute: ${key}`);
|
|
353
|
+
attributes[definition.name] = castValue(requireValue(attvalue), definition.type);
|
|
354
|
+
}
|
|
355
|
+
const native: Record<string, unknown> = Object.create(null);
|
|
356
|
+
if (element['@_weight'] !== undefined)
|
|
357
|
+
native.weight = castValue(String(element['@_weight']), 'double');
|
|
358
|
+
if (element['@_kind'] !== undefined) native.kind = element['@_kind'];
|
|
359
|
+
const visualization: Record<string, unknown> = Object.create(null);
|
|
360
|
+
for (const name of ['color', 'position', 'size', 'shape', 'thickness']) {
|
|
361
|
+
const value = element[`viz:${name}`];
|
|
362
|
+
if (value === undefined) continue;
|
|
363
|
+
const properties: Record<string, unknown> = Object.create(null);
|
|
364
|
+
for (const [key, property] of Object.entries(getElement(value, `viz:${name}`))) {
|
|
365
|
+
if (key.startsWith('@_') && !key.startsWith('@_xmlns')) {
|
|
366
|
+
const propertyName = key.slice(2);
|
|
367
|
+
properties[propertyName] =
|
|
368
|
+
['r', 'g', 'b', 'a', 'x', 'y', 'z'].includes(propertyName) ||
|
|
369
|
+
(propertyName === 'value' && (name === 'size' || name === 'thickness'))
|
|
370
|
+
? castValue(String(property), 'double')
|
|
371
|
+
: property;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
visualization[name] = properties;
|
|
375
|
+
}
|
|
376
|
+
if (Object.keys(visualization).length) native.viz = visualization;
|
|
377
|
+
if (Object.keys(native).length) attributes.gexf = native;
|
|
378
|
+
return {
|
|
379
|
+
id: identifier ?? requireAttribute(element, 'id'),
|
|
380
|
+
label: element['@_label'] as string | undefined,
|
|
381
|
+
attributes
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Reads an attribute value, allowing the empty string. */
|
|
386
|
+
function requireValue(element: XMLElement): string {
|
|
387
|
+
const value = element['@_value'];
|
|
388
|
+
if (typeof value !== 'string') throw new Error('GEXF attvalue requires a value attribute.');
|
|
389
|
+
return value;
|
|
390
|
+
}
|
package/src/lib/parse-graphml.ts
CHANGED
|
@@ -2,9 +2,18 @@
|
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
// Copyright (c) vis.gl contributors
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
import type {
|
|
5
|
+
import {parseXMLSync} from '@loaders.gl/documents/xml-parser';
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
GraphData,
|
|
9
|
+
GraphEdge,
|
|
10
|
+
GraphNode,
|
|
11
|
+
GraphOutput,
|
|
12
|
+
GraphShape,
|
|
13
|
+
GraphAttributeSchemas,
|
|
14
|
+
GraphAttributeType
|
|
15
|
+
} from '../graph-types';
|
|
16
|
+
import {buildGraphTables} from './build-graph-tables';
|
|
8
17
|
|
|
9
18
|
const XML_ATTRIBUTE_PREFIX = '@_';
|
|
10
19
|
const XML_TEXT_KEY = '#text';
|
|
@@ -12,9 +21,6 @@ const XML_TEXT_KEY = '#text';
|
|
|
12
21
|
/** Supported key scopes. */
|
|
13
22
|
type GraphMLDomain = 'all' | 'node' | 'edge' | 'graph';
|
|
14
23
|
|
|
15
|
-
/** Supported scalar attribute types. */
|
|
16
|
-
type GraphMLAttributeType = 'boolean' | 'int' | 'long' | 'float' | 'double' | 'string';
|
|
17
|
-
|
|
18
24
|
/** A declared GraphML key and its default. */
|
|
19
25
|
type GraphMLKeyDefinition = {
|
|
20
26
|
/** Key identifier. */
|
|
@@ -24,34 +30,41 @@ type GraphMLKeyDefinition = {
|
|
|
24
30
|
/** Elements to which defaults apply. */
|
|
25
31
|
domain: GraphMLDomain;
|
|
26
32
|
/** Declared scalar type. */
|
|
27
|
-
type:
|
|
33
|
+
type: GraphAttributeType;
|
|
28
34
|
/** Typed default value. */
|
|
29
35
|
defaultValue?: unknown;
|
|
36
|
+
/** Whether long values retain exact integer precision. */
|
|
37
|
+
lossless: boolean;
|
|
30
38
|
};
|
|
31
39
|
|
|
32
|
-
/** XML element representation emitted by
|
|
40
|
+
/** XML element representation emitted by the documents XML parser. */
|
|
33
41
|
type GraphMLObject = Record<string, unknown> & {
|
|
34
42
|
/** Element text content. */
|
|
35
43
|
[XML_TEXT_KEY]?: string;
|
|
36
44
|
};
|
|
37
45
|
|
|
38
|
-
const
|
|
46
|
+
const GRAPHML_XML_OPTIONS = {
|
|
39
47
|
ignoreAttributes: false,
|
|
40
48
|
attributeNamePrefix: XML_ATTRIBUTE_PREFIX,
|
|
41
49
|
textNodeName: XML_TEXT_KEY,
|
|
42
|
-
trimValues:
|
|
50
|
+
trimValues: false,
|
|
43
51
|
parseAttributeValue: false,
|
|
44
52
|
parseTagValue: false,
|
|
45
53
|
removeNSPrefix: true
|
|
46
|
-
}
|
|
54
|
+
};
|
|
47
55
|
|
|
48
56
|
/** Accepted GraphML document inputs. */
|
|
49
57
|
export type GraphMLInput = string | ArrayBuffer | Uint8Array;
|
|
50
58
|
|
|
51
59
|
/** Parses the first GraphML graph into framework-independent node and edge records. */
|
|
52
|
-
export function parseGraphML(graphml: GraphMLInput): GraphData
|
|
60
|
+
export function parseGraphML(graphml: GraphMLInput): GraphData;
|
|
61
|
+
export function parseGraphML(graphml: GraphMLInput, shape: GraphShape): GraphOutput;
|
|
62
|
+
export function parseGraphML(
|
|
63
|
+
graphml: GraphMLInput,
|
|
64
|
+
shape: GraphShape = 'plain-graph-data'
|
|
65
|
+
): GraphOutput {
|
|
53
66
|
const xmlText = decodeGraphML(graphml);
|
|
54
|
-
const document =
|
|
67
|
+
const document = parseXMLSync(xmlText, GRAPHML_XML_OPTIONS) as GraphMLObject;
|
|
55
68
|
const graphmlRoot = getGraphMLRoot(document);
|
|
56
69
|
if (!graphmlRoot) {
|
|
57
70
|
throw new Error('GraphML document does not contain a <graphml> element.');
|
|
@@ -62,22 +75,50 @@ export function parseGraphML(graphml: GraphMLInput): GraphData {
|
|
|
62
75
|
throw new Error('GraphML document does not contain a <graph> element.');
|
|
63
76
|
}
|
|
64
77
|
|
|
65
|
-
const keyDefinitions = collectKeyDefinitions(
|
|
78
|
+
const keyDefinitions = collectKeyDefinitions(
|
|
79
|
+
graphmlRoot,
|
|
80
|
+
graphElement,
|
|
81
|
+
shape !== 'plain-graph-data'
|
|
82
|
+
);
|
|
66
83
|
const defaultDirected = parseEdgeDefault(graphElement[`${XML_ATTRIBUTE_PREFIX}edgedefault`]);
|
|
67
84
|
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
|
|
85
|
+
const declarations: GraphAttributeSchemas = {nodes: new Map(), edges: new Map()};
|
|
86
|
+
for (const definition of keyDefinitions.values()) {
|
|
87
|
+
if (definition.domain === 'all' || definition.domain === 'node')
|
|
88
|
+
declarations.nodes.set(definition.name, definition.type);
|
|
89
|
+
if (definition.domain === 'all' || definition.domain === 'edge')
|
|
90
|
+
declarations.edges.set(definition.name, definition.type);
|
|
91
|
+
}
|
|
92
|
+
return buildGraphTables(
|
|
93
|
+
iterateNodes(graphElement.node, keyDefinitions),
|
|
94
|
+
iterateEdges(graphElement.edge, keyDefinitions, defaultDirected),
|
|
95
|
+
shape,
|
|
96
|
+
declarations
|
|
71
97
|
);
|
|
98
|
+
}
|
|
72
99
|
|
|
73
|
-
|
|
74
|
-
|
|
100
|
+
/** Emits valid nodes directly to the selected output builder. */
|
|
101
|
+
function* iterateNodes(
|
|
102
|
+
nodes: unknown,
|
|
103
|
+
definitions: Map<string, GraphMLKeyDefinition>
|
|
104
|
+
): Iterable<GraphNode> {
|
|
105
|
+
for (const node of normalizeArray(nodes)) {
|
|
106
|
+
const record = parseNode(node, definitions);
|
|
107
|
+
if (record) yield record;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
75
110
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
111
|
+
/** Emits valid edges while preserving generated IDs and document order. */
|
|
112
|
+
function* iterateEdges(
|
|
113
|
+
edges: unknown,
|
|
114
|
+
definitions: Map<string, GraphMLKeyDefinition>,
|
|
115
|
+
directed: boolean
|
|
116
|
+
): Iterable<GraphEdge> {
|
|
117
|
+
let index = 0;
|
|
118
|
+
for (const edge of normalizeArray(edges)) {
|
|
119
|
+
const record = parseEdge(edge, index++, definitions, directed);
|
|
120
|
+
if (record) yield record;
|
|
121
|
+
}
|
|
81
122
|
}
|
|
82
123
|
|
|
83
124
|
/** Decodes text or UTF-8 binary input. */
|
|
@@ -110,13 +151,18 @@ function getGraphMLRoot(document: GraphMLObject): GraphMLObject | null {
|
|
|
110
151
|
/** Selects the first graph element. */
|
|
111
152
|
function getGraphElement(graphmlRoot: GraphMLObject): GraphMLObject | null {
|
|
112
153
|
const graph = graphmlRoot.graph;
|
|
154
|
+
if (typeof graph === 'string' && !graph.trim()) {
|
|
155
|
+
return {};
|
|
156
|
+
}
|
|
113
157
|
if (!graph) {
|
|
114
158
|
return null;
|
|
115
159
|
}
|
|
116
160
|
|
|
117
161
|
if (Array.isArray(graph)) {
|
|
118
|
-
const firstGraph = graph.find(
|
|
119
|
-
|
|
162
|
+
const firstGraph = graph.find(
|
|
163
|
+
entry => isObject(entry) || (typeof entry === 'string' && !entry.trim())
|
|
164
|
+
);
|
|
165
|
+
return isObject(firstGraph) ? firstGraph : typeof firstGraph === 'string' ? {} : null;
|
|
120
166
|
}
|
|
121
167
|
|
|
122
168
|
return isObject(graph) ? graph : null;
|
|
@@ -125,7 +171,8 @@ function getGraphElement(graphmlRoot: GraphMLObject): GraphMLObject | null {
|
|
|
125
171
|
/** Collects scoped keys and typed defaults. */
|
|
126
172
|
function collectKeyDefinitions(
|
|
127
173
|
graphmlRoot: GraphMLObject,
|
|
128
|
-
graphElement: GraphMLObject
|
|
174
|
+
graphElement: GraphMLObject,
|
|
175
|
+
lossless: boolean
|
|
129
176
|
): Map<string, GraphMLKeyDefinition> {
|
|
130
177
|
const keys = new Map<string, GraphMLKeyDefinition>();
|
|
131
178
|
|
|
@@ -140,9 +187,10 @@ function collectKeyDefinitions(
|
|
|
140
187
|
const name = String(candidate[`${XML_ATTRIBUTE_PREFIX}attr.name`] ?? id).trim();
|
|
141
188
|
const type = normalizeType(candidate[`${XML_ATTRIBUTE_PREFIX}attr.type`]);
|
|
142
189
|
const defaultNode = candidate.default ?? null;
|
|
143
|
-
const defaultValue =
|
|
190
|
+
const defaultValue =
|
|
191
|
+
defaultNode !== null ? castDataValue(defaultNode, type, lossless) : undefined;
|
|
144
192
|
|
|
145
|
-
keys.set(id, {id, domain, name, type, defaultValue});
|
|
193
|
+
keys.set(id, {id, domain, name, type, defaultValue, lossless});
|
|
146
194
|
}
|
|
147
195
|
}
|
|
148
196
|
}
|
|
@@ -260,14 +308,17 @@ function assignAttributeFromDataEntry(
|
|
|
260
308
|
|
|
261
309
|
const definition = keyDefinitions.get(keyId);
|
|
262
310
|
const attributeName = definition?.name ?? keyId;
|
|
263
|
-
const value =
|
|
311
|
+
const value =
|
|
312
|
+
definition?.type === 'boolean' && extractTextContent(entry) === undefined
|
|
313
|
+
? false
|
|
314
|
+
: castDataValue(entry, definition?.type ?? 'string', definition?.lossless);
|
|
264
315
|
if (value !== undefined) {
|
|
265
316
|
attributes[attributeName] = value;
|
|
266
317
|
}
|
|
267
318
|
}
|
|
268
319
|
|
|
269
320
|
/** Converts GraphML scalar types while preserving unparseable numeric text. */
|
|
270
|
-
function castDataValue(value: unknown, type:
|
|
321
|
+
function castDataValue(value: unknown, type: GraphAttributeType, lossless = false): unknown {
|
|
271
322
|
if (value === null || typeof value === 'undefined') {
|
|
272
323
|
return undefined;
|
|
273
324
|
}
|
|
@@ -280,6 +331,9 @@ function castDataValue(value: unknown, type: GraphMLAttributeType): unknown {
|
|
|
280
331
|
if (type === 'boolean') {
|
|
281
332
|
return parseBoolean(text);
|
|
282
333
|
}
|
|
334
|
+
if (type === 'long' && lossless) {
|
|
335
|
+
return /^[+-]?\d+$/.test(text.trim()) ? BigInt(text.trim()) : text;
|
|
336
|
+
}
|
|
283
337
|
if (type === 'int' || type === 'long') {
|
|
284
338
|
const parsed = Number.parseInt(text, 10);
|
|
285
339
|
return Number.isNaN(parsed) ? text : parsed;
|
|
@@ -307,15 +361,14 @@ function extractTextContent(value: unknown): string | undefined {
|
|
|
307
361
|
|
|
308
362
|
if (isObject(value)) {
|
|
309
363
|
const text = value[XML_TEXT_KEY];
|
|
310
|
-
if (typeof text === 'string') {
|
|
311
|
-
return text;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
364
|
const nonAttributeEntries = Object.entries(value).filter(
|
|
315
|
-
([key]) => !key.startsWith(XML_ATTRIBUTE_PREFIX)
|
|
365
|
+
([key]) => !key.startsWith(XML_ATTRIBUTE_PREFIX) && key !== XML_TEXT_KEY
|
|
316
366
|
);
|
|
317
367
|
if (nonAttributeEntries.length === 0) {
|
|
318
|
-
return undefined;
|
|
368
|
+
return typeof text === 'string' ? text : undefined;
|
|
369
|
+
}
|
|
370
|
+
if (typeof text === 'string' && text.trim()) {
|
|
371
|
+
nonAttributeEntries.push([XML_TEXT_KEY, text]);
|
|
319
372
|
}
|
|
320
373
|
return JSON.stringify(Object.fromEntries(nonAttributeEntries));
|
|
321
374
|
}
|
|
@@ -350,7 +403,7 @@ function normalizeDomain(value: unknown): GraphMLDomain {
|
|
|
350
403
|
}
|
|
351
404
|
|
|
352
405
|
/** Normalizes supported GraphML scalar types. */
|
|
353
|
-
function normalizeType(value: unknown):
|
|
406
|
+
function normalizeType(value: unknown): GraphAttributeType {
|
|
354
407
|
if (typeof value !== 'string') {
|
|
355
408
|
return 'string';
|
|
356
409
|
}
|
|
@@ -368,7 +421,6 @@ function normalizeType(value: unknown): GraphMLAttributeType {
|
|
|
368
421
|
return 'string';
|
|
369
422
|
}
|
|
370
423
|
|
|
371
|
-
/** Converts an edge element with its effective direction. */
|
|
372
424
|
/** Reads the graph-wide default edge direction. */
|
|
373
425
|
function parseEdgeDefault(value: unknown): boolean {
|
|
374
426
|
if (typeof value !== 'string') {
|
|
@@ -410,5 +462,5 @@ function parseBoolean(value: string): boolean {
|
|
|
410
462
|
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'n') {
|
|
411
463
|
return false;
|
|
412
464
|
}
|
|
413
|
-
return Boolean(
|
|
465
|
+
return Boolean(normalized);
|
|
414
466
|
}
|