@graphty/graph-io 0.3.18 → 0.3.19

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 (118) hide show
  1. package/README.md +37 -2
  2. package/dist/chunks/{escape-D-gZWO26.js → escape-CWExcecC.js} +3 -3
  3. package/dist/chunks/{escape-D-gZWO26.js.map → escape-CWExcecC.js.map} +1 -1
  4. package/dist/chunks/exporter-CbMZyVt-.js +735 -0
  5. package/dist/chunks/exporter-CbMZyVt-.js.map +1 -0
  6. package/dist/chunks/importer-BAP4PrxR.js +2194 -0
  7. package/dist/chunks/importer-BAP4PrxR.js.map +1 -0
  8. package/dist/chunks/{importer-Br_QeAeE.js → importer-BNFuV1-K.js} +5 -4
  9. package/dist/chunks/{importer-Br_QeAeE.js.map → importer-BNFuV1-K.js.map} +1 -1
  10. package/dist/chunks/{importer-d0uQxFp6.js → importer-BVGtU1NA.js} +5 -4
  11. package/dist/chunks/{importer-d0uQxFp6.js.map → importer-BVGtU1NA.js.map} +1 -1
  12. package/dist/chunks/{importer-DHagxvDD.js → importer-BW-Ft2ps.js} +5 -4
  13. package/dist/chunks/{importer-DHagxvDD.js.map → importer-BW-Ft2ps.js.map} +1 -1
  14. package/dist/chunks/{importer-Du5crN9l.js → importer-Bm1_5vPS.js} +275 -82
  15. package/dist/chunks/importer-Bm1_5vPS.js.map +1 -0
  16. package/dist/chunks/{importer-aNJfe0qu.js → importer-ByPGO-09.js} +2 -2
  17. package/dist/chunks/{importer-aNJfe0qu.js.map → importer-ByPGO-09.js.map} +1 -1
  18. package/dist/chunks/importer-DOepkgnG.js +1713 -0
  19. package/dist/chunks/importer-DOepkgnG.js.map +1 -0
  20. package/dist/chunks/json-elements-CZY1wiZh.js +779 -0
  21. package/dist/chunks/json-elements-CZY1wiZh.js.map +1 -0
  22. package/dist/chunks/{records-Bk9jgodz.js → records-BzNicMsf.js} +2 -2
  23. package/dist/chunks/{records-Bk9jgodz.js.map → records-BzNicMsf.js.map} +1 -1
  24. package/dist/chunks/{report-BOk0p5y8.js → report-BcWboivV.js} +95 -95
  25. package/dist/chunks/{report-BOk0p5y8.js.map → report-BcWboivV.js.map} +1 -1
  26. package/dist/chunks/weights-CwISIpCP.js +176 -0
  27. package/dist/chunks/weights-CwISIpCP.js.map +1 -0
  28. package/dist/chunks/{writer-GAdltGmC.js → writer-DQiKgQJc.js} +8 -179
  29. package/dist/chunks/writer-DQiKgQJc.js.map +1 -0
  30. package/dist/csv.js +5 -4
  31. package/dist/csv.js.map +1 -1
  32. package/dist/cx.d.ts +1 -0
  33. package/dist/cx.js +6 -0
  34. package/dist/cx.js.map +1 -0
  35. package/dist/cx2.d.ts +1 -0
  36. package/dist/cx2.js +10 -0
  37. package/dist/cx2.js.map +1 -0
  38. package/dist/dot.js +1 -1
  39. package/dist/gexf.js +15 -4
  40. package/dist/gexf.js.map +1 -1
  41. package/dist/gml.js +5 -4
  42. package/dist/gml.js.map +1 -1
  43. package/dist/graph-io.js +145 -389
  44. package/dist/graph-io.js.map +1 -1
  45. package/dist/graphml.js +1 -1
  46. package/dist/json.js +1 -1
  47. package/dist/neo4j.js +5 -4
  48. package/dist/neo4j.js.map +1 -1
  49. package/dist/obo.js +1 -1
  50. package/dist/pajek.js +1 -1
  51. package/dist/src/common/json-elements.d.ts +286 -0
  52. package/dist/src/common/json-elements.d.ts.map +1 -0
  53. package/dist/src/common/json-elements.js +926 -0
  54. package/dist/src/common/json-elements.js.map +1 -0
  55. package/dist/src/formats/cx/importer.d.ts +133 -0
  56. package/dist/src/formats/cx/importer.d.ts.map +1 -0
  57. package/dist/src/formats/cx/importer.js +2220 -0
  58. package/dist/src/formats/cx/importer.js.map +1 -0
  59. package/dist/src/formats/cx/index.d.ts +7 -0
  60. package/dist/src/formats/cx/index.d.ts.map +1 -0
  61. package/dist/src/formats/cx/index.js +7 -0
  62. package/dist/src/formats/cx/index.js.map +1 -0
  63. package/dist/src/formats/cx2/exporter.d.ts +59 -0
  64. package/dist/src/formats/cx2/exporter.d.ts.map +1 -0
  65. package/dist/src/formats/cx2/exporter.js +864 -0
  66. package/dist/src/formats/cx2/exporter.js.map +1 -0
  67. package/dist/src/formats/cx2/importer.d.ts +169 -0
  68. package/dist/src/formats/cx2/importer.d.ts.map +1 -0
  69. package/dist/src/formats/cx2/importer.js +1652 -0
  70. package/dist/src/formats/cx2/importer.js.map +1 -0
  71. package/dist/src/formats/cx2/index.d.ts +7 -0
  72. package/dist/src/formats/cx2/index.d.ts.map +1 -0
  73. package/dist/src/formats/cx2/index.js +7 -0
  74. package/dist/src/formats/cx2/index.js.map +1 -0
  75. package/dist/src/formats/gexf/exporter.d.ts +2 -0
  76. package/dist/src/formats/gexf/exporter.d.ts.map +1 -1
  77. package/dist/src/formats/gexf/exporter.js +6 -0
  78. package/dist/src/formats/gexf/exporter.js.map +1 -1
  79. package/dist/src/formats/gexf/importer.d.ts +2 -4
  80. package/dist/src/formats/gexf/importer.d.ts.map +1 -1
  81. package/dist/src/formats/gexf/importer.js +2 -4
  82. package/dist/src/formats/gexf/importer.js.map +1 -1
  83. package/dist/src/formats/gexf/index.d.ts +1 -1
  84. package/dist/src/formats/gexf/index.js +2 -2
  85. package/dist/src/formats/gexf/index.js.map +1 -1
  86. package/dist/src/formats/gml/exporter.js +1 -1
  87. package/dist/src/formats/gml/exporter.js.map +1 -1
  88. package/dist/src/formats/json/importer.d.ts.map +1 -1
  89. package/dist/src/formats/json/importer.js +6 -104
  90. package/dist/src/formats/json/importer.js.map +1 -1
  91. package/dist/src/index.d.ts +2 -0
  92. package/dist/src/index.d.ts.map +1 -1
  93. package/dist/src/index.js +2 -0
  94. package/dist/src/index.js.map +1 -1
  95. package/dist/src/registry.d.ts.map +1 -1
  96. package/dist/src/registry.js +5 -0
  97. package/dist/src/registry.js.map +1 -1
  98. package/dist/src/sniff.d.ts +1 -1
  99. package/dist/src/sniff.d.ts.map +1 -1
  100. package/dist/src/sniff.js +2 -0
  101. package/dist/src/sniff.js.map +1 -1
  102. package/package.json +11 -1
  103. package/src/common/json-elements.ts +1147 -0
  104. package/src/formats/cx/importer.ts +2733 -0
  105. package/src/formats/cx/index.ts +7 -0
  106. package/src/formats/cx2/exporter.ts +1036 -0
  107. package/src/formats/cx2/importer.ts +2187 -0
  108. package/src/formats/cx2/index.ts +7 -0
  109. package/src/formats/gexf/exporter.ts +11 -0
  110. package/src/formats/gexf/importer.ts +2 -2
  111. package/src/formats/gexf/index.ts +1 -1
  112. package/src/formats/gml/exporter.ts +1 -1
  113. package/src/formats/json/importer.ts +6 -105
  114. package/src/index.ts +10 -0
  115. package/src/registry.ts +5 -0
  116. package/src/sniff.ts +14 -1
  117. package/dist/chunks/importer-Du5crN9l.js.map +0 -1
  118. package/dist/chunks/writer-GAdltGmC.js.map +0 -1
