@graphty/graph-io 0.3.19 → 0.3.21

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 (201) hide show
  1. package/README.md +49 -4
  2. package/dist/chunks/{escape-CWExcecC.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-DQiKgQJc.js → export-Bh60Dv-n.js} +5 -79
  5. package/dist/chunks/export-Bh60Dv-n.js.map +1 -0
  6. package/dist/chunks/{exporter-CbMZyVt-.js → exporter-BGrsWamJ.js} +7 -6
  7. package/dist/chunks/{exporter-CbMZyVt-.js.map → exporter-BGrsWamJ.js.map} +1 -1
  8. package/dist/chunks/exporter-DIeGJXAZ.js +834 -0
  9. package/dist/chunks/exporter-DIeGJXAZ.js.map +1 -0
  10. package/dist/chunks/{importer-BNFuV1-K.js → importer-BWY2FFc5.js} +28 -7
  11. package/dist/chunks/importer-BWY2FFc5.js.map +1 -0
  12. package/dist/chunks/importer-DD-xv9_Y.js +1471 -0
  13. package/dist/chunks/importer-DD-xv9_Y.js.map +1 -0
  14. package/dist/chunks/{importer-BAP4PrxR.js → importer-DET1tg6s.js} +30 -20
  15. package/dist/chunks/importer-DET1tg6s.js.map +1 -0
  16. package/dist/chunks/{importer-ByPGO-09.js → importer-DEcKhsmQ.js} +2 -2
  17. package/dist/chunks/{importer-ByPGO-09.js.map → importer-DEcKhsmQ.js.map} +1 -1
  18. package/dist/chunks/{importer-BW-Ft2ps.js → importer-DIrbbnAf.js} +6 -5
  19. package/dist/chunks/{importer-BW-Ft2ps.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-Bm1_5vPS.js → importer-eUREhBGj.js} +11 -8
  23. package/dist/chunks/importer-eUREhBGj.js.map +1 -0
  24. package/dist/chunks/{importer-DOepkgnG.js → importer-vi3bSdtw.js} +4 -4
  25. package/dist/chunks/{importer-DOepkgnG.js.map → importer-vi3bSdtw.js.map} +1 -1
  26. package/dist/chunks/{importer-BVGtU1NA.js → importer-zGLo8Dg_.js} +6 -5
  27. package/dist/chunks/{importer-BVGtU1NA.js.map → importer-zGLo8Dg_.js.map} +1 -1
  28. package/dist/chunks/{json-elements-CZY1wiZh.js → json-elements-DDS8N2Dd.js} +2 -2
  29. package/dist/chunks/{json-elements-CZY1wiZh.js.map → json-elements-DDS8N2Dd.js.map} +1 -1
  30. package/dist/chunks/{records-BzNicMsf.js → records-dbRkxwaq.js} +2 -2
  31. package/dist/chunks/{records-BzNicMsf.js.map → records-dbRkxwaq.js.map} +1 -1
  32. package/dist/chunks/{report-BcWboivV.js → report-B1z4WT9e.js} +143 -111
  33. package/dist/chunks/{report-BcWboivV.js.map → report-B1z4WT9e.js.map} +1 -1
  34. package/dist/chunks/{weights-CwISIpCP.js → weights-Dzba96G3.js} +5 -5
  35. package/dist/chunks/{weights-CwISIpCP.js.map → weights-Dzba96G3.js.map} +1 -1
  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 -5
  39. package/dist/csv.js.map +1 -1
  40. package/dist/cx.js +1 -1
  41. package/dist/cx2.js +2 -2
  42. package/dist/cys.d.ts +1 -0
  43. package/dist/cys.js +6 -0
  44. package/dist/cys.js.map +1 -0
  45. package/dist/dot.js +1 -1
  46. package/dist/gexf.js +5 -4
  47. package/dist/gexf.js.map +1 -1
  48. package/dist/gml.js +12 -14
  49. package/dist/gml.js.map +1 -1
  50. package/dist/graph-io.js +288 -141
  51. package/dist/graph-io.js.map +1 -1
  52. package/dist/graphml.js +1 -1
  53. package/dist/json.js +1 -1
  54. package/dist/neo4j.js +6 -5
  55. package/dist/neo4j.js.map +1 -1
  56. package/dist/obo.js +1 -1
  57. package/dist/pajek.js +1 -1
  58. package/dist/src/common/cell-budget.d.ts +84 -0
  59. package/dist/src/common/cell-budget.d.ts.map +1 -0
  60. package/dist/src/common/cell-budget.js +138 -0
  61. package/dist/src/common/cell-budget.js.map +1 -0
  62. package/dist/src/common/input.d.ts +10 -0
  63. package/dist/src/common/input.d.ts.map +1 -1
  64. package/dist/src/common/input.js +39 -0
  65. package/dist/src/common/input.js.map +1 -1
  66. package/dist/src/common/options.d.ts +6 -0
  67. package/dist/src/common/options.d.ts.map +1 -1
  68. package/dist/src/common/options.js +1 -1
  69. package/dist/src/common/options.js.map +1 -1
  70. package/dist/src/common/xml.d.ts +37 -3
  71. package/dist/src/common/xml.d.ts.map +1 -1
  72. package/dist/src/common/xml.js +86 -6
  73. package/dist/src/common/xml.js.map +1 -1
  74. package/dist/src/common/zip.d.ts +92 -0
  75. package/dist/src/common/zip.d.ts.map +1 -0
  76. package/dist/src/common/zip.js +399 -0
  77. package/dist/src/common/zip.js.map +1 -0
  78. package/dist/src/formats/cx/importer.d.ts.map +1 -1
  79. package/dist/src/formats/cx/importer.js +26 -17
  80. package/dist/src/formats/cx/importer.js.map +1 -1
  81. package/dist/src/formats/cys/constants.d.ts +68 -0
  82. package/dist/src/formats/cys/constants.d.ts.map +1 -0
  83. package/dist/src/formats/cys/constants.js +76 -0
  84. package/dist/src/formats/cys/constants.js.map +1 -0
  85. package/dist/src/formats/cys/importer.d.ts +38 -0
  86. package/dist/src/formats/cys/importer.d.ts.map +1 -0
  87. package/dist/src/formats/cys/importer.js +830 -0
  88. package/dist/src/formats/cys/importer.js.map +1 -0
  89. package/dist/src/formats/cys/index.d.ts +7 -0
  90. package/dist/src/formats/cys/index.d.ts.map +1 -0
  91. package/dist/src/formats/cys/index.js +7 -0
  92. package/dist/src/formats/cys/index.js.map +1 -0
  93. package/dist/src/formats/cys/session.d.ts +132 -0
  94. package/dist/src/formats/cys/session.d.ts.map +1 -0
  95. package/dist/src/formats/cys/session.js +315 -0
  96. package/dist/src/formats/cys/session.js.map +1 -0
  97. package/dist/src/formats/cys/tables.d.ts +90 -0
  98. package/dist/src/formats/cys/tables.d.ts.map +1 -0
  99. package/dist/src/formats/cys/tables.js +293 -0
  100. package/dist/src/formats/cys/tables.js.map +1 -0
  101. package/dist/src/formats/dot/exporter.d.ts +2 -0
  102. package/dist/src/formats/dot/exporter.d.ts.map +1 -1
  103. package/dist/src/formats/dot/exporter.js +12 -0
  104. package/dist/src/formats/dot/exporter.js.map +1 -1
  105. package/dist/src/formats/dot/importer.js +6 -2
  106. package/dist/src/formats/dot/importer.js.map +1 -1
  107. package/dist/src/formats/gexf/importer.d.ts +2 -2
  108. package/dist/src/formats/gexf/importer.d.ts.map +1 -1
  109. package/dist/src/formats/gexf/importer.js +1 -1
  110. package/dist/src/formats/gexf/importer.js.map +1 -1
  111. package/dist/src/formats/gml/exporter.d.ts +0 -8
  112. package/dist/src/formats/gml/exporter.d.ts.map +1 -1
  113. package/dist/src/formats/gml/exporter.js +7 -17
  114. package/dist/src/formats/gml/exporter.js.map +1 -1
  115. package/dist/src/formats/json/exporter.d.ts.map +1 -1
  116. package/dist/src/formats/json/exporter.js +3 -1
  117. package/dist/src/formats/json/exporter.js.map +1 -1
  118. package/dist/src/formats/json/importer.d.ts.map +1 -1
  119. package/dist/src/formats/json/importer.js +3 -2
  120. package/dist/src/formats/json/importer.js.map +1 -1
  121. package/dist/src/formats/xgmml/columns.d.ts +152 -0
  122. package/dist/src/formats/xgmml/columns.d.ts.map +1 -0
  123. package/dist/src/formats/xgmml/columns.js +593 -0
  124. package/dist/src/formats/xgmml/columns.js.map +1 -0
  125. package/dist/src/formats/xgmml/constants.d.ts +196 -0
  126. package/dist/src/formats/xgmml/constants.d.ts.map +1 -0
  127. package/dist/src/formats/xgmml/constants.js +197 -0
  128. package/dist/src/formats/xgmml/constants.js.map +1 -0
  129. package/dist/src/formats/xgmml/document.d.ts +361 -0
  130. package/dist/src/formats/xgmml/document.d.ts.map +1 -0
  131. package/dist/src/formats/xgmml/document.js +695 -0
  132. package/dist/src/formats/xgmml/document.js.map +1 -0
  133. package/dist/src/formats/xgmml/emit.d.ts +341 -0
  134. package/dist/src/formats/xgmml/emit.d.ts.map +1 -0
  135. package/dist/src/formats/xgmml/emit.js +1275 -0
  136. package/dist/src/formats/xgmml/emit.js.map +1 -0
  137. package/dist/src/formats/xgmml/exporter.d.ts +24 -0
  138. package/dist/src/formats/xgmml/exporter.d.ts.map +1 -0
  139. package/dist/src/formats/xgmml/exporter.js +999 -0
  140. package/dist/src/formats/xgmml/exporter.js.map +1 -0
  141. package/dist/src/formats/xgmml/importer.d.ts +58 -0
  142. package/dist/src/formats/xgmml/importer.d.ts.map +1 -0
  143. package/dist/src/formats/xgmml/importer.js +360 -0
  144. package/dist/src/formats/xgmml/importer.js.map +1 -0
  145. package/dist/src/formats/xgmml/index.d.ts +8 -0
  146. package/dist/src/formats/xgmml/index.d.ts.map +1 -0
  147. package/dist/src/formats/xgmml/index.js +8 -0
  148. package/dist/src/formats/xgmml/index.js.map +1 -0
  149. package/dist/src/formats/xgmml/values.d.ts +60 -0
  150. package/dist/src/formats/xgmml/values.d.ts.map +1 -0
  151. package/dist/src/formats/xgmml/values.js +148 -0
  152. package/dist/src/formats/xgmml/values.js.map +1 -0
  153. package/dist/src/index.d.ts +2 -0
  154. package/dist/src/index.d.ts.map +1 -1
  155. package/dist/src/index.js +2 -0
  156. package/dist/src/index.js.map +1 -1
  157. package/dist/src/registry.d.ts +7 -0
  158. package/dist/src/registry.d.ts.map +1 -1
  159. package/dist/src/registry.js +25 -10
  160. package/dist/src/registry.js.map +1 -1
  161. package/dist/src/sniff.d.ts +1 -1
  162. package/dist/src/sniff.d.ts.map +1 -1
  163. package/dist/src/sniff.js +2 -0
  164. package/dist/src/sniff.js.map +1 -1
  165. package/dist/xgmml.d.ts +1 -0
  166. package/dist/xgmml.js +9 -0
  167. package/dist/xgmml.js.map +1 -0
  168. package/package.json +15 -2
  169. package/src/common/cell-budget.ts +170 -0
  170. package/src/common/input.ts +40 -0
  171. package/src/common/options.ts +1 -1
  172. package/src/common/xml.ts +127 -6
  173. package/src/common/zip.ts +472 -0
  174. package/src/formats/cx/importer.ts +27 -19
  175. package/src/formats/cys/constants.ts +99 -0
  176. package/src/formats/cys/importer.ts +1100 -0
  177. package/src/formats/cys/index.ts +7 -0
  178. package/src/formats/cys/session.ts +428 -0
  179. package/src/formats/cys/tables.ts +400 -0
  180. package/src/formats/dot/exporter.ts +17 -0
  181. package/src/formats/dot/importer.ts +6 -2
  182. package/src/formats/gexf/importer.ts +0 -1
  183. package/src/formats/gml/exporter.ts +7 -18
  184. package/src/formats/json/exporter.ts +3 -1
  185. package/src/formats/json/importer.ts +3 -2
  186. package/src/formats/xgmml/columns.ts +747 -0
  187. package/src/formats/xgmml/constants.ts +265 -0
  188. package/src/formats/xgmml/document.ts +975 -0
  189. package/src/formats/xgmml/emit.ts +1563 -0
  190. package/src/formats/xgmml/exporter.ts +1215 -0
  191. package/src/formats/xgmml/importer.ts +491 -0
  192. package/src/formats/xgmml/index.ts +8 -0
  193. package/src/formats/xgmml/values.ts +183 -0
  194. package/src/index.ts +9 -0
  195. package/src/registry.ts +41 -15
  196. package/src/sniff.ts +5 -1
  197. package/dist/chunks/escape-CWExcecC.js.map +0 -1
  198. package/dist/chunks/importer-BAP4PrxR.js.map +0 -1
  199. package/dist/chunks/importer-BNFuV1-K.js.map +0 -1
  200. package/dist/chunks/importer-Bm1_5vPS.js.map +0 -1
  201. package/dist/chunks/writer-DQiKgQJc.js.map +0 -1
