@graphty/graph-io 0.3.8 → 0.3.10

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.
Files changed (125) hide show
  1. package/README.md +29 -5
  2. package/dist/chunks/{escape-BjmIaFFo.js → escape-D1f9-cwf.js} +4 -4
  3. package/dist/chunks/{escape-BjmIaFFo.js.map → escape-D1f9-cwf.js.map} +1 -1
  4. package/dist/chunks/{importer-BddBW3sf.js → importer-9PxhqH4v.js} +336 -92
  5. package/dist/chunks/importer-9PxhqH4v.js.map +1 -0
  6. package/dist/chunks/{importer-DfI8POXb.js → importer-B8lsjFWx.js} +6 -5
  7. package/dist/chunks/importer-B8lsjFWx.js.map +1 -0
  8. package/dist/chunks/{importer-BtHFICnf.js → importer-C3HcYWAn.js} +5 -3
  9. package/dist/chunks/importer-C3HcYWAn.js.map +1 -0
  10. package/dist/chunks/{importer-BOxwmef_.js → importer-C7mnGdr_.js} +125 -8
  11. package/dist/chunks/importer-C7mnGdr_.js.map +1 -0
  12. package/dist/chunks/{records-BKSMowhR.js → records-IHsCfv7s.js} +20 -7
  13. package/dist/chunks/records-IHsCfv7s.js.map +1 -0
  14. package/dist/chunks/{writer-BdMak4_J.js → writer-BtWpUaiH.js} +4 -4
  15. package/dist/chunks/{writer-BdMak4_J.js.map → writer-BtWpUaiH.js.map} +1 -1
  16. package/dist/csv.js +239 -18
  17. package/dist/csv.js.map +1 -1
  18. package/dist/dot.js +1 -1
  19. package/dist/gexf.js +72 -33
  20. package/dist/gexf.js.map +1 -1
  21. package/dist/gml.js +44 -23
  22. package/dist/gml.js.map +1 -1
  23. package/dist/graph-io.js +11 -11
  24. package/dist/graphml.js +1 -1
  25. package/dist/json.js +1 -1
  26. package/dist/neo4j.js +5 -4
  27. package/dist/neo4j.js.map +1 -1
  28. package/dist/pajek.js +1 -1
  29. package/dist/src/common/export.js +1 -1
  30. package/dist/src/common/export.js.map +1 -1
  31. package/dist/src/formats/csv/exporter.d.ts +15 -4
  32. package/dist/src/formats/csv/exporter.d.ts.map +1 -1
  33. package/dist/src/formats/csv/exporter.js +125 -6
  34. package/dist/src/formats/csv/exporter.js.map +1 -1
  35. package/dist/src/formats/csv/importer.d.ts +36 -4
  36. package/dist/src/formats/csv/importer.d.ts.map +1 -1
  37. package/dist/src/formats/csv/importer.js +169 -8
  38. package/dist/src/formats/csv/importer.js.map +1 -1
  39. package/dist/src/formats/csv/records.d.ts +14 -1
  40. package/dist/src/formats/csv/records.d.ts.map +1 -1
  41. package/dist/src/formats/csv/records.js +28 -7
  42. package/dist/src/formats/csv/records.js.map +1 -1
  43. package/dist/src/formats/dot/exporter.d.ts.map +1 -1
  44. package/dist/src/formats/dot/exporter.js +2 -0
  45. package/dist/src/formats/dot/exporter.js.map +1 -1
  46. package/dist/src/formats/gexf/exporter.d.ts.map +1 -1
  47. package/dist/src/formats/gexf/exporter.js +36 -10
  48. package/dist/src/formats/gexf/exporter.js.map +1 -1
  49. package/dist/src/formats/gexf/importer.d.ts +9 -4
  50. package/dist/src/formats/gexf/importer.d.ts.map +1 -1
  51. package/dist/src/formats/gexf/importer.js +44 -21
  52. package/dist/src/formats/gexf/importer.js.map +1 -1
  53. package/dist/src/formats/gexf/index.d.ts +1 -1
  54. package/dist/src/formats/gexf/index.d.ts.map +1 -1
  55. package/dist/src/formats/gexf/index.js +1 -1
  56. package/dist/src/formats/gexf/index.js.map +1 -1
  57. package/dist/src/formats/gexf/schema.d.ts +2 -0
  58. package/dist/src/formats/gexf/schema.d.ts.map +1 -1
  59. package/dist/src/formats/gexf/schema.js +18 -0
  60. package/dist/src/formats/gexf/schema.js.map +1 -1
  61. package/dist/src/formats/gml/exporter.d.ts.map +1 -1
  62. package/dist/src/formats/gml/exporter.js +1 -0
  63. package/dist/src/formats/gml/exporter.js.map +1 -1
  64. package/dist/src/formats/gml/importer.d.ts +7 -4
  65. package/dist/src/formats/gml/importer.d.ts.map +1 -1
  66. package/dist/src/formats/gml/importer.js +32 -19
  67. package/dist/src/formats/gml/importer.js.map +1 -1
  68. package/dist/src/formats/gml/index.d.ts +3 -1
  69. package/dist/src/formats/gml/index.d.ts.map +1 -1
  70. package/dist/src/formats/gml/index.js +4 -2
  71. package/dist/src/formats/gml/index.js.map +1 -1
  72. package/dist/src/formats/graphml/constants.d.ts +2 -0
  73. package/dist/src/formats/graphml/constants.d.ts.map +1 -1
  74. package/dist/src/formats/graphml/constants.js +2 -0
  75. package/dist/src/formats/graphml/constants.js.map +1 -1
  76. package/dist/src/formats/graphml/exporter.d.ts.map +1 -1
  77. package/dist/src/formats/graphml/exporter.js +14 -2
  78. package/dist/src/formats/graphml/exporter.js.map +1 -1
  79. package/dist/src/formats/graphml/importer.d.ts +2 -1
  80. package/dist/src/formats/graphml/importer.d.ts.map +1 -1
  81. package/dist/src/formats/graphml/importer.js +91 -27
  82. package/dist/src/formats/graphml/importer.js.map +1 -1
  83. package/dist/src/formats/graphml/yfiles.d.ts +62 -0
  84. package/dist/src/formats/graphml/yfiles.d.ts.map +1 -0
  85. package/dist/src/formats/graphml/yfiles.js +269 -0
  86. package/dist/src/formats/graphml/yfiles.js.map +1 -0
  87. package/dist/src/formats/json/exporter.d.ts.map +1 -1
  88. package/dist/src/formats/json/exporter.js +9 -1
  89. package/dist/src/formats/json/exporter.js.map +1 -1
  90. package/dist/src/formats/json/importer.d.ts +14 -0
  91. package/dist/src/formats/json/importer.d.ts.map +1 -1
  92. package/dist/src/formats/json/importer.js +165 -4
  93. package/dist/src/formats/json/importer.js.map +1 -1
  94. package/dist/src/formats/neo4j/exporter.d.ts.map +1 -1
  95. package/dist/src/formats/neo4j/exporter.js +1 -0
  96. package/dist/src/formats/neo4j/exporter.js.map +1 -1
  97. package/dist/src/formats/pajek/exporter.d.ts.map +1 -1
  98. package/dist/src/formats/pajek/exporter.js +3 -2
  99. package/dist/src/formats/pajek/exporter.js.map +1 -1
  100. package/package.json +2 -2
  101. package/src/common/export.ts +1 -1
  102. package/src/formats/csv/exporter.ts +140 -10
  103. package/src/formats/csv/importer.ts +207 -18
  104. package/src/formats/csv/records.ts +41 -6
  105. package/src/formats/dot/exporter.ts +2 -0
  106. package/src/formats/gexf/exporter.ts +40 -11
  107. package/src/formats/gexf/importer.ts +47 -25
  108. package/src/formats/gexf/index.ts +1 -1
  109. package/src/formats/gexf/schema.ts +18 -0
  110. package/src/formats/gml/exporter.ts +1 -0
  111. package/src/formats/gml/importer.ts +47 -24
  112. package/src/formats/gml/index.ts +4 -1
  113. package/src/formats/graphml/constants.ts +2 -0
  114. package/src/formats/graphml/exporter.ts +21 -2
  115. package/src/formats/graphml/importer.ts +99 -28
  116. package/src/formats/graphml/yfiles.ts +292 -0
  117. package/src/formats/json/exporter.ts +9 -1
  118. package/src/formats/json/importer.ts +202 -4
  119. package/src/formats/neo4j/exporter.ts +1 -0
  120. package/src/formats/pajek/exporter.ts +3 -1
  121. package/dist/chunks/importer-BOxwmef_.js.map +0 -1
  122. package/dist/chunks/importer-BddBW3sf.js.map +0 -1
  123. package/dist/chunks/importer-BtHFICnf.js.map +0 -1
  124. package/dist/chunks/importer-DfI8POXb.js.map +0 -1
  125. package/dist/chunks/records-BKSMowhR.js.map +0 -1