@@ -0,0 +1,1036 @@
1
+ /**
2
+ * The CX2 exporter (design/graph-io/cytoscape-and-obo/design.md section 1.3; graphty issue #307):
3
+ * writes a snapshot as one CX2 document -- the descriptor, the pre-metadata, one
4
+ * `attributeDeclarations` block (aliases from `origin.id`, defaults from `meta.default`),
5
+ * `networkAttributes`, `nodes` (x / y from the position with y flipped back to screen coordinates,
6
+ * `z` from the `z` column), `edges`, the `cx2.bypass` columns as `nodeBypasses` / `edgeBypasses`,
7
+ * the opaque aspects a CX2 import kept (which returns its own style rules) and `status`.
8
+ *
9
+ * What CX2 cannot hold is announced by check() before anything is written: every edge is
10
+ * directed (W_CX2_UNDIRECTED_AS_DIRECTED, W_MUTUAL_EXPANDED), node ids are integers (E_ID_CHARSET
11
+ * unless `sanitizeIds: "mangle"`, which keeps the original in the `graphty:originalId` attribute
12
+ * the importer restores), nested values are written as JSON text (W_CX2_JSON_AS_STRING), NaN and
13
+ * the infinities as null (W_CX2_NONFINITE_AS_NULL), and the generic notes of checkCapabilities().
14
+ */
15
+
16
+ import { type Column, GraphFormatError, type GraphSnapshot, type NodeId } from "@graphty/graph-format";
17
+
18
+ import { type PairFolding, pairFolding } from "../../common/direction.js";
19
+ import { capabilities, checkCapabilities, LOSS } from "../../common/export.js";
20
+ import { isRecord, POSITION_COLUMN } from "../../common/json-elements.js";
21
+ import { type ResolvedExportOptions, resolveExportOptions } from "../../common/options.js";
22
+ import { type ExplicitWeights, explicitWeights } from "../../common/weights.js";
23
+ import { encodeChunks, joinText } from "../../common/writer.js";
24
+ import { type CommonExportOptions, type ExportCapabilities, type GraphExporter, type LossNote } from "../../types.js";
25
+ import { BYPASS_NAMESPACE, CX2_FORMAT, cx2Type, ORIGINAL_ID_ATTRIBUTE } from "./importer.js";
26
+
27
+ /** The format-specific options of the CX2 exporter: none yet. */
28
+ export type Cx2ExportOptions = Readonly<Record<never, never>>;
29
+
30
+ /**
31
+ * The loss notes the CX2 exporter's check() returns (design section 1.3), by name. A key is the
32
+ * code without its severity and format prefixes.
33
+ */
34
+ export const CX2_LOSS = Object.freeze({
35
+ /** Every edge is written directed: an undirected snapshot, or the undirected pairs of a mixed one. */
36
+ UNDIRECTED_AS_DIRECTED: "W_CX2_UNDIRECTED_AS_DIRECTED",
37
+ /** A nested (json) column is written as a string attribute holding its JSON text. */
38
+ JSON_AS_STRING: "W_CX2_JSON_AS_STRING",
39
+ /** NaN and the infinities cannot be written; they are written as null and read back unset. */
40
+ NONFINITE_AS_NULL: "W_CX2_NONFINITE_AS_NULL",
41
+ /** A mutual pair is written as two directed edges without its mark. */
42
+ MUTUAL_EXPANDED: LOSS.MUTUAL_EXPANDED,
43
+ /** A plain edge column named like the weight key reads back as THE weight (or is skipped when weights are written). */
44
+ WEIGHT_KEY_CLASH: LOSS.WEIGHT_KEY_CLASH,
45
+ /** A parent / parents column: CX2 has no containment. */
46
+ HIERARCHY_DROPPED: LOSS.HIERARCHY,
47
+ /** A start / end / timestamp column: CX2 has no time. */
48
+ TEMPORAL_DROPPED: LOSS.TEMPORAL,
49
+ /** A role column written as a plain attribute. */
50
+ ROLE_DROPPED: LOSS.ROLE,
51
+ /** A dtype CX2 declares as another (f32 as double, u32 as long, dict as string, ...). */
52
+ DTYPE_UNSUPPORTED: LOSS.DTYPE,
53
+ /** Node ids that are not integers under the default sanitizeIds "error": export() throws E_INVALID_ID. */
54
+ ID_CHARSET: LOSS.ID_CHARSET,
55
+ /** Node ids that are not integers under sanitizeIds "mangle": renumbered, originals kept. */
56
+ ID_MANGLED: LOSS.ID_MANGLED,
57
+ /** Edges without a usable id get generated integer ids. */
58
+ EDGE_IDS_GENERATED: LOSS.EDGE_IDS_GENERATED,
59
+ });
60
+
61
+ /**
62
+ * What CX2 keeps (design section 1.3): directed multigraphs with self-loops, integer node ids,
63
+ * required integer edge ids, declared string / double / integer / boolean columns and lists of
64
+ * them, declared defaults, network attributes and the position role. f32, u32, u8 and dict columns
65
+ * are written as the nearest declared type and read back as it.
66
+ */
67
+ export const CX2_CAPABILITIES: ExportCapabilities = capabilities({
68
+ mixedDirection: false,
69
+ multiEdges: true,
70
+ selfLoops: true,
71
+ edgeIds: "required",
72
+ idCharset: "integer",
73
+ dtypes: ["string", "f64", "i32", "bool"],
74
+ components: false,
75
+ lists: true,
76
+ json: false,
77
+ defaults: true,
78
+ options: false,
79
+ hierarchy: false,
80
+ temporal: "none",
81
+ graphAttributes: true,
82
+ positions: true,
83
+ viz: false,
84
+ });
85
+
86
+ /** The roles CX2 has a slot for, and the column names its importer gives them. */
87
+ const SLOT_ROLES: ReadonlySet<string> = new Set(["label", "position", "id"]);
88
+ const ROLE_NAMES: Readonly<Record<string, string>> = Object.freeze({
89
+ label: "name",
90
+ position: POSITION_COLUMN,
91
+ });
92
+
93
+ /** Roles whose columns are never written as attributes. */
94
+ const SKIPPED_ROLES: ReadonlySet<string> = new Set([
95
+ "directed",
96
+ "pair",
97
+ "mutual",
98
+ "weight",
99
+ "timeText",
100
+ "originalId",
101
+ "parent",
102
+ "parents",
103
+ "start",
104
+ "end",
105
+ "timestamp",
106
+ "timestamps",
107
+ "spells",
108
+ "open",
109
+ "spellsOpen",
110
+ "position",
111
+ "color",
112
+ "size",
113
+ "shape",
114
+ "thickness",
115
+ ]);
116
+
117
+ /** The aspects the exporter writes itself; an opaque aspect of the same name is not written again. */
118
+ const CORE_ASPECTS: ReadonlySet<string> = new Set([
119
+ "nodes",
120
+ "edges",
121
+ "attributeDeclarations",
122
+ "networkAttributes",
123
+ "nodeBypasses",
124
+ "edgeBypasses",
125
+ "metaData",
126
+ "status",
127
+ ]);
128
+
129
+ /** The weight attribute name. */
130
+ const WEIGHT_ATTRIBUTE = "weight";
131
+
132
+ /**
133
+ * The prefix of a string that stands for a raw number literal in the output: -0 (which
134
+ * JSON.stringify writes as 0) and an integer id beyond 2^53 (kept as its digits).
135
+ */
136
+ const RAW = `${String.fromCharCode(0)}cx2:`;
137
+
138
+ /** A raw literal as JSON.stringify writes it, to be unquoted. */
139
+ const RAW_JSON = /"\\u0000cx2:(-?[0-9]+)"/g;
140
+
141
+ /** An integer literal beyond 2^53 as text: a CX2 id graph-io keeps as its digits. */
142
+ const BIG_INTEGER_TEXT = /^-?[1-9][0-9]{15,}$/;
143
+
144
+ /** The node ids as written: the original, a renumbered one, or a raw big integer. */
145
+ interface WrittenIds {
146
+ /** How many ids were renumbered (sanitizeIds "mangle"). */
147
+ readonly changed: number;
148
+ /** The written id of node i (a number, or a RAW string for a big integer). */
149
+ idAt(i: number): NodeId;
150
+ /** Whether node i was renumbered. */
151
+ isChanged(i: number): boolean;
152
+ /** The original id of node i. */
153
+ originalAt(i: number): NodeId;
154
+ }
155
+
156
+ /**
157
+ * Whether an id can be written as a CX2 id unchanged: a safe integer, or the digits of an integer
158
+ * beyond 2^53 (the CX2 importer reads those back as the same digits).
159
+ * @param id - the id
160
+ * @returns true when writable
161
+ */
162
+ function writableId(id: NodeId): boolean {
163
+ return typeof id === "number"
164
+ ? Number.isSafeInteger(id)
165
+ : BIG_INTEGER_TEXT.test(id) && !Number.isSafeInteger(Number(id));
166
+ }
167
+
168
+ /**
169
+ * The written node ids; under "error" an id that is not writable throws E_INVALID_ID, under
170
+ * "mangle" it gets the next unused integer.
171
+ * @param snapshot - the snapshot
172
+ * @param mode - the sanitizeIds option
173
+ * @returns the ids
174
+ */
175
+ function writtenIds(snapshot: GraphSnapshot, mode: "error" | "mangle"): WrittenIds {
176
+ const { ids } = snapshot;
177
+ const bad: number[] = [];
178
+ const used = new Set<number>();
179
+ for (let i = 0; i < ids.size; i++) {
180
+ const id = ids.idOf(i);
181
+ if (!writableId(id)) {
182
+ bad.push(i);
183
+ } else if (typeof id === "number") {
184
+ used.add(id);
185
+ }
186
+ }
187
+ if (bad.length > 0 && mode === "error") {
188
+ const first = ids.idOf(bad[0]);
189
+ throw new GraphFormatError(
190
+ "E_INVALID_ID",
191
+ `${bad.length} node id(s) cannot be written as CX2 integers (first: ${JSON.stringify(first)} at index ${bad[0]}); pass sanitizeIds: "mangle" to rewrite them`,
192
+ { reason: "charset", charset: "integer", count: bad.length, index: bad[0] },
193
+ );
194
+ }
195
+ const renumbered = new Map<number, number>();
196
+ let next = 0;
197
+ for (const i of bad) {
198
+ while (used.has(next)) {
199
+ next++;
200
+ }
201
+ used.add(next);
202
+ renumbered.set(i, next);
203
+ }
204
+ return {
205
+ changed: bad.length,
206
+ idAt: (i: number): NodeId => {
207
+ const id = renumbered.get(i) ?? ids.idOf(i);
208
+ return typeof id === "string" ? `${RAW}${id}` : id;
209
+ },
210
+ isChanged: (i: number): boolean => renumbered.has(i),
211
+ originalAt: (i: number): NodeId => ids.idOf(i),
212
+ };
213
+ }
214
+
215
+ /**
216
+ * How many node ids are not writable unchanged.
217
+ * @param snapshot - the snapshot
218
+ * @returns the count
219
+ */
220
+ function unwritableIds(snapshot: GraphSnapshot): number {
221
+ let count = 0;
222
+ for (let i = 0; i < snapshot.ids.size; i++) {
223
+ if (!writableId(snapshot.ids.idOf(i))) {
224
+ count++;
225
+ }
226
+ }
227
+ return count;
228
+ }
229
+
230
+ /** One attribute column as it will be written. */
231
+ interface AttributePlan {
232
+ readonly column: Column;
233
+ /** The full attribute name. */
234
+ readonly name: string;
235
+ /** The key used in v (the alias, or the name). */
236
+ readonly key: string;
237
+ /** The declared type text. */
238
+ readonly d: string;
239
+ /** Whether values are written as their JSON text (a json column). */
240
+ readonly jsonText: boolean;
241
+ }
242
+
243
+ /** Everything export() and check() need, computed once. */
244
+ interface Plan {
245
+ readonly notes: LossNote[];
246
+ readonly fatal: GraphFormatError | null;
247
+ readonly ids: WrittenIds | null;
248
+ readonly folding: PairFolding;
249
+ readonly weights: ExplicitWeights;
250
+ readonly nodeAttrs: readonly AttributePlan[];
251
+ readonly edgeAttrs: readonly AttributePlan[];
252
+ readonly graphAttrs: readonly AttributePlan[];
253
+ readonly nodeBypasses: readonly Column[];
254
+ readonly edgeBypasses: readonly Column[];
255
+ readonly position: Column | null;
256
+ readonly positionZ: boolean;
257
+ readonly z: Column | null;
258
+ readonly edgeIds: readonly number[];
259
+ readonly originalIds: boolean;
260
+ }
261
+
262
+ /**
263
+ * Whether a column is a bypass column of a CX2 import.
264
+ * @param column - the column
265
+ * @returns true for the cx2.bypass namespace
266
+ */
267
+ function isBypass(column: Column): boolean {
268
+ return column.meta.origin?.namespace === BYPASS_NAMESPACE;
269
+ }
270
+
271
+ /**
272
+ * Whether a column is the z (stacking order) column.
273
+ * @param column - the column
274
+ * @returns true for the column marked by a Cytoscape-family importer
275
+ */
276
+ function isZ(column: Column): boolean {
277
+ return column.meta.extra.cytoscape === "z";
278
+ }
279
+
280
+ /**
281
+ * The CX2 type of a scalar dtype.
282
+ * @param dtype - the dtype
283
+ * @param longOrigin - whether the source declared the column long
284
+ * @returns the type text
285
+ */
286
+ function scalarType(dtype: string, longOrigin: boolean): string {
287
+ switch (dtype) {
288
+ case "f64":
289
+ return longOrigin ? "long" : "double";
290
+ case "f32":
291
+ return "double";
292
+ case "i32":
293
+ case "u8":
294
+ return "integer";
295
+ case "u32":
296
+ return "long";
297
+ case "bool":
298
+ return "boolean";
299
+ default:
300
+ return "string";
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Whether every set value of an f64 column is integral (so a declared long stays long).
306
+ * @param column - the column
307
+ * @returns true when every set value is a safe integer
308
+ */
309
+ function allIntegral(column: Column): boolean {
310
+ for (let i = 0; i < column.length; i++) {
311
+ if (column.isSet(i) && !Number.isSafeInteger(column.value(i))) {
312
+ return false;
313
+ }
314
+ }
315
+ return true;
316
+ }
317
+
318
+ /**
319
+ * The declared type of a column.
320
+ * @param column - the column
321
+ * @returns the CX2 type text
322
+ */
323
+ function declaredType(column: Column): string {
324
+ const { meta } = column;
325
+ const origin = meta.origin?.type ?? null;
326
+ const longOrigin = origin !== null && /long$/.test(origin) && (meta.dtype !== "f64" || allIntegral(column));
327
+ if (meta.dtype === "list") {
328
+ return meta.itemDtype === null || meta.itemDtype === "json"
329
+ ? "string"
330
+ : `list_of_${scalarType(meta.itemDtype, longOrigin)}`;
331
+ }
332
+ // a multi-component column (a color, a 3D vector) is written as a list of its components
333
+ return meta.components > 1 ? `list_of_${scalarType(meta.dtype, false)}` : scalarType(meta.dtype, longOrigin);
334
+ }
335
+
336
+ /**
337
+ * Count the non-finite numbers of a column.
338
+ * @param column - the column
339
+ * @returns how many set cells (or list items) are NaN or infinite
340
+ */
341
+ function nonFiniteCount(column: Column): number {
342
+ const { dtype, itemDtype } = column.meta;
343
+ const numeric =
344
+ dtype === "f32" || dtype === "f64" || (dtype === "list" && (itemDtype === "f32" || itemDtype === "f64"));
345
+ if (!numeric) {
346
+ return 0;
347
+ }
348
+ let count = 0;
349
+ for (let i = 0; i < column.length; i++) {
350
+ if (!column.isSet(i)) {
351
+ continue;
352
+ }
353
+ const value = column.value(i);
354
+ if (dtype === "list") {
355
+ if ((value as readonly number[]).some((v) => !Number.isFinite(v))) {
356
+ count++;
357
+ }
358
+ } else if (!Number.isFinite(value as number)) {
359
+ count++;
360
+ }
361
+ }
362
+ return count;
363
+ }
364
+
365
+ /**
366
+ * Plan the attribute columns of one table.
367
+ * @param table - the table
368
+ * @param skip - columns not written as attributes
369
+ * @param rename - the written name of a column, when it differs (the label as `name`)
370
+ * @param reserved - keys already used in v
371
+ * @returns the plans
372
+ */
373
+ function planAttributes(
374
+ table: Iterable<Column>,
375
+ skip: (column: Column) => boolean,
376
+ rename: (column: Column) => string,
377
+ reserved: ReadonlySet<string> = new Set(),
378
+ ): AttributePlan[] {
379
+ const plans: AttributePlan[] = [];
380
+ const used = new Set<string>(reserved);
381
+ const candidates = [...table].filter((c) => !skip(c));
382
+ for (const column of candidates) {
383
+ used.add(rename(column));
384
+ }
385
+ for (const column of candidates) {
386
+ const name = rename(column);
387
+ const { origin } = column.meta;
388
+ let key = name;
389
+ const alias = origin?.format === CX2_FORMAT && typeof origin.id === "string" ? origin.id : null;
390
+ if (alias !== null && alias.length > 0 && alias !== name && !used.has(alias)) {
391
+ key = alias;
392
+ used.add(alias);
393
+ }
394
+ plans.push({ column, name, key, d: declaredType(column), jsonText: column.meta.dtype === "json" });
395
+ }
396
+ return plans;
397
+ }
398
+
399
+ /**
400
+ * Plan an export: the notes, the fatal condition and what is written.
401
+ * @param snapshot - the snapshot
402
+ * @param common - the resolved common options
403
+ * @returns the plan
404
+ */
405
+ function plan(snapshot: GraphSnapshot, common: ResolvedExportOptions): Plan {
406
+ const notes: LossNote[] = [];
407
+ let fatal: GraphFormatError | null = null;
408
+ const note = (code: string, message: string, column: string | null = null, count: number | null = null): void => {
409
+ notes.push(Object.freeze({ code, message, column, count }));
410
+ };
411
+ // bypass columns are written as bypasses, which hold any JSON value
412
+ const bypassNames = new Set(
413
+ [...snapshot.nodes, ...snapshot.edges].filter((c) => isBypass(c) || isZ(c)).map((c) => c.meta.name),
414
+ );
415
+ for (const gen of checkCapabilities(snapshot, CX2_CAPABILITIES, common, {
416
+ roles: SLOT_ROLES,
417
+ roleNames: ROLE_NAMES,
418
+ })) {
419
+ if (gen.column !== null && bypassNames.has(gen.column) && (gen.code === LOSS.JSON || gen.code === LOSS.DTYPE)) {
420
+ continue;
421
+ }
422
+ if (gen.code === LOSS.ID_CHARSET || gen.code === LOSS.ID_MANGLED) {
423
+ // counted below: CX2 also keeps integer ids beyond 2^53, which the generic rule refuses
424
+ continue;
425
+ }
426
+ if (gen.code === LOSS.JSON) {
427
+ note(
428
+ CX2_LOSS.JSON_AS_STRING,
429
+ `${gen.column === null ? "a column" : `column "${gen.column}"`} holds nested values; written as a string attribute holding their JSON text`,
430
+ gen.column,
431
+ gen.count,
432
+ );
433
+ continue;
434
+ }
435
+ if (gen.code === LOSS.MIXED_DIRECTION_ERROR && fatal === null) {
436
+ fatal = new GraphFormatError("E_DIRECTED", gen.message, { reason: "mixed direction" });
437
+ }
438
+ notes.push(gen);
439
+ }
440
+
441
+ const folding = pairFolding(snapshot);
442
+ directionNotes(snapshot, folding, common, note);
443
+
444
+ const unwritable = unwritableIds(snapshot);
445
+ if (unwritable > 0) {
446
+ note(
447
+ common.sanitizeIds === "mangle" ? LOSS.ID_MANGLED : LOSS.ID_CHARSET,
448
+ common.sanitizeIds === "mangle"
449
+ ? `${unwritable} node id(s) that are not integers are renumbered; the originals are kept in the ${ORIGINAL_ID_ATTRIBUTE} attribute (restored by restoreMangledIds)`
450
+ : `${unwritable} node id(s) that are not integers; export() will throw unless sanitizeIds is "mangle"`,
451
+ null,
452
+ unwritable,
453
+ );
454
+ }
455
+ let ids: WrittenIds | null = null;
456
+ try {
457
+ ids = writtenIds(snapshot, common.sanitizeIds);
458
+ } catch (err) {
459
+ if (!(err instanceof GraphFormatError)) {
460
+ throw err;
461
+ }
462
+ fatal ??= err;
463
+ }
464
+
465
+ const weights = explicitWeights(snapshot);
466
+ const label = snapshot.nodes.byRole("label");
467
+ const nodeAttrs = planAttributes(
468
+ snapshot.nodes,
469
+ (c) => (c.meta.role !== null && SKIPPED_ROLES.has(c.meta.role)) || isBypass(c) || isZ(c),
470
+ (c) => (c === label && !snapshot.nodes.has("name") ? "name" : c.meta.name),
471
+ ids !== null && ids.changed > 0 ? new Set([ORIGINAL_ID_ATTRIBUTE]) : new Set(),
472
+ );
473
+ const plainWeight = snapshot.edges.get(WEIGHT_ATTRIBUTE);
474
+ const weightColumnClash = plainWeight !== null && plainWeight.meta.role === null;
475
+ if (weightColumnClash) {
476
+ note(
477
+ LOSS.WEIGHT_KEY_CLASH,
478
+ weights.weighted
479
+ ? `edge column "${WEIGHT_ATTRIBUTE}" is not written: the explicit weights are written under that name`
480
+ : `edge column "${WEIGHT_ATTRIBUTE}" reads back as the edge weight`,
481
+ WEIGHT_ATTRIBUTE,
482
+ null,
483
+ );
484
+ }
485
+ const edgeAttrs = planAttributes(
486
+ snapshot.edges,
487
+ (c) =>
488
+ (c.meta.role !== null && (SKIPPED_ROLES.has(c.meta.role) || c.meta.role === "id")) ||
489
+ isBypass(c) ||
490
+ (weights.weighted && c === plainWeight),
491
+ (c) => c.meta.name,
492
+ weights.weighted ? new Set([WEIGHT_ATTRIBUTE]) : new Set(),
493
+ );
494
+ const graphAttrs = planAttributes(
495
+ snapshot.graph,
496
+ () => false,
497
+ (c) => c.meta.name,
498
+ ).map((p) => ({ ...p, key: p.name }));
499
+
500
+ let nonfinite = 0;
501
+ for (const p of [...nodeAttrs, ...edgeAttrs, ...graphAttrs]) {
502
+ nonfinite += nonFiniteCount(p.column);
503
+ }
504
+ for (let e = 0; e < snapshot.edgeCount; e++) {
505
+ if (weights.isExplicit(e) && !Number.isFinite(weights.value(e))) {
506
+ nonfinite++;
507
+ }
508
+ }
509
+ const position = snapshot.nodes.byRole("position");
510
+ if (position !== null) {
511
+ nonfinite += nonFinitePositions(position);
512
+ }
513
+ if (nonfinite > 0) {
514
+ note(
515
+ CX2_LOSS.NONFINITE_AS_NULL,
516
+ `${nonfinite} NaN or infinite value(s) cannot be written in CX2; written as null, they read back unset`,
517
+ null,
518
+ nonfinite,
519
+ );
520
+ }
521
+
522
+ const edgeIds = planEdgeIds(snapshot, note);
523
+ const idColumn = snapshot.edges.byRole("id");
524
+ if (idColumn !== null && idColumn.dtype !== "f64") {
525
+ note(
526
+ LOSS.DTYPE,
527
+ `edge id column "${idColumn.meta.name}" is ${idColumn.dtype}; CX2 edge ids are integers and read back as f64`,
528
+ idColumn.meta.name,
529
+ snapshot.edgeCount - idColumn.nullCount,
530
+ );
531
+ }
532
+ const z = [...snapshot.nodes].find(isZ) ?? null;
533
+ const positionZ = position !== null && z === null && position.meta.extra.sourceDims === 3;
534
+ return {
535
+ notes,
536
+ fatal,
537
+ ids,
538
+ folding,
539
+ weights,
540
+ nodeAttrs,
541
+ edgeAttrs,
542
+ graphAttrs,
543
+ nodeBypasses: [...snapshot.nodes].filter(isBypass),
544
+ edgeBypasses: [...snapshot.edges].filter(isBypass),
545
+ position,
546
+ positionZ,
547
+ z,
548
+ edgeIds,
549
+ originalIds: ids !== null && ids.changed > 0,
550
+ };
551
+ }
552
+
553
+ /**
554
+ * The direction notes: every CX2 edge is directed, so an undirected snapshot (or the undirected
555
+ * pairs of a mixed one, folded under onMixedDirection) is written directed, and a mutual pair as
556
+ * two edges without its mark.
557
+ * @param snapshot - the snapshot
558
+ * @param folding - the pair folding
559
+ * @param common - the resolved common options
560
+ * @param note - records a note
561
+ */
562
+ function directionNotes(
563
+ snapshot: GraphSnapshot,
564
+ folding: PairFolding,
565
+ common: ResolvedExportOptions,
566
+ note: (code: string, message: string, column?: string | null, count?: number | null) => void,
567
+ ): void {
568
+ if (!snapshot.directed) {
569
+ note(
570
+ CX2_LOSS.UNDIRECTED_AS_DIRECTED,
571
+ `the snapshot is undirected; every edge is written as a directed CX2 edge (${snapshot.edgeCount} edge(s))`,
572
+ null,
573
+ snapshot.edgeCount,
574
+ );
575
+ } else if (common.onMixedDirection !== "error") {
576
+ let undirected = 0;
577
+ for (let e = 0; e < snapshot.edgeCount; e++) {
578
+ if (!folding.folded(e) && !folding.sourceDirected(e)) {
579
+ undirected++;
580
+ }
581
+ }
582
+ if (undirected > 0) {
583
+ note(
584
+ CX2_LOSS.UNDIRECTED_AS_DIRECTED,
585
+ `${undirected} undirected edge(s) are written as one directed edge each (a pair folded to its primary); CX2 has no undirected edge`,
586
+ null,
587
+ undirected,
588
+ );
589
+ }
590
+ }
591
+ if (folding.mutualCount > 0) {
592
+ note(
593
+ LOSS.MUTUAL_EXPANDED,
594
+ `${folding.mutualCount} mutual pair(s) are written as two directed edges; the mutual mark is lost`,
595
+ null,
596
+ folding.mutualCount,
597
+ );
598
+ }
599
+ }
600
+
601
+ /**
602
+ * Count the positions with a non-finite x or y.
603
+ * @param position - the position column
604
+ * @returns the count
605
+ */
606
+ function nonFinitePositions(position: Column): number {
607
+ let count = 0;
608
+ for (let i = 0; i < position.length; i++) {
609
+ if (position.isSet(i)) {
610
+ const p = position.value(i) as ArrayLike<number>;
611
+ if (!Number.isFinite(p[0]) || !Number.isFinite(p[1])) {
612
+ count++;
613
+ }
614
+ }
615
+ }
616
+ return count;
617
+ }
618
+
619
+ /**
620
+ * The edge ids to write: the id role column's values when they are distinct safe integers,
621
+ * next unused integers for edges without one.
622
+ * @param snapshot - the snapshot
623
+ * @param note - records a note
624
+ * @returns one id per edge
625
+ */
626
+ function planEdgeIds(
627
+ snapshot: GraphSnapshot,
628
+ note: (code: string, message: string, column?: string | null, count?: number | null) => void,
629
+ ): number[] {
630
+ const column = snapshot.edges.byRole("id");
631
+ const ids: (number | null)[] = new Array<number | null>(snapshot.edgeCount).fill(null);
632
+ const used = new Set<number>();
633
+ let generated = 0;
634
+ if (column !== null) {
635
+ for (let e = 0; e < snapshot.edgeCount; e++) {
636
+ const value = column.isSet(e) ? column.value(e) : undefined;
637
+ let n = NaN;
638
+ if (typeof value === "number") {
639
+ n = value;
640
+ } else if (typeof value === "string") {
641
+ n = Number(value);
642
+ }
643
+ if (Number.isSafeInteger(n) && !used.has(n)) {
644
+ ids[e] = n === 0 ? 0 : n;
645
+ used.add(n);
646
+ }
647
+ }
648
+ }
649
+ let next = 0;
650
+ const out: number[] = [];
651
+ for (let e = 0; e < snapshot.edgeCount; e++) {
652
+ let id = ids[e];
653
+ if (id === null) {
654
+ while (used.has(next)) {
655
+ next++;
656
+ }
657
+ id = next;
658
+ used.add(next);
659
+ generated++;
660
+ }
661
+ out.push(id);
662
+ }
663
+ if (column !== null && generated > 0) {
664
+ note(
665
+ LOSS.EDGE_IDS_GENERATED,
666
+ `${generated} edge(s) have no distinct integer id in "${column.meta.name}"; they are written with generated ids`,
667
+ column.meta.name,
668
+ generated,
669
+ );
670
+ }
671
+ return out;
672
+ }
673
+
674
+ // ============================================================ writing
675
+
676
+ /**
677
+ * The JSON text of a value, keeping -0.
678
+ * @param value - the value
679
+ * @returns the text
680
+ */
681
+ function stringify(value: unknown): string {
682
+ const text = JSON.stringify(value, (_key, v: unknown) => (Object.is(v, -0) ? `${RAW}-0` : v));
683
+ return text.includes("\\u0000cx2:") ? text.replace(RAW_JSON, "$1") : text;
684
+ }
685
+
686
+ /**
687
+ * The value written for one cell.
688
+ * @param p - the attribute plan
689
+ * @param row - the row
690
+ * @returns the value, or undefined for an unset cell
691
+ */
692
+ function cellValue(p: AttributePlan, row: number): unknown {
693
+ const { column } = p;
694
+ if (!column.isSet(row)) {
695
+ return undefined;
696
+ }
697
+ const value = column.value(row);
698
+ if (p.jsonText) {
699
+ return JSON.stringify(value);
700
+ }
701
+ if (column.meta.dtype === "list" || column.meta.components > 1) {
702
+ const items = Array.from(value as ArrayLike<unknown>);
703
+ if (p.d === "string") {
704
+ return JSON.stringify(items);
705
+ }
706
+ return items.some((v) => typeof v === "number" && !Number.isFinite(v)) ? null : items;
707
+ }
708
+ if (typeof value === "number" && !Number.isFinite(value)) {
709
+ return null;
710
+ }
711
+ return value;
712
+ }
713
+
714
+ /**
715
+ * The declarations of one table.
716
+ * @param plans - the attribute plans
717
+ * @param defaults - whether defaults and aliases may be written (not for network attributes)
718
+ * @returns the declaration object
719
+ */
720
+ function declarations(plans: readonly AttributePlan[], defaults: boolean): Record<string, unknown> {
721
+ const out: Record<string, unknown> = {};
722
+ for (const p of plans) {
723
+ const decl: Record<string, unknown> = { d: p.d };
724
+ if (defaults && p.key !== p.name) {
725
+ decl.a = p.key;
726
+ }
727
+ const fallback = p.column.meta.default;
728
+ if (defaults && fallback !== undefined && fallback !== null) {
729
+ if (defaultFits(cx2Type(p.d), fallback)) {
730
+ decl.v = fallback;
731
+ }
732
+ }
733
+ out[p.name] = decl;
734
+ }
735
+ return out;
736
+ }
737
+
738
+ /**
739
+ * Whether a declared default can be written as the value of a declared type.
740
+ * @param type - the type
741
+ * @param fallback - the default
742
+ * @returns true when it matches
743
+ */
744
+ function defaultFits(type: ReturnType<typeof cx2Type>, fallback: unknown): boolean {
745
+ if (type === null) {
746
+ return false;
747
+ }
748
+ if (type.list) {
749
+ return Array.isArray(fallback);
750
+ }
751
+ switch (type.scalar) {
752
+ case "string":
753
+ return typeof fallback === "string";
754
+ case "boolean":
755
+ return typeof fallback === "boolean";
756
+ default:
757
+ return typeof fallback === "number" && Number.isFinite(fallback);
758
+ }
759
+ }
760
+
761
+ /**
762
+ * The v object of one element.
763
+ * @param plans - the attribute plans
764
+ * @param row - the row
765
+ * @returns the object, or null when empty
766
+ */
767
+ function attributesOf(plans: readonly AttributePlan[], row: number): Record<string, unknown> | null {
768
+ let out: Record<string, unknown> | null = null;
769
+ for (const p of plans) {
770
+ const value = cellValue(p, row);
771
+ if (value !== undefined) {
772
+ out ??= {};
773
+ out[p.key] = value;
774
+ }
775
+ }
776
+ return out;
777
+ }
778
+
779
+ /**
780
+ * Write the document as text parts.
781
+ * @param snapshot - the snapshot
782
+ * @param p - the plan
783
+ * @yields the document's text
784
+ * @returns nothing
785
+ */
786
+ function* write(snapshot: GraphSnapshot, p: Plan): Generator<string, void, undefined> {
787
+ if (p.fatal !== null) {
788
+ throw p.fatal;
789
+ }
790
+ const { ids } = p;
791
+ if (ids === null) {
792
+ throw new GraphFormatError("E_INVALID_ID", "node ids cannot be written as CX2 integers", { reason: "charset" });
793
+ }
794
+ const { src, dst } = snapshot.edgeList();
795
+ const edges: number[] = [];
796
+ for (let e = 0; e < snapshot.edgeCount; e++) {
797
+ if (!p.folding.folded(e)) {
798
+ edges.push(e);
799
+ }
800
+ }
801
+
802
+ const nodeDecls = declarations(p.nodeAttrs, true);
803
+ if (p.originalIds) {
804
+ nodeDecls[ORIGINAL_ID_ATTRIBUTE] = { d: "string" };
805
+ }
806
+ const edgeDecls = declarations(p.edgeAttrs, true);
807
+ if (p.weights.weighted) {
808
+ edgeDecls[WEIGHT_ATTRIBUTE] = { d: "double" };
809
+ }
810
+ const networkDecls = declarations(p.graphAttrs, false);
811
+ const network: Record<string, unknown> = {};
812
+ for (const a of p.graphAttrs) {
813
+ const value = cellValue(a, 0);
814
+ if (value !== undefined) {
815
+ network[a.name] = value;
816
+ }
817
+ }
818
+ const { meta } = snapshot;
819
+ for (const [key, value] of [
820
+ ["name", meta.name],
821
+ ["description", meta.description],
822
+ ] as const) {
823
+ if (value !== null && !(key in network) && !(key in networkDecls)) {
824
+ network[key] = value;
825
+ networkDecls[key] = { d: "string" };
826
+ }
827
+ }
828
+ const kept = isRecord(meta.extra.cx2) ? meta.extra.cx2 : {};
829
+ const otherDecls = isRecord(kept.declarations) ? kept.declarations : {};
830
+ const opaque = meta.sourceFormat === CX2_FORMAT && isRecord(kept.opaque) ? kept.opaque : {};
831
+ const declarationsElement: Record<string, unknown> = { ...otherDecls };
832
+ for (const [key, table] of [
833
+ ["networkAttributes", networkDecls],
834
+ ["nodes", nodeDecls],
835
+ ["edges", edgeDecls],
836
+ ] as const) {
837
+ if (Object.keys(table).length > 0) {
838
+ declarationsElement[key] = table;
839
+ }
840
+ }
841
+ const nodeBypassRows = bypassRows(p.nodeBypasses, snapshot.nodeCount);
842
+ const edgeBypassRows = bypassRows(p.edgeBypasses, snapshot.edgeCount).filter((e) => !p.folding.folded(e));
843
+ const opaqueAspects = Object.entries(opaque).filter(
844
+ (entry): entry is [string, unknown[]] => !CORE_ASPECTS.has(entry[0]) && Array.isArray(entry[1]),
845
+ );
846
+
847
+ const counts: [string, number][] = [];
848
+ const hasDeclarations = Object.keys(declarationsElement).length > 0;
849
+ if (hasDeclarations) {
850
+ counts.push(["attributeDeclarations", 1]);
851
+ }
852
+ if (Object.keys(network).length > 0) {
853
+ counts.push(["networkAttributes", 1]);
854
+ }
855
+ counts.push(["nodes", snapshot.nodeCount], ["edges", edges.length]);
856
+ if (nodeBypassRows.length > 0) {
857
+ counts.push(["nodeBypasses", nodeBypassRows.length]);
858
+ }
859
+ if (edgeBypassRows.length > 0) {
860
+ counts.push(["edgeBypasses", edgeBypassRows.length]);
861
+ }
862
+ for (const [name, elements] of opaqueAspects) {
863
+ counts.push([name, elements.length]);
864
+ }
865
+
866
+ yield '[{"CXVersion":"2.0","hasFragments":false},\n';
867
+ yield `{"metaData":${stringify(counts.map(([name, elementCount]) => ({ name, elementCount })))}},\n`;
868
+ if (hasDeclarations) {
869
+ yield `{"attributeDeclarations":[${stringify(declarationsElement)}]},\n`;
870
+ }
871
+ if (Object.keys(network).length > 0) {
872
+ yield `{"networkAttributes":[${stringify(network)}]},\n`;
873
+ }
874
+ yield* block("nodes", snapshot.nodeCount, (i) => stringify(nodeElement(p, ids, i)));
875
+ yield* block("edges", edges.length, (k) => {
876
+ const e = edges[k];
877
+ const element: Record<string, unknown> = {
878
+ id: p.edgeIds[e],
879
+ s: ids.idAt(src[e]),
880
+ t: ids.idAt(dst[e]),
881
+ };
882
+ let v = attributesOf(p.edgeAttrs, e);
883
+ if (p.weights.isExplicit(e)) {
884
+ const w = p.weights.value(e);
885
+ v ??= {};
886
+ v[WEIGHT_ATTRIBUTE] = Number.isFinite(w) ? w : null;
887
+ }
888
+ if (v !== null) {
889
+ element.v = v;
890
+ }
891
+ return stringify(element);
892
+ });
893
+ if (nodeBypassRows.length > 0) {
894
+ yield* block("nodeBypasses", nodeBypassRows.length, (k) => {
895
+ const i = nodeBypassRows[k];
896
+ return stringify({ id: ids.idAt(i), v: bypassValues(p.nodeBypasses, i) });
897
+ });
898
+ }
899
+ if (edgeBypassRows.length > 0) {
900
+ yield* block("edgeBypasses", edgeBypassRows.length, (k) => {
901
+ const e = edgeBypassRows[k];
902
+ return stringify({ id: p.edgeIds[e], v: bypassValues(p.edgeBypasses, e) });
903
+ });
904
+ }
905
+ for (const [name, elements] of opaqueAspects) {
906
+ yield* block(name, elements.length, (k) => stringify(elements[k]));
907
+ }
908
+ yield '{"status":[{"error":"","success":true}]}]\n';
909
+ }
910
+
911
+ /**
912
+ * The text of one aspect block, element by element.
913
+ * @param aspect - the aspect name
914
+ * @param count - its element count
915
+ * @param element - the JSON text of element k
916
+ * @yields the block's text
917
+ * @returns nothing
918
+ */
919
+ function* block(aspect: string, count: number, element: (k: number) => string): Generator<string, void, undefined> {
920
+ yield `{${JSON.stringify(aspect)}:[`;
921
+ for (let k = 0; k < count; k++) {
922
+ yield k === 0 ? `\n${element(k)}` : `,\n${element(k)}`;
923
+ }
924
+ yield "]},\n";
925
+ }
926
+
927
+ /**
928
+ * The element of node i.
929
+ * @param p - the plan
930
+ * @param ids - the written ids
931
+ * @param i - the node index
932
+ * @returns the element
933
+ */
934
+ function nodeElement(p: Plan, ids: WrittenIds, i: number): Record<string, unknown> {
935
+ const element: Record<string, unknown> = { id: ids.idAt(i) };
936
+ if (p.position?.isSet(i) === true) {
937
+ const point = p.position.value(i) as ArrayLike<number>;
938
+ if (Number.isFinite(point[0]) && Number.isFinite(point[1])) {
939
+ element.x = point[0];
940
+ element.y = point[1] === 0 ? 0 : -point[1];
941
+ if (p.positionZ && Number.isFinite(point[2])) {
942
+ element.z = point[2];
943
+ }
944
+ }
945
+ }
946
+ if (p.z?.isSet(i) === true) {
947
+ const z = p.z.value(i);
948
+ if (typeof z === "number" && Number.isFinite(z)) {
949
+ element.z = z;
950
+ }
951
+ }
952
+ let v = attributesOf(p.nodeAttrs, i);
953
+ if (p.originalIds && ids.isChanged(i)) {
954
+ v ??= {};
955
+ v[ORIGINAL_ID_ATTRIBUTE] = String(ids.originalAt(i));
956
+ }
957
+ if (v !== null) {
958
+ element.v = v;
959
+ }
960
+ return element;
961
+ }
962
+
963
+ /**
964
+ * The rows with at least one bypass value.
965
+ * @param columns - the bypass columns
966
+ * @param rows - the row count
967
+ * @returns the rows, ascending
968
+ */
969
+ function bypassRows(columns: readonly Column[], rows: number): number[] {
970
+ const out: number[] = [];
971
+ if (columns.length === 0) {
972
+ return out;
973
+ }
974
+ for (let i = 0; i < rows; i++) {
975
+ if (columns.some((c) => c.isSet(i))) {
976
+ out.push(i);
977
+ }
978
+ }
979
+ return out;
980
+ }
981
+
982
+ /**
983
+ * The bypass values of one row.
984
+ * @param columns - the bypass columns
985
+ * @param row - the row
986
+ * @returns property -> value
987
+ */
988
+ function bypassValues(columns: readonly Column[], row: number): Record<string, unknown> {
989
+ const out: Record<string, unknown> = {};
990
+ for (const column of columns) {
991
+ if (column.isSet(row)) {
992
+ // the visual property's own name: a column renamed for a clash with an attribute keeps it in origin.id
993
+ const property = column.meta.origin?.id;
994
+ out[typeof property === "string" && property.length > 0 ? property : column.meta.name] = column.value(row);
995
+ }
996
+ }
997
+ return out;
998
+ }
999
+
1000
+ /**
1001
+ * The CX2 exporter plugin (design section 1.3).
1002
+ */
1003
+ export const cx2Exporter: GraphExporter<Cx2ExportOptions> = Object.freeze({
1004
+ format: CX2_FORMAT,
1005
+ capabilities: CX2_CAPABILITIES,
1006
+
1007
+ /**
1008
+ * Pre-flight: what export() would lose, without writing anything.
1009
+ * @param snapshot - the snapshot
1010
+ * @param options - the common options
1011
+ * @returns the notes, empty when the export is exact
1012
+ */
1013
+ check(snapshot: GraphSnapshot, options?: Cx2ExportOptions & CommonExportOptions): readonly LossNote[] {
1014
+ return Object.freeze([...plan(snapshot, resolveExportOptions(options)).notes]);
1015
+ },
1016
+
1017
+ /**
1018
+ * Write the document as UTF-8 chunks.
1019
+ * @param snapshot - the snapshot
1020
+ * @param options - the common options
1021
+ * @returns the chunks
1022
+ */
1023
+ export(snapshot: GraphSnapshot, options?: Cx2ExportOptions & CommonExportOptions): AsyncIterable<Uint8Array> {
1024
+ return encodeChunks(write(snapshot, plan(snapshot, resolveExportOptions(options))));
1025
+ },
1026
+
1027
+ /**
1028
+ * Write the document as one string.
1029
+ * @param snapshot - the snapshot
1030
+ * @param options - the common options
1031
+ * @returns the document
1032
+ */
1033
+ exportToString(snapshot: GraphSnapshot, options?: Cx2ExportOptions & CommonExportOptions): Promise<string> {
1034
+ return joinText(write(snapshot, plan(snapshot, resolveExportOptions(options))));
1035
+ },
1036
+ });