@graphty/graph-io 0.3.18 → 0.3.20

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 (235) hide show
  1. package/README.md +80 -2
  2. package/dist/chunks/{escape-D-gZWO26.js → escape-B-tkk_Cl.js} +74 -16
  3. package/dist/chunks/escape-B-tkk_Cl.js.map +1 -0
  4. package/dist/chunks/{writer-GAdltGmC.js → export-Bh60Dv-n.js} +7 -252
  5. package/dist/chunks/export-Bh60Dv-n.js.map +1 -0
  6. package/dist/chunks/exporter-BGrsWamJ.js +736 -0
  7. package/dist/chunks/exporter-BGrsWamJ.js.map +1 -0
  8. package/dist/chunks/exporter-DIeGJXAZ.js +834 -0
  9. package/dist/chunks/exporter-DIeGJXAZ.js.map +1 -0
  10. package/dist/chunks/{importer-Br_QeAeE.js → importer-BWY2FFc5.js} +28 -6
  11. package/dist/chunks/importer-BWY2FFc5.js.map +1 -0
  12. package/dist/chunks/importer-BaQpCEcJ.js +2194 -0
  13. package/dist/chunks/importer-BaQpCEcJ.js.map +1 -0
  14. package/dist/chunks/importer-DD-xv9_Y.js +1471 -0
  15. package/dist/chunks/importer-DD-xv9_Y.js.map +1 -0
  16. package/dist/chunks/{importer-aNJfe0qu.js → importer-DEcKhsmQ.js} +2 -2
  17. package/dist/chunks/{importer-aNJfe0qu.js.map → importer-DEcKhsmQ.js.map} +1 -1
  18. package/dist/chunks/{importer-DHagxvDD.js → importer-DIrbbnAf.js} +6 -4
  19. package/dist/chunks/{importer-DHagxvDD.js.map → importer-DIrbbnAf.js.map} +1 -1
  20. package/dist/chunks/importer-D_e7e7LX.js +3055 -0
  21. package/dist/chunks/importer-D_e7e7LX.js.map +1 -0
  22. package/dist/chunks/{importer-Du5crN9l.js → importer-Wv4C4gaF.js} +278 -82
  23. package/dist/chunks/importer-Wv4C4gaF.js.map +1 -0
  24. package/dist/chunks/importer-vi3bSdtw.js +1713 -0
  25. package/dist/chunks/importer-vi3bSdtw.js.map +1 -0
  26. package/dist/chunks/{importer-d0uQxFp6.js → importer-zGLo8Dg_.js} +6 -4
  27. package/dist/chunks/{importer-d0uQxFp6.js.map → importer-zGLo8Dg_.js.map} +1 -1
  28. package/dist/chunks/json-elements-DDS8N2Dd.js +779 -0
  29. package/dist/chunks/json-elements-DDS8N2Dd.js.map +1 -0
  30. package/dist/chunks/{records-Bk9jgodz.js → records-dbRkxwaq.js} +2 -2
  31. package/dist/chunks/{records-Bk9jgodz.js.map → records-dbRkxwaq.js.map} +1 -1
  32. package/dist/chunks/{report-BOk0p5y8.js → report-B1z4WT9e.js} +143 -111
  33. package/dist/chunks/{report-BOk0p5y8.js.map → report-B1z4WT9e.js.map} +1 -1
  34. package/dist/chunks/weights-Dzba96G3.js +176 -0
  35. package/dist/chunks/weights-Dzba96G3.js.map +1 -0
  36. package/dist/chunks/writer-C7flM-Ih.js +77 -0
  37. package/dist/chunks/writer-C7flM-Ih.js.map +1 -0
  38. package/dist/csv.js +6 -4
  39. package/dist/csv.js.map +1 -1
  40. package/dist/cx.d.ts +1 -0
  41. package/dist/cx.js +6 -0
  42. package/dist/cx.js.map +1 -0
  43. package/dist/cx2.d.ts +1 -0
  44. package/dist/cx2.js +10 -0
  45. package/dist/cx2.js.map +1 -0
  46. package/dist/cys.d.ts +1 -0
  47. package/dist/cys.js +6 -0
  48. package/dist/cys.js.map +1 -0
  49. package/dist/dot.js +1 -1
  50. package/dist/gexf.js +16 -4
  51. package/dist/gexf.js.map +1 -1
  52. package/dist/gml.js +13 -14
  53. package/dist/gml.js.map +1 -1
  54. package/dist/graph-io.js +302 -399
  55. package/dist/graph-io.js.map +1 -1
  56. package/dist/graphml.js +1 -1
  57. package/dist/json.js +1 -1
  58. package/dist/neo4j.js +6 -4
  59. package/dist/neo4j.js.map +1 -1
  60. package/dist/obo.js +1 -1
  61. package/dist/pajek.js +1 -1
  62. package/dist/src/common/cell-budget.d.ts +84 -0
  63. package/dist/src/common/cell-budget.d.ts.map +1 -0
  64. package/dist/src/common/cell-budget.js +138 -0
  65. package/dist/src/common/cell-budget.js.map +1 -0
  66. package/dist/src/common/input.d.ts +10 -0
  67. package/dist/src/common/input.d.ts.map +1 -1
  68. package/dist/src/common/input.js +39 -0
  69. package/dist/src/common/input.js.map +1 -1
  70. package/dist/src/common/json-elements.d.ts +286 -0
  71. package/dist/src/common/json-elements.d.ts.map +1 -0
  72. package/dist/src/common/json-elements.js +926 -0
  73. package/dist/src/common/json-elements.js.map +1 -0
  74. package/dist/src/common/options.d.ts +6 -0
  75. package/dist/src/common/options.d.ts.map +1 -1
  76. package/dist/src/common/options.js +1 -1
  77. package/dist/src/common/options.js.map +1 -1
  78. package/dist/src/common/xml.d.ts +37 -3
  79. package/dist/src/common/xml.d.ts.map +1 -1
  80. package/dist/src/common/xml.js +86 -6
  81. package/dist/src/common/xml.js.map +1 -1
  82. package/dist/src/common/zip.d.ts +92 -0
  83. package/dist/src/common/zip.d.ts.map +1 -0
  84. package/dist/src/common/zip.js +399 -0
  85. package/dist/src/common/zip.js.map +1 -0
  86. package/dist/src/formats/cx/importer.d.ts +133 -0
  87. package/dist/src/formats/cx/importer.d.ts.map +1 -0
  88. package/dist/src/formats/cx/importer.js +2220 -0
  89. package/dist/src/formats/cx/importer.js.map +1 -0
  90. package/dist/src/formats/cx/index.d.ts +7 -0
  91. package/dist/src/formats/cx/index.d.ts.map +1 -0
  92. package/dist/src/formats/cx/index.js +7 -0
  93. package/dist/src/formats/cx/index.js.map +1 -0
  94. package/dist/src/formats/cx2/exporter.d.ts +59 -0
  95. package/dist/src/formats/cx2/exporter.d.ts.map +1 -0
  96. package/dist/src/formats/cx2/exporter.js +864 -0
  97. package/dist/src/formats/cx2/exporter.js.map +1 -0
  98. package/dist/src/formats/cx2/importer.d.ts +169 -0
  99. package/dist/src/formats/cx2/importer.d.ts.map +1 -0
  100. package/dist/src/formats/cx2/importer.js +1652 -0
  101. package/dist/src/formats/cx2/importer.js.map +1 -0
  102. package/dist/src/formats/cx2/index.d.ts +7 -0
  103. package/dist/src/formats/cx2/index.d.ts.map +1 -0
  104. package/dist/src/formats/cx2/index.js +7 -0
  105. package/dist/src/formats/cx2/index.js.map +1 -0
  106. package/dist/src/formats/cys/constants.d.ts +68 -0
  107. package/dist/src/formats/cys/constants.d.ts.map +1 -0
  108. package/dist/src/formats/cys/constants.js +76 -0
  109. package/dist/src/formats/cys/constants.js.map +1 -0
  110. package/dist/src/formats/cys/importer.d.ts +38 -0
  111. package/dist/src/formats/cys/importer.d.ts.map +1 -0
  112. package/dist/src/formats/cys/importer.js +830 -0
  113. package/dist/src/formats/cys/importer.js.map +1 -0
  114. package/dist/src/formats/cys/index.d.ts +7 -0
  115. package/dist/src/formats/cys/index.d.ts.map +1 -0
  116. package/dist/src/formats/cys/index.js +7 -0
  117. package/dist/src/formats/cys/index.js.map +1 -0
  118. package/dist/src/formats/cys/session.d.ts +132 -0
  119. package/dist/src/formats/cys/session.d.ts.map +1 -0
  120. package/dist/src/formats/cys/session.js +315 -0
  121. package/dist/src/formats/cys/session.js.map +1 -0
  122. package/dist/src/formats/cys/tables.d.ts +90 -0
  123. package/dist/src/formats/cys/tables.d.ts.map +1 -0
  124. package/dist/src/formats/cys/tables.js +293 -0
  125. package/dist/src/formats/cys/tables.js.map +1 -0
  126. package/dist/src/formats/dot/exporter.d.ts +2 -0
  127. package/dist/src/formats/dot/exporter.d.ts.map +1 -1
  128. package/dist/src/formats/dot/exporter.js +12 -0
  129. package/dist/src/formats/dot/exporter.js.map +1 -1
  130. package/dist/src/formats/dot/importer.js +6 -2
  131. package/dist/src/formats/dot/importer.js.map +1 -1
  132. package/dist/src/formats/gexf/exporter.d.ts +2 -0
  133. package/dist/src/formats/gexf/exporter.d.ts.map +1 -1
  134. package/dist/src/formats/gexf/exporter.js +6 -0
  135. package/dist/src/formats/gexf/exporter.js.map +1 -1
  136. package/dist/src/formats/gexf/importer.d.ts +0 -2
  137. package/dist/src/formats/gexf/importer.d.ts.map +1 -1
  138. package/dist/src/formats/gexf/importer.js +1 -3
  139. package/dist/src/formats/gexf/importer.js.map +1 -1
  140. package/dist/src/formats/gexf/index.d.ts +1 -1
  141. package/dist/src/formats/gexf/index.js +2 -2
  142. package/dist/src/formats/gexf/index.js.map +1 -1
  143. package/dist/src/formats/gml/exporter.d.ts +0 -8
  144. package/dist/src/formats/gml/exporter.d.ts.map +1 -1
  145. package/dist/src/formats/gml/exporter.js +8 -18
  146. package/dist/src/formats/gml/exporter.js.map +1 -1
  147. package/dist/src/formats/json/importer.d.ts.map +1 -1
  148. package/dist/src/formats/json/importer.js +6 -104
  149. package/dist/src/formats/json/importer.js.map +1 -1
  150. package/dist/src/formats/xgmml/columns.d.ts +152 -0
  151. package/dist/src/formats/xgmml/columns.d.ts.map +1 -0
  152. package/dist/src/formats/xgmml/columns.js +593 -0
  153. package/dist/src/formats/xgmml/columns.js.map +1 -0
  154. package/dist/src/formats/xgmml/constants.d.ts +196 -0
  155. package/dist/src/formats/xgmml/constants.d.ts.map +1 -0
  156. package/dist/src/formats/xgmml/constants.js +197 -0
  157. package/dist/src/formats/xgmml/constants.js.map +1 -0
  158. package/dist/src/formats/xgmml/document.d.ts +361 -0
  159. package/dist/src/formats/xgmml/document.d.ts.map +1 -0
  160. package/dist/src/formats/xgmml/document.js +695 -0
  161. package/dist/src/formats/xgmml/document.js.map +1 -0
  162. package/dist/src/formats/xgmml/emit.d.ts +341 -0
  163. package/dist/src/formats/xgmml/emit.d.ts.map +1 -0
  164. package/dist/src/formats/xgmml/emit.js +1275 -0
  165. package/dist/src/formats/xgmml/emit.js.map +1 -0
  166. package/dist/src/formats/xgmml/exporter.d.ts +24 -0
  167. package/dist/src/formats/xgmml/exporter.d.ts.map +1 -0
  168. package/dist/src/formats/xgmml/exporter.js +999 -0
  169. package/dist/src/formats/xgmml/exporter.js.map +1 -0
  170. package/dist/src/formats/xgmml/importer.d.ts +58 -0
  171. package/dist/src/formats/xgmml/importer.d.ts.map +1 -0
  172. package/dist/src/formats/xgmml/importer.js +360 -0
  173. package/dist/src/formats/xgmml/importer.js.map +1 -0
  174. package/dist/src/formats/xgmml/index.d.ts +8 -0
  175. package/dist/src/formats/xgmml/index.d.ts.map +1 -0
  176. package/dist/src/formats/xgmml/index.js +8 -0
  177. package/dist/src/formats/xgmml/index.js.map +1 -0
  178. package/dist/src/formats/xgmml/values.d.ts +60 -0
  179. package/dist/src/formats/xgmml/values.d.ts.map +1 -0
  180. package/dist/src/formats/xgmml/values.js +148 -0
  181. package/dist/src/formats/xgmml/values.js.map +1 -0
  182. package/dist/src/index.d.ts +4 -0
  183. package/dist/src/index.d.ts.map +1 -1
  184. package/dist/src/index.js +4 -0
  185. package/dist/src/index.js.map +1 -1
  186. package/dist/src/registry.d.ts +7 -0
  187. package/dist/src/registry.d.ts.map +1 -1
  188. package/dist/src/registry.js +30 -10
  189. package/dist/src/registry.js.map +1 -1
  190. package/dist/src/sniff.d.ts +1 -1
  191. package/dist/src/sniff.d.ts.map +1 -1
  192. package/dist/src/sniff.js +4 -0
  193. package/dist/src/sniff.js.map +1 -1
  194. package/dist/xgmml.d.ts +1 -0
  195. package/dist/xgmml.js +9 -0
  196. package/dist/xgmml.js.map +1 -0
  197. package/package.json +25 -2
  198. package/src/common/cell-budget.ts +170 -0
  199. package/src/common/input.ts +40 -0
  200. package/src/common/json-elements.ts +1147 -0
  201. package/src/common/options.ts +1 -1
  202. package/src/common/xml.ts +127 -6
  203. package/src/common/zip.ts +472 -0
  204. package/src/formats/cx/importer.ts +2733 -0
  205. package/src/formats/cx/index.ts +7 -0
  206. package/src/formats/cx2/exporter.ts +1036 -0
  207. package/src/formats/cx2/importer.ts +2187 -0
  208. package/src/formats/cx2/index.ts +7 -0
  209. package/src/formats/cys/constants.ts +99 -0
  210. package/src/formats/cys/importer.ts +1100 -0
  211. package/src/formats/cys/index.ts +7 -0
  212. package/src/formats/cys/session.ts +428 -0
  213. package/src/formats/cys/tables.ts +400 -0
  214. package/src/formats/dot/exporter.ts +17 -0
  215. package/src/formats/dot/importer.ts +6 -2
  216. package/src/formats/gexf/exporter.ts +11 -0
  217. package/src/formats/gexf/importer.ts +1 -2
  218. package/src/formats/gexf/index.ts +1 -1
  219. package/src/formats/gml/exporter.ts +8 -19
  220. package/src/formats/json/importer.ts +6 -105
  221. package/src/formats/xgmml/columns.ts +747 -0
  222. package/src/formats/xgmml/constants.ts +265 -0
  223. package/src/formats/xgmml/document.ts +975 -0
  224. package/src/formats/xgmml/emit.ts +1563 -0
  225. package/src/formats/xgmml/exporter.ts +1215 -0
  226. package/src/formats/xgmml/importer.ts +491 -0
  227. package/src/formats/xgmml/index.ts +8 -0
  228. package/src/formats/xgmml/values.ts +183 -0
  229. package/src/index.ts +19 -0
  230. package/src/registry.ts +46 -15
  231. package/src/sniff.ts +18 -1
  232. package/dist/chunks/escape-D-gZWO26.js.map +0 -1
  233. package/dist/chunks/importer-Br_QeAeE.js.map +0 -1
  234. package/dist/chunks/importer-Du5crN9l.js.map +0 -1
  235. package/dist/chunks/writer-GAdltGmC.js.map +0 -1