@@ -0,0 +1,400 @@
1
+ /**
2
+ * The tables of a Cytoscape 3 session: the CyCSV `.cytable` files (`research-session-and-style.md`
3
+ * section 2.4; Cytoscape's `CSVCyReader`) and the virtual columns `tables/cytables.xml` joins into
4
+ * them. Cells become XGMML att records, typed by the column's Java class, so the XGMML emitter
5
+ * applies the same column rules (types, widening, equations, hidden columns, renames) to a
6
+ * session's tables as to an XGMML file's atts.
7
+ */
8
+
9
+ import { type ResolvedImportOptions } from "../../common/options.js";
10
+ import { ImportReportBuilder } from "../../common/report.js";
11
+ import { ImportError } from "../../types.js";
12
+ import { CsvRecordReader } from "../csv/records.js";
13
+ import { type AttRec } from "../xgmml/document.js";
14
+ import { CYS_ISSUE, FORMAT } from "./constants.js";
15
+
16
+ /** The Cytoscape type names of the Java classes a CyCSV column can hold. */
17
+ const JAVA_TYPES: Readonly<Record<string, string>> = {
18
+ "java.lang.String": "String",
19
+ "java.lang.Long": "Long",
20
+ "java.lang.Integer": "Integer",
21
+ "java.lang.Double": "Double",
22
+ "java.lang.Boolean": "Boolean",
23
+ };
24
+
25
+ /** A list column's class: `java.util.List<java.lang.X>`. */
26
+ const LIST_CLASS = /^java\.util\.List<\s*([\w.]+)\s*>$/;
27
+
28
+ /** One column of a table. */
29
+ interface CyColumn {
30
+ /** The name. */
31
+ readonly name: string;
32
+ /** The Cytoscape type of a scalar column, or of a list's items. */
33
+ readonly type: string;
34
+ /** A list column. */
35
+ readonly list: boolean;
36
+ }
37
+
38
+ /** One table, read. */
39
+ export interface CyTable {
40
+ /** The table's path under `tables/` as the archive spells it (what cytables.xml names). */
41
+ readonly path: string;
42
+ /** The entry name, for messages. */
43
+ readonly entry: string;
44
+ /** The columns; the first is the primary key. */
45
+ readonly columns: readonly CyColumn[];
46
+ /** The rows by key text, in file order (the first row of a repeated key). */
47
+ readonly rows: ReadonlyMap<string, readonly string[]>;
48
+ /** The line each row starts on, by key. */
49
+ readonly lines: ReadonlyMap<string, number>;
50
+ }
51
+
52
+ /** What a table read reports besides its rows. */
53
+ interface RowProblems {
54
+ short: number;
55
+ long: number;
56
+ repeated: number;
57
+ }
58
+
59
+ /**
60
+ * Read one CyCSV table. A table that cannot be read at all (an unknown CyCSV version or column
61
+ * class, a malformed header, broken CSV) is recorded as E_CYS_TABLE and skipped (null); rows with
62
+ * the wrong number of cells or a repeated key are counted in one W_CYS_TABLE_ROW.
63
+ * @param bytes - the entry's bytes
64
+ * @param path - the table's path under `tables/`
65
+ * @param entry - the entry name, for messages
66
+ * @param report - the import's report
67
+ * @param common - cancellation
68
+ * @returns the table, or null
69
+ */
70
+ export async function readCyTable(
71
+ bytes: Uint8Array,
72
+ path: string,
73
+ entry: string,
74
+ report: ImportReportBuilder,
75
+ common: ResolvedImportOptions,
76
+ ): Promise<CyTable | null> {
77
+ const scratch = new ImportReportBuilder(FORMAT, 0);
78
+ const reader = new CsvRecordReader(bytes, scratch, { delimiter: ",", encoding: "utf-8", signal: common.signal });
79
+ const records: { readonly cells: string[]; readonly line: number }[] = [];
80
+ try {
81
+ for await (const cells of reader) {
82
+ records.push({ cells, line: reader.line });
83
+ }
84
+ } catch (err) {
85
+ if (err instanceof ImportError) {
86
+ return tableError(report, entry, scratch.issues.at(-1)?.message ?? err.message);
87
+ }
88
+ throw err;
89
+ }
90
+ let at = 0;
91
+ let version = 0;
92
+ if (records[0]?.cells.length === 2 && records[0].cells[0] === "CyCSV-Version") {
93
+ version = Number(records[0].cells[1]);
94
+ at = 1;
95
+ }
96
+ if (version !== 0 && version !== 1) {
97
+ return tableError(report, entry, `CyCSV version "${records[0].cells[1]}" is not 0 or 1`);
98
+ }
99
+ // version 1: names, types, column options, table title; version 0: names, types, table title
100
+ const headerLines = version === 1 ? 4 : 3;
101
+ if (records.length < at + headerLines) {
102
+ return tableError(report, entry, "the table header is incomplete");
103
+ }
104
+ const names = records[at].cells;
105
+ const classes = records[at + 1].cells;
106
+ if (names.length === 0 || classes.length !== names.length) {
107
+ return tableError(report, entry, `${names.length} column name(s) but ${classes.length} column class(es)`);
108
+ }
109
+ const columns: CyColumn[] = [];
110
+ for (let i = 0; i < names.length; i++) {
111
+ const column = columnOf(names[i], classes[i]);
112
+ if (column === null) {
113
+ return tableError(
114
+ report,
115
+ entry,
116
+ `column "${names[i]}" has the class "${classes[i]}", which graph-io cannot read`,
117
+ );
118
+ }
119
+ columns.push(column);
120
+ }
121
+ const rows = new Map<string, readonly string[]>();
122
+ const lines = new Map<string, number>();
123
+ const problems: RowProblems = { short: 0, long: 0, repeated: 0 };
124
+ for (const record of records.slice(at + headerLines)) {
125
+ const { cells } = record;
126
+ if (cells.length < columns.length) {
127
+ problems.short++;
128
+ } else if (cells.length > columns.length) {
129
+ problems.long++;
130
+ }
131
+ const key = cells[0];
132
+ if (rows.has(key)) {
133
+ problems.repeated++;
134
+ continue;
135
+ }
136
+ rows.set(key, cells.slice(0, columns.length));
137
+ lines.set(key, record.line);
138
+ }
139
+ if (problems.short + problems.long + problems.repeated > 0) {
140
+ report.warning(
141
+ "parse-error",
142
+ CYS_ISSUE.TABLE_ROW,
143
+ `${entry}: ${problems.short} row(s) with too few cells (the rest are unset), ${problems.long} with too many (the extra cells are ignored), ${problems.repeated} repeating a key (the first row is read)`,
144
+ );
145
+ }
146
+ return { path, entry, columns, rows, lines };
147
+ }
148
+
149
+ /**
150
+ * The column of a name and a Java class.
151
+ * @param name - the name
152
+ * @param javaClass - the class text
153
+ * @returns the column, or null for a class graph-io cannot read
154
+ */
155
+ function columnOf(name: string, javaClass: string): CyColumn | null {
156
+ const scalar = JAVA_TYPES[javaClass.trim()];
157
+ if (scalar !== undefined) {
158
+ return { name, type: scalar, list: false };
159
+ }
160
+ const list = LIST_CLASS.exec(javaClass.trim());
161
+ const item = list === null ? undefined : JAVA_TYPES[list[1]];
162
+ return item === undefined ? null : { name, type: item, list: true };
163
+ }
164
+
165
+ /**
166
+ * Record E_CYS_TABLE for a table that is skipped.
167
+ * @param report - the report
168
+ * @param entry - the entry name
169
+ * @param reason - why
170
+ * @returns null
171
+ */
172
+ function tableError(report: ImportReportBuilder, entry: string, reason: string): null {
173
+ report.error("parse-error", CYS_ISSUE.TABLE, `${entry}: ${reason}; the table is not read`);
174
+ return null;
175
+ }
176
+
177
+ /**
178
+ * The att record of one cell, or null for an unset cell. CyCSV's rules: an empty cell is unset
179
+ * except in a String column, where it is the empty string; a list cell holds its items separated
180
+ * by newlines (Java's `split`, so trailing empty items vanish; an empty String-list cell is one
181
+ * empty item, any other empty list cell unset); a cell starting with `=` is an equation.
182
+ * @param column - the column
183
+ * @param text - the cell text
184
+ * @param line - the row's line
185
+ * @param namespace - the table namespace the column belongs to (null for the network's own table)
186
+ * @param hidden - whether the column is hidden (HIDDEN and app tables)
187
+ * @returns the att, or null
188
+ */
189
+ export function cellAtt(
190
+ column: CyColumn,
191
+ text: string,
192
+ line: number,
193
+ namespace: string | null,
194
+ hidden: boolean,
195
+ ): AttRec | null {
196
+ if (text.startsWith("=")) {
197
+ return att(column.name, column.list ? "List" : column.type, null, text, line, namespace, hidden, true);
198
+ }
199
+ if (!column.list) {
200
+ if (text.length === 0 && column.type !== "String") {
201
+ return null;
202
+ }
203
+ return att(column.name, column.type, null, text, line, namespace, hidden, false);
204
+ }
205
+ if (text.length === 0 && column.type !== "String") {
206
+ return null;
207
+ }
208
+ const list = att(column.name, "List", column.type, null, line, namespace, hidden, false);
209
+ for (const item of javaSplitLines(text)) {
210
+ if (item.length === 0 && column.type !== "String") {
211
+ continue;
212
+ }
213
+ list.children.push(att(column.name, column.type, null, item, line, namespace, hidden, false));
214
+ }
215
+ return list;
216
+ }
217
+
218
+ /**
219
+ * Java's `String.split("\n")`: trailing empty strings are removed, and a text without a newline
220
+ * is itself (so `""` is one empty item).
221
+ * @param text - the cell
222
+ * @returns the items
223
+ */
224
+ function javaSplitLines(text: string): string[] {
225
+ if (!text.includes("\n")) {
226
+ return [text];
227
+ }
228
+ const items = text.split("\n");
229
+ while (items.length > 0 && items[items.length - 1].length === 0) {
230
+ items.pop();
231
+ }
232
+ return items;
233
+ }
234
+
235
+ /**
236
+ * An att record.
237
+ * @param name - the name
238
+ * @param cyType - the Cytoscape type
239
+ * @param elementType - a list's item type, or null
240
+ * @param value - the value, or null
241
+ * @param line - the line
242
+ * @param namespace - the table namespace, or null
243
+ * @param hidden - a hidden column
244
+ * @param equation - the value is a formula
245
+ * @returns the record
246
+ */
247
+ function att(
248
+ name: string,
249
+ cyType: string,
250
+ elementType: string | null,
251
+ value: string | null,
252
+ line: number,
253
+ namespace: string | null,
254
+ hidden: boolean,
255
+ equation: boolean,
256
+ ): AttRec {
257
+ return {
258
+ name,
259
+ type: null,
260
+ cyType,
261
+ elementType,
262
+ value,
263
+ hidden,
264
+ equation,
265
+ children: [],
266
+ extra: {},
267
+ xml: null,
268
+ hasGraph: false,
269
+ text: "",
270
+ line,
271
+ namespace,
272
+ };
273
+ }
274
+
275
+ /** One virtual column of `tables/cytables.xml`. */
276
+ export interface VirtualColumn {
277
+ /** The column name in the target table. */
278
+ readonly name: string;
279
+ /** The target table's path under `tables/`. */
280
+ readonly targetTable: string;
281
+ /** The source table's path. */
282
+ readonly sourceTable: string;
283
+ /** The source column's name. */
284
+ readonly sourceColumn: string;
285
+ /** The join column of the source (normally SUID). */
286
+ readonly sourceJoinKey: string;
287
+ /** The join column of the target (normally SUID). */
288
+ readonly targetJoinKey: string;
289
+ }
290
+
291
+ /** A resolved virtual column: its type and its value per target row key. */
292
+ interface VirtualValues {
293
+ readonly column: CyColumn;
294
+ readonly values: ReadonlyMap<string, string>;
295
+ }
296
+
297
+ /**
298
+ * Resolve the virtual columns of one table: each one's values joined from its source table,
299
+ * following a source that is itself a virtual column. A column whose source table or column is
300
+ * missing, or whose sources form a cycle, is E_CYS_TABLE and skipped.
301
+ * @param target - the table
302
+ * @param virtuals - every virtual column of the session
303
+ * @param tableOf - reads (and caches) a table by path, null when it cannot be read or is absent
304
+ * @param report - the report
305
+ * @returns the virtual columns of the table, in cytables.xml order
306
+ */
307
+ export async function virtualColumnsOf(
308
+ target: CyTable,
309
+ virtuals: readonly VirtualColumn[],
310
+ tableOf: (path: string) => Promise<CyTable | null>,
311
+ report: ImportReportBuilder,
312
+ ): Promise<VirtualValues[]> {
313
+ const out: VirtualValues[] = [];
314
+ for (const virtual of virtuals) {
315
+ if (virtual.targetTable !== target.path) {
316
+ continue;
317
+ }
318
+ const resolved = await resolve(virtual, virtuals, tableOf, new Set());
319
+ if (typeof resolved === "string") {
320
+ report.error(
321
+ "parse-error",
322
+ CYS_ISSUE.TABLE,
323
+ `${target.entry}: the virtual column "${virtual.name}" ${resolved}; the column is not read`,
324
+ );
325
+ continue;
326
+ }
327
+ const targetKey = target.columns.findIndex((c) => c.name === virtual.targetJoinKey);
328
+ const values = new Map<string, string>();
329
+ for (const [key, cells] of target.rows) {
330
+ const join = targetKey <= 0 ? key : cells[targetKey];
331
+ const value = join === undefined ? undefined : resolved.values.get(join);
332
+ if (value !== undefined) {
333
+ values.set(key, value);
334
+ }
335
+ }
336
+ out.push({ column: { ...resolved.column, name: virtual.name }, values });
337
+ }
338
+ return out;
339
+ }
340
+
341
+ /**
342
+ * The values of a virtual column's source, by the source's join key.
343
+ * @param virtual - the virtual column
344
+ * @param virtuals - every virtual column
345
+ * @param tableOf - the table reader
346
+ * @param seen - the virtual columns on the current chain (a cycle check)
347
+ * @returns the values, or the reason they cannot be read
348
+ */
349
+ async function resolve(
350
+ virtual: VirtualColumn,
351
+ virtuals: readonly VirtualColumn[],
352
+ tableOf: (path: string) => Promise<CyTable | null>,
353
+ seen: Set<VirtualColumn>,
354
+ ): Promise<VirtualValues | string> {
355
+ if (seen.has(virtual)) {
356
+ return "is part of a cycle of virtual columns";
357
+ }
358
+ seen.add(virtual);
359
+ const source = await tableOf(virtual.sourceTable);
360
+ if (source === null) {
361
+ return `names the source table "${virtual.sourceTable}", which the session does not hold or cannot read`;
362
+ }
363
+ const joinIndex = source.columns.findIndex((c) => c.name === virtual.sourceJoinKey);
364
+ if (joinIndex < 0) {
365
+ return `joins on the column "${virtual.sourceJoinKey}", which its source table does not have`;
366
+ }
367
+ const joinOf = (key: string, cells: readonly string[]): string | undefined =>
368
+ joinIndex === 0 ? key : cells[joinIndex];
369
+ const own = source.columns.findIndex((c) => c.name === virtual.sourceColumn);
370
+ if (own >= 0) {
371
+ const values = new Map<string, string>();
372
+ for (const [key, cells] of source.rows) {
373
+ const join = joinOf(key, cells);
374
+ if (join !== undefined && cells[own] !== undefined) {
375
+ values.set(join, cells[own]);
376
+ }
377
+ }
378
+ return { column: source.columns[own], values };
379
+ }
380
+ const chained = virtuals.find((v) => v.targetTable === source.path && v.name === virtual.sourceColumn);
381
+ if (chained === undefined) {
382
+ return `names the source column "${virtual.sourceColumn}", which its source table does not have`;
383
+ }
384
+ const inner = await resolve(chained, virtuals, tableOf, seen);
385
+ if (typeof inner === "string") {
386
+ return inner;
387
+ }
388
+ // re-key the chained values (by the chained source's join key) to this source's join key
389
+ const values = new Map<string, string>();
390
+ const chainedJoin = source.columns.findIndex((c) => c.name === chained.targetJoinKey);
391
+ for (const [key, cells] of source.rows) {
392
+ const lookup = chainedJoin <= 0 ? key : cells[chainedJoin];
393
+ const value = lookup === undefined ? undefined : inner.values.get(lookup);
394
+ const join = joinOf(key, cells);
395
+ if (value !== undefined && join !== undefined) {
396
+ values.set(join, value);
397
+ }
398
+ }
399
+ return { column: inner.column, values };
400
+ }
@@ -70,6 +70,8 @@ export const DOT_LOSS = Object.freeze({
70
70
  EMPTY_COLUMN_DROPPED: LOSS.EMPTY_COLUMN,
71
71
  /** A role-less column named `label` reads back with the label role. */
72
72
  ROLE_ASSUMED: LOSS.ROLE_ASSUMED,
73
+ /** A parent that is a plain node is written as a node and a cluster of one name; it reads back marked as a cluster. */
74
+ CLUSTER_MARKED: "W_DOT_CLUSTER_MARKED",
73
75
  });