@@ -9,7 +9,8 @@
9
9
  * `sourceport` / `targetport` edge attributes are kept as role columns; hyperedges follow the
10
10
  * `hyperedges` option (refuse, skip with a report entry, star, clique); yFiles `yfiles.type` keys
11
11
  * become `json` columns holding the nested XML as a tree (origin.namespace "yfiles"), reported as
12
- * a loss note because the structure, not the bytes, is preserved.
12
+ * a loss note because the structure, not the bytes, is preserved; a `y:ShapeNode` or
13
+ * `y:PolyLineEdge` in such a tree is also read into typed `yfiles.*` columns (yfiles.ts).
13
14
  *
14
15
  * Ids are coerced with the common rule (`ids: "canonical"` by default, design section 4.1); the
15
16
  * edge attribute whose `attr.name` is `weightFrom` ("weight" by default) is THE weight and is
@@ -82,12 +83,19 @@ import {
82
83
  XSI_NAMESPACE,
83
84
  } from "./constants.js";
84
85
  import { XmlTreeBuilder } from "./tree.js";
85
-
86
+ import { graphicsDecl, graphicsValues } from "./yfiles.js";
86
87
 
87
88
  /** The XML attributes the importer reads on `<graph>`, `<node>` and `<edge>`; any other is reported. */