@@ -0,0 +1,1563 @@
1
+ /**
2
+ * Resolving a parsed XGMML document (document.ts) into a sink: which nodes and edges a graph
3
+ * holds, their ids, endpoints, direction, weights, containment, positions, graphics and attribute
4
+ * columns. The `.cys` importer calls the same code with the session's tables and views merged into
5
+ * the records first, so both readers share every rule.
6
+ */
7
+
8
+ import { type ColumnDecl, type ColumnHandle, type GraphSink, INVALID_INDEX, type NodeId } from "@graphty/graph-format";
9
+
10
+ import { declareResolved } from "../../common/attributes.js";
11
+ import { DirectionResolver, type EdgeKind } from "../../common/direction.js";
12
+ import { IdCoercer } from "../../common/ids.js";
13
+ import { throwIfAborted } from "../../common/input.js";
14
+ import { type ResolvedImportOptions } from "../../common/options.js";
15
+ import { type ImportReportBuilder, type IssueLocation } from "../../common/report.js";
16
+ import { parseWeightText } from "../../common/weights.js";
17
+ import { ColumnSet } from "./columns.js";
18
+ import {
19
+ CYTOSCAPE_ORIGIN_NAMESPACE,
20
+ EDGE_ID_COLUMN,
21
+ FORMAT,
22
+ GRAPHICS_COLUMN,
23
+ INTERACTION_COLUMN,
24
+ LABEL_COLUMN,
25
+ META_KEY,
26
+ NESTED_NETWORK_COLUMN,
27
+ NETWORK_POINTER_COLUMN,
28
+ NETWORKS_COLUMN,
29
+ PARENT_COLUMN,
30
+ PARENTS_COLUMN,
31
+ POSITION_COLUMN,
32
+ SUBGRAPH_COLUMN,
33
+ XGMML_ISSUE,
34
+ XGMML_ORIGIN_NAMESPACE,
35
+ Z_COLUMN,
36
+ } from "./constants.js";
37
+ import {
38
+ attJson,
39
+ type AttRec,
40
+ type EdgeRec,
41
+ type GraphRec,
42
+ isCyTrue,
43
+ type NodeRec,
44
+ type XgmmlDocument,
45
+ } from "./document.js";
46
+
47
+ /** The XGMML-specific options, resolved. */
48
+ export interface XgmmlSettings {
49
+ /** Resolve missing or unknown endpoints through `"a (pp) b"` edge labels. */
50
+ readonly labelAliases: boolean;
51
+ /** Decode Cytoscape's `\n` / `\t` in strings. */
52
+ readonly cytoscapeEscapes: boolean;
53
+ /** Where Cytoscape's z goes. */
54
+ readonly zAs: "column" | "position";
55
+ }
56
+
57
+ /** What a session adds to the document's own rules. */
58
+ export interface EmitExtras {
59
+ /** Ids of nodes the session's group bookkeeping lists as groups. */
60
+ readonly groupNodes?: ReadonlySet<string> | undefined;
61
+ /** The network name a cross-file pointer (`file.xgmml#id`) names, or null when unknown. */
62
+ readonly resolvePointer?: ((href: string) => string | null) | undefined;
63
+ /** Graph-level attributes and the graph name come from elsewhere: skip the graph's own. */
64
+ readonly graphName?: string | null | undefined;
65
+ /** Extra json graph columns. */
66
+ readonly graphJson?: Readonly<Record<string, unknown>> | undefined;
67
+ /** Extra entries of `meta.extra`. */
68
+ readonly metaExtra?: Readonly<Record<string, unknown>> | undefined;
69
+ /** The format name the report and metadata carry ("cys"). */
70
+ readonly sourceFormat?: string | undefined;
71
+ /** Prefix of every issue message (the session entry name). */
72
+ readonly entry?: string | undefined;
73
+ /**
74
+ * A group member that is not in the graph being read (a collapsed group's member in a
75
+ * session): the session records it instead of E_UNKNOWN_PARENT.
76
+ */
77
+ readonly onMissingMember?: ((group: string, member: string) => void) | undefined;
78
+ }
79
+
80
+ /** The dialect facts the rules depend on. */
81
+ export interface Dialect {
82
+ /** The documentVersion as written, or null. */
83
+ readonly versionText: string | null;
84
+ /** The parsed version, or 0 when absent or unparseable. */
85
+ readonly version: number;
86
+ /** Cytoscape wrote the file (the `cy` namespace is in use). */
87
+ readonly cytoscape: boolean;
88
+ /** A Cytoscape 2.x document (Cytoscape dialect with a version below 3). */
89
+ readonly cy2: boolean;
90
+ /** The 3.x session network dialect (root `cy:registered="0"` with registered subnetworks). */
91
+ readonly session: boolean;
92
+ /** A session view document (root `cy:view="1"`). */
93
+ readonly view: boolean;
94
+ }
95
+
96
+ /** The graph atts that are document metadata, not columns. */
97
+ const META_ATTS: ReadonlySet<string> = new Set(["documentVersion", "networkMetadata"]);
98
+
99
+ /** The root XML attributes that are structure, not graph columns. */
100
+ const GRAPH_STRUCTURE: ReadonlySet<string> = new Set([
101
+ "id",
102
+ "label",
103
+ "directed",
104
+ "xlink:href",
105
+ "cy:documentVersion",
106
+ "cy:view",
107
+ "cy:registered",
108
+ "cy:networkId",
109
+ "cy:visualStyle",
110
+ "cy:rendererId",
111
+ ]);
112
+
113
+ /** Elements between two checks of the cancellation signal. */
114
+ const ABORT_CHECK_INTERVAL = 64;
115
+
116
+ /** Cytoscape's label alias shape: `source (interaction) target`. */
117
+ const ALIAS_SPLIT = /[()]/;
118
+
119
+ /**
120
+ * The dialect of a document (`research-xgmml.md` section 2).
121
+ * @param doc - the document
122
+ * @param report - the report the unparseable-version warning goes to
123
+ * @returns the dialect
124
+ */
125
+ export function dialectOf(doc: XgmmlDocument, report: ImportReportBuilder): Dialect {
126
+ const { root } = doc;
127
+ let versionText = root.attrs.get("cy:documentVersion") ?? null;
128
+ for (const att of root.atts) {
129
+ if (att.name === "documentVersion" && att.value !== null) {
130
+ versionText ??= att.value;
131
+ }
132
+ }
133
+ let version = 0;
134
+ if (versionText !== null) {
135
+ version = Number(versionText.trim());
136
+ if (!Number.isFinite(version) || versionText.trim().length === 0) {
137
+ report.warning(
138
+ "validation-error",
139
+ XGMML_ISSUE.DOCUMENT_VERSION,
140
+ `documentVersion "${versionText}" does not parse; the dialect is chosen from the content`,
141
+ { line: root.line },
142
+ );
143
+ version = 0;
144
+ }
145
+ }
146
+ const registered = root.subgraphs.some((g) => isCyTrue(g.registered));
147
+ return {
148
+ versionText,
149
+ version,
150
+ cytoscape: doc.cytoscape,
151
+ cy2: doc.cytoscape && version < 3,
152
+ session: root.registered !== null && !isCyTrue(root.registered) && registered,
153
+ view: isCyTrue(root.attrs.get("cy:view")),
154
+ };
155
+ }
156
+
157
+ /**
158
+ * The graphs a document offers: the registered subnetworks of a session network document, else
159
+ * the root.
160
+ * @param doc - the document
161
+ * @param dialect - its dialect
162
+ * @returns the graphs, in document order
163
+ */
164
+ export function graphsOf(doc: XgmmlDocument, dialect: Dialect): GraphRec[] {
165
+ return dialect.session ? doc.root.subgraphs.filter((g) => isCyTrue(g.registered)) : [doc.root];
166
+ }
167
+
168
+ /**
169
+ * The id a `#id` reference names, or null for a cross-file or malformed reference.
170
+ * @param href - the xlink:href text
171
+ * @returns the id
172
+ */
173
+ function localRef(href: string): string | null {
174
+ return href.startsWith("#") ? href.slice(1) : null;
175
+ }
176
+
177
+ /**
178
+ * The nodes and edges a graph holds: its direct members, references resolved.
179
+ * @param doc - the document
180
+ * @param graph - the graph (a registered subnetwork), or null for the whole document
181
+ * @returns the declared node and edge records it holds, references resolved, in order
182
+ */
183
+ export function membersOf(
184
+ doc: XgmmlDocument,
185
+ graph: GraphRec | null,
186
+ ): { readonly nodes: NodeRec[]; readonly edges: EdgeRec[]; readonly dangling: number } {
187
+ if (graph === null) {
188
+ return { nodes: doc.nodes, edges: doc.edges, dangling: 0 };
189
+ }
190
+ const nodeById = indexById(doc.nodes);
191
+ const edgeById = indexById(doc.edges);
192
+ const nodes: NodeRec[] = [];
193
+ const edges: EdgeRec[] = [];
194
+ let dangling = 0;
195
+ for (const member of graph.members) {
196
+ if (member.href === null) {
197
+ if (member.kind === "node") {
198
+ nodes.push(member);
199
+ } else {
200
+ edges.push(member);
201
+ }
202
+ continue;
203
+ }
204
+ const id = localRef(member.href);
205
+ let target: NodeRec | EdgeRec | undefined;
206
+ if (id !== null) {
207
+ target = member.kind === "node" ? nodeById.get(id) : edgeById.get(id);
208
+ }
209
+ if (target === undefined) {
210
+ dangling++;
211
+ } else if (target.kind === "node") {
212
+ nodes.push(target);
213
+ } else {
214
+ edges.push(target);
215
+ }
216
+ }
217
+ return { nodes, edges, dangling };
218
+ }
219
+
220
+ /**
221
+ * The first declaration of each id.
222
+ * @param records - node or edge records
223
+ * @returns id to record
224
+ */
225
+ function indexById<T extends NodeRec | EdgeRec>(records: readonly T[]): Map<string, T> {
226
+ const out = new Map<string, T>();
227
+ for (const record of records) {
228
+ if (record.id !== null && !out.has(record.id)) {
229
+ out.set(record.id, record);
230
+ }
231
+ }
232
+ return out;
233
+ }
234
+
235
+ /** Per-node state while emitting. */
236
+ interface NodeRow {
237
+ readonly id: string;
238
+ readonly records: NodeRec[];
239
+ index: number;
240
+ readonly row: number;
241
+ }
242
+
243
+ /**
244
+ * Pushes one graph of a document into a sink.
245
+ */
246
+ export class XgmmlEmitter {
247
+ private readonly doc: XgmmlDocument;
248
+
249
+ private readonly dialect: Dialect;
250
+
251
+ private readonly sink: GraphSink;
252
+
253
+ private readonly report: ImportReportBuilder;
254
+
255
+ private readonly options: ResolvedImportOptions;
256
+
257
+ private readonly settings: XgmmlSettings;
258
+
259
+ private readonly extras: EmitExtras;
260
+
261
+ private readonly coercer: IdCoercer;
262
+
263
+ private readonly direction: DirectionResolver;
264
+
265
+ private readonly nodeColumns: ColumnSet;
266
+
267
+ private readonly edgeColumns: ColumnSet;
268
+
269
+ private readonly graphColumns: ColumnSet;
270
+
271
+ private readonly rows = new Map<string, NodeRow>();
272
+
273
+ private readonly rowList: NodeRow[] = [];
274
+
275
+ private readonly edgeIndex: number[] = [];
276
+
277
+ private sinceCheck = 0;
278
+
279
+ private aliasResolved = 0;
280
+
281
+ private aliasInteractions = 0;
282
+
283
+ private weighted = false;
284
+
285
+ /**
286
+ * Create an emitter for one graph.
287
+ * @param doc - the document
288
+ * @param dialect - its dialect
289
+ * @param sink - the sink
290
+ * @param report - the report
291
+ * @param options - the resolved common options
292
+ * @param settings - the XGMML options
293
+ * @param extras - what a session adds
294
+ */
295
+ constructor(
296
+ doc: XgmmlDocument,
297
+ dialect: Dialect,
298
+ sink: GraphSink,
299
+ report: ImportReportBuilder,
300
+ options: ResolvedImportOptions,
301
+ settings: XgmmlSettings,
302
+ extras: EmitExtras = {},
303
+ ) {
304
+ this.doc = doc;
305
+ this.dialect = dialect;
306
+ this.sink = sink;
307
+ this.report = report;
308
+ this.options = options;
309
+ this.settings = settings;
310
+ this.extras = extras;
311
+ this.coercer = new IdCoercer(options.ids);
312
+ this.direction = new DirectionResolver(sink, report, options.onMixedDirection);
313
+ const base = { unescape: settings.cytoscapeEscapes, long: options.long };
314
+ this.nodeColumns = new ColumnSet("node", report, {
315
+ ...base,
316
+ reserved: new Set([
317
+ LABEL_COLUMN,
318
+ POSITION_COLUMN,
319
+ Z_COLUMN,
320
+ GRAPHICS_COLUMN,
321
+ PARENT_COLUMN,
322
+ PARENTS_COLUMN,
323
+ SUBGRAPH_COLUMN,
324
+ NESTED_NETWORK_COLUMN,
325
+ NETWORK_POINTER_COLUMN,
326
+ NETWORKS_COLUMN,
327
+ ]),
328
+ });
329
+ this.edgeColumns = new ColumnSet("edge", report, {
330
+ ...base,
331
+ reserved: new Set([EDGE_ID_COLUMN, LABEL_COLUMN, GRAPHICS_COLUMN]),
332
+ });
333
+ this.graphColumns = new ColumnSet("graph", report, { ...base, reserved: new Set([GRAPHICS_COLUMN]) });
334
+ }
335
+
336
+ /**
337
+ * Push the graph: nodes, containment, positions, edges, columns, metadata.
338
+ * @param graph - the subnetwork, or null for the whole document
339
+ */
340
+ emit(graph: GraphRec | null): void {
341
+ const members = membersOf(this.doc, graph);
342
+ if (members.dangling > 0) {
343
+ this.report.warning(
344
+ "validation-error",
345
+ XGMML_ISSUE.DANGLING_REFERENCE,
346
+ `${this.prefix()}${members.dangling} xlink:href member reference(s) name no node or edge of the document`,
347
+ { line: graph?.line ?? null },
348
+ );
349
+ }
350
+ const header = this.headerDirected();
351
+ this.direction.setHeader(this.sinkDirection(members.edges, header), { line: this.doc.root.line });
352
+ this.addNodes(members.nodes);
353
+ if (graph === null) {
354
+ this.checkReferences();
355
+ }
356
+ const containment = new Containment(this);
357
+ containment.resolve(this.rowList, this.groupTest());
358
+ this.writeNodeExtras(graph);
359
+ this.addEdges(members.edges, header);
360
+ this.finishAliases();
361
+ this.writeGraph(graph);
362
+ const nodeIndex = (row: number): number => this.rowList[row].index;
363
+ this.nodeColumns.write(this.sink, nodeIndex);
364
+ this.edgeColumns.write(this.sink, (row) => this.edgeIndex[row] ?? -1);
365
+ containment.write();
366
+ this.writeMeta(graph);
367
+ throwIfAborted(this.options.signal);
368
+ }
369
+
370
+ // ------------------------------------------------------------------ shared helpers (also used by Containment)
371
+
372
+ /**
373
+ * The sink.
374
+ * @returns the sink
375
+ */
376
+ get target(): GraphSink {
377
+ return this.sink;
378
+ }
379
+
380
+ /**
381
+ * The report.
382
+ * @returns the report
383
+ */
384
+ get issues(): ImportReportBuilder {
385
+ return this.report;
386
+ }
387
+
388
+ /**
389
+ * The row of a node id in this graph.
390
+ * @param id - the id text
391
+ * @returns the row, or undefined
392
+ */
393
+ rowOf(id: string): NodeRow | undefined {
394
+ return this.rows.get(id);
395
+ }
396
+
397
+ /**
398
+ * The network name a cross-file pointer names, when a session can tell.
399
+ * @param href - the pointer
400
+ * @returns the name, or null
401
+ */
402
+ resolvePointer(href: string): string | null {
403
+ return this.extras.resolvePointer?.(href) ?? null;
404
+ }
405
+
406
+ /**
407
+ * Hand a group member the graph does not hold to the session, when one is reading.
408
+ * @param group - the group node's id
409
+ * @param member - the member's id
410
+ * @returns true when the session took it
411
+ */
412
+ missingMember(group: string, member: string): boolean {
413
+ if (this.extras.onMissingMember === undefined) {
414
+ return false;
415
+ }
416
+ this.extras.onMissingMember(group, member);
417
+ return true;
418
+ }
419
+
420
+ /**
421
+ * The first graph of the document with an id.
422
+ * @param id - the id
423
+ * @returns the graph, or undefined
424
+ */
425
+ graphById(id: string): GraphRec | undefined {
426
+ return this.doc.graphs.find((g) => g.id === id);
427
+ }
428
+
429
+ /**
430
+ * The prefix of every message (the session entry), or "".
431
+ * @returns the prefix
432
+ */
433
+ prefix(): string {
434
+ return this.extras.entry === undefined ? "" : `${this.extras.entry}: `;
435
+ }
436
+
437
+ /**
438
+ * Check the cancellation signal every few dozen elements.
439
+ */
440
+ checkAbort(): void {
441
+ if (++this.sinceCheck >= ABORT_CHECK_INTERVAL) {
442
+ this.sinceCheck = 0;
443
+ throwIfAborted(this.options.signal);
444
+ }
445
+ }
446
+
447
+ /**
448
+ * Declare a column the importer owns on first use (the 5.6 rename rule applies).
449
+ * @param domain - node or edge
450
+ * @param decl - the declaration
451
+ * @returns the handle, or null when the sink refused it (recorded)
452
+ */
453
+ declare(domain: "node" | "edge", decl: ColumnDecl): ColumnHandle | null {
454
+ try {
455
+ return declareResolved(this.sink, domain, decl, this.report, { element: decl.name }).handle;
456
+ } catch (err) {
457
+ this.report.recordError(err, { element: decl.name });
458
+ return null;
459
+ }
460
+ }
461
+
462
+ // ------------------------------------------------------------------ nodes
463
+
464
+ /**
465
+ * The header direction: the root's `directed` (0 by the DTD), else the defaultDirected option.
466
+ * @returns whether the graph is directed by default
467
+ */
468
+ private headerDirected(): boolean {
469
+ const text = this.doc.root.attrs.get("directed");
470
+ if (text === undefined) {
471
+ return this.options.defaultDirected;
472
+ }
473
+ return this.flag(text, this.options.defaultDirected, { line: this.doc.root.line, element: "directed" });
474
+ }
475
+
476
+ /** The direction of each edge, decided once (its warning is recorded once). */
477
+ private readonly directions = new Map<EdgeRec, boolean>();
478
+
479
+ /**
480
+ * The direction of one edge: its cy:directed, else the header.
481
+ * @param record - the edge
482
+ * @param header - the header direction
483
+ * @returns whether the edge is directed
484
+ */
485
+ private edgeDirection(record: EdgeRec, header: boolean): boolean {
486
+ let directed = this.directions.get(record);
487
+ if (directed === undefined) {
488
+ directed =
489
+ record.directed === null
490
+ ? header
491
+ : this.flag(record.directed, header, { line: record.line, element: record.id ?? record.label });
492
+ this.directions.set(record, directed);
493
+ }
494
+ return directed;
495
+ }
496
+
497
+ /**
498
+ * The direction the sink starts with: the one every edge has when they all agree (a
499
+ * Cytoscape file writes cy:directed on every edge and may omit the root attribute), else the
500
+ * header, so a graph of one direction is never expanded into a mixed one.
501
+ * @param edges - the edges of the graph
502
+ * @param header - the header direction
503
+ * @returns the sink direction
504
+ */
505
+ private sinkDirection(edges: readonly EdgeRec[], header: boolean): boolean {
506
+ let first: boolean | null = null;
507
+ for (const edge of edges) {
508
+ const directed = this.edgeDirection(edge, header);
509
+ if (first === null) {
510
+ first = directed;
511
+ } else if (first !== directed) {
512
+ return header;
513
+ }
514
+ }
515
+ return first ?? header;
516
+ }
517
+
518
+ /**
519
+ * A 0 / 1 direction flag: true, false, yes and no are accepted with a warning; anything else
520
+ * falls back.
521
+ * @param text - the text
522
+ * @param fallback - the direction for an unreadable value
523
+ * @param where - the location
524
+ * @returns the direction
525
+ */
526
+ private flag(text: string, fallback: boolean, where: IssueLocation): boolean {
527
+ const t = text.trim();
528
+ if (t === "1" || t === "0") {
529
+ return t === "1";
530
+ }
531
+ const lower = t.toLowerCase();
532
+ const readable = lower === "true" || lower === "yes" || lower === "false" || lower === "no";
533
+ this.report.warnOnce(
534
+ "validation-error",
535
+ XGMML_ISSUE.BAD_DIRECTED,
536
+ readable
537
+ ? `${this.prefix()}directed="${text}" is not 0 or 1; read as ${lower === "true" || lower === "yes" ? "1" : "0"}`
538
+ : `${this.prefix()}directed="${text}" is not 0 or 1; read as ${fallback ? "directed" : "undirected"}`,
539
+ where,
540
+ `${XGMML_ISSUE.BAD_DIRECTED}:${text}`,
541
+ );
542
+ return readable ? lower === "true" || lower === "yes" : fallback;
543
+ }
544
+
545
+ /**
546
+ * Add every node record to the sink (one node per id; later declarations merge).
547
+ * @param records - the node records
548
+ */
549
+ private addNodes(records: readonly NodeRec[]): void {
550
+ for (const record of records) {
551
+ const { id } = record;
552
+ if (id === null) {
553
+ continue;
554
+ }
555
+ const existing = this.rows.get(id);
556
+ if (existing !== undefined) {
557
+ existing.records.push(record);
558
+ this.report.warning(
559
+ "validation-error",
560
+ XGMML_ISSUE.DUPLICATE_NODE,
561
+ `${this.prefix()}node "${id}" is declared twice; the declarations are merged`,
562
+ { line: record.line, element: id },
563
+ );
564
+ continue;
565
+ }
566
+ // -1 (not INVALID_INDEX, which is positive) marks a node the sink refused
567
+ const row: NodeRow = { id, records: [record], index: -1, row: this.rowList.length };
568
+ try {
569
+ row.index = this.sink.addNode(this.nodeId(id, record.line));
570
+ this.report.counts.nodes++;
571
+ } catch (err) {
572
+ this.report.counts.skippedNodes++;
573
+ this.report.recordError(err, { line: record.line, element: id });
574
+ }
575
+ this.rows.set(id, row);
576
+ this.rowList.push(row);
577
+ this.checkAbort();
578
+ }
579
+ }
580
+
581
+ /**
582
+ * The coerced id of a node text.
583
+ * @param text - the id text
584
+ * @param line - the line
585
+ * @returns the id
586
+ */
587
+ private nodeId(text: string, line: number): NodeId {
588
+ const id = this.coercer.text(text);
589
+ const merge = this.coercer.lastMerge;
590
+ if (merge !== null) {
591
+ this.report.warning(
592
+ "coercion",
593
+ XGMML_ISSUE.ID_MERGED,
594
+ `id "${merge.text}" merged with "${merge.previousText}" as ${merge.id} under ids: "number"`,
595
+ { line, element: text },
596
+ );
597
+ }
598
+ return id;
599
+ }
600
+
601
+ /**
602
+ * Whether a node's nested graph is a group (containment) rather than a nested-network
603
+ * pointer: any graph in the 1.0 draft dialect, a 2.x node with `__groupState`, a 3.x node with
604
+ * `__isGroup` true, or a node the session lists.
605
+ * @returns the test
606
+ */
607
+ private groupTest(): (node: NodeRec) => boolean {
608
+ const { groupNodes } = this.extras;
609
+ const { cytoscape } = this.dialect;
610
+ return (node: NodeRec): boolean => {
611
+ if (!cytoscape) {
612
+ return true;
613
+ }
614
+ if (node.id !== null && groupNodes?.has(node.id) === true) {
615
+ return true;
616
+ }
617
+ return node.atts.some((a) => a.name === "__groupState" || (a.name === "__isGroup" && isCyTrue(a.value)));
618
+ };
619
+ }
620
+
621
+ /**
622
+ * W_DANGLING_REFERENCE for the `xlink:href` members of a generic document's non-group graphs
623
+ * that name no node or edge (a group's are E_UNKNOWN_PARENT, reported by the containment).
624
+ */
625
+ private checkReferences(): void {
626
+ const isGroup = this.groupTest();
627
+ const edgeIds = new Set(this.doc.edges.map((e) => e.id));
628
+ let dangling = 0;
629
+ for (const graph of this.doc.graphs) {
630
+ if (graph.owner !== null && isGroup(graph.owner)) {
631
+ continue;
632
+ }
633
+ for (const member of graph.members) {
634
+ if (member.href === null) {
635
+ continue;
636
+ }
637
+ const id = localRef(member.href);
638
+ const known = id !== null && (member.kind === "node" ? this.rows.has(id) : edgeIds.has(id));
639
+ if (!known) {
640
+ dangling++;
641
+ }
642
+ }
643
+ }
644
+ if (dangling > 0) {
645
+ this.report.warning(
646
+ "validation-error",
647
+ XGMML_ISSUE.DANGLING_REFERENCE,
648
+ `${this.prefix()}${dangling} xlink:href reference(s) name no node or edge of the document`,
649
+ );
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Write the per-node values: label, attributes, XML attributes, graphics, positions, nested
655
+ * network pointers and subgraph memberships.
656
+ * @param graph - the subnetwork, or null
657
+ */
658
+ private writeNodeExtras(graph: GraphRec | null): void {
659
+ const labels: (string | undefined)[] = [];
660
+ const positions = new Positions(this, this.dialect.cytoscape ? this.settings.zAs : "position");
661
+ const graphics = new JsonColumn(this, "node", GRAPHICS_COLUMN, XGMML_ORIGIN_NAMESPACE, "graphics");
662
+ const pointers = new Pointers(this, this.groupTest());
663
+ for (const row of this.rowList) {
664
+ const where = { line: row.records[0].line, element: row.id };
665
+ const seen = new Set<string>();
666
+ for (const record of row.records) {
667
+ labels[row.row] ??= record.label ?? undefined;
668
+ this.addElementColumns(this.nodeColumns, row.row, record, seen, where);
669
+ positions.add(row, record);
670
+ if (record.graphics !== null && Object.keys(record.graphics).length > 0) {
671
+ graphics.set(row.index, record.graphics);
672
+ }
673
+ pointers.add(row, record);
674
+ }
675
+ this.checkAbort();
676
+ }
677
+ this.writeLabels("node", labels, (row) => this.rowList[row].index);
678
+ positions.write();
679
+ pointers.write();
680
+ if (graph === null) {
681
+ this.writeNetworks();
682
+ }
683
+ }
684
+
685
+ /**
686
+ * Add an element's XML attributes (text grammar) and atts to a column set; atts of a later
687
+ * duplicate declaration only fill names the earlier ones left unset.
688
+ * @param columns - the column set
689
+ * @param row - the row
690
+ * @param record - the node or edge
691
+ * @param seen - the names set by earlier declarations of the same node
692
+ * @param where - the location
693
+ */
694
+ private addElementColumns(
695
+ columns: ColumnSet,
696
+ row: number,
697
+ record: NodeRec | EdgeRec,
698
+ seen: Set<string>,
699
+ where: IssueLocation,
700
+ ): void {
701
+ const merging = seen.size > 0;
702
+ const names = new Set<string>();
703
+ if (record.name !== null) {
704
+ if (!(merging && seen.has("name"))) {
705
+ columns.addText(row, "name", record.name, where);
706
+ }
707
+ names.add("name");
708
+ }
709
+ for (const [key, value] of record.xmlAttrs) {
710
+ if (!(merging && seen.has(key))) {
711
+ columns.addText(row, key, value, where);
712
+ }
713
+ names.add(key);
714
+ }
715
+ for (const att of record.atts) {
716
+ if (this.skipAtt(record, att) || (merging && att.name !== null && seen.has(att.name))) {
717
+ continue;
718
+ }
719
+ if (record.kind === "node" && att.name === "__isGroup" && record.nested.length > 0 && isCyTrue(att.value)) {
720
+ // the group is the containment itself (parent / parents and xgmml.subgraph)
721
+ continue;
722
+ }
723
+ if (att.hasGraph && att.name === null) {
724
+ continue;
725
+ }
726
+ columns.addAtt(row, att, where);
727
+ if (att.name !== null) {
728
+ names.add(att.name);
729
+ }
730
+ }
731
+ for (const name of names) {
732
+ seen.add(name);
733
+ }
734
+ }
735
+
736
+ /**
737
+ * Whether an att is not a column: an edge's weight att under weightFrom.
738
+ * @param record - the element
739
+ * @param att - the att
740
+ * @returns true to skip it
741
+ */
742
+ private skipAtt(record: NodeRec | EdgeRec, att: AttRec): boolean {
743
+ return record.kind === "edge" && record.weight === null && att.name === this.options.weightFrom;
744
+ }
745
+
746
+ /**
747
+ * Write a label column (role label).
748
+ * @param domain - node or edge
749
+ * @param labels - the label of each row
750
+ * @param index - the sink index of a row
751
+ */
752
+ private writeLabels(
753
+ domain: "node" | "edge",
754
+ labels: readonly (string | undefined)[],
755
+ index: (row: number) => number,
756
+ ): void {
757
+ if (!labels.some((l) => l !== undefined)) {
758
+ return;
759
+ }
760
+ const handle = this.declare(domain, {
761
+ name: LABEL_COLUMN,
762
+ dtype: "string",
763
+ role: "label",
764
+ nullable: true,
765
+ origin: { format: FORMAT, id: null, title: null, type: "label", namespace: null },
766
+ });
767
+ if (handle === null) {
768
+ return;
769
+ }
770
+ labels.forEach((label, row) => {
771
+ const i = index(row);
772
+ if (label !== undefined && i >= 0) {
773
+ if (domain === "node") {
774
+ this.sink.setNodeValue(handle, i, label);
775
+ } else {
776
+ this.sink.setEdgeValue(handle, i, label);
777
+ }
778
+ }
779
+ });
780
+ }
781
+
782
+ /** The `xgmml.networks` membership of nodes in root-level subgraphs of a generic document. */
783
+ private writeNetworks(): void {
784
+ const { subgraphs } = this.doc.root;
785
+ if (subgraphs.length === 0) {
786
+ return;
787
+ }
788
+ const member = new Map<number, string[]>();
789
+ for (const graph of subgraphs) {
790
+ const name = graph.id ?? graph.label ?? "";
791
+ for (const m of graph.members) {
792
+ const id = m.kind === "node" ? (m.id ?? (m.href === null ? null : localRef(m.href))) : null;
793
+ const row = id === null ? undefined : this.rows.get(id);
794
+ if (row !== undefined && row.index >= 0) {
795
+ const list = member.get(row.index) ?? [];
796
+ list.push(name);
797
+ member.set(row.index, list);
798
+ }
799
+ }
800
+ }
801
+ if (member.size === 0) {
802
+ return;
803
+ }
804
+ const handle = this.declare("node", {
805
+ name: NETWORKS_COLUMN,
806
+ dtype: "list",
807
+ itemDtype: "string",
808
+ nullable: true,
809
+ origin: { format: FORMAT, id: null, title: null, type: "graph", namespace: XGMML_ORIGIN_NAMESPACE },
810
+ });
811
+ if (handle !== null) {
812
+ for (const [index, list] of member) {
813
+ this.sink.setNodeValue(handle, index, list);
814
+ }
815
+ }
816
+ }
817
+
818
+ // ------------------------------------------------------------------ edges
819
+
820
+ /**
821
+ * Push every edge record.
822
+ * @param records - the edge records
823
+ * @param header - the header direction
824
+ */
825
+ private addEdges(records: readonly EdgeRec[], header: boolean): void {
826
+ const ids = new Set<string>();
827
+ const dedupe = this.dialect.cy2 && this.doc.nodes.some((n) => n.nested.length > 0) ? new Set<string>() : null;
828
+ const labels: (string | undefined)[] = [];
829
+ const edgeIds: (string | undefined)[] = [];
830
+ const graphics: [number, Record<string, unknown>][] = [];
831
+ let row = 0;
832
+ for (const record of records) {
833
+ const where = { line: record.line, element: record.id ?? record.label };
834
+ const key = record.id ?? record.label;
835
+ if (dedupe !== null && key !== null) {
836
+ if (dedupe.has(key)) {
837
+ this.report.warnOnce(
838
+ "merged",
839
+ XGMML_ISSUE.GROUP_DUPLICATE_EDGE,
840
+ `${this.prefix()}the Cytoscape 2.x writer repeats the edges of a group; each repeat is dropped`,
841
+ where,
842
+ );
843
+ continue;
844
+ }
845
+ dedupe.add(key);
846
+ }
847
+ const index = this.addEdge(record, header, ids, where);
848
+ this.checkAbort();
849
+ if (index < 0) {
850
+ continue;
851
+ }
852
+ this.edgeIndex[row] = index;
853
+ labels[row] = record.label ?? undefined;
854
+ edgeIds[row] = record.id ?? undefined;
855
+ this.addElementColumns(this.edgeColumns, row, record, new Set(), where);
856
+ this.fillInteraction(record, row, where);
857
+ if (record.graphics !== null && Object.keys(record.graphics).length > 0) {
858
+ graphics.push([index, record.graphics]);
859
+ }
860
+ row++;
861
+ }
862
+ this.writeEdgeIds(edgeIds);
863
+ this.writeLabels("edge", labels, (r) => this.edgeIndex[r] ?? -1);
864
+ const column = new JsonColumn(this, "edge", GRAPHICS_COLUMN, XGMML_ORIGIN_NAMESPACE, "graphics");
865
+ for (const [index, value] of graphics) {
866
+ column.set(index, value);
867
+ }
868
+ }
869
+
870
+ /**
871
+ * Push one edge: endpoints (with label aliases), duplicate id check, direction and weight.
872
+ * @param record - the edge
873
+ * @param header - the header direction
874
+ * @param ids - the edge ids used so far
875
+ * @param where - the location
876
+ * @returns the edge index, or -1 when it was skipped
877
+ */
878
+ private addEdge(record: EdgeRec, header: boolean, ids: Set<string>, where: IssueLocation): number {
879
+ const alias = this.settings.labelAliases ? aliasesOf(record.label) : null;
880
+ let source: NodeId | null;
881
+ let target: NodeId | null;
882
+ try {
883
+ source = this.endpoint(record.source, alias?.[0] ?? null, "source", where);
884
+ target = this.endpoint(record.target, alias?.[2] ?? null, "target", where);
885
+ } catch (err) {
886
+ // an endpoint the id rule or the sink refuses (ids: "number", a caller's sink)
887
+ this.report.counts.skippedEdges++;
888
+ this.report.recordError(err, where);
889
+ return -1;
890
+ }
891
+ if (source === null || target === null) {
892
+ this.report.counts.skippedEdges++;
893
+ return -1;
894
+ }
895
+ if (record.id !== null && ids.has(record.id)) {
896
+ this.report.counts.skippedEdges++;
897
+ this.report.error(
898
+ "validation-error",
899
+ XGMML_ISSUE.DUPLICATE_EDGE_ID,
900
+ `${this.prefix()}edge id "${record.id}" is used twice; the second edge is skipped`,
901
+ where,
902
+ );
903
+ return -1;
904
+ }
905
+ const directed = this.edgeDirection(record, header);
906
+ const kind: EdgeKind = directed ? "directed" : "undirected";
907
+ let weight: number | undefined;
908
+ try {
909
+ weight = this.weightOf(record);
910
+ const before = this.sink.edgeCount;
911
+ const e = this.direction.addEdge(source, target, kind, weight, where);
912
+ this.report.counts.edges += this.sink.edgeCount - before;
913
+ if (record.id !== null) {
914
+ ids.add(record.id);
915
+ }
916
+ return e;
917
+ } catch (err) {
918
+ this.report.counts.skippedEdges++;
919
+ this.report.recordError(err, where);
920
+ return -1;
921
+ }
922
+ }
923
+
924
+ /**
925
+ * The weight of an edge: its `weight` XML attribute (or the att weightFrom names).
926
+ * @param record - the edge
927
+ * @returns the weight, or undefined
928
+ */
929
+ private weightOf(record: EdgeRec): number | undefined {
930
+ const from = this.options.weightFrom;
931
+ if (from === null) {
932
+ return undefined;
933
+ }
934
+ let text: string | null = from === "weight" ? record.weight : null;
935
+ if (text === null) {
936
+ text = record.atts.find((a) => a.name === from && a.children.length === 0)?.value ?? null;
937
+ }
938
+ if (text === null) {
939
+ return undefined;
940
+ }
941
+ const weight = parseWeightText(text);
942
+ this.weighted ||= weight !== undefined;
943
+ return weight;
944
+ }
945
+
946
+ /**
947
+ * Resolve one endpoint: a node of this graph by id, else (under labelAliases) the label alias
948
+ * by id or by unique node label; a missing endpoint is E_MISSING_ENDPOINT, an unknown one
949
+ * E_UNKNOWN_NODE unless addMissingNodes adds it.
950
+ * @param text - the source or target text, or null
951
+ * @param alias - the label alias, or null
952
+ * @param side - which end
953
+ * @param where - the location
954
+ * @returns the node id, or null when the edge cannot be added
955
+ */
956
+ private endpoint(text: string | null, alias: string | null, side: string, where: IssueLocation): NodeId | null {
957
+ if (text !== null) {
958
+ const row = this.rows.get(text);
959
+ if (row !== undefined && row.index >= 0) {
960
+ return this.coercer.text(text);
961
+ }
962
+ }
963
+ if (alias !== null) {
964
+ const resolved = this.aliasRow(alias);
965
+ if (resolved !== null) {
966
+ this.aliasResolved++;
967
+ return this.coercer.text(resolved.id);
968
+ }
969
+ }
970
+ if (text === null) {
971
+ this.report.error(
972
+ "missing-value",
973
+ XGMML_ISSUE.MISSING_ENDPOINT,
974
+ `${this.prefix()}<edge> without a ${side}`,
975
+ where,
976
+ );
977
+ return null;
978
+ }
979
+ if (this.options.addMissingNodes) {
980
+ const id = this.coercer.text(text);
981
+ if (this.sink.indexOf(id) === INVALID_INDEX) {
982
+ this.sink.addNode(id);
983
+ this.report.counts.nodes++;
984
+ }
985
+ return id;
986
+ }
987
+ this.report.error(
988
+ "validation-error",
989
+ XGMML_ISSUE.UNKNOWN_NODE,
990
+ `${this.prefix()}edge ${side} "${text}" is not a node of this graph; the edge is skipped`,
991
+ where,
992
+ );
993
+ return null;
994
+ }
995
+
996
+ /** Node labels to rows, for label aliases; null marks a label two nodes share. */
997
+ private labelRows: Map<string, NodeRow | null> | null = null;
998
+
999
+ /**
1000
+ * The node a label alias names: by id, else by a label only one node has.
1001
+ * @param alias - the alias
1002
+ * @returns the row, or null
1003
+ */
1004
+ private aliasRow(alias: string): NodeRow | null {
1005
+ const byId = this.rows.get(alias);
1006
+ if (byId !== undefined && byId.index >= 0) {
1007
+ return byId;
1008
+ }
1009
+ if (this.labelRows === null) {
1010
+ this.labelRows = new Map();
1011
+ for (const row of this.rowList) {
1012
+ const { label } = row.records[0];
1013
+ if (label !== null && row.index >= 0) {
1014
+ this.labelRows.set(label, this.labelRows.has(label) ? null : row);
1015
+ }
1016
+ }
1017
+ }
1018
+ return this.labelRows.get(alias) ?? null;
1019
+ }
1020
+
1021
+ /**
1022
+ * Fill the interaction of an edge without one from its label alias, as Cytoscape does.
1023
+ * @param record - the edge
1024
+ * @param row - the edge row
1025
+ * @param where - the location
1026
+ */
1027
+ private fillInteraction(record: EdgeRec, row: number, where: IssueLocation): void {
1028
+ if (
1029
+ !this.settings.labelAliases ||
1030
+ this.dialect.session ||
1031
+ record.atts.some((a) => a.name === INTERACTION_COLUMN)
1032
+ ) {
1033
+ return;
1034
+ }
1035
+ const alias = aliasesOf(record.label);
1036
+ if (alias !== null) {
1037
+ this.edgeColumns.addText(row, INTERACTION_COLUMN, alias[1], where);
1038
+ this.aliasInteractions++;
1039
+ }
1040
+ }
1041
+
1042
+ /** Report the label alias uses, once. */
1043
+ private finishAliases(): void {
1044
+ if (this.aliasResolved === 0 && this.aliasInteractions === 0) {
1045
+ return;
1046
+ }
1047
+ this.report.warning(
1048
+ "coercion",
1049
+ XGMML_ISSUE.LABEL_ALIAS,
1050
+ `${this.prefix()}Cytoscape label aliases ("a (pp) b"): ${this.aliasResolved} endpoint(s) resolved, ${this.aliasInteractions} interaction(s) filled from edge labels`,
1051
+ );
1052
+ }
1053
+
1054
+ /**
1055
+ * Write the edge `id` column (role id, unique).
1056
+ * @param ids - the id of each row
1057
+ */
1058
+ private writeEdgeIds(ids: readonly (string | undefined)[]): void {
1059
+ if (!ids.some((id) => id !== undefined)) {
1060
+ return;
1061
+ }
1062
+ const handle = this.declare("edge", {
1063
+ name: EDGE_ID_COLUMN,
1064
+ dtype: "string",
1065
+ role: "id",
1066
+ unique: true,
1067
+ nullable: true,
1068
+ origin: { format: FORMAT, id: null, title: null, type: "id", namespace: null },
1069
+ });
1070
+ if (handle === null) {
1071
+ return;
1072
+ }
1073
+ ids.forEach((id, row) => {
1074
+ const e = this.edgeIndex[row];
1075
+ if (id !== undefined && e !== undefined) {
1076
+ try {
1077
+ this.sink.setEdgeValue(handle, e, id);
1078
+ } catch (err) {
1079
+ this.report.recordError(err, { element: id });
1080
+ }
1081
+ }
1082
+ });
1083
+ }
1084
+
1085
+ // ------------------------------------------------------------------ graph and metadata
1086
+
1087
+ /**
1088
+ * The graph table: the graph's atts and other XML attributes, and its graphics.
1089
+ * @param graph - the subnetwork, or null for the root
1090
+ */
1091
+ private writeGraph(graph: GraphRec | null): void {
1092
+ const g = graph ?? this.doc.root;
1093
+ const where = { line: g.line, element: g.id };
1094
+ if (graph === null) {
1095
+ for (const [key, value] of g.attrs) {
1096
+ if (!GRAPH_STRUCTURE.has(key) && key !== "xmlns" && !key.startsWith("xmlns:")) {
1097
+ this.graphColumns.addText(0, key, value, where);
1098
+ }
1099
+ }
1100
+ }
1101
+ for (const att of g.atts) {
1102
+ if (att.name !== null && META_ATTS.has(att.name)) {
1103
+ continue;
1104
+ }
1105
+ if (att.hasGraph && att.name === null) {
1106
+ continue;
1107
+ }
1108
+ this.graphColumns.addAtt(0, att, where);
1109
+ }
1110
+ this.graphColumns.write(this.sink, () => 0);
1111
+ const json: Record<string, unknown> = { ...(this.extras.graphJson ?? {}) };
1112
+ if (g.graphics !== null && Object.keys(g.graphics).length > 0) {
1113
+ json[GRAPHICS_COLUMN] = g.graphics;
1114
+ }
1115
+ for (const [name, value] of Object.entries(json)) {
1116
+ this.sink.setGraphValue(name, value, {
1117
+ name,
1118
+ dtype: "json",
1119
+ nullable: true,
1120
+ origin: { format: FORMAT, id: null, title: null, type: name, namespace: XGMML_ORIGIN_NAMESPACE },
1121
+ });
1122
+ }
1123
+ }
1124
+
1125
+ /**
1126
+ * The graph metadata: name, description and date from the RDF metadata, the format, version
1127
+ * and what the exporter needs.
1128
+ * @param graph - the subnetwork, or null
1129
+ */
1130
+ private writeMeta(graph: GraphRec | null): void {
1131
+ const g = graph ?? this.doc.root;
1132
+ const { rdf } = this.doc;
1133
+ const name = this.extras.graphName ?? g.label ?? rdf.title ?? g.id ?? undefined;
1134
+ this.sink.setMeta({
1135
+ name: name ?? undefined,
1136
+ description: rdf.description ?? undefined,
1137
+ created: rdf.date ?? undefined,
1138
+ sourceFormat: this.extras.sourceFormat ?? FORMAT,
1139
+ sourceVersion: this.dialect.versionText ?? undefined,
1140
+ weightOrigin: this.weighted
1141
+ ? { format: FORMAT, id: "weight", title: null, type: "weight", namespace: null }
1142
+ : undefined,
1143
+ extra: {
1144
+ [META_KEY]: {
1145
+ graphId: g.id,
1146
+ directed: this.doc.root.attrs.get("directed") ?? null,
1147
+ rdf: Object.keys(rdf).length > 0 ? { ...rdf } : null,
1148
+ },
1149
+ ...(this.extras.metaExtra ?? {}),
1150
+ },
1151
+ });
1152
+ }
1153
+ }
1154
+
1155
+ /**
1156
+ * The three parts of a Cytoscape edge label (`source (interaction) target`).
1157
+ * @param label - the label, or null
1158
+ * @returns source alias, interaction, target alias; null when the label has another shape
1159
+ */
1160
+ export function aliasesOf(label: string | null): [string, string, string] | null {
1161
+ if (label === null) {
1162
+ return null;
1163
+ }
1164
+ const parts = label.split(ALIAS_SPLIT);
1165
+ return parts.length === 3 ? [parts[0].trim(), parts[1], parts[2].trim()] : null;
1166
+ }
1167
+
1168
+ /**
1169
+ * A json column the importer owns, declared on first value.
1170
+ */
1171
+ class JsonColumn {
1172
+ private handle: ColumnHandle | null | undefined;
1173
+
1174
+ /**
1175
+ * Create the column.
1176
+ * @param emitter - the emitter
1177
+ * @param domain - node or edge
1178
+ * @param name - the column name
1179
+ * @param namespace - its origin namespace
1180
+ * @param type - its origin type
1181
+ * @param dtype - json (default) or string
1182
+ */
1183
+ constructor(
1184
+ private readonly emitter: XgmmlEmitter,
1185
+ private readonly domain: "node" | "edge",
1186
+ private readonly name: string,
1187
+ private readonly namespace: string,
1188
+ private readonly type: string,
1189
+ private readonly dtype: "json" | "string" = "json",
1190
+ ) {}
1191
+
1192
+ /**
1193
+ * Set one value.
1194
+ * @param index - the node or edge index
1195
+ * @param value - the value
1196
+ */
1197
+ set(index: number, value: unknown): void {
1198
+ if (index < 0) {
1199
+ return;
1200
+ }
1201
+ if (this.handle === undefined) {
1202
+ this.handle = this.emitter.declare(this.domain, {
1203
+ name: this.name,
1204
+ dtype: this.dtype,
1205
+ nullable: true,
1206
+ origin: { format: FORMAT, id: null, title: null, type: this.type, namespace: this.namespace },
1207
+ });
1208
+ }
1209
+ if (this.handle === null) {
1210
+ return;
1211
+ }
1212
+ const sink = this.emitter.target;
1213
+ if (this.domain === "node") {
1214
+ sink.setNodeValue(this.handle, index, value);
1215
+ } else {
1216
+ sink.setEdgeValue(this.handle, index, value);
1217
+ }
1218
+ }
1219
+ }
1220
+
1221
+ /**
1222
+ * Node positions: x and y (y negated: Cytoscape's screen y grows down, graph-io's grows up), z
1223
+ * as a coordinate in the draft dialect or under zAs "position", else as the `z` column.
1224
+ */
1225
+ class Positions {
1226
+ private readonly values = new Map<number, [number, number, number]>();
1227
+
1228
+ private readonly zValues = new Map<number, number>();
1229
+
1230
+ private hasZ = false;
1231
+
1232
+ /**
1233
+ * Create the collector.
1234
+ * @param emitter - the emitter
1235
+ * @param zAs - where z goes
1236
+ */
1237
+ constructor(
1238
+ private readonly emitter: XgmmlEmitter,
1239
+ private readonly zAs: "column" | "position",
1240
+ ) {}
1241
+
1242
+ /**
1243
+ * Read one node record's coordinates.
1244
+ * @param row - the node row
1245
+ * @param record - the record
1246
+ */
1247
+ add(row: NodeRow, record: NodeRec): void {
1248
+ if (row.index < 0 || (record.x === null && record.y === null && record.z === null)) {
1249
+ return;
1250
+ }
1251
+ const where = { line: record.line, element: row.id };
1252
+ const x = this.number(record.x, where);
1253
+ const y = this.number(record.y, where);
1254
+ const z = this.number(record.z, where);
1255
+ if (z !== null) {
1256
+ this.hasZ = true;
1257
+ if (this.zAs === "column") {
1258
+ this.zValues.set(row.index, z);
1259
+ }
1260
+ }
1261
+ if (x !== null && y !== null) {
1262
+ const flipped = y === 0 ? 0 : -y;
1263
+ this.values.set(row.index, [x, flipped, this.zAs === "position" ? (z ?? 0) : 0]);
1264
+ }
1265
+ }
1266
+
1267
+ /**
1268
+ * A coordinate.
1269
+ * @param text - the text, or null
1270
+ * @param where - the location
1271
+ * @returns the number, or null when absent or invalid (recorded)
1272
+ */
1273
+ private number(text: string | null, where: IssueLocation): number | null {
1274
+ if (text === null) {
1275
+ return null;
1276
+ }
1277
+ const n = Number(text.trim());
1278
+ if (text.trim().length === 0 || !Number.isFinite(n)) {
1279
+ this.emitter.issues.error(
1280
+ "validation-error",
1281
+ XGMML_ISSUE.BAD_VALUE,
1282
+ `${this.emitter.prefix()}graphics coordinate "${text}" is not a number`,
1283
+ where,
1284
+ );
1285
+ return null;
1286
+ }
1287
+ return n;
1288
+ }
1289
+
1290
+ /** Declare and write the position and z columns. */
1291
+ write(): void {
1292
+ const sink = this.emitter.target;
1293
+ if (this.values.size > 0) {
1294
+ const handle = this.emitter.declare("node", {
1295
+ name: POSITION_COLUMN,
1296
+ dtype: "f32",
1297
+ components: 3,
1298
+ role: "position",
1299
+ nullable: true,
1300
+ origin: { format: FORMAT, id: null, title: null, type: "graphics", namespace: XGMML_ORIGIN_NAMESPACE },
1301
+ extra: { sourceDims: this.zAs === "position" && this.hasZ ? 3 : 2, units: "file" },
1302
+ });
1303
+ if (handle !== null) {
1304
+ for (const [index, xyz] of this.values) {
1305
+ sink.setNodeValue(handle, index, xyz);
1306
+ }
1307
+ }
1308
+ }
1309
+ if (this.zValues.size > 0) {
1310
+ const handle = this.emitter.declare("node", {
1311
+ name: Z_COLUMN,
1312
+ dtype: "f64",
1313
+ nullable: true,
1314
+ origin: {
1315
+ format: FORMAT,
1316
+ id: null,
1317
+ title: null,
1318
+ type: "graphics",
1319
+ namespace: CYTOSCAPE_ORIGIN_NAMESPACE,
1320
+ },
1321
+ });
1322
+ if (handle !== null) {
1323
+ for (const [index, z] of this.zValues) {
1324
+ sink.setNodeValue(handle, index, z);
1325
+ }
1326
+ }
1327
+ }
1328
+ }
1329
+ }
1330
+
1331
+ /**
1332
+ * Nested-network pointers: a node-nested graph that is not a group names a network.
1333
+ */
1334
+ class Pointers {
1335
+ private readonly names = new Map<number, string>();
1336
+
1337
+ private readonly crossFile = new Map<number, string>();
1338
+
1339
+ private dangling = 0;
1340
+
1341
+ /**
1342
+ * Create the collector.
1343
+ * @param emitter - the emitter
1344
+ * @param isGroup - the group test
1345
+ */
1346
+ constructor(
1347
+ private readonly emitter: XgmmlEmitter,
1348
+ private readonly isGroup: (node: NodeRec) => boolean,
1349
+ ) {}
1350
+
1351
+ /**
1352
+ * Read one node record's pointers.
1353
+ * @param row - the node row
1354
+ * @param record - the record
1355
+ */
1356
+ add(row: NodeRow, record: NodeRec): void {
1357
+ if (record.nested.length === 0 || this.isGroup(record) || row.index < 0) {
1358
+ return;
1359
+ }
1360
+ for (const graph of record.nested) {
1361
+ if (graph.href === null) {
1362
+ this.names.set(row.index, graph.label ?? graph.id ?? "");
1363
+ continue;
1364
+ }
1365
+ const id = localRef(graph.href);
1366
+ if (id === null) {
1367
+ const resolved = this.emitter.resolvePointer(graph.href);
1368
+ if (resolved !== null) {
1369
+ this.names.set(row.index, resolved);
1370
+ } else {
1371
+ this.crossFile.set(row.index, graph.href);
1372
+ }
1373
+ continue;
1374
+ }
1375
+ const target = this.emitter.graphById(id);
1376
+ if (target === undefined) {
1377
+ this.dangling++;
1378
+ } else {
1379
+ this.names.set(row.index, target.label ?? target.id ?? id);
1380
+ }
1381
+ }
1382
+ }
1383
+
1384
+ /** Write the pointer columns and their warnings. */
1385
+ write(): void {
1386
+ const prefix = this.emitter.prefix();
1387
+ if (this.dangling > 0) {
1388
+ this.emitter.issues.warning(
1389
+ "validation-error",
1390
+ XGMML_ISSUE.DANGLING_REFERENCE,
1391
+ `${prefix}${this.dangling} nested-network pointer(s) name no graph of the document`,
1392
+ );
1393
+ }
1394
+ const names = new JsonColumn(
1395
+ this.emitter,
1396
+ "node",
1397
+ NESTED_NETWORK_COLUMN,
1398
+ CYTOSCAPE_ORIGIN_NAMESPACE,
1399
+ "nestedNetwork",
1400
+ "string",
1401
+ );
1402
+ for (const [index, name] of this.names) {
1403
+ names.set(index, name);
1404
+ }
1405
+ if (this.crossFile.size > 0) {
1406
+ this.emitter.issues.warning(
1407
+ "unsupported",
1408
+ XGMML_ISSUE.CROSS_FILE_REFERENCE,
1409
+ `${prefix}${this.crossFile.size} nested-network pointer(s) point into another file; each is kept as text in ${NETWORK_POINTER_COLUMN}`,
1410
+ );
1411
+ const column = new JsonColumn(
1412
+ this.emitter,
1413
+ "node",
1414
+ NETWORK_POINTER_COLUMN,
1415
+ XGMML_ORIGIN_NAMESPACE,
1416
+ "xlink:href",
1417
+ "string",
1418
+ );
1419
+ for (const [index, href] of this.crossFile) {
1420
+ column.set(index, href);
1421
+ }
1422
+ }
1423
+ }
1424
+ }
1425
+
1426
+ /**
1427
+ * Group containment: every node (declared or referenced) in a group's nested graph has the group
1428
+ * node as a parent; a node in several groups gets the `parents` list instead of `parent`.
1429
+ */
1430
+ class Containment {
1431
+ private readonly parents = new Map<number, number[]>();
1432
+
1433
+ private readonly subgraphs = new Map<number, Record<string, unknown>>();
1434
+
1435
+ /**
1436
+ * Create the resolver.
1437
+ * @param emitter - the emitter
1438
+ */
1439
+ constructor(private readonly emitter: XgmmlEmitter) {}
1440
+
1441
+ /**
1442
+ * Collect the memberships of every group node of the graph.
1443
+ * @param rows - the node rows
1444
+ * @param isGroup - the group test
1445
+ */
1446
+ resolve(rows: readonly NodeRow[], isGroup: (node: NodeRec) => boolean): void {
1447
+ let unknown = 0;
1448
+ for (const row of rows) {
1449
+ for (const record of row.records) {
1450
+ if (row.index < 0 || record.nested.length === 0 || !isGroup(record)) {
1451
+ continue;
1452
+ }
1453
+ for (const graph of record.nested) {
1454
+ const atts = graph.atts.filter((a) => a.name !== null);
1455
+ if (graph.id !== null || graph.label !== null || atts.length > 0) {
1456
+ this.subgraphs.set(row.index, {
1457
+ id: graph.id,
1458
+ label: graph.label,
1459
+ atts: Object.fromEntries(atts.map((a) => [a.name, attJson(a)])),
1460
+ });
1461
+ }
1462
+ for (const member of graph.members) {
1463
+ if (member.kind !== "node") {
1464
+ continue;
1465
+ }
1466
+ const id = member.id ?? (member.href === null ? null : localRef(member.href));
1467
+ const child = id === null ? undefined : this.emitter.rowOf(id);
1468
+ if (child === undefined) {
1469
+ if (id === null || !this.emitter.missingMember(row.id, id)) {
1470
+ unknown++;
1471
+ }
1472
+ continue;
1473
+ }
1474
+ if (child.index >= 0) {
1475
+ this.link(child.index, row.index, member.line);
1476
+ }
1477
+ }
1478
+ }
1479
+ }
1480
+ }
1481
+ if (unknown > 0) {
1482
+ this.emitter.issues.error(
1483
+ "missing-value",
1484
+ XGMML_ISSUE.UNKNOWN_PARENT,
1485
+ `${this.emitter.prefix()}${unknown} group member(s) name no node of this graph`,
1486
+ );
1487
+ }
1488
+ }
1489
+
1490
+ /**
1491
+ * Add one membership unless it closes a parent cycle.
1492
+ * @param child - the member's index
1493
+ * @param parent - the group node's index
1494
+ * @param line - the line
1495
+ */
1496
+ private link(child: number, parent: number, line: number): void {
1497
+ if (child === parent || this.isAncestor(child, parent)) {
1498
+ this.emitter.issues.error(
1499
+ "validation-error",
1500
+ XGMML_ISSUE.PARENT_CYCLE,
1501
+ `${this.emitter.prefix()}a group membership would make a node its own ancestor; that membership is dropped`,
1502
+ { line },
1503
+ );
1504
+ return;
1505
+ }
1506
+ const list = this.parents.get(child) ?? [];
1507
+ if (!list.includes(parent)) {
1508
+ list.push(parent);
1509
+ }
1510
+ this.parents.set(child, list);
1511
+ }
1512
+
1513
+ /**
1514
+ * Whether a node is an ancestor of another through the memberships so far.
1515
+ * @param ancestor - the candidate ancestor
1516
+ * @param node - the node
1517
+ * @returns true when following parents from node reaches ancestor
1518
+ */
1519
+ private isAncestor(ancestor: number, node: number): boolean {
1520
+ const stack = [node];
1521
+ const seen = new Set<number>();
1522
+ while (stack.length > 0) {
1523
+ const n = stack.pop() as number;
1524
+ if (n === ancestor) {
1525
+ return true;
1526
+ }
1527
+ if (seen.has(n)) {
1528
+ continue;
1529
+ }
1530
+ seen.add(n);
1531
+ stack.push(...(this.parents.get(n) ?? []));
1532
+ }
1533
+ return false;
1534
+ }
1535
+
1536
+ /** Declare and write the parent (or parents) and subgraph columns. */
1537
+ write(): void {
1538
+ const sink = this.emitter.target;
1539
+ if (this.parents.size > 0) {
1540
+ const multi = [...this.parents.values()].some((list) => list.length > 1);
1541
+ const handle = this.emitter.declare("node", {
1542
+ name: multi ? PARENTS_COLUMN : PARENT_COLUMN,
1543
+ dtype: multi ? "list" : "u32",
1544
+ itemDtype: multi ? "u32" : undefined,
1545
+ role: multi ? "parents" : "parent",
1546
+ refersTo: "node",
1547
+ nullable: true,
1548
+ origin: { format: FORMAT, id: null, title: null, type: "graph", namespace: null },
1549
+ });
1550
+ if (handle !== null) {
1551
+ for (const [child, list] of this.parents) {
1552
+ sink.setNodeValue(handle, child, multi ? list : list[0]);
1553
+ }
1554
+ }
1555
+ }
1556
+ if (this.subgraphs.size > 0) {
1557
+ const column = new JsonColumn(this.emitter, "node", SUBGRAPH_COLUMN, XGMML_ORIGIN_NAMESPACE, "graph");
1558
+ for (const [index, value] of this.subgraphs) {
1559
+ column.set(index, value);
1560
+ }
1561
+ }
1562
+ }
1563
+ }