74
76
 
75
77
  /** The roles DOT has a slot for (the label attribute, key, ports, clusters); every other role is reported. */
@@ -510,6 +512,21 @@ class ExportPlan {
510
512
  }
511
513
  }
512
514
  }
515
+ let unmarked = 0;
516
+ for (let i = 0; i < snapshot.nodeCount; i++) {
517
+ const marked = this.cluster !== null && this.cluster.isSet(i) && this.cluster.value(i) === true;
518
+ if (!marked && this.children.childrenOf(i).length > 0) {
519
+ unmarked++;
520
+ }
521
+ }
522
+ if (unmarked > 0) {
523
+ note(
524
+ DOT_LOSS.CLUSTER_MARKED,
525
+ `${unmarked} parent node(s) are written as a node and a cluster of the same name and read back with "${CLUSTER_COLUMN}" true`,
526
+ CLUSTER_COLUMN,
527
+ unmarked,
528
+ );
529
+ }
513
530
  const mutual = this.folding.mutualCount;
514
531
  if (mutual > 0) {
515
532
  note(
@@ -69,7 +69,7 @@ import {
69
69
  resolveImportOptions,
70
70
  } from "../../common/options.js";
71
71
  import { ImportReportBuilder } from "../../common/report.js";
72
- import { parseTextCell, TextCellWriter, WIDENING_UNSUPPORTED_CODE } from "../../common/text.js";
72
+ import { inferTextDtype, parseTextCell, TextCellWriter, WIDENING_UNSUPPORTED_CODE } from "../../common/text.js";
73
73
  import { parseWeightText } from "../../common/weights.js";
74
74
  import { type CommonImportOptions, type GraphImporter, type ImportInput, type ImportReport } from "../../types.js";
75
75
  import {
@@ -1474,7 +1474,11 @@ class DotParser {
1474
1474
  if (attribute.name === LABEL_ATTRIBUTE) {
1475
1475
  this.sink.setGraphValue(attribute.name, attribute.value, { dtype: "string", origin: DOT_ORIGIN });
1476
1476
  } else {
1477
- this.sink.setGraphValue(attribute.name, parseTextCell(attribute.value), { origin: DOT_ORIGIN });
1477
+ // the dtype comes from the text's grammar, so "20.0" stays f64 as it does in a node column
1478
+ this.sink.setGraphValue(attribute.name, parseTextCell(attribute.value), {
1479
+ dtype: inferTextDtype(attribute.value),
1480
+ origin: DOT_ORIGIN,
1481
+ });
1478
1482
  }
1479
1483
  } catch (err) {
1480
1484
  this.report.recordError(err, { line: attribute.line, element: attribute.name });
@@ -148,7 +148,6 @@ export const VIZ_VALUE_CODE = "W_GEXF_VIZ_VALUE";
148
148
  export const OPEN_BOUND_CONFLICT_CODE = "W_GEXF_OPEN_BOUND_CONFLICT";
149
149
 
150
150
  export {
151
- DUPLICATE_ATTRIBUTE_CODE,
152
151
  DUPLICATE_NODE_CODE,
153
152
  ID_MERGED_CODE,
154
153
  MISSING_ENDPOINT_CODE,
@@ -830,17 +830,6 @@ function countMangled(snapshot: GraphSnapshot): number {
830
830
 
831
831
  // ============================================================ writing
832
832
 
833
- /**
834
- * The GML text of a real: the shortest text of the dtype with a decimal point guaranteed
835
- * (`2.0`, `1.0e-7`), the NetworkX spellings `+INF` / `-INF` / `NAN` for the non-finite values.
836
- * @param value - the value
837
- * @param dtype - the dtype the value came from (f32 uses the fround-shortest text)
838
- * @returns the text
839
- */
840
- export function gmlRealText(value: number, dtype: "f32" | "f64" | "i32" | "u32" | "u8" = "f64"): string {
841
- return formatGmlReal(value, dtype);
842
- }
843
-
844
833
  /**
845
834
  * The GML text of a number by the dtype and origin of its column: an integer text for the integer
846
835
  * dtypes and for an f64 column that came from GML `int` values, a real text otherwise.
@@ -855,12 +844,12 @@ function numberText(value: number, meta: ColumnMeta): string {
855
844
  case "u8":
856
845
  return formatInteger(value);
857
846
  case "f32":
858
- return gmlRealText(value, "f32");
847
+ return formatGmlReal(value, "f32");
859
848
  default:
860
849
  if (meta.origin?.type === "int" && Number.isInteger(value)) {
861
850
  return formatInteger(value);
862
851
  }
863
- return gmlRealText(value, "f64");
852
+ return formatGmlReal(value, "f64");
864
853
  }
865
854
  }
866
855
 
@@ -873,7 +862,7 @@ function numberText(value: number, meta: ColumnMeta): string {
873
862
  function jsonScalarText(value: number | string | boolean): string {
874
863
  switch (typeof value) {
875
864
  case "number":
876
- return Number.isInteger(value) ? formatInteger(value) : gmlRealText(value, "f64");
865
+ return Number.isInteger(value) ? formatInteger(value) : formatGmlReal(value, "f64");
877
866
  case "boolean":
878
867
  return value ? "1" : "0";
879
868
  default:
@@ -897,13 +886,13 @@ function itemText(item: unknown, itemDtype: string, meta: ColumnMeta): string {
897
886
  case "u8":
898
887
  return formatInteger(item);
899
888
  case "f32":
900
- return gmlRealText(item, "f32");
889
+ return formatGmlReal(item, "f32");
901
890
  case "json":
902
891
  return jsonScalarText(item);
903
892
  default:
904
893
  return meta.origin?.type === "int" && Number.isInteger(item)
905
894
  ? formatInteger(item)
906
- : gmlRealText(item, "f64");
895
+ : formatGmlReal(item, "f64");
907
896
  }
908
897
  case "boolean":
909
898
  return item ? "1" : "0";
@@ -1234,7 +1223,7 @@ function originalIdText(id: NodeId): string {
1234
1223
  if (typeof id === "string") {
1235
1224
  return quoteGmlString(id);
1236
1225
  }
1237
- return Number.isSafeInteger(id) ? formatInteger(id) : gmlRealText(id, "f64");
1226
+ return Number.isSafeInteger(id) ? formatInteger(id) : formatGmlReal(id, "f64");
1238
1227
  }
1239
1228
 
1240
1229
  /**
@@ -1308,7 +1297,7 @@ function* gmlParts(context: WriteContext): Generator<string, void, undefined> {
1308
1297
  const text =
1309
1298
  integerWeights && Number.isInteger(weight)
1310
1299
  ? formatInteger(weight)
1311
- : gmlRealText(weight, context.weights.dtype);
1300
+ : formatGmlReal(weight, context.weights.dtype);
1312
1301
  w.line(" ", plan.weightKey, text);
1313
1302
  }
1314
1303
  for (const { column, key } of context.edgeColumns) {
@@ -23,6 +23,7 @@ import { type Column, GraphFormatError, type GraphSnapshot, INVALID_INDEX, type
23
23
  import { type PairFolding, pairFolding } from "../../common/direction.js";
24
24
  import { checkCapabilities, countMixedEdges, LOSS } from "../../common/export.js";
25
25
  import { formatF32, formatF64 } from "../../common/format.js";
26
+ import { flipY } from "../../common/json-elements.js";
26
27
  import { type ResolvedExportOptions, resolveExportOptions } from "../../common/options.js";
27
28
  import { explicitWeights } from "../../common/weights.js";
28
29
  import { encodeChunks, joinText } from "../../common/writer.js";
@@ -1520,7 +1521,8 @@ function* writeCytoscape(snapshot: GraphSnapshot, p: Plan): Generator<string, vo
1520
1521
  w.key("position");
1521
1522
  w.open("{");
1522
1523
  w.member("x", numberText(value[0], f32, c.nonfinite));
1523
- w.member("y", numberText(value[1], f32, c.nonfinite));
1524
+ // stored y-up; Cytoscape's y grows downward
1525
+ w.member("y", numberText(flipY(value[1]), f32, c.nonfinite));
1524
1526
  w.close("}");
1525
1527
  }
1526
1528
  }
@@ -66,7 +66,7 @@ import {
66
66
  import { DirectionResolver, type EdgeKind } from "../../common/direction.js";
67
67
  import { ID_MERGED_CODE, IdCoercer } from "../../common/ids.js";
68
68
  import { readText, textChunks, throwIfAborted } from "../../common/input.js";
69
- import { MAYBE_UNSAFE_INTEGER, reviveNonstandard, rewriteNumbers } from "../../common/json-elements.js";
69
+ import { flipY, MAYBE_UNSAFE_INTEGER, reviveNonstandard, rewriteNumbers } from "../../common/json-elements.js";
70
70
  import {
71
71
  chooseGraph,
72
72
  type ImportFormatDefaults,
@@ -2669,7 +2669,8 @@ function importCytoscape(ctx: ImportContext, root: unknown): void {
2669
2669
  if (position !== undefined && position !== null) {
2670
2670
  if (isJsonObject(position) && typeof position.x === "number" && typeof position.y === "number") {
2671
2671
  point[0] = position.x;
2672
- point[1] = position.y;
2672
+ // Cytoscape's y grows downward; positions are stored y-up (the exporter flips back)
2673
+ point[1] = flipY(position.y);
2673
2674
  ctx.nodes.set(positionColumn, index, point);
2674
2675
  } else {
2675
2676
  report.error(