@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,1215 @@
1
+ /**
2
+ * The XGMML exporter (design `design/graph-io/cytoscape-and-obo/design.md` section 1.1;
3
+ * `research-xgmml.md` section 4.7): the Cytoscape 3.x dialect, which Cytoscape 3 opens with every
4
+ * attribute type intact. It writes `cy:documentVersion="3.0"`, the root `directed` from the
5
+ * snapshot and `cy:directed` on every edge (Cytoscape ignores the root attribute), `type` plus
6
+ * `cy:type` (and `cy:elementType`) on every att, positions as `graphics x y z` with y negated
7
+ * back to screen coordinates, the importer's `graphics` json columns back as graphics attributes
8
+ * and nested atts, containment as each group node's nested graph of `xlink:href` member
9
+ * references (every node is declared at the top level, so the node order survives), and newline
10
+ * and tab as character references unless `cytoscapeEscapes` asks for Cytoscape's two-character
11
+ * form. It writes the graph's structure and columns, never styles.
12
+ */
13
+
14
+ import { type Column, GraphFormatError, type GraphSnapshot, type NodeId } from "@graphty/graph-format";
15
+
16
+ import { type ChildrenCsr, childrenCsr } from "../../children.js";
17
+ import { DictHeuristic } from "../../common/attributes.js";
18
+ import { type PairFolding, pairFolding } from "../../common/direction.js";
19
+ import { escapeXmlAttribute, escapeXmlText } from "../../common/escape.js";
20
+ import { capabilities, checkCapabilities, LOSS } from "../../common/export.js";
21
+ import { formatF32, formatF64 } from "../../common/format.js";
22
+ import { type ResolvedExportOptions, resolveExportOptions } from "../../common/options.js";
23
+ import { type ExplicitWeights, explicitWeights } from "../../common/weights.js";
24
+ import { encodeChunks, joinText } from "../../common/writer.js";
25
+ import { xmlIllegalTextNotes } from "../../common/xml.js";
26
+ import { type CommonExportOptions, type ExportCapabilities, type GraphExporter, type LossNote } from "../../types.js";
27
+ import {
28
+ CY_NAMESPACE,
29
+ CYTOSCAPE_ORIGIN_NAMESPACE,
30
+ EDGE_ID_COLUMN,
31
+ FORMAT,
32
+ GRAPHICS_COLUMN,
33
+ INTERACTION_COLUMN,
34
+ LABEL_COLUMN,
35
+ META_KEY,
36
+ NESTED_NETWORK_COLUMN,
37
+ NETWORK_POINTER_COLUMN,
38
+ NETWORKS_COLUMN,
39
+ PARENT_COLUMN,
40
+ PARENTS_COLUMN,
41
+ POSITION_COLUMN,
42
+ SUBGRAPH_COLUMN,
43
+ XGMML_LOSS,
44
+ XGMML_NAMESPACE,
45
+ XGMML_ORIGIN_NAMESPACE,
46
+ XLINK_NAMESPACE,
47
+ Z_COLUMN,
48
+ } from "./constants.js";
49
+ import { aliasesOf } from "./emit.js";
50
+
51
+ /** The format-specific options of the XGMML exporter. */
52
+ export interface XgmmlExportOptions {
53
+ /**
54
+ * Write newline and tab in string values as Cytoscape's two-character `\n` and `\t` (what the
55
+ * Cytoscape writer does) instead of the character references `
` and `	` (default).
56
+ */
57
+ cytoscapeEscapes?: boolean | undefined;
58
+ }
59
+
60
+ /** What XGMML keeps (design section 1.1, all 16 fields). */
61
+ const CAPABILITIES: ExportCapabilities = capabilities({
62
+ mixedDirection: true,
63
+ multiEdges: true,
64
+ selfLoops: true,
65
+ edgeIds: "optional",
66
+ idCharset: "any",
67
+ dtypes: ["string", "dict", "f64", "f32", "i32", "u32", "u8", "bool", "list"],
68
+ components: false,
69
+ lists: true,
70
+ json: false,
71
+ defaults: false,
72
+ options: false,
73
+ hierarchy: true,
74
+ temporal: "none",
75
+ graphAttributes: true,
76
+ positions: true,
77
+ viz: false,
78
+ });
79
+
80
+ /** The roles with a slot: label (XML attribute), the edge id, containment and position. */
81
+ const SLOT_ROLES: ReadonlySet<string> = new Set(["label", "id", "parent", "parents", "position"]);
82
+
83
+ /** The names the importer gives the slot columns. */
84
+ const ROLE_NAMES: Readonly<Record<string, string>> = Object.freeze({
85
+ label: LABEL_COLUMN,
86
+ id: EDGE_ID_COLUMN,
87
+ parent: PARENT_COLUMN,
88
+ position: POSITION_COLUMN,
89
+ });
90
+
91
+ /** Roles checkCapabilities() reports as not written (no slot in XGMML): the column is skipped. */
92
+ const DROPPED_ROLES: ReadonlySet<string> = new Set([
93
+ "color",
94
+ "size",
95
+ "shape",
96
+ "thickness",
97
+ "start",
98
+ "end",
99
+ "timestamp",
100
+ "timestamps",
101
+ "spells",
102
+ "open",
103
+ "spellsOpen",
104
+ ]);
105
+
106
+ /** XML attributes of `<node>` and `<edge>` the importer reads itself; a column of such a name is an att. */
107
+ const READ_ATTRIBUTES: Readonly<Record<"node" | "edge", ReadonlySet<string>>> = {
108
+ node: new Set(["id", "label", "name"]),
109
+ edge: new Set(["id", "label", "name", "source", "target", "weight"]),
110
+ };
111
+
112
+ /** Roles never written as atts. */
113
+ const STRUCTURAL_ROLES: ReadonlySet<string> = new Set([
114
+ "directed",
115
+ "pair",
116
+ "mutual",
117
+ "weight",
118
+ "parent",
119
+ "parents",
120
+ "position",
121
+ ]);
122
+
123
+ /** Node column names the importer owns; a plain column of such a name reads back renamed. */
124
+ const RESERVED_NODE_NAMES: ReadonlySet<string> = new Set([
125
+ LABEL_COLUMN,
126
+ POSITION_COLUMN,
127
+ Z_COLUMN,
128
+ GRAPHICS_COLUMN,
129
+ PARENT_COLUMN,
130
+ PARENTS_COLUMN,
131
+ SUBGRAPH_COLUMN,
132
+ NESTED_NETWORK_COLUMN,
133
+ NETWORK_POINTER_COLUMN,
134
+ NETWORKS_COLUMN,
135
+ ]);
136
+
137
+ /** Edge column names the importer owns. */
138
+ const RESERVED_EDGE_NAMES: ReadonlySet<string> = new Set([EDGE_ID_COLUMN, LABEL_COLUMN, GRAPHICS_COLUMN]);
139
+
140
+ /** The importer-owned names of each table. */
141
+ const RESERVED_NAMES: Readonly<Record<"graph" | "node" | "edge", ReadonlySet<string>>> = {
142
+ graph: new Set([GRAPHICS_COLUMN]),
143
+ node: RESERVED_NODE_NAMES,
144
+ edge: RESERVED_EDGE_NAMES,
145
+ };
146
+
147
+ /** The XGMML and Cytoscape `<graphics>` XML attributes; every other graphics key is a nested att. */
148
+ const GRAPHICS_ATTRIBUTES: ReadonlySet<string> = new Set([
149
+ "type",
150
+ "w",
151
+ "h",
152
+ "d",
153
+ "image",
154
+ "bitmap",
155
+ "width",
156
+ "arrow",
157
+ "capstyle",
158
+ "joinstyle",
159
+ "smooth",
160
+ "splinesteps",
161
+ "justify",
162
+ "font",
163
+ "background",
164
+ "foreground",
165
+ "extent",
166
+ "start",
167
+ "style",
168
+ "stipple",
169
+ "visible",
170
+ "fill",
171
+ "outline",
172
+ "anchor",
173
+ ]);
174
+
175
+ const I32_MAX = 2147483647;
176
+
177
+ /** A note-recording callback. */
178
+ type NoteFn = (code: string, message: string, column?: string | null, count?: number | null) => void;
179
+
180
+ /** How one column is written. */
181
+ interface ColumnWrite {
182
+ readonly column: Column;
183
+ readonly name: string;
184
+ /** "att" for a typed att, or the importer-owned slot it goes to. */
185
+ readonly kind: "att" | "xml" | "label" | "graphics" | "z" | "subgraph" | "nested" | "pointer" | "networks";
186
+ /** The att's type and cy:type; null when the source att had none. */
187
+ readonly type: string | null;
188
+ readonly cyType: string | null;
189
+ readonly elementType: string | null;
190
+ }
191
+
192
+ /** Everything check() and export() agree on. */
193
+ interface Plan {
194
+ readonly options: ResolvedExportOptions;
195
+ readonly escapes: boolean;
196
+ readonly notes: LossNote[];
197
+ readonly graph: ColumnWrite[];
198
+ readonly nodes: ColumnWrite[];
199
+ readonly edges: ColumnWrite[];
200
+ readonly folding: PairFolding;
201
+ readonly weights: ExplicitWeights;
202
+ readonly hierarchy: ChildrenCsr;
203
+ /** 1 for a node a traversal from the roots reaches; the others are written without parents. */
204
+ readonly reachable: Uint8Array;
205
+ readonly position: Column | null;
206
+ readonly edgeId: Column | null;
207
+ }
208
+
209
+ /**
210
+ * The Cytoscape type of a scalar dtype and the XGMML type.
211
+ * @param column - the column
212
+ * @param dtype - the dtype (a list's item dtype)
213
+ * @returns [type, cy:type]
214
+ */
215
+ function typesOf(column: Column, dtype: string | null): [string, string] {
216
+ if (column.meta.components > 1) {
217
+ return ["string", "String"];
218
+ }
219
+ switch (dtype) {
220
+ case "bool":
221
+ return ["boolean", "Boolean"];
222
+ case "i32":
223
+ case "u8":
224
+ return ["integer", "Integer"];
225
+ case "u32":
226
+ return maxValue(column) > I32_MAX ? ["integer", "Long"] : ["integer", "Integer"];
227
+ case "f32":
228
+ case "f64":
229
+ return column.meta.origin?.type === "long" && allSafeIntegers(column)
230
+ ? ["integer", "Long"]
231
+ : ["real", "Double"];
232
+ default:
233
+ return ["string", "String"];
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Whether every value of a numeric column (or of its list items) is a safe integer.
239
+ * @param column - the column
240
+ * @returns true when Long can carry every value
241
+ */
242
+ function allSafeIntegers(column: Column): boolean {
243
+ for (let r = 0; r < column.length; r++) {
244
+ if (!column.isSet(r)) {
245
+ continue;
246
+ }
247
+ const items: unknown[] = column.dtype === "list" ? [...column.sliceOf(r)] : [column.value(r)];
248
+ if (!items.every((item) => typeof item === "number" && Number.isSafeInteger(item))) {
249
+ return false;
250
+ }
251
+ }
252
+ return true;
253
+ }
254
+
255
+ /**
256
+ * The largest value of a u32 column (or of its list items).
257
+ * @param column - the column
258
+ * @returns the maximum, 0 when empty
259
+ */
260
+ function maxValue(column: Column): number {
261
+ let max = 0;
262
+ for (let r = 0; r < column.length; r++) {
263
+ if (!column.isSet(r)) {
264
+ continue;
265
+ }
266
+ const value = column.value(r);
267
+ const items: unknown[] = column.dtype === "list" ? [...column.sliceOf(r)] : [value];
268
+ for (const item of items) {
269
+ if (typeof item === "number" && item > max) {
270
+ max = item;
271
+ }
272
+ }
273
+ }
274
+ return max;
275
+ }
276
+
277
+ /**
278
+ * Whether a column came from the XGMML importer's own slot of that name.
279
+ * @param column - the column
280
+ * @param namespace - the origin namespace the importer gives it
281
+ * @returns true for an importer-owned column
282
+ */
283
+ function owned(column: Column, namespace: string): boolean {
284
+ const { origin } = column.meta;
285
+ return origin !== null && origin.namespace === namespace;
286
+ }
287
+
288
+ /**
289
+ * Plan the columns of one table.
290
+ * @param table - the columns
291
+ * @param domain - the table
292
+ * @param snapshot - the snapshot
293
+ * @param note - the recorder
294
+ * @returns the writes
295
+ */
296
+ function planTable(
297
+ table: Iterable<Column>,
298
+ domain: "graph" | "node" | "edge",
299
+ snapshot: GraphSnapshot,
300
+ note: NoteFn,
301
+ ): ColumnWrite[] {
302
+ const writes: ColumnWrite[] = [];
303
+ const reserved = RESERVED_NAMES[domain];
304
+ const hasLabelRole =
305
+ domain !== "graph" && (domain === "node" ? snapshot.nodes : snapshot.edges).byRole("label") !== null;
306
+ for (const column of table) {
307
+ const { meta } = column;
308
+ if (
309
+ meta.role !== null &&
310
+ (STRUCTURAL_ROLES.has(meta.role) ||
311
+ DROPPED_ROLES.has(meta.role) ||
312
+ meta.role === "id" ||
313
+ meta.role === "timeText")
314
+ ) {
315
+ continue;
316
+ }
317
+ const kind = slotOf(column, domain, hasLabelRole);
318
+ const set = column.length - column.nullCount;
319
+ if (kind === "att" && reserved.has(meta.name)) {
320
+ note(
321
+ LOSS.COLUMN_NAME_CHANGED,
322
+ `${domain} column "${meta.name}" is named like a column the XGMML importer owns and reads back renamed`,
323
+ meta.name,
324
+ set,
325
+ );
326
+ } else if (kind === "label" && meta.dtype !== "string" && meta.dtype !== "dict") {
327
+ note(
328
+ LOSS.DTYPE,
329
+ `${domain} label column "${meta.name}" (${meta.dtype}) is written as text and reads back as string`,
330
+ meta.name,
331
+ set,
332
+ );
333
+ } else if (kind === "label" && meta.role === null) {
334
+ note(
335
+ LOSS.ROLE_ASSUMED,
336
+ `${domain} column "${meta.name}" is written as the label and reads back with the label role`,
337
+ meta.name,
338
+ set,
339
+ );
340
+ }
341
+ if (kind === "att") {
342
+ attNotes(column, domain, note);
343
+ }
344
+ const item = meta.dtype === "list" ? meta.itemDtype : meta.dtype;
345
+ const [type, cyType] = meta.dtype === "list" ? ["list", "List"] : declaredTypes(column, typesOf(column, item));
346
+ writes.push({
347
+ column,
348
+ name: meta.name,
349
+ kind,
350
+ type,
351
+ cyType,
352
+ elementType: meta.dtype === "list" ? typesOf(column, item)[1] : null,
353
+ });
354
+ }
355
+ return writes;
356
+ }
357
+
358
+ /**
359
+ * Where a column goes.
360
+ * @param column - the column
361
+ * @param domain - the table
362
+ * @param hasLabelRole - whether the table has a label role column
363
+ * @returns the slot
364
+ */
365
+ function slotOf(column: Column, domain: "graph" | "node" | "edge", hasLabelRole: boolean): ColumnWrite["kind"] {
366
+ const { meta } = column;
367
+ if (domain !== "graph" && untypedAttribute(column, domain)) {
368
+ return "xml";
369
+ }
370
+ if (
371
+ meta.role === "label" ||
372
+ (!hasLabelRole &&
373
+ domain !== "graph" &&
374
+ meta.role === null &&
375
+ meta.name === LABEL_COLUMN &&
376
+ (meta.dtype === "string" || meta.dtype === "dict"))
377
+ ) {
378
+ return "label";
379
+ }
380
+ if (meta.dtype === "json" && meta.name === GRAPHICS_COLUMN && owned(column, XGMML_ORIGIN_NAMESPACE)) {
381
+ return "graphics";
382
+ }
383
+ if (domain !== "node") {
384
+ return "att";
385
+ }
386
+ if (meta.name === Z_COLUMN && meta.dtype === "f64" && meta.role === null) {
387
+ return "z";
388
+ }
389
+ if (meta.name === SUBGRAPH_COLUMN && meta.dtype === "json" && owned(column, XGMML_ORIGIN_NAMESPACE)) {
390
+ return "subgraph";
391
+ }
392
+ if (meta.name === NESTED_NETWORK_COLUMN && meta.dtype === "string" && owned(column, CYTOSCAPE_ORIGIN_NAMESPACE)) {
393
+ return "nested";
394
+ }
395
+ if (meta.name === NETWORK_POINTER_COLUMN && meta.dtype === "string" && owned(column, XGMML_ORIGIN_NAMESPACE)) {
396
+ return "pointer";
397
+ }
398
+ if (meta.name === NETWORKS_COLUMN && meta.dtype === "list" && owned(column, XGMML_ORIGIN_NAMESPACE)) {
399
+ return "networks";
400
+ }
401
+ return "att";
402
+ }
403
+
404
+ /**
405
+ * Whether a column came from an XML attribute of the element (the importer's 5.1 text grammar:
406
+ * xgmml origin, no declared type) and can be written back as one, so it reads back the same.
407
+ * @param column - the column
408
+ * @param domain - node or edge
409
+ * @returns true to write it as an XML attribute
410
+ */
411
+ function untypedAttribute(column: Column, domain: "node" | "edge"): boolean {
412
+ const { meta } = column;
413
+ const { origin } = meta;
414
+ return (
415
+ origin !== null &&
416
+ origin.format === FORMAT &&
417
+ origin.type === null &&
418
+ origin.namespace === null &&
419
+ meta.role === null &&
420
+ meta.components === 1 &&
421
+ (meta.dtype === "string" || meta.dtype === "bool" || meta.dtype === "i32" || meta.dtype === "f64") &&
422
+ /^[A-Za-z_][A-Za-z0-9_.-]*$/.test(meta.name) &&
423
+ !READ_ATTRIBUTES[domain].has(meta.name)
424
+ );
425
+ }
426
+
427
+ /**
428
+ * The type and cy:type of an att: the source's own declaration when the column came from an
429
+ * XGMML att that declared only `type` (a draft or 2.x file), else both.
430
+ * @param column - the column
431
+ * @param types - the type and cy:type of its dtype
432
+ * @returns the pair to write; a null member is not written
433
+ */
434
+ function declaredTypes(column: Column, types: [string, string]): [string | null, string | null] {
435
+ const { origin } = column.meta;
436
+ if (origin !== null && origin.format === FORMAT && origin.type === types[0] && column.meta.components === 1) {
437
+ return [types[0], null];
438
+ }
439
+ return types;
440
+ }
441
+
442
+ /**
443
+ * The notes of a column written as a typed att: dtypes Cytoscape widens, json written as text,
444
+ * literal backslash escapes, the string / dict heuristic.
445
+ * @param column - the column
446
+ * @param domain - the table
447
+ * @param note - the recorder
448
+ */
449
+ function attNotes(column: Column, domain: string, note: NoteFn): void {
450
+ const { meta } = column;
451
+ const set = column.length - column.nullCount;
452
+ const label = `${domain} column "${meta.name}"`;
453
+ const item = meta.dtype === "list" ? meta.itemDtype : meta.dtype;
454
+ if (meta.dtype === "json") {
455
+ note(
456
+ XGMML_LOSS.JSON_AS_STRING,
457
+ `${label} holds nested values; they are written as JSON text and read back as strings`,
458
+ meta.name,
459
+ set,
460
+ );
461
+ return;
462
+ }
463
+ if (item === "f32" || item === "u8" || item === "u32") {
464
+ note(
465
+ XGMML_LOSS.WIDENED_TYPE,
466
+ `${label} (${item}) is written as a wider Cytoscape type and reads back as ${readBackDtype(column, item)}`,
467
+ meta.name,
468
+ set,
469
+ );
470
+ }
471
+ if (item === "string" || item === "dict") {
472
+ let escapes = 0;
473
+ for (let r = 0; r < column.length; r++) {
474
+ if (column.isSet(r)) {
475
+ const values = column.dtype === "list" ? [...column.sliceOf(r)] : [column.value(r)];
476
+ if (values.some((v) => typeof v === "string" && /\\[nt]/.test(v))) {
477
+ escapes++;
478
+ }
479
+ }
480
+ }
481
+ if (escapes > 0) {
482
+ note(
483
+ XGMML_LOSS.BACKSLASH_ESCAPE,
484
+ `${label}: ${escapes} value(s) hold a literal \\n or \\t, which Cytoscape's escape convention reads back as a newline or tab`,
485
+ meta.name,
486
+ escapes,
487
+ );
488
+ }
489
+ }
490
+ if (meta.dtype === "string" || meta.dtype === "dict") {
491
+ const heuristic = new DictHeuristic();
492
+ for (let r = 0; r < column.length && !heuristic.decided; r++) {
493
+ if (column.isSet(r)) {
494
+ heuristic.observe(column.value(r) as string);
495
+ }
496
+ }
497
+ const readsAs = heuristic.decide();
498
+ if (readsAs !== meta.dtype) {
499
+ note(
500
+ XGMML_LOSS.STORAGE_CLASS_CHANGED,
501
+ `${label} reads back as ${readsAs} (the cardinality heuristic)`,
502
+ meta.name,
503
+ null,
504
+ );
505
+ }
506
+ }
507
+ }
508
+
509
+ /**
510
+ * The dtype a widened column reads back as.
511
+ * @param column - the column
512
+ * @param item - its dtype (a list's item dtype)
513
+ * @returns the dtype the importer gives it
514
+ */
515
+ function readBackDtype(column: Column, item: string): string {
516
+ if (item === "u8" || (item === "u32" && maxValue(column) <= I32_MAX)) {
517
+ return "i32";
518
+ }
519
+ return "f64";
520
+ }
521
+
522
+ /**
523
+ * Build the plan and its loss notes.
524
+ * @param snapshot - the snapshot
525
+ * @param options - the common options
526
+ * @param escapes - the cytoscapeEscapes option
527
+ * @returns the plan
528
+ */
529
+ function planExport(snapshot: GraphSnapshot, options: ResolvedExportOptions, escapes: boolean): Plan {
530
+ const notes = checkCapabilities(snapshot, CAPABILITIES, options, {
531
+ roles: SLOT_ROLES,
532
+ roleNames: ROLE_NAMES,
533
+ positionDtype: "f32",
534
+ }).filter((n) => n.code !== LOSS.JSON);
535
+ const note: NoteFn = (code, message, column = null, count = null): void => {
536
+ notes.push(Object.freeze({ code, message, column, count }));
537
+ };
538
+ notes.push(...xmlIllegalTextNotes(snapshot));
539
+ const folding = pairFolding(snapshot);
540
+ if (folding.mutualCount > 0) {
541
+ note(
542
+ XGMML_LOSS.MUTUAL_EXPANDED,
543
+ `${folding.mutualCount} mutual pair(s) are written as two directed edges; the mutual mark is lost`,
544
+ null,
545
+ folding.mutualCount,
546
+ );
547
+ }
548
+ const numericIds = countNumericIds(snapshot);
549
+ if (numericIds > 0) {
550
+ note(
551
+ XGMML_LOSS.ID_TEXT_TYPE,
552
+ `${numericIds} numeric node id(s) are written as text and read back as strings`,
553
+ null,
554
+ numericIds,
555
+ );
556
+ }
557
+ const edgeId = snapshot.edges.byRole("id");
558
+ if (edgeId !== null && edgeId.dtype !== "string" && edgeId.dtype !== "dict") {
559
+ note(
560
+ XGMML_LOSS.EDGE_ID_TEXT,
561
+ `edge id column "${edgeId.meta.name}" (${edgeId.dtype}) is written as text and reads back as strings`,
562
+ edgeId.meta.name,
563
+ null,
564
+ );
565
+ }
566
+ const hierarchy = childrenCsr(snapshot);
567
+ if (hierarchy.unreachable > 0) {
568
+ note(
569
+ XGMML_LOSS.PARENT_CYCLE,
570
+ `${hierarchy.unreachable} node(s) whose parent chain never reaches a root are written at the top level and lose their parent`,
571
+ null,
572
+ hierarchy.unreachable,
573
+ );
574
+ }
575
+ const parents = snapshot.nodes.byRole("parents");
576
+ if (parents !== null && snapshot.nodes.byRole("parent") !== null) {
577
+ note(
578
+ XGMML_LOSS.PARENTS_DROPPED,
579
+ `parents column "${parents.meta.name}" is not written: the "parent" column is the containment written`,
580
+ parents.meta.name,
581
+ parents.length - parents.nullCount,
582
+ );
583
+ } else if (parents !== null && !multiParent(parents)) {
584
+ note(
585
+ LOSS.COLUMN_NAME_CHANGED,
586
+ `parents column "${parents.meta.name}" holds one parent per node and reads back as the "parent" column`,
587
+ parents.meta.name,
588
+ null,
589
+ );
590
+ }
591
+ const position = snapshot.nodes.byRole("position");
592
+ if (position !== null) {
593
+ positionNotes(position, snapshot, note);
594
+ }
595
+ interactionNote(snapshot, note);
596
+ const weights = explicitWeights(snapshot);
597
+ for (const column of snapshot.edges) {
598
+ if (column.meta.name === "weight" && column.meta.role === null && !weights.weighted) {
599
+ note(LOSS.WEIGHT_KEY_CLASH, `edge column "weight" reads back as THE weight`, "weight", null);
600
+ }
601
+ }
602
+ return {
603
+ options,
604
+ escapes,
605
+ notes,
606
+ graph: planTable(snapshot.graph, "graph", snapshot, note),
607
+ nodes: planTable(snapshot.nodes, "node", snapshot, note),
608
+ edges: planTable(snapshot.edges, "edge", snapshot, note),
609
+ folding,
610
+ weights,
611
+ hierarchy,
612
+ reachable: reachableNodes(snapshot.nodeCount, hierarchy),
613
+ position,
614
+ edgeId,
615
+ };
616
+ }
617
+
618
+ /**
619
+ * The note for edge labels the importer reads as Cytoscape label aliases (`a (i) b`): without an
620
+ * `interaction` column they read back with one, as Cytoscape fills it.
621
+ * @param snapshot - the snapshot
622
+ * @param note - the recorder
623
+ */
624
+ function interactionNote(snapshot: GraphSnapshot, note: NoteFn): void {
625
+ const label = snapshot.edges.byRole("label") ?? snapshot.edges.get(LABEL_COLUMN);
626
+ if (label === null || snapshot.edges.get(INTERACTION_COLUMN) !== null) {
627
+ return;
628
+ }
629
+ let shaped = 0;
630
+ for (let e = 0; e < label.length; e++) {
631
+ if (label.isSet(e) && aliasesOf(scalarText(label.value(e))) !== null) {
632
+ shaped++;
633
+ }
634
+ }
635
+ if (shaped > 0) {
636
+ note(
637
+ XGMML_LOSS.INTERACTION_FROM_LABEL,
638
+ `${shaped} edge label(s) have Cytoscape's "a (i) b" shape and read back with an "${INTERACTION_COLUMN}" column`,
639
+ INTERACTION_COLUMN,
640
+ shaped,
641
+ );
642
+ }
643
+ }
644
+
645
+ /**
646
+ * The nodes a traversal of the containment from its roots reaches.
647
+ * @param nodeCount - the number of nodes
648
+ * @param hierarchy - the children CSR
649
+ * @returns 1 per reached node
650
+ */
651
+ function reachableNodes(nodeCount: number, hierarchy: ChildrenCsr): Uint8Array {
652
+ const reached = new Uint8Array(nodeCount);
653
+ const stack = Array.from(hierarchy.roots);
654
+ for (const root of stack) {
655
+ reached[root] = 1;
656
+ }
657
+ while (stack.length > 0) {
658
+ for (const child of hierarchy.childrenOf(stack.pop() as number)) {
659
+ if (reached[child] === 0) {
660
+ reached[child] = 1;
661
+ stack.push(child);
662
+ }
663
+ }
664
+ }
665
+ return reached;
666
+ }
667
+
668
+ /**
669
+ * Whether a parents column gives some node more than one parent.
670
+ * @param column - the parents column
671
+ * @returns true when a row holds two or more parents
672
+ */
673
+ function multiParent(column: Column): boolean {
674
+ for (let r = 0; r < column.length; r++) {
675
+ if (column.isSet(r) && column.dtype === "list" && column.sliceOf(r).length > 1) {
676
+ return true;
677
+ }
678
+ }
679
+ return false;
680
+ }
681
+
682
+ /**
683
+ * The notes of a position column: its shape, non-finite coordinates, and a z that reads back in
684
+ * the z column.
685
+ * @param position - the position column
686
+ * @param snapshot - the snapshot
687
+ * @param note - the recorder
688
+ */
689
+ function positionNotes(position: Column, snapshot: GraphSnapshot, note: NoteFn): void {
690
+ const { meta } = position;
691
+ if (meta.components !== 3) {
692
+ note(
693
+ XGMML_LOSS.POSITION,
694
+ `position column "${meta.name}" has ${meta.components} component(s) and reads back with 3`,
695
+ meta.name,
696
+ null,
697
+ );
698
+ }
699
+ let nonFinite = 0;
700
+ let depth = 0;
701
+ for (let r = 0; r < position.length; r++) {
702
+ if (!position.isSet(r)) {
703
+ continue;
704
+ }
705
+ const xyz = Array.from(position.value(r) as ArrayLike<number>);
706
+ if (xyz.slice(0, 2).some((v) => !Number.isFinite(v))) {
707
+ nonFinite++;
708
+ } else if ((xyz[2] ?? 0) !== 0) {
709
+ depth++;
710
+ }
711
+ }
712
+ if (nonFinite > 0) {
713
+ note(
714
+ XGMML_LOSS.POSITION,
715
+ `${nonFinite} position(s) with a non-finite coordinate are not written`,
716
+ meta.name,
717
+ nonFinite,
718
+ );
719
+ }
720
+ const zColumn = snapshot.nodes.get(Z_COLUMN);
721
+ if (depth > 0) {
722
+ note(
723
+ XGMML_LOSS.POSITION,
724
+ zColumn === null
725
+ ? `${depth} position(s) have a z; it is written as graphics z and reads back in the z column (zAs: "position" reads it as a coordinate)`
726
+ : `${depth} position(s) have a z, but graphics z holds the z column; the position z is lost`,
727
+ meta.name,
728
+ depth,
729
+ );
730
+ }
731
+ }
732
+
733
+ /**
734
+ * Node ids that are numbers (written as text, read back as strings).
735
+ * @param snapshot - the snapshot
736
+ * @returns the count
737
+ */
738
+ function countNumericIds(snapshot: GraphSnapshot): number {
739
+ let n = 0;
740
+ for (let i = 0; i < snapshot.nodeCount; i++) {
741
+ if (typeof snapshot.ids.idOf(i) === "number") {
742
+ n++;
743
+ }
744
+ }
745
+ return n;
746
+ }
747
+
748
+ /**
749
+ * The text of a scalar for an att value.
750
+ * @param value - the value
751
+ * @param dtype - its dtype
752
+ * @param escapes - Cytoscape's two-character newline and tab
753
+ * @returns the attribute text, escaped
754
+ */
755
+ function valueText(value: unknown, dtype: string | null, escapes: boolean): string {
756
+ let text: string;
757
+ if (typeof value === "boolean") {
758
+ text = value ? "1" : "0";
759
+ } else if (typeof value === "number") {
760
+ text = dtype === "f32" ? formatF32(value) : formatF64(value);
761
+ } else if (typeof value === "string") {
762
+ text = escapes ? value.replace(/\n/g, "\\n").replace(/\t/g, "\\t") : value;
763
+ } else if (ArrayBuffer.isView(value)) {
764
+ text = JSON.stringify(Array.from(value as unknown as ArrayLike<number>));
765
+ } else {
766
+ text = JSON.stringify(value) ?? "";
767
+ }
768
+ return escapeXmlAttribute(text);
769
+ }
770
+
771
+ /**
772
+ * The text of a scalar cell (a label, an edge id, a graphics value): numbers in their shortest
773
+ * form, anything that is not a scalar as JSON.
774
+ * @param value - the value
775
+ * @returns the text
776
+ */
777
+ function scalarText(value: unknown): string {
778
+ if (typeof value === "string") {
779
+ return value;
780
+ }
781
+ if (typeof value === "number") {
782
+ return formatF64(value);
783
+ }
784
+ if (typeof value === "boolean") {
785
+ return value ? "true" : "false";
786
+ }
787
+ return JSON.stringify(value) ?? "";
788
+ }
789
+
790
+ /**
791
+ * The text of an id.
792
+ * @param id - the id
793
+ * @returns the text
794
+ */
795
+ function idText(id: NodeId): string {
796
+ return typeof id === "number" ? formatF64(id) : id;
797
+ }
798
+
799
+ /**
800
+ * The XML attributes of one row: the columns that came from XML attributes (`untypedAttribute`).
801
+ * @param writes - the column writes of the table
802
+ * @param row - the row
803
+ * @returns the attributes, each with a leading space
804
+ */
805
+ function xmlAttributesOf(writes: readonly ColumnWrite[], row: number): string {
806
+ let out = "";
807
+ for (const w of writes) {
808
+ if (w.kind === "xml" && w.column.isSet(row)) {
809
+ out += ` ${w.name}="${escapeXmlAttribute(scalarText(w.column.value(row)))}"`;
810
+ }
811
+ }
812
+ return out;
813
+ }
814
+
815
+ /**
816
+ * The atts of one row of a table.
817
+ * @param writes - the column writes of the table
818
+ * @param row - the row
819
+ * @param first - whether this is the table's first row (an all-unset column is declared there)
820
+ * @param plan - the plan
821
+ * @param indent - the indentation
822
+ * @yields the att elements
823
+ * @returns nothing
824
+ */
825
+ function* attsOf(
826
+ writes: readonly ColumnWrite[],
827
+ row: number,
828
+ first: boolean,
829
+ plan: Plan,
830
+ indent: string,
831
+ ): Generator<string, void, undefined> {
832
+ for (const w of writes) {
833
+ if (w.kind !== "att") {
834
+ continue;
835
+ }
836
+ const { column } = w;
837
+ const isSet = column.isSet(row);
838
+ if (!isSet && !(first && column.nullCount === column.length)) {
839
+ continue;
840
+ }
841
+ const name = `${indent}<att name="${escapeXmlAttribute(w.name)}"`;
842
+ const types = `${w.type === null ? "" : ` type="${w.type}"`}${w.cyType === null ? "" : ` cy:type="${w.cyType}"`}`;
843
+ const extra = `${column.meta.extra.hidden === true ? ' cy:hidden="1"' : ""}${column.meta.extra.equation === true ? ' cy:equation="1"' : ""}`;
844
+ if (!isSet) {
845
+ yield `${name}${types}${w.elementType === null ? "" : ` cy:elementType="${w.elementType}"`}${extra}/>\n`;
846
+ continue;
847
+ }
848
+ if (column.dtype === "list") {
849
+ const items = [...column.sliceOf(row)];
850
+ const [itemType, itemCy] = typesOf(column, column.meta.itemDtype);
851
+ yield `${name}${types} cy:elementType="${w.elementType ?? "String"}"${extra}${items.length === 0 ? "/>" : ">"}\n`;
852
+ if (items.length > 0) {
853
+ for (const item of items) {
854
+ yield `${indent} <att name="${escapeXmlAttribute(w.name)}" value="${valueText(item, column.meta.itemDtype, plan.escapes)}" type="${itemType}" cy:type="${itemCy}"/>\n`;
855
+ }
856
+ yield `${indent}</att>\n`;
857
+ }
858
+ continue;
859
+ }
860
+ yield `${name} value="${valueText(column.value(row), column.dtype, plan.escapes)}"${types}${extra}/>\n`;
861
+ }
862
+ }
863
+
864
+ /**
865
+ * A graphics record as `<graphics>` XML attributes and nested atts.
866
+ * @param record - the record (the importer's graphics json value)
867
+ * @param coords - the x, y, z texts to write, or empty
868
+ * @param indent - the indentation
869
+ * @yields the graphics element
870
+ * @returns nothing
871
+ */
872
+ function* graphicsOf(
873
+ record: Record<string, unknown> | null,
874
+ coords: readonly [string, string][],
875
+ indent: string,
876
+ ): Generator<string, void, undefined> {
877
+ const attrs: [string, string][] = [...coords];
878
+ const nested: [string, unknown][] = [];
879
+ for (const [key, value] of Object.entries(record ?? {})) {
880
+ if (key === "Line") {
881
+ nested.push([key, value]);
882
+ } else if (typeof value === "string" && (GRAPHICS_ATTRIBUTES.has(key) || key.startsWith("cy:"))) {
883
+ attrs.push([key, value]);
884
+ } else {
885
+ nested.push([key, value]);
886
+ }
887
+ }
888
+ if (attrs.length === 0 && nested.length === 0) {
889
+ return;
890
+ }
891
+ const head = `${indent}<graphics${attrs.map(([k, v]) => ` ${k}="${escapeXmlAttribute(v)}"`).join("")}`;
892
+ if (nested.length === 0) {
893
+ yield `${head}/>\n`;
894
+ return;
895
+ }
896
+ yield `${head}>\n`;
897
+ for (const [key, value] of nested) {
898
+ if (key === "Line" && Array.isArray(value)) {
899
+ yield `${indent} <Line>\n`;
900
+ for (const point of value) {
901
+ const entries =
902
+ typeof point === "object" && point !== null ? Object.entries(point as Record<string, unknown>) : [];
903
+ yield `${indent} <point${entries.map(([k, v]) => ` ${k}="${escapeXmlAttribute(String(v))}"`).join("")}/>\n`;
904
+ }
905
+ yield `${indent} </Line>\n`;
906
+ } else {
907
+ yield* jsonAtt(key, value, `${indent} `);
908
+ }
909
+ }
910
+ yield `${indent}</graphics>\n`;
911
+ }
912
+
913
+ /**
914
+ * A graphics value as an att: a string as its value, an array as a list of child atts, a record
915
+ * as named child atts (a record of strings with no further structure keeps its keys).
916
+ * @param name - the att name
917
+ * @param value - the value
918
+ * @param indent - the indentation
919
+ * @yields the att element
920
+ * @returns nothing
921
+ */
922
+ function* jsonAtt(name: string | null, value: unknown, indent: string): Generator<string, void, undefined> {
923
+ const nameAttr = name === null ? "" : ` name="${escapeXmlAttribute(name)}"`;
924
+ if (Array.isArray(value)) {
925
+ yield `${indent}<att${nameAttr} type="list">\n`;
926
+ for (const item of value) {
927
+ yield* jsonAtt(name, item, `${indent} `);
928
+ }
929
+ yield `${indent}</att>\n`;
930
+ } else if (typeof value === "object" && value !== null) {
931
+ const entries = Object.entries(value as Record<string, unknown>);
932
+ if (
933
+ name !== null &&
934
+ entries.every(([, v]) => typeof v === "string") &&
935
+ entries.length <= 3 &&
936
+ entries.every(([k]) => /^[a-z]$/.test(k))
937
+ ) {
938
+ yield `${indent}<att${nameAttr}${entries.map(([k, v]) => ` ${k}="${escapeXmlAttribute(v as string)}"`).join("")}/>\n`;
939
+ return;
940
+ }
941
+ yield `${indent}<att${nameAttr}>\n`;
942
+ for (const [key, item] of entries) {
943
+ yield* jsonAtt(key, item, `${indent} `);
944
+ }
945
+ yield `${indent}</att>\n`;
946
+ } else if (value === null || value === undefined) {
947
+ yield `${indent}<att${nameAttr} type="string"/>\n`;
948
+ } else {
949
+ yield `${indent}<att${nameAttr} value="${escapeXmlAttribute(scalarText(value))}" type="string"/>\n`;
950
+ }
951
+ }
952
+
953
+ /**
954
+ * The importer's slot column of a kind, if the table has it.
955
+ * @param writes - the writes
956
+ * @param kind - the slot
957
+ * @returns the column, or null
958
+ */
959
+ function slot(writes: readonly ColumnWrite[], kind: ColumnWrite["kind"]): Column | null {
960
+ return writes.find((w) => w.kind === kind)?.column ?? null;
961
+ }
962
+
963
+ /**
964
+ * The value of a set cell, else null.
965
+ * @param column - the column, or null
966
+ * @param row - the row
967
+ * @returns the value
968
+ */
969
+ function cellOf(column: Column | null, row: number): unknown {
970
+ return column !== null && column.isSet(row) ? column.value(row) : null;
971
+ }
972
+
973
+ /**
974
+ * The graphics coordinates of a node: x, y negated back to screen coordinates, z.
975
+ * @param plan - the plan
976
+ * @param zColumn - the z column, or null
977
+ * @param node - the node
978
+ * @returns the coordinates as XML attributes
979
+ */
980
+ function coordsOf(plan: Plan, zColumn: Column | null, node: number): [string, string][] {
981
+ const out: [string, string][] = [];
982
+ const xyz = cellOf(plan.position, node);
983
+ if (xyz !== null) {
984
+ const [x, y, z] = Array.from(xyz as ArrayLike<number>);
985
+ if (Number.isFinite(x) && Number.isFinite(y)) {
986
+ out.push(["x", formatF32(x)], ["y", formatF32(y === 0 ? 0 : -y)]);
987
+ if (zColumn === null && z !== undefined && z !== 0 && Number.isFinite(z)) {
988
+ out.push(["z", formatF32(z)]);
989
+ }
990
+ }
991
+ }
992
+ const z = cellOf(zColumn, node);
993
+ if (typeof z === "number") {
994
+ out.push(["z", formatF64(z)]);
995
+ }
996
+ return out;
997
+ }
998
+
999
+ /**
1000
+ * The XML of one node and its group graph, whose members are `xlink:href` references (every
1001
+ * node is declared at the top level, in index order).
1002
+ * @param snapshot - the snapshot
1003
+ * @param plan - the plan
1004
+ * @param node - the node
1005
+ * @param indent - the indentation
1006
+ * @yields the node element
1007
+ * @returns nothing
1008
+ */
1009
+ function* nodeOf(
1010
+ snapshot: GraphSnapshot,
1011
+ plan: Plan,
1012
+ node: number,
1013
+ indent: string,
1014
+ ): Generator<string, void, undefined> {
1015
+ const labelCol = slot(plan.nodes, "label");
1016
+ const label = cellOf(labelCol, node);
1017
+ yield `${indent}<node id="${escapeXmlAttribute(idText(snapshot.ids.idOf(node)))}"${label === null ? "" : ` label="${escapeXmlAttribute(scalarText(label))}"`}${xmlAttributesOf(plan.nodes, node)}>\n`;
1018
+ const inner = `${indent} `;
1019
+ yield* attsOf(plan.nodes, node, node === 0, plan, inner);
1020
+ const children = plan.reachable[node] === 1 ? plan.hierarchy.childrenOf(node) : [];
1021
+ const nested = cellOf(slot(plan.nodes, "nested"), node);
1022
+ const pointer = cellOf(slot(plan.nodes, "pointer"), node);
1023
+ const subgraph = cellOf(slot(plan.nodes, "subgraph"), node);
1024
+ if (children.length > 0 || subgraph !== null) {
1025
+ const sub = (subgraph ?? {}) as Record<string, unknown>;
1026
+ yield `${inner}<att name="__isGroup" value="1" type="boolean" cy:type="Boolean" cy:hidden="1"/>\n`;
1027
+ const id = typeof sub.id === "string" ? ` id="${escapeXmlAttribute(sub.id)}"` : "";
1028
+ const lbl = typeof sub.label === "string" ? ` label="${escapeXmlAttribute(sub.label)}"` : "";
1029
+ yield `${inner}<att>\n${inner} <graph${id}${lbl}>\n`;
1030
+ const atts =
1031
+ typeof sub.atts === "object" && sub.atts !== null
1032
+ ? Object.entries(sub.atts as Record<string, unknown>)
1033
+ : [];
1034
+ for (const [key, value] of atts) {
1035
+ yield* jsonAtt(key, value, `${inner} `);
1036
+ }
1037
+ for (const child of children) {
1038
+ yield `${inner} <node xlink:href="#${escapeXmlAttribute(idText(snapshot.ids.idOf(child)))}"/>\n`;
1039
+ }
1040
+ yield `${inner} </graph>\n${inner}</att>\n`;
1041
+ } else if (typeof nested === "string") {
1042
+ yield `${inner}<att>\n${inner} <graph label="${escapeXmlAttribute(nested)}"/>\n${inner}</att>\n`;
1043
+ } else if (typeof pointer === "string") {
1044
+ yield `${inner}<att>\n${inner} <graph xlink:href="${escapeXmlAttribute(pointer)}"/>\n${inner}</att>\n`;
1045
+ }
1046
+ const graphics = cellOf(slot(plan.nodes, "graphics"), node) as Record<string, unknown> | null;
1047
+ yield* graphicsOf(graphics, coordsOf(plan, slot(plan.nodes, "z"), node), inner);
1048
+ yield `${indent}</node>\n`;
1049
+ }
1050
+
1051
+ /**
1052
+ * The document, as string parts; the plan (and the E_ conditions it throws) is made when the
1053
+ * first part is asked for.
1054
+ * @param snapshot - the snapshot
1055
+ * @param options - the options
1056
+ * @yields the XML text
1057
+ * @returns nothing
1058
+ */
1059
+ function* write(
1060
+ snapshot: GraphSnapshot,
1061
+ options: (XgmmlExportOptions & CommonExportOptions) | undefined,
1062
+ ): Generator<string, void, undefined> {
1063
+ const plan = planFor(snapshot, options);
1064
+ const meta = snapshot.meta.extra[META_KEY] as { graphId?: unknown; directed?: unknown } | undefined;
1065
+ const graphId = typeof meta?.graphId === "string" ? ` id="${escapeXmlAttribute(meta.graphId)}"` : "";
1066
+ // Every edge carries cy:directed, so when they all agree the root attribute does not decide
1067
+ // the direction and the source's own text is written back.
1068
+ const keep =
1069
+ (meta?.directed === "0" || meta?.directed === "1") &&
1070
+ snapshot.edgeCount > 0 &&
1071
+ snapshot.edges.byRole("directed") === null;
1072
+ let directed = snapshot.directed ? "1" : "0";
1073
+ if (keep) {
1074
+ directed = meta?.directed as string;
1075
+ }
1076
+ const name = snapshot.meta.name === null ? "" : ` label="${escapeXmlAttribute(snapshot.meta.name)}"`;
1077
+ yield '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
1078
+ yield `<graph${graphId}${name} directed="${directed}" cy:documentVersion="3.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xlink="${XLINK_NAMESPACE}" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:cy="${CY_NAMESPACE}" xmlns="${XGMML_NAMESPACE}">\n`;
1079
+ yield* metadataOf(snapshot);
1080
+ yield* attsOf(plan.graph, 0, true, plan, " ");
1081
+ yield* graphicsOf(cellOf(slot(plan.graph, "graphics"), 0) as Record<string, unknown> | null, [], " ");
1082
+ for (let u = 0; u < snapshot.nodeCount; u++) {
1083
+ yield* nodeOf(snapshot, plan, u, " ");
1084
+ }
1085
+ yield* networksOf(snapshot, plan);
1086
+ yield* edgesOf(snapshot, plan);
1087
+ yield "</graph>\n";
1088
+ }
1089
+
1090
+ /**
1091
+ * The RDF network metadata, when the snapshot has a description or a creation date.
1092
+ * @param snapshot - the snapshot
1093
+ * @yields the networkMetadata att
1094
+ * @returns nothing
1095
+ */
1096
+ function* metadataOf(snapshot: GraphSnapshot): Generator<string, void, undefined> {
1097
+ const { name, description, created } = snapshot.meta;
1098
+ if (description === null && created === null) {
1099
+ return;
1100
+ }
1101
+ yield ' <att name="networkMetadata">\n <rdf:RDF>\n <rdf:Description rdf:about="http://www.cytoscape.org/">\n';
1102
+ if (name !== null) {
1103
+ yield ` <dc:title>${escapeXmlText(name)}</dc:title>\n`;
1104
+ }
1105
+ if (description !== null) {
1106
+ yield ` <dc:description>${escapeXmlText(description)}</dc:description>\n`;
1107
+ }
1108
+ if (created !== null) {
1109
+ yield ` <dc:date>${escapeXmlText(created)}</dc:date>\n`;
1110
+ }
1111
+ yield " </rdf:Description>\n </rdf:RDF>\n </att>\n";
1112
+ }
1113
+
1114
+ /**
1115
+ * The root-level subgraphs of an `xgmml.networks` membership column.
1116
+ * @param snapshot - the snapshot
1117
+ * @param plan - the plan
1118
+ * @yields the subgraph atts
1119
+ * @returns nothing
1120
+ */
1121
+ function* networksOf(snapshot: GraphSnapshot, plan: Plan): Generator<string, void, undefined> {
1122
+ const column = slot(plan.nodes, "networks");
1123
+ if (column?.dtype !== "list") {
1124
+ return;
1125
+ }
1126
+ const members = new Map<string, number[]>();
1127
+ for (let u = 0; u < snapshot.nodeCount; u++) {
1128
+ for (const id of column.isSet(u) ? column.sliceOf(u) : []) {
1129
+ const list = members.get(String(id)) ?? [];
1130
+ list.push(u);
1131
+ members.set(String(id), list);
1132
+ }
1133
+ }
1134
+ for (const [id, nodes] of members) {
1135
+ yield ` <att>\n <graph id="${escapeXmlAttribute(id)}">\n`;
1136
+ for (const u of nodes) {
1137
+ yield ` <node xlink:href="#${escapeXmlAttribute(idText(snapshot.ids.idOf(u)))}"/>\n`;
1138
+ }
1139
+ yield " </graph>\n </att>\n";
1140
+ }
1141
+ }
1142
+
1143
+ /**
1144
+ * The edges: one per logical edge, expanded pairs folded.
1145
+ * @param snapshot - the snapshot
1146
+ * @param plan - the plan
1147
+ * @yields the edge elements
1148
+ * @returns nothing
1149
+ */
1150
+ function* edgesOf(snapshot: GraphSnapshot, plan: Plan): Generator<string, void, undefined> {
1151
+ const { folding, weights } = plan;
1152
+ const labelCol = slot(plan.edges, "label");
1153
+ const graphicsCol = slot(plan.edges, "graphics");
1154
+ const list = snapshot.edgeList();
1155
+ let first = true;
1156
+ for (let e = 0; e < snapshot.edgeCount; e++) {
1157
+ if (folding.folded(e)) {
1158
+ continue;
1159
+ }
1160
+ const u = list.src[e];
1161
+ const v = list.dst[e];
1162
+ const directed = snapshot.directed && folding.sourceDirected(e);
1163
+ const id = cellOf(plan.edgeId, e);
1164
+ const label = cellOf(labelCol, e);
1165
+ const weight = weights.text(e);
1166
+ yield ` <edge${id === null ? "" : ` id="${escapeXmlAttribute(scalarText(id))}"`}${label === null ? "" : ` label="${escapeXmlAttribute(scalarText(label))}"`} source="${escapeXmlAttribute(idText(snapshot.ids.idOf(u)))}" target="${escapeXmlAttribute(idText(snapshot.ids.idOf(v)))}" cy:directed="${directed ? "1" : "0"}"${weight === null ? "" : ` weight="${weight}"`}${xmlAttributesOf(plan.edges, e)}>\n`;
1167
+ yield* attsOf(plan.edges, e, first, plan, " ");
1168
+ yield* graphicsOf(cellOf(graphicsCol, e) as Record<string, unknown> | null, [], " ");
1169
+ yield " </edge>\n";
1170
+ first = false;
1171
+ }
1172
+ }
1173
+
1174
+ /**
1175
+ * Resolve the format-specific options.
1176
+ * @param options - the caller's options
1177
+ * @returns the cytoscapeEscapes flag
1178
+ */
1179
+ function resolveEscapes(options: XgmmlExportOptions | undefined): boolean {
1180
+ const value = options?.cytoscapeEscapes ?? false;
1181
+ if (typeof value !== "boolean") {
1182
+ throw new GraphFormatError("E_UNSUPPORTED", "option cytoscapeEscapes must be a boolean", {
1183
+ option: "cytoscapeEscapes",
1184
+ found: typeof value,
1185
+ });
1186
+ }
1187
+ return value;
1188
+ }
1189
+
1190
+ /**
1191
+ * The plan of one export call; throws the E_ conditions before anything is written.
1192
+ * @param snapshot - the snapshot
1193
+ * @param options - the options
1194
+ * @returns the plan
1195
+ */
1196
+ function planFor(snapshot: GraphSnapshot, options: (XgmmlExportOptions & CommonExportOptions) | undefined): Plan {
1197
+ const plan = planExport(snapshot, resolveExportOptions(options), resolveEscapes(options));
1198
+ const illegal = plan.notes.find((n) => n.code === XGMML_LOSS.XML_ILLEGAL_CHAR);
1199
+ if (illegal !== undefined) {
1200
+ throw new GraphFormatError("E_COLUMN_TYPE", illegal.message, { code: illegal.code, column: illegal.column });
1201
+ }
1202
+ return plan;
1203
+ }
1204
+
1205
+ /** The XGMML exporter. */
1206
+ export const xgmmlExporter: GraphExporter<XgmmlExportOptions> = Object.freeze({
1207
+ format: FORMAT,
1208
+ capabilities: CAPABILITIES,
1209
+ check: (snapshot: GraphSnapshot, options?: XgmmlExportOptions & CommonExportOptions): readonly LossNote[] =>
1210
+ Object.freeze(planExport(snapshot, resolveExportOptions(options), resolveEscapes(options)).notes),
1211
+ export: (snapshot: GraphSnapshot, options?: XgmmlExportOptions & CommonExportOptions): AsyncIterable<Uint8Array> =>
1212
+ encodeChunks(write(snapshot, options)),
1213
+ exportToString: (snapshot: GraphSnapshot, options?: XgmmlExportOptions & CommonExportOptions): Promise<string> =>
1214
+ joinText(write(snapshot, options)),
1215
+ });