88
89
  const GRAPH_ATTRIBUTES: ReadonlySet<string> = new Set(["id", "edgedefault", "parse.nodes", "parse.edges"]);
89
90
  const NODE_ATTRIBUTES: ReadonlySet<string> = new Set(["id"]);
90
- const EDGE_ATTRIBUTES: ReadonlySet<string> = new Set(["id", "source", "target", "directed", "sourceport", "targetport"]);
91
+ const EDGE_ATTRIBUTES: ReadonlySet<string> = new Set([
92
+ "id",
93
+ "source",
94
+ "target",
95
+ "directed",
96
+ "sourceport",
97
+ "targetport",
98
+ ]);
91
99
 
92
100
  /** The format-specific options of the GraphML importer. */
93
101
  export interface GraphmlImportOptions {
@@ -316,7 +324,8 @@ class GraphmlReader implements XmlHandler {
316
324
 
317
325
  private readonly ctx: Ctx[] = [];
318
326
 
319
- private readonly keys = new Map<string, KeyEntry>();
327
+ /** Keys by id; one id may be declared once per domain (igraph writes `name` for graph and node). */
328
+ private readonly keys = new Map<string, KeyEntry[]>();
320
329
 
321
330
  private readonly graphs: GraphState[] = [];
322
331
 
@@ -366,6 +375,9 @@ class GraphmlReader implements XmlHandler {
366
375
 
367
376
  private yfilesLossNoted = false;
368
377
 
378
+ /** The mapped yFiles graphics columns declared so far, by domain and field. */
379
+ private readonly graphicsColumns = new Map<string, ColumnHandle>();
380
+
369
381
  private hyperedgeCount = 0;
370
382
 
371
383
  private edgeIdHandle: ColumnHandle = INVALID_INDEX as ColumnHandle;
@@ -999,8 +1011,9 @@ class GraphmlReader implements XmlHandler {
999
1011
  }
1000
1012
  this.pendingKey = {
1001
1013
  id,
1002
- attrName: attrs.get("attr.name") ?? null,
1003
- attrType: attrs.get("attr.type") ?? null,
1014
+ // `name` / `type` without the `attr.` prefix are written by some tools; the spec's spelling wins
1015
+ attrName: attrs.get("attr.name") ?? attrs.get("name") ?? null,
1016
+ attrType: attrs.get("attr.type") ?? attrs.get("type") ?? null,
1004
1017
  yfilesType: attrs.get("yfiles.type") ?? null,
1005
1018
  domains,
1006
1019
  line,
@@ -1023,11 +1036,12 @@ class GraphmlReader implements XmlHandler {
1023
1036
  this.report.error("validation-error", GRAPHML_ISSUE.KEY_MISSING_ID, "<key> without an id", where);
1024
1037
  return;
1025
1038
  }
1026
- if (this.keys.has(key.id)) {
1039
+ const declared = this.keys.get(key.id) ?? [];
1040
+ if (declared.some((entry) => entry.domains.some((domain) => key.domains.includes(domain)))) {
1027
1041
  this.report.error(
1028
1042
  "validation-error",
1029
1043
  GRAPHML_ISSUE.DUPLICATE_KEY,
1030
- `key "${key.id}" is declared twice`,
1044
+ `key "${key.id}" is declared twice for the same kind of element`,
1031
1045
  where,
1032
1046
  );
1033
1047
  return;
@@ -1078,18 +1092,21 @@ class GraphmlReader implements XmlHandler {
1078
1092
  this.report.recordError(err, where);
1079
1093
  }
1080
1094
  }
1081
- this.keys.set(key.id, {
1082
- id: key.id,
1083
- name,
1084
- node,
1085
- edge,
1086
- graph,
1087
- weight,
1088
- originalId,
1089
- yfiles,
1090
- skipped,
1091
- domains: key.domains,
1092
- });
1095
+ this.keys.set(key.id, [
1096
+ ...declared,
1097
+ {
1098
+ id: key.id,
1099
+ name,
1100
+ node,
1101
+ edge,
1102
+ graph,
1103
+ weight,
1104
+ originalId,
1105
+ yfiles,
1106
+ skipped,
1107
+ domains: key.domains,
1108
+ },
1109
+ ]);
1093
1110
  }
1094
1111
 
1095
1112
  /**
@@ -1241,7 +1258,8 @@ class GraphmlReader implements XmlHandler {
1241
1258
  line,
1242
1259
  });
1243
1260
  } else {
1244
- const key = this.keys.get(keyId);
1261
+ const entries = this.keys.get(keyId);
1262
+ const key = entries?.find((entry) => entry.domains.includes(domain)) ?? entries?.[0];
1245
1263
  if (key === undefined) {
1246
1264
  this.report.error(
1247
1265
  "validation-error",
@@ -1324,17 +1342,17 @@ class GraphmlReader implements XmlHandler {
1324
1342
  }
1325
1343
  return;
1326
1344
  }
1345
+ if (key.yfiles) {
1346
+ for (const [handle, mapped] of this.graphics("node", key, value, where)) {
1347
+ this.setNodeData(node, handle, mapped);
1348
+ }
1349
+ }
1327
1350
  if (key.node === null) {
1328
1351
  return;
1329
1352
  }
1330
1353
  const parsed = this.parseValue(key.node, value, where);
1331
- if (parsed === undefined) {
1332
- return;
1333
- }
1334
- if (node.index === INVALID_INDEX) {
1335
- node.pending.push(key.node.handle, parsed);
1336
- } else if (!node.failed) {
1337
- this.sink.setNodeValue(key.node.handle, node.index, parsed);
1354
+ if (parsed !== undefined) {
1355
+ this.setNodeData(node, key.node.handle, parsed);
1338
1356
  }
1339
1357
  return;
1340
1358
  }
@@ -1361,6 +1379,11 @@ class GraphmlReader implements XmlHandler {
1361
1379
  }
1362
1380
  return;
1363
1381
  }
1382
+ if (key.yfiles) {
1383
+ for (const [handle, mapped] of this.graphics("edge", key, value, where)) {
1384
+ edge.pending.push(handle, mapped);
1385
+ }
1386
+ }
1364
1387
  if (key.edge === null) {
1365
1388
  return;
1366
1389
  }
@@ -1391,6 +1414,54 @@ class GraphmlReader implements XmlHandler {
1391
1414
  }
1392
1415
  }
1393
1416
 
1417
+ /**
1418
+ * Write a node value now, or keep it until the node is added.
1419
+ * @param node - the node state
1420
+ * @param handle - the column
1421
+ * @param value - the value
1422
+ */
1423
+ private setNodeData(node: NodeState, handle: ColumnHandle, value: unknown): void {
1424
+ if (node.index === INVALID_INDEX) {
1425
+ node.pending.push(handle, value);
1426
+ } else if (!node.failed) {
1427
+ this.sink.setNodeValue(handle, node.index, value);
1428
+ }
1429
+ }
1430
+
1431
+ /**
1432
+ * The typed values mapped from a yFiles graphics tree (yfiles.ts), each with its column,
1433
+ * declared on first use.
1434
+ * @param domain - node or edge
1435
+ * @param key - the yFiles key
1436
+ * @param tree - the `<data>` content
1437
+ * @param where - the location for issues
1438
+ * @returns column handle and value pairs
1439
+ */
1440
+ private graphics(domain: "node" | "edge", key: KeyEntry, tree: unknown, where: Where): [ColumnHandle, unknown][] {
1441
+ const out: [ColumnHandle, unknown][] = [];
1442
+ for (const [field, value] of graphicsValues(domain, tree)) {
1443
+ const id = `${domain}:${field}`;
1444
+ let handle = this.graphicsColumns.get(id);
1445
+ if (handle === undefined) {
1446
+ try {
1447
+ ({ handle } = declareResolved(
1448
+ this.sink,
1449
+ domain,
1450
+ graphicsDecl(domain, field, key.id),
1451
+ this.report,
1452
+ where,
1453
+ ));
1454
+ } catch (err) {
1455
+ this.report.recordError(err, where);
1456
+ continue;
1457
+ }
1458
+ this.graphicsColumns.set(id, handle);
1459
+ }
1460
+ out.push([handle, value]);
1461
+ }
1462
+ return out;
1463
+ }
1464
+
1394
1465
  /**
1395
1466
  * Parse the content of a `<data>` by its column's declaration.
1396
1467
  * @param target - the column
@@ -0,0 +1,292 @@
1
+ /**
2
+ * yFiles / yEd graphics read out of the JSON tree of a `yfiles.type` key (tree.ts) into typed
3
+ * columns, beside the tree itself: a `y:ShapeNode` gives the node's position, size, fill and
4
+ * border colours, label and shape, and a `y:PolyLineEdge` gives the edge's line colour and width,
5
+ * its arrows and the direction they draw. The values are the ones graphty-element's own GraphML
6
+ * parser produced, so a consumer rebuilds its records by dropping the `yfiles.` prefix: colours
7
+ * are `#RRGGBB` in upper case (a `#RGB` is expanded, any other text is kept as written), numbers
8
+ * are parsed with parseFloat, the position is `[x, y, 0]` and the shape is the yFiles shape type
9
+ * as written. One difference: a label whose text is a number (`<y:NodeLabel>0</y:NodeLabel>`) is
10
+ * kept as that text, where the element's parser turned it into a number and stored `""`, nothing
11
+ * or the number.
12
+ *
13
+ * The columns are views of the tree: the exporter writes the tree and never these columns, and
14
+ * re-importing the tree gives them back. A value edited after import (a layout writing the
15
+ * position) or a tree column removed is therefore not written, and the exporter reports it as a
16
+ * loss (staleGraphicsRows()).
17
+ */
18
+
19
+ import { type Column, type ColumnDecl, type JsonColumn } from "@graphty/graph-format";
20
+
21
+ import { localName } from "../../common/xml.js";
22
+
23
+ /** The name prefix of every column mapped from yFiles graphics. */
24
+ const YFILES_COLUMN_PREFIX = "yfiles.";
25
+
26
+ type Tree = Record<string, unknown>;
27
+
28
+ /** The mapped node columns, by the field name graphty-element's records use. */
29
+ const NODE_FIELDS: Readonly<Record<string, Omit<ColumnDecl, "name">>> = {
30
+ position: { dtype: "f64", components: 3, role: "position", mutable: true },
31
+ width: { dtype: "f64" },
32
+ height: { dtype: "f64" },
33
+ color: { dtype: "string" },
34
+ borderColor: { dtype: "string" },
35
+ borderWidth: { dtype: "f64" },
36
+ label: { dtype: "string", role: "label" },
37
+ shape: { dtype: "string" },
38
+ };
39
+
40
+ /** The mapped edge columns, by field name. */
41
+ const EDGE_FIELDS: Readonly<Record<string, Omit<ColumnDecl, "name">>> = {
42
+ color: { dtype: "string" },
43
+ width: { dtype: "f64" },
44
+ directed: { dtype: "bool" },
45
+ targetArrow: { dtype: "string" },
46
+ sourceArrow: { dtype: "string" },
47
+ };
48
+
49
+ /**
50
+ * The declaration of a mapped column.
51
+ * @param domain - node or edge
52
+ * @param field - the field name (a key of the values graphicsValues() returns)
53
+ * @param keyId - the id of the GraphML key whose tree it is read from
54
+ * @returns the declaration
55
+ */
56
+ export function graphicsDecl(domain: "node" | "edge", field: string, keyId: string): ColumnDecl {
57
+ const fields = domain === "node" ? NODE_FIELDS : EDGE_FIELDS;
58
+ return {
59
+ ...fields[field],
60
+ name: YFILES_COLUMN_PREFIX + field,
61
+ nullable: true,
62
+ origin: {
63
+ format: "graphml",
64
+ id: keyId,
65
+ title: null,
66
+ type: domain === "node" ? "ShapeNode" : "PolyLineEdge",
67
+ namespace: "yfiles",
68
+ },
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Whether a column is one graphicsDecl() declared (a view of a yFiles tree, never exported).
74
+ * @param meta - the column's name, dtype and origin
75
+ * @param meta.name - the column name
76
+ * @param meta.dtype - the column dtype
77
+ * @param meta.origin - the column origin
78
+ * @returns true for a mapped graphics column
79
+ */
80
+ export function isGraphicsColumn(meta: {
81
+ readonly name: string;
82
+ readonly dtype: string;
83
+ readonly origin: { readonly namespace: string | null } | null;
84
+ }): boolean {
85
+ return meta.dtype !== "json" && meta.origin?.namespace === "yfiles" && meta.name.startsWith(YFILES_COLUMN_PREFIX);
86
+ }
87
+
88
+ /**
89
+ * The rows of a mapped graphics column whose value is not one its yFiles tree columns (in the
90
+ * same table) give: every set row when no tree column gives a value for the row. A file may
91
+ * declare several yFiles keys for one domain, and the mapped column holds the values of all of
92
+ * them, so a row is current when any tree's value matches. These are the values an export loses,
93
+ * since only the trees are written.
94
+ * @param column - the mapped column (isGraphicsColumn())
95
+ * @param table - the table it is in
96
+ * @param domain - node or edge
97
+ * @returns the number of rows that differ from the trees
98
+ */
99
+ export function staleGraphicsRows(column: Column, table: Iterable<Column>, domain: "node" | "edge"): number {
100
+ const field = column.meta.name.slice(YFILES_COLUMN_PREFIX.length);
101
+ const trees: JsonColumn[] = [];
102
+ for (const c of table) {
103
+ if (c.dtype === "json" && c.meta.origin?.namespace === "yfiles") {
104
+ trees.push(c);
105
+ }
106
+ }
107
+ let stale = 0;
108
+ for (let r = 0; r < column.length; r++) {
109
+ const wants = new Set<string>();
110
+ for (const tree of trees) {
111
+ const want = graphicsValues(domain, tree.values[r]).find(([f]) => f === field);
112
+ if (want !== undefined) {
113
+ wants.add(JSON.stringify(want[1]));
114
+ }
115
+ }
116
+ if (wants.size === 0) {
117
+ wants.add(JSON.stringify(undefined));
118
+ }
119
+ const value = column.value(r);
120
+ const have = column.meta.components > 1 && value !== undefined ? Array.from(value as ArrayLike<number>) : value;
121
+ if (!wants.has(JSON.stringify(have))) {
122
+ stale++;
123
+ }
124
+ }
125
+ return stale;
126
+ }
127
+
128
+ /**
129
+ * The mapped values of one `<data>` tree of a yFiles key: a ShapeNode's for a node, a
130
+ * PolyLineEdge's for an edge; nothing for any other graphics (GenericNode, BezierEdge, ...).
131
+ * @param domain - node or edge
132
+ * @param tree - the `<data>` content as tree.ts builds it
133
+ * @returns field name -> value, in declaration order
134
+ */
135
+ export function graphicsValues(domain: "node" | "edge", tree: unknown): [string, unknown][] {
136
+ const out: [string, unknown][] = [];
137
+ if (domain === "node") {
138
+ const shape = child(tree, "ShapeNode");
139
+ if (shape !== undefined) {
140
+ shapeNodeValues(shape, out);
141
+ }
142
+ } else {
143
+ const edge = child(tree, "PolyLineEdge");
144
+ if (edge !== undefined) {
145
+ polyLineEdgeValues(edge, out);
146
+ }
147
+ }
148
+ return out;
149
+ }
150
+
151
+ /**
152
+ * Read a ShapeNode.
153
+ * @param shape - the ShapeNode element
154
+ * @param out - receives the values
155
+ */
156
+ function shapeNodeValues(shape: unknown, out: [string, unknown][]): void {
157
+ const geometry = child(shape, "Geometry");
158
+ // yFiles requires x, y, width and height on Geometry; a position needs both coordinates
159
+ const x = number(attr(geometry, "x"));
160
+ const y = number(attr(geometry, "y"));
161
+ if (x !== undefined && y !== undefined) {
162
+ out.push(["position", [x, y, 0]]);
163
+ }
164
+ push(out, "width", number(attr(geometry, "width")));
165
+ push(out, "height", number(attr(geometry, "height")));
166
+ push(out, "color", color(attr(child(shape, "Fill"), "color")));
167
+ const border = child(shape, "BorderStyle");
168
+ push(out, "borderColor", color(attr(border, "color")));
169
+ push(out, "borderWidth", number(attr(border, "width")));
170
+ const label = child(shape, "NodeLabel");
171
+ // an element with no text and no attributes is "" (no label); one with attributes but no text is a blank label
172
+ if (typeof label === "string" ? label.length > 0 : label !== undefined) {
173
+ // trimmed, as the element's parser (trimValues) reads it: pretty-printed yEd puts whitespace before the label's children
174
+ out.push(["label", (typeof label === "string" ? label : (text(label) ?? "")).trim()]);
175
+ }
176
+ push(out, "shape", nonEmpty(attr(child(shape, "Shape"), "type")));
177
+ }
178
+
179
+ /**
180
+ * Read a PolyLineEdge.
181
+ * @param edge - the PolyLineEdge element
182
+ * @param out - receives the values
183
+ */
184
+ function polyLineEdgeValues(edge: unknown, out: [string, unknown][]): void {
185
+ const line = child(edge, "LineStyle");
186
+ push(out, "color", color(attr(line, "color")));
187
+ push(out, "width", number(attr(line, "width")));
188
+ const arrows = child(edge, "Arrows");
189
+ if (arrows !== undefined && typeof arrows === "object") {
190
+ const target = attr(arrows, "target");
191
+ const source = attr(arrows, "source");
192
+ // yEd draws direction with the target arrow; the file's edgedefault still decides topology
193
+ out.push(["directed", target !== undefined && target !== "none"]);
194
+ push(out, "targetArrow", target === "none" ? undefined : nonEmpty(target));
195
+ push(out, "sourceArrow", source === "none" ? undefined : nonEmpty(source));
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Append a value when it is defined.
201
+ * @param out - the values
202
+ * @param field - the field name
203
+ * @param value - the value
204
+ */
205
+ function push(out: [string, unknown][], field: string, value: unknown): void {
206
+ if (value !== undefined) {
207
+ out.push([field, value]);
208
+ }
209
+ }
210
+
211
+ /**
212
+ * The first child element of a tree node by local name, whatever its namespace prefix.
213
+ * @param node - the tree node
214
+ * @param local - the local name
215
+ * @returns the child, or undefined
216
+ */
217
+ function child(node: unknown, local: string): unknown {
218
+ if (typeof node !== "object" || node === null) {
219
+ return undefined;
220
+ }
221
+ for (const [key, value] of Object.entries(node as Tree)) {
222
+ if (!key.startsWith("@_") && key !== "#text" && localName(key) === local) {
223
+ return Array.isArray(value) ? value[0] : value;
224
+ }
225
+ }
226
+ return undefined;
227
+ }
228
+
229
+ /**
230
+ * An attribute of a tree node.
231
+ * @param node - the tree node
232
+ * @param name - the attribute name
233
+ * @returns the text, or undefined
234
+ */
235
+ function attr(node: unknown, name: string): string | undefined {
236
+ if (typeof node !== "object" || node === null) {
237
+ return undefined;
238
+ }
239
+ const value = (node as Tree)[`@_${name}`];
240
+ return typeof value === "string" ? value : undefined;
241
+ }
242
+
243
+ /**
244
+ * The character data of an element that also has attributes or children.
245
+ * @param node - the tree node
246
+ * @returns the text, or undefined
247
+ */
248
+ function text(node: unknown): string | undefined {
249
+ const value = typeof node === "object" && node !== null ? (node as Tree)["#text"] : undefined;
250
+ return typeof value === "string" ? value : undefined;
251
+ }
252
+
253
+ /**
254
+ * A non-empty text.
255
+ * @param value - the text
256
+ * @returns the text, or undefined when absent or empty
257
+ */
258
+ function nonEmpty(value: string | undefined): string | undefined {
259
+ return value === undefined || value.length === 0 ? undefined : value;
260
+ }
261
+
262
+ /**
263
+ * A number attribute, parsed as graphty-element's parser parsed it.
264
+ * @param value - the text
265
+ * @returns the number, or undefined when absent, empty or not a number
266
+ */
267
+ function number(value: string | undefined): number | undefined {
268
+ if (value === undefined || value.length === 0) {
269
+ return undefined;
270
+ }
271
+ const n = Number.parseFloat(value);
272
+ return Number.isNaN(n) ? undefined : n;
273
+ }
274
+
275
+ /**
276
+ * A colour attribute: `#RRGGBB` upper-cased, `#RGB` expanded, anything else as written.
277
+ * @param value - the text
278
+ * @returns the colour, or undefined when absent or empty
279
+ */
280
+ function color(value: string | undefined): string | undefined {
281
+ if (value === undefined || value.length === 0) {
282
+ return undefined;
283
+ }
284
+ if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
285
+ return value.toUpperCase();
286
+ }
287
+ if (/^#[0-9A-Fa-f]{3}$/.test(value)) {
288
+ const [, r, g, b] = value;
289
+ return `#${r}${r}${g}${g}${b}${b}`.toUpperCase();
290
+ }
291
+ return value;
292
+ }
@@ -124,7 +124,15 @@ const STRUCTURAL_ROLES: ReadonlySet<string> = new Set([
124
124
  ]);
125
125
 
126
126
  /** The roles no JSON dialect can carry; skipped (checkCapabilities reports them). */
127
- const TEMPORAL_ROLES: ReadonlySet<string> = new Set(["start", "end", "timestamp", "timestamps", "spells", "open"]);
127
+ const TEMPORAL_ROLES: ReadonlySet<string> = new Set([
128
+ "start",
129
+ "end",
130
+ "timestamp",
131
+ "timestamps",
132
+ "spells",
133
+ "open",
134
+ "spellsOpen",
135
+ ]);
128
136
 
129
137
  /** The dialects whose attributes live in a nested dict (data / metadata / attributes). */
130
138
  const NESTED_DIALECTS: ReadonlySet<JsonDialect> = new Set(["jgf", "cytoscape", "graphology"]);