@graphty/graph-io 0.3.28 → 0.3.30

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 (142) hide show
  1. package/dist/chunks/{choose-DrXtu_96.js → choose-BfOLq6OX.js} +2 -2
  2. package/dist/chunks/{choose-DrXtu_96.js.map → choose-BfOLq6OX.js.map} +1 -1
  3. package/dist/chunks/{constants-ikG85dT8.js → constants-M8Qsjz1C.js} +2 -2
  4. package/dist/chunks/{constants-ikG85dT8.js.map → constants-M8Qsjz1C.js.map} +1 -1
  5. package/dist/chunks/{escape-D1HPTUrP.js → escape-UMMpQZqs.js} +2 -2
  6. package/dist/chunks/{escape-D1HPTUrP.js.map → escape-UMMpQZqs.js.map} +1 -1
  7. package/dist/chunks/{export-CHd4tQE2.js → export-Dq4U8Ubq.js} +165 -148
  8. package/dist/chunks/{export-CHd4tQE2.js.map → export-Dq4U8Ubq.js.map} +1 -1
  9. package/dist/chunks/{exporter-DhtW6FxK.js → exporter-BUosqV0K.js} +34 -34
  10. package/dist/chunks/exporter-BUosqV0K.js.map +1 -0
  11. package/dist/chunks/{exporter-D23CKNqF.js → exporter-Bd_VZ9ph.js} +9 -10
  12. package/dist/chunks/exporter-Bd_VZ9ph.js.map +1 -0
  13. package/dist/chunks/{exporter-DfWTgrti.js → exporter-COgVm-ff.js} +8 -7
  14. package/dist/chunks/{exporter-DfWTgrti.js.map → exporter-COgVm-ff.js.map} +1 -1
  15. package/dist/chunks/{exporter-Cl021ZIE.js → exporter-DEk_XLLs.js} +4 -4
  16. package/dist/chunks/{exporter-Cl021ZIE.js.map → exporter-DEk_XLLs.js.map} +1 -1
  17. package/dist/chunks/{importer-BZB7iP9r.js → importer-AQ3KpNHz.js} +11 -20
  18. package/dist/chunks/importer-AQ3KpNHz.js.map +1 -0
  19. package/dist/chunks/{importer-BVRE--Un.js → importer-BsZa2Phx.js} +5 -5
  20. package/dist/chunks/importer-BsZa2Phx.js.map +1 -0
  21. package/dist/chunks/{importer-DpEW5zeG.js → importer-CV8au8gH.js} +4 -4
  22. package/dist/chunks/{importer-DpEW5zeG.js.map → importer-CV8au8gH.js.map} +1 -1
  23. package/dist/chunks/{importer-BSALvkui.js → importer-Cj4-lXlv.js} +10 -9
  24. package/dist/chunks/importer-Cj4-lXlv.js.map +1 -0
  25. package/dist/chunks/{importer-B2W_58pz.js → importer-DQsgmhxc.js} +5 -5
  26. package/dist/chunks/{importer-B2W_58pz.js.map → importer-DQsgmhxc.js.map} +1 -1
  27. package/dist/chunks/{importer-CIAzt-wo.js → importer-Dkn79TjB.js} +6 -6
  28. package/dist/chunks/importer-Dkn79TjB.js.map +1 -0
  29. package/dist/chunks/{importer-z_FL4VMb.js → importer-DwSZvMDq.js} +10 -19
  30. package/dist/chunks/importer-DwSZvMDq.js.map +1 -0
  31. package/dist/chunks/{json-elements-BAmNBZok.js → json-elements-CreeKoaH.js} +12 -16
  32. package/dist/chunks/{json-elements-BAmNBZok.js.map → json-elements-CreeKoaH.js.map} +1 -1
  33. package/dist/chunks/{ontology-export-Bab4eUA8.js → ontology-export-C5Xd3W-7.js} +2 -2
  34. package/dist/chunks/{ontology-export-Bab4eUA8.js.map → ontology-export-C5Xd3W-7.js.map} +1 -1
  35. package/dist/chunks/{records-CdlvL4RO.js → records-BwaH_2ll.js} +2 -2
  36. package/dist/chunks/{records-CdlvL4RO.js.map → records-BwaH_2ll.js.map} +1 -1
  37. package/dist/chunks/{text-DzXjAiTl.js → text-4HHe6YH3.js} +2 -2
  38. package/dist/chunks/{text-DzXjAiTl.js.map → text-4HHe6YH3.js.map} +1 -1
  39. package/dist/csv.js +22 -15
  40. package/dist/csv.js.map +1 -1
  41. package/dist/cx.js +1 -1
  42. package/dist/cx2.js +2 -2
  43. package/dist/cys.js +1 -1
  44. package/dist/dot.js +1 -1
  45. package/dist/gexf.js +9 -8
  46. package/dist/gexf.js.map +1 -1
  47. package/dist/gml.js +3 -3
  48. package/dist/graph-io.js +69 -69
  49. package/dist/graphml.js +2 -2
  50. package/dist/json.js +1 -1
  51. package/dist/neo4j.js +3 -3
  52. package/dist/obo.js +1 -1
  53. package/dist/pajek.js +1 -1
  54. package/dist/src/common/export.d.ts +6 -0
  55. package/dist/src/common/export.d.ts.map +1 -1
  56. package/dist/src/common/export.js +62 -28
  57. package/dist/src/common/export.js.map +1 -1
  58. package/dist/src/common/format.d.ts +0 -8
  59. package/dist/src/common/format.d.ts.map +1 -1
  60. package/dist/src/common/format.js +0 -10
  61. package/dist/src/common/format.js.map +1 -1
  62. package/dist/src/common/json-elements.d.ts +1 -9
  63. package/dist/src/common/json-elements.d.ts.map +1 -1
  64. package/dist/src/common/json-elements.js +2 -12
  65. package/dist/src/common/json-elements.js.map +1 -1
  66. package/dist/src/formats/csv/exporter.d.ts.map +1 -1
  67. package/dist/src/formats/csv/exporter.js +18 -7
  68. package/dist/src/formats/csv/exporter.js.map +1 -1
  69. package/dist/src/formats/cx/importer.d.ts.map +1 -1
  70. package/dist/src/formats/cx/importer.js +3 -3
  71. package/dist/src/formats/cx/importer.js.map +1 -1
  72. package/dist/src/formats/cx2/importer.d.ts.map +1 -1
  73. package/dist/src/formats/cx2/importer.js +3 -3
  74. package/dist/src/formats/cx2/importer.js.map +1 -1
  75. package/dist/src/formats/cys/exporter.d.ts.map +1 -1
  76. package/dist/src/formats/cys/exporter.js +4 -3
  77. package/dist/src/formats/cys/exporter.js.map +1 -1
  78. package/dist/src/formats/dot/exporter.js +1 -1
  79. package/dist/src/formats/dot/importer.d.ts +2 -2
  80. package/dist/src/formats/dot/importer.d.ts.map +1 -1
  81. package/dist/src/formats/dot/importer.js +7 -11
  82. package/dist/src/formats/dot/importer.js.map +1 -1
  83. package/dist/src/formats/gexf/exporter.d.ts +2 -2
  84. package/dist/src/formats/gexf/exporter.d.ts.map +1 -1
  85. package/dist/src/formats/gexf/exporter.js +7 -5
  86. package/dist/src/formats/gexf/exporter.js.map +1 -1
  87. package/dist/src/formats/gexf/schema.js +1 -1
  88. package/dist/src/formats/json/exporter.js +1 -1
  89. package/dist/src/formats/json/importer.d.ts.map +1 -1
  90. package/dist/src/formats/json/importer.js +4 -6
  91. package/dist/src/formats/json/importer.js.map +1 -1
  92. package/dist/src/formats/obo/syntax.d.ts +4 -0
  93. package/dist/src/formats/obo/syntax.d.ts.map +1 -1
  94. package/dist/src/formats/obo/syntax.js +35 -31
  95. package/dist/src/formats/obo/syntax.js.map +1 -1
  96. package/dist/src/formats/pajek/exporter.d.ts.map +1 -1
  97. package/dist/src/formats/pajek/exporter.js +5 -4
  98. package/dist/src/formats/pajek/exporter.js.map +1 -1
  99. package/dist/src/formats/pajek/importer.d.ts.map +1 -1
  100. package/dist/src/formats/pajek/importer.js +3 -7
  101. package/dist/src/formats/pajek/importer.js.map +1 -1
  102. package/dist/src/formats/pajek/syntax.d.ts +1 -1
  103. package/dist/src/formats/pajek/syntax.js +1 -1
  104. package/dist/src/formats/xgmml/constants.d.ts +1 -1
  105. package/dist/src/formats/xgmml/constants.js +1 -1
  106. package/dist/src/formats/xgmml/emit.js +1 -1
  107. package/dist/src/formats/xgmml/exporter.d.ts.map +1 -1
  108. package/dist/src/formats/xgmml/exporter.js +4 -3
  109. package/dist/src/formats/xgmml/exporter.js.map +1 -1
  110. package/dist/src/types.d.ts +6 -0
  111. package/dist/src/types.d.ts.map +1 -1
  112. package/dist/src/types.js.map +1 -1
  113. package/dist/xgmml.js +2 -2
  114. package/package.json +1 -1
  115. package/src/common/export.ts +105 -62
  116. package/src/common/format.ts +0 -11
  117. package/src/common/json-elements.ts +2 -13
  118. package/src/formats/csv/exporter.ts +19 -15
  119. package/src/formats/cx/importer.ts +2 -3
  120. package/src/formats/cx2/importer.ts +2 -3
  121. package/src/formats/cys/exporter.ts +4 -3
  122. package/src/formats/dot/exporter.ts +1 -1
  123. package/src/formats/dot/importer.ts +7 -17
  124. package/src/formats/gexf/exporter.ts +7 -5
  125. package/src/formats/gexf/schema.ts +1 -1
  126. package/src/formats/json/exporter.ts +1 -1
  127. package/src/formats/json/importer.ts +4 -7
  128. package/src/formats/obo/syntax.ts +35 -31
  129. package/src/formats/pajek/exporter.ts +5 -4
  130. package/src/formats/pajek/importer.ts +3 -13
  131. package/src/formats/pajek/syntax.ts +1 -1
  132. package/src/formats/xgmml/constants.ts +1 -1
  133. package/src/formats/xgmml/emit.ts +1 -1
  134. package/src/formats/xgmml/exporter.ts +4 -3
  135. package/src/types.ts +6 -0
  136. package/dist/chunks/exporter-D23CKNqF.js.map +0 -1
  137. package/dist/chunks/exporter-DhtW6FxK.js.map +0 -1
  138. package/dist/chunks/importer-BSALvkui.js.map +0 -1
  139. package/dist/chunks/importer-BVRE--Un.js.map +0 -1
  140. package/dist/chunks/importer-BZB7iP9r.js.map +0 -1
  141. package/dist/chunks/importer-CIAzt-wo.js.map +0 -1
  142. package/dist/chunks/importer-z_FL4VMb.js.map +0 -1
@@ -318,6 +318,12 @@ export interface CheckExtras {
318
318
  * W_WEIGHTS_DROPPED note. Default true.
319
319
  */
320
320
  readonly weights?: boolean | undefined;
321
+ /**
322
+ * The tables the export writes, of "node", "edge" and "graph"; a gap in a table it does not
323
+ * write is not a loss of this export and gets no note. Default all three. A CSV node table is
324
+ * `["node"]`, so its notes are about node ids and node columns only.
325
+ */
326
+ readonly tables?: readonly ("node" | "edge" | "graph")[] | undefined;
321
327
  /**
322
328
  * How the format's importer turns id text back into ids ("canonical" for most text formats), so
323
329
  * an id that comes back with another type (the text "7" as the number 7) is reported as
@@ -353,60 +359,11 @@ export function checkCapabilities(
353
359
  const note = (code: string, message: string, column: string | null = null, count: number | null = null): void => {
354
360
  notes.push(Object.freeze({ code, message, column, count }));
355
361
  };
362
+ const tables = extras.tables ?? ["node", "edge", "graph"];
363
+ const edges = tables.includes("edge");
356
364
 
357
- // mixed direction (design section 3.6): an expanded snapshot carries the pair / directed roles
358
- const mixed = countMixedEdges(snapshot);
359
- if (mixed > 0 && !caps.mixedDirection) {
360
- if (options.onMixedDirection === "error") {
361
- note(
362
- LOSS.MIXED_DIRECTION_ERROR,
363
- `${mixed} undirected edge${plural(mixed)} in a directed graph, and the format holds one direction per file, so the save fails unless onMixedDirection is "directed" or "undirected"`,
364
- null,
365
- mixed,
366
- );
367
- } else {
368
- note(
369
- LOSS.MIXED_DIRECTION,
370
- `${mixed} undirected edge${plural(mixed)} in a directed graph ${agree(mixed, "is", "are")} written as ${options.onMixedDirection} (onMixedDirection), and the format holds one direction per file`,
371
- null,
372
- mixed,
373
- );
374
- }
375
- }
376
-
377
- if (snapshot.flags.multigraph && !caps.multiEdges) {
378
- const parallel = countParallelArcs(snapshot);
379
- note(
380
- LOSS.MULTI_EDGES,
381
- `${parallel} parallel edge${plural(parallel)}; the format has no parallel edges`,
382
- null,
383
- parallel,
384
- );
385
- }
386
- if (snapshot.selfLoopCount > 0 && !caps.selfLoops) {
387
- note(
388
- LOSS.SELF_LOOPS,
389
- `${snapshot.selfLoopCount} self-loop${plural(snapshot.selfLoopCount)}; the format has no self-loops`,
390
- null,
391
- snapshot.selfLoopCount,
392
- );
393
- }
394
-
395
- const edgeIds = snapshot.edges.byRole("id");
396
- if (caps.edgeIds === "required" && edgeIds === null && snapshot.edgeCount > 0) {
397
- note(
398
- LOSS.EDGE_IDS_GENERATED,
399
- "the format requires edge ids, and the graph has none; the edges are written with generated ids, which read back as a new edge id column",
400
- null,
401
- snapshot.edgeCount,
402
- );
403
- } else if (caps.edgeIds === "none" && edgeIds !== null) {
404
- note(
405
- LOSS.EDGE_IDS_DROPPED,
406
- `edge id column "${edgeIds.meta.name}" cannot be written`,
407
- edgeIds.meta.name,
408
- snapshot.edgeCount,
409
- );
365
+ if (edges) {
366
+ checkEdgeStructure(snapshot, caps, options, note);
410
367
  }
411
368
 
412
369
  const unrepresentable = countUnrepresentableIds(snapshot, caps.idCharset);
@@ -455,24 +412,40 @@ export function checkCapabilities(
455
412
  }
456
413
  }
457
414
 
458
- checkWeights(snapshot, extras, note);
459
- checkColumns(snapshot.nodes, "node", caps, extras, note);
460
- checkColumns(snapshot.edges, "edge", caps, extras, note);
461
- const graphColumns = snapshot.graph.names();
415
+ if (edges) {
416
+ checkWeights(snapshot, extras, note);
417
+ }
418
+ if (tables.includes("node")) {
419
+ checkColumns(snapshot.nodes, "node", caps, extras, note);
420
+ }
421
+ if (edges) {
422
+ checkColumns(snapshot.edges, "edge", caps, extras, note);
423
+ }
424
+ const graphColumns = tables.includes("graph") ? snapshot.graph.names() : [];
462
425
  if (graphColumns.length > 0) {
463
426
  if (caps.graphAttributes) {
464
427
  checkColumns(snapshot.graph, "graph", caps, extras, note);
465
428
  } else {
466
- note(
467
- LOSS.GRAPH_ATTRIBUTES,
468
- `${graphColumns.length} graph attribute${plural(graphColumns.length)} cannot be written`,
469
- null,
470
- graphColumns.length,
429
+ notes.push(
430
+ Object.freeze({
431
+ code: LOSS.GRAPH_ATTRIBUTES,
432
+ message: `${graphColumns.length} graph attribute${plural(graphColumns.length)} cannot be written`,
433
+ column: null,
434
+ count: graphColumns.length,
435
+ columns: Object.freeze([...graphColumns]),
436
+ }),
471
437
  );
472
438
  }
473
439
  }
474
440
 
475
441
  for (const [name, table] of snapshot.extensions) {
442
+ // "temporal:node:<column>" is the node table's, "temporal:edge:<column>" the edge table's;
443
+ // any other extension table is the graph's.
444
+ const [, kind] = name.split(":");
445
+ const owner = name.startsWith("temporal:") && (kind === "node" || kind === "edge") ? kind : "graph";
446
+ if (!tables.includes(owner)) {
447
+ continue;
448
+ }
476
449
  if (name.startsWith("temporal:")) {
477
450
  if (caps.temporal !== "dynamic-values") {
478
451
  note(
@@ -494,6 +467,76 @@ export function checkCapabilities(
494
467
  return notes;
495
468
  }
496
469
 
470
+ /**
471
+ * The edge-structure checks of checkCapabilities(): direction, parallel edges, self-loops and
472
+ * edge ids. Skipped for an export that writes no edge table.
473
+ * @param snapshot - the snapshot about to be exported
474
+ * @param caps - the exporter's capabilities
475
+ * @param options - the resolved common export options
476
+ * @param note - the recorder
477
+ */
478
+ function checkEdgeStructure(
479
+ snapshot: GraphSnapshot,
480
+ caps: ExportCapabilities,
481
+ options: ResolvedExportOptions,
482
+ note: NoteFn,
483
+ ): void {
484
+ // mixed direction (design section 3.6): an expanded snapshot carries the pair / directed roles
485
+ const mixed = countMixedEdges(snapshot);
486
+ if (mixed > 0 && !caps.mixedDirection) {
487
+ if (options.onMixedDirection === "error") {
488
+ note(
489
+ LOSS.MIXED_DIRECTION_ERROR,
490
+ `${mixed} undirected edge${plural(mixed)} in a directed graph, and the format holds one direction per file, so the save fails unless onMixedDirection is "directed" or "undirected"`,
491
+ null,
492
+ mixed,
493
+ );
494
+ } else {
495
+ note(
496
+ LOSS.MIXED_DIRECTION,
497
+ `${mixed} undirected edge${plural(mixed)} in a directed graph ${agree(mixed, "is", "are")} written as ${options.onMixedDirection} (onMixedDirection), and the format holds one direction per file`,
498
+ null,
499
+ mixed,
500
+ );
501
+ }
502
+ }
503
+
504
+ if (snapshot.flags.multigraph && !caps.multiEdges) {
505
+ const parallel = countParallelArcs(snapshot);
506
+ note(
507
+ LOSS.MULTI_EDGES,
508
+ `${parallel} parallel edge${plural(parallel)}; the format has no parallel edges`,
509
+ null,
510
+ parallel,
511
+ );
512
+ }
513
+ if (snapshot.selfLoopCount > 0 && !caps.selfLoops) {
514
+ note(
515
+ LOSS.SELF_LOOPS,
516
+ `${snapshot.selfLoopCount} self-loop${plural(snapshot.selfLoopCount)}; the format has no self-loops`,
517
+ null,
518
+ snapshot.selfLoopCount,
519
+ );
520
+ }
521
+
522
+ const edgeIds = snapshot.edges.byRole("id");
523
+ if (caps.edgeIds === "required" && edgeIds === null && snapshot.edgeCount > 0) {
524
+ note(
525
+ LOSS.EDGE_IDS_GENERATED,
526
+ "the format requires edge ids, and the graph has none; the edges are written with generated ids, which read back as a new edge id column",
527
+ null,
528
+ snapshot.edgeCount,
529
+ );
530
+ } else if (caps.edgeIds === "none" && edgeIds !== null) {
531
+ note(
532
+ LOSS.EDGE_IDS_DROPPED,
533
+ `edge id column "${edgeIds.meta.name}" cannot be written`,
534
+ edgeIds.meta.name,
535
+ snapshot.edgeCount,
536
+ );
537
+ }
538
+ }
539
+
497
540
  /**
498
541
  * The ids a charset cannot hold, as a message phrase.
499
542
  * @param charset - the format's id charset
@@ -30,17 +30,6 @@ export function formatF32(value: number): string {
30
30
  return String(value);
31
31
  }
32
32
 
33
- /**
34
- * Whether a number read from a file keeps its decimal value in an f32 column: the shortest decimal
35
- * of its f32 rounding reads back as the same number (0.2 does; 123456789.123 and 0.123456789 do not).
36
- * @param value - a finite number
37
- * @returns true when nothing a reader would see is lost
38
- * @category Plugin helpers
39
- */
40
- export function survivesF32(value: number): boolean {
41
- return Number(formatF32(Math.fround(value))) === value;
42
- }
43
-
44
33
  /**
45
34
  * The shortest text of an f64 value: `String(x)`, which is already the shortest round-tripping
46
35
  * decimal in JS, except for negative zero, which `String()` writes as "0".
@@ -1655,7 +1655,7 @@ export const POSITION_COLUMN = "position";
1655
1655
  const Z_COLUMN = "z";
1656
1656
 
1657
1657
  /**
1658
- * The position column of a Cytoscape-family importer: f32 x3, y-up, 2 source dimensions.
1658
+ * The position column of a Cytoscape-family importer: f64 x3, y-up, 2 source dimensions.
1659
1659
  * @param format - the importer's format name
1660
1660
  * @param sourceDims - 3 when z goes into the position (zAs "position")
1661
1661
  * @returns the declaration
@@ -1664,7 +1664,7 @@ const Z_COLUMN = "z";
1664
1664
  export function positionDecl(format: string, sourceDims = 2): ColumnDecl {
1665
1665
  return {
1666
1666
  name: POSITION_COLUMN,
1667
- dtype: "f32",
1667
+ dtype: "f64",
1668
1668
  components: 3,
1669
1669
  role: "position",
1670
1670
  mutable: true,
@@ -1690,17 +1690,6 @@ export function zDecl(format: string): ColumnDecl {
1690
1690
  };
1691
1691
  }
1692
1692
 
1693
- /**
1694
- * Whether a coordinate fits the f32 position column: finite once rounded to f32 (1e39 and the
1695
- * Infinity of a literal like 1e400 do not, nor does NaN).
1696
- * @param value - the coordinate
1697
- * @returns true when the column can hold it
1698
- * @category Plugin helpers
1699
- */
1700
- export function fitsF32(value: number): boolean {
1701
- return Number.isFinite(Math.fround(value));
1702
- }
1703
-
1704
1693
  /**
1705
1694
  * The head of an input as text for a sniff: UTF-16 by its byte order mark, else UTF-8, never
1706
1695
  * failing, without the BOM.
@@ -385,13 +385,9 @@ function cellText(column: Column, row: number): string | null {
385
385
  * W_CSV_NODE_TABLE: the node columns an edge-table or adjacency export leaves for a second, node-table export.
386
386
  * @param names - the node columns, label first
387
387
  * @param table - the table being written ("edges" or "adjacency")
388
- * @param note - the recorder
388
+ * @param notes - receives the note
389
389
  */
390
- function nodeTableNote(
391
- names: readonly string[],
392
- table: string,
393
- note: (code: string, message: string, column?: string | null, count?: number | null) => void,
394
- ): void {
390
+ function nodeTableNote(names: readonly string[], table: string, notes: LossNote[]): void {
395
391
  const written = names.length;
396
392
  if (written > 0) {
397
393
  const shown =
@@ -399,11 +395,14 @@ function nodeTableNote(
399
395
  .slice(0, 3)
400
396
  .map((n) => JSON.stringify(n))
401
397
  .join(", ") + (written > 3 ? ", ..." : "");
402
- note(
403
- CSV_LOSS.NODE_TABLE,
404
- `the ${table === "adjacency" ? "adjacency" : "edge"} table has no room for node attributes: ${written} node column${plural(written)} (${shown}) ${agree(written, "is", "are")} written only by a second export with table: "nodes"`,
405
- null,
406
- written,
398
+ notes.push(
399
+ Object.freeze({
400
+ code: CSV_LOSS.NODE_TABLE,
401
+ message: `the ${table === "adjacency" ? "adjacency" : "edge"} table has no room for node attributes: ${written} node column${plural(written)} (${shown}) ${agree(written, "is", "are")} written only by a second export with table: "nodes"`,
402
+ column: null,
403
+ count: written,
404
+ columns: Object.freeze([...names]),
405
+ }),
407
406
  );
408
407
  }
409
408
  }
@@ -483,7 +482,8 @@ function planExport(
483
482
  edgeRows.push(e);
484
483
  }
485
484
  }
486
- if (csv.dialect.type === null || csv.table === "adjacency") {
485
+ // A node table holds no edge: the direction and mutual notes are the edge table's.
486
+ if (csv.table !== "nodes" && (csv.dialect.type === null || csv.table === "adjacency")) {
487
487
  const where = csv.table === "adjacency" ? "an adjacency table" : "the generic dialect";
488
488
  const mixed = countMixedEdges(snapshot);
489
489
  if (!snapshot.directed) {
@@ -509,7 +509,7 @@ function planExport(
509
509
  0,
510
510
  );
511
511
  }
512
- if (folding.mutualCount > 0) {
512
+ if (folding.mutualCount > 0 && csv.table !== "nodes") {
513
513
  note(
514
514
  CSV_LOSS.MUTUAL_EXPANDED,
515
515
  `${folding.mutualCount} mutual pair${plural(folding.mutualCount)} ${agree(folding.mutualCount, "is", "are")} written as two directed rows; the mutual mark is lost`,
@@ -586,7 +586,7 @@ function planExport(
586
586
  nodeTableNote(
587
587
  [...(nodeLabel === null ? [] : [nodeLabel.meta.name]), ...nodeColumns.map((c) => c.column.meta.name)],
588
588
  csv.table,
589
- note,
589
+ notes,
590
590
  );
591
591
  }
592
592
 
@@ -1116,6 +1116,7 @@ function headerlessNotes(plan: Plan): LossNote[] {
1116
1116
  message: `without a header the file is read back by position (${csv.table === "nodes" ? "id" : "source, target, weight"}, then unnamed columns), so ${names.length === 1 ? "the column" : "the columns"} ${shown} ${agree(names.length, "reads", "read")} back unnamed or in another column's place; write the header, or for an edge list pass dialect: "generic" and write only source, target and weight`,
1117
1117
  column: null,
1118
1118
  count: names.length,
1119
+ columns: Object.freeze([...names]),
1119
1120
  }),
1120
1121
  ];
1121
1122
  }
@@ -1166,7 +1167,10 @@ function headerlessEdgeColumns(plan: Plan): string[] {
1166
1167
  function check(snapshot: GraphSnapshot, options?: CsvExportOptions & CommonExportOptions): readonly LossNote[] {
1167
1168
  const plan = planExport(snapshot, options, true);
1168
1169
  return Object.freeze([
1169
- ...checkCapabilities(snapshot, CSV_CAPABILITIES, plan.common, { roles: KEPT_ROLES }),
1170
+ ...checkCapabilities(snapshot, CSV_CAPABILITIES, plan.common, {
1171
+ roles: KEPT_ROLES,
1172
+ ...(plan.csv.table === "nodes" ? { tables: ["node"] as const } : {}),
1173
+ }),
1170
1174
  ...plan.notes,
1171
1175
  ...headerlessNotes(plan),
1172
1176
  ]);
@@ -86,7 +86,6 @@ import {
86
86
  CxStructure,
87
87
  declareFresh,
88
88
  ExactInteger,
89
- fitsF32,
90
89
  flipY,
91
90
  headText,
92
91
  inexactLiteral,
@@ -2445,11 +2444,11 @@ class CxReader {
2445
2444
  );
2446
2445
  continue;
2447
2446
  }
2448
- if (!fitsF32(value.x) || !fitsF32(value.y)) {
2447
+ if (!Number.isFinite(value.x) || !Number.isFinite(value.y)) {
2449
2448
  this.report.error(
2450
2449
  "validation-error",
2451
2450
  BAD_VALUE_CODE,
2452
- `a cartesianLayout element for node ${shown(value.node)} has the coordinates ${String(value.x)}, ${String(value.y)}, beyond what the f32 position column holds; skipped`,
2451
+ `a cartesianLayout element for node ${shown(value.node)} has the coordinates ${String(value.x)}, ${String(value.y)}, which are not both finite; skipped`,
2453
2452
  { line, element: "cartesianLayout" },
2454
2453
  );
2455
2454
  continue;
@@ -79,7 +79,6 @@ import {
79
79
  CxStructure,
80
80
  declareFresh,
81
81
  ExactInteger,
82
- fitsF32,
83
82
  flipY,
84
83
  headText,
85
84
  inexactLiteral,
@@ -1638,11 +1637,11 @@ class Cx2Reader {
1638
1637
  `${CX2_ISSUE.PARTIAL_LAYOUT}:z`,
1639
1638
  );
1640
1639
  }
1641
- if (x !== null && y !== null && (!fitsF32(x) || !fitsF32(y))) {
1640
+ if (x !== null && y !== null && (!Number.isFinite(x) || !Number.isFinite(y))) {
1642
1641
  this.report.error(
1643
1642
  "validation-error",
1644
1643
  BAD_VALUE_CODE,
1645
- `${element}: the coordinates ${String(x)}, ${String(y)} are beyond what the f32 position column holds; the position is unset`,
1644
+ `${element}: the coordinates ${String(x)}, ${String(y)} are not both finite; the position is unset`,
1646
1645
  { line, element },
1647
1646
  );
1648
1647
  } else if (x !== null && y !== null) {
@@ -966,7 +966,7 @@ function plan(snapshot: GraphSnapshot, common: ResolvedExportOptions): Plan {
966
966
  for (const gen of checkCapabilities(snapshot, CYS_CAPABILITIES, common, {
967
967
  roles: SLOT_ROLES,
968
968
  roleNames: ROLE_NAMES,
969
- positionDtype: "f32",
969
+ positionDtype: "f64",
970
970
  })) {
971
971
  // json is reported per column below; ids by the SUID rule (positive integers)
972
972
  if (gen.code !== LOSS.JSON && gen.code !== LOSS.ID_CHARSET && gen.code !== LOSS.ID_MANGLED) {
@@ -1224,8 +1224,9 @@ function viewCoords(p: Plan, i: number): string[] {
1224
1224
  const coords: string[] = [];
1225
1225
  if (p.position?.isSet(i) === true) {
1226
1226
  const [x, y, z] = Array.from(p.position.value(i) as ArrayLike<number>);
1227
+ const fmt = p.position.dtype === "f32" ? formatF32 : formatF64;
1227
1228
  if (Number.isFinite(x) && Number.isFinite(y)) {
1228
- coords.push(`x="${formatF32(x)}"`, `y="${formatF32(y === 0 ? 0 : -y)}"`);
1229
+ coords.push(`x="${fmt(x)}"`, `y="${fmt(y === 0 ? 0 : -y)}"`);
1229
1230
  }
1230
1231
  if (
1231
1232
  Number.isFinite(x) &&
@@ -1235,7 +1236,7 @@ function viewCoords(p: Plan, i: number): string[] {
1235
1236
  z !== 0 &&
1236
1237
  Number.isFinite(z)
1237
1238
  ) {
1238
- coords.push(`z="${formatF32(z)}"`);
1239
+ coords.push(`z="${fmt(z)}"`);
1239
1240
  }
1240
1241
  }
1241
1242
  if (p.z?.isSet(i) === true) {
@@ -206,7 +206,7 @@ export const dotExporter: GraphExporter<DotExportOptions> = Object.freeze({
206
206
  check(snapshot: GraphSnapshot, options?: DotExportOptions & CommonExportOptions): readonly LossNote[] {
207
207
  const resolved = resolveExportOptions(options);
208
208
  const notes = checkCapabilities(snapshot, CAPABILITIES, resolved, {
209
- positionDtype: "f32",
209
+ positionDtype: "f64",
210
210
  roles: KEPT_ROLES,
211
211
  roleNames: ROLE_NAMES,
212
212
  });
@@ -21,7 +21,7 @@
21
21
  * name (fdp's cluster edges) merge into that one node with a warning. Other subgraphs are
22
22
  * transparent grouping; their attributes are reported as dropped.
23
23
  * - attribute values are ID strings inferred per column by the sink under the 5.1 grammar, except
24
- * `label` (text, role label), `pos` on a node (the position role column, f32 x3, design section
24
+ * `label` (text, role label), `pos` on a node (the position role column, f64 x3, design section
25
25
  * 5.2), `weight` (THE weight, `weightFrom`) and `key` (cgraph's edge identity, the edge id role).
26
26
  * - ids are coerced with the common rule (`canonical` by default: `1` and `"1"` are the same node,
27
27
  * as the DOT grammar says).
@@ -68,7 +68,6 @@ import {
68
68
  UNKNOWN_ENCODING_CODE,
69
69
  } from "../../common/codes.js";
70
70
  import { DirectionResolver, type EdgeKind } from "../../common/direction.js";
71
- import { survivesF32 } from "../../common/format.js";
72
71
  import { IdCoercer } from "../../common/ids.js";
73
72
  import { canonicalEncoding, readText, throwIfAborted } from "../../common/input.js";
74
73
  import {
@@ -177,7 +176,7 @@ export const DOT_ISSUE = Object.freeze({
177
176
  CLUSTER_NODE_MERGED: "W_DOT_CLUSTER_NODE_MERGED",
178
177
  /** A node mentioned in two unrelated clusters keeps the first. */
179
178
  CLUSTER_CONFLICT: "W_DOT_CLUSTER_CONFLICT",
180
- /** A node's `pos` is not a point, or is too large for a 32-bit float position; the value was dropped. */
179
+ /** A node's `pos` is not a point, or has a coordinate that is not finite; the value was dropped. */
181
180
  BAD_POS: "W_DOT_BAD_POS",
182
181
  /** Node `pos` values mix two and three coordinates; the position column records the first's. */
183
182
  POS_DIMS: "W_DOT_POS_DIMS",
@@ -1672,25 +1671,16 @@ class DotParser {
1672
1671
  const z = match[3] === undefined ? 0 : Number(match[3]);
1673
1672
  const dims = match[3] === undefined ? 2 : 3;
1674
1673
  const point = [x, y, z];
1675
- if (point.some((v) => !Number.isFinite(Math.fround(v)))) {
1676
- // the position column is f32: a half-infinite point would be a half-valid position
1674
+ if (point.some((v) => !Number.isFinite(v))) {
1675
+ // a half-infinite point would be a half-valid position
1677
1676
  this.report.warning(
1678
1677
  "validation-error",
1679
1678
  DOT_ISSUE.BAD_POS,
1680
- `pos ${JSON.stringify(text)} is beyond the f32 range of the position column; dropped`,
1679
+ `pos ${JSON.stringify(text)} has a coordinate that is not finite; dropped`,
1681
1680
  { line, element },
1682
1681
  );
1683
1682
  return;
1684
1683
  }
1685
- if (!point.every(survivesF32)) {
1686
- this.report.warnOnce(
1687
- "precision",
1688
- DOT_ISSUE.PRECISION,
1689
- `pos ${JSON.stringify(text)} has a coordinate the f32 position column rounds (warned once; positions=false keeps pos as written)`,
1690
- { line, element },
1691
- `${DOT_ISSUE.PRECISION}:pos`,
1692
- );
1693
- }
1694
1684
  if (this.posDims === 0) {
1695
1685
  this.posDims = dims;
1696
1686
  } else if (dims !== this.posDims) {
@@ -1769,7 +1759,7 @@ class DotParser {
1769
1759
  }
1770
1760
 
1771
1761
  /**
1772
- * The position column (f32 x3, role position, design section 5.2), declared on the first `pos`.
1762
+ * The position column (f64 x3, role position, design section 5.2), declared on the first `pos`.
1773
1763
  * @param dims - the dimensions of the first value, recorded in extra.sourceDims
1774
1764
  * @returns the handle
1775
1765
  */
@@ -1777,7 +1767,7 @@ class DotParser {
1777
1767
  if (this.positionHandle === INVALID_INDEX) {
1778
1768
  this.positionHandle = this.declare("node", {
1779
1769
  name: POS_ATTRIBUTE,
1780
- dtype: "f32",
1770
+ dtype: "f64",
1781
1771
  components: 3,
1782
1772
  nullable: true,
1783
1773
  mutable: true,
@@ -93,8 +93,8 @@ export const GEXF_LOSS = Object.freeze({
93
93
  /** A numeric edge id column: GEXF edge ids read back as strings. */
94
94
  EDGE_ID_TEXT: "W_GEXF_EDGE_ID_TEXT",
95
95
  /**
96
- * A visual attribute (position, color, size, thickness) is stored at more precision than GEXF keeps; it reads back
97
- * as a 32-bit float.
96
+ * A visual attribute (position, color, size, thickness) is stored at another precision than the importer gives it;
97
+ * a position reads back as a 64-bit float, a color, size or thickness as a 32-bit float.
98
98
  */
99
99
  VIZ_DTYPE: "W_GEXF_VIZ_DTYPE",
100
100
  /**
@@ -271,7 +271,7 @@ function planExport(
271
271
  ...checkCapabilities(snapshot, caps, resolved, {
272
272
  openIntervals: version === "1.2",
273
273
  temporalText: true,
274
- positionDtype: "f32",
274
+ positionDtype: "f64",
275
275
  roles: MAPPED_ROLES,
276
276
  // GEXF 1.2 has no edge kind: the kind column is dropped (W_GEXF_KIND_DROPPED), not renamed
277
277
  roleNames: version === "1.2" ? ROLE_NAMES_1_2 : ROLE_NAMES,
@@ -608,10 +608,12 @@ function collectRoles(
608
608
  }
609
609
  continue;
610
610
  }
611
- if (VIZ_NUMERIC_ROLES.has(role) && column.dtype !== "f32") {
611
+ // the importer reads a position as f64 and every other viz value as f32
612
+ const vizDtype = role === "position" ? "f64" : "f32";
613
+ if (VIZ_NUMERIC_ROLES.has(role) && column.dtype !== vizDtype) {
612
614
  note(
613
615
  GEXF_LOSS.VIZ_DTYPE,
614
- `${domain} column "${meta.name}" (${role}) is ${column.dtype}; viz values read back as f32`,
616
+ `${domain} column "${meta.name}" (${role}) is ${column.dtype}; viz values read back as ${vizDtype}`,
615
617
  meta.name,
616
618
  column.length - column.nullCount,
617
619
  );
@@ -184,7 +184,7 @@ export const NODE_DECLS: Readonly<Record<keyof typeof NODE_COLUMNS, ColumnDecl>>
184
184
  },
185
185
  position: {
186
186
  name: NODE_COLUMNS.position,
187
- dtype: "f32",
187
+ dtype: "f64",
188
188
  components: 3,
189
189
  role: "position",
190
190
  mutable: true,
@@ -648,7 +648,7 @@ function plan(snapshot: GraphSnapshot, resolved: Resolved): Plan {
648
648
  // plain attributes and read back without their role)
649
649
  const notes = checkCapabilities(snapshot, caps, resolved.common, {
650
650
  roles: SLOT_ROLES[dialect],
651
- positionDtype: "f32",
651
+ positionDtype: "f64",
652
652
  }).filter((n) => n.code !== LOSS.POSITIONS && n.code !== LOSS.EDGE_IDS_DROPPED);
653
653
  const note: NoteFn = (code, message, column = null, count = null): void => {
654
654
  notes.push(Object.freeze({ code, message, column, count }));
@@ -3369,7 +3369,7 @@ function importCytoscape(ctx: ImportContext, root: unknown): void {
3369
3369
  const positionColumn = ctx.nodes.declareIf(
3370
3370
  {
3371
3371
  name: POSITION_COLUMN,
3372
- dtype: "f32",
3372
+ dtype: "f64",
3373
3373
  components: 3,
3374
3374
  role: "position",
3375
3375
  mutable: true,
@@ -3450,7 +3450,7 @@ function importCytoscape(ctx: ImportContext, root: unknown): void {
3450
3450
  report.error(
3451
3451
  "validation-error",
3452
3452
  JSON_ISSUE.BAD_VALUE,
3453
- `${element}: position must be an object with numeric x and y (and z) that are finite and within the f32 range`,
3453
+ `${element}: position must be an object with numeric x and y (and z) that are finite numbers`,
3454
3454
  { element },
3455
3455
  );
3456
3456
  }
@@ -3656,16 +3656,13 @@ function hasPositionZ(element: unknown): boolean {
3656
3656
  return isJsonObject(element) && isJsonObject(element.position) && typeof element.position.z === "number";
3657
3657
  }
3658
3658
 
3659
- /** The largest finite f32, the bound of a position coordinate. */
3660
- const F32_MAX = 3.4028234663852886e38;
3661
-
3662
3659
  /**
3663
- * Whether a value is a coordinate the f32 position column holds: a finite number within the f32 range.
3660
+ * Whether a value is a coordinate the position column holds: a finite number.
3664
3661
  * @param value - the value
3665
3662
  * @returns true for a storable coordinate
3666
3663
  */
3667
3664
  function isCoordinate(value: unknown): value is number {
3668
- return typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= F32_MAX;
3665
+ return typeof value === "number" && Number.isFinite(value);
3669
3666
  }
3670
3667
 
3671
3668
  /**