@graphty/graph-io 0.0.0 → 0.2.1
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.
- package/LICENSE +21 -0
- package/README.md +250 -28
- package/dist/chunks/children-CL3Cy0ez.js +238 -0
- package/dist/chunks/children-CL3Cy0ez.js.map +1 -0
- package/dist/chunks/escape-DyI8JofU.js +938 -0
- package/dist/chunks/escape-DyI8JofU.js.map +1 -0
- package/dist/chunks/importer-CQnJuWJw.js +2987 -0
- package/dist/chunks/importer-CQnJuWJw.js.map +1 -0
- package/dist/chunks/importer-CpCpfbxr.js +2015 -0
- package/dist/chunks/importer-CpCpfbxr.js.map +1 -0
- package/dist/chunks/importer-DbnGYr3_.js +2342 -0
- package/dist/chunks/importer-DbnGYr3_.js.map +1 -0
- package/dist/chunks/importer-GozH8DkN.js +3050 -0
- package/dist/chunks/importer-GozH8DkN.js.map +1 -0
- package/dist/chunks/records-CGpxszm1.js +605 -0
- package/dist/chunks/records-CGpxszm1.js.map +1 -0
- package/dist/chunks/text-CajMdVFy.js +189 -0
- package/dist/chunks/text-CajMdVFy.js.map +1 -0
- package/dist/chunks/writer-DxSKC7TL.js +2842 -0
- package/dist/chunks/writer-DxSKC7TL.js.map +1 -0
- package/dist/csv.d.ts +1 -0
- package/dist/csv.js +1702 -0
- package/dist/csv.js.map +1 -0
- package/dist/dot.d.ts +1 -0
- package/dist/dot.js +8 -0
- package/dist/dot.js.map +1 -0
- package/dist/gexf.d.ts +1 -0
- package/dist/gexf.js +3466 -0
- package/dist/gexf.js.map +1 -0
- package/dist/gml.d.ts +1 -0
- package/dist/gml.js +2647 -0
- package/dist/gml.js.map +1 -0
- package/dist/graph-io.d.ts +1 -0
- package/dist/graph-io.js +790 -0
- package/dist/graph-io.js.map +1 -0
- package/dist/graphml.d.ts +1 -0
- package/dist/graphml.js +8 -0
- package/dist/graphml.js.map +1 -0
- package/dist/json.d.ts +1 -0
- package/dist/json.js +11 -0
- package/dist/json.js.map +1 -0
- package/dist/neo4j.d.ts +1 -0
- package/dist/neo4j.js +2046 -0
- package/dist/neo4j.js.map +1 -0
- package/dist/pajek.d.ts +1 -0
- package/dist/pajek.js +8 -0
- package/dist/pajek.js.map +1 -0
- package/dist/src/children.d.ts +134 -0
- package/dist/src/children.d.ts.map +1 -0
- package/dist/src/children.js +274 -0
- package/dist/src/children.js.map +1 -0
- package/dist/src/common/attributes.d.ts +229 -0
- package/dist/src/common/attributes.d.ts.map +1 -0
- package/dist/src/common/attributes.js +368 -0
- package/dist/src/common/attributes.js.map +1 -0
- package/dist/src/common/codes.d.ts +105 -0
- package/dist/src/common/codes.d.ts.map +1 -0
- package/dist/src/common/codes.js +107 -0
- package/dist/src/common/codes.js.map +1 -0
- package/dist/src/common/declared-types.d.ts +84 -0
- package/dist/src/common/declared-types.d.ts.map +1 -0
- package/dist/src/common/declared-types.js +326 -0
- package/dist/src/common/declared-types.js.map +1 -0
- package/dist/src/common/direction.d.ts +206 -0
- package/dist/src/common/direction.d.ts.map +1 -0
- package/dist/src/common/direction.js +370 -0
- package/dist/src/common/direction.js.map +1 -0
- package/dist/src/common/escape.d.ts +92 -0
- package/dist/src/common/escape.d.ts.map +1 -0
- package/dist/src/common/escape.js +212 -0
- package/dist/src/common/escape.js.map +1 -0
- package/dist/src/common/export.d.ts +249 -0
- package/dist/src/common/export.d.ts.map +1 -0
- package/dist/src/common/export.js +594 -0
- package/dist/src/common/export.js.map +1 -0
- package/dist/src/common/format.d.ts +59 -0
- package/dist/src/common/format.d.ts.map +1 -0
- package/dist/src/common/format.js +106 -0
- package/dist/src/common/format.js.map +1 -0
- package/dist/src/common/ids.d.ts +83 -0
- package/dist/src/common/ids.d.ts.map +1 -0
- package/dist/src/common/ids.js +158 -0
- package/dist/src/common/ids.js.map +1 -0
- package/dist/src/common/input.d.ts +100 -0
- package/dist/src/common/input.d.ts.map +1 -0
- package/dist/src/common/input.js +335 -0
- package/dist/src/common/input.js.map +1 -0
- package/dist/src/common/lists.d.ts +34 -0
- package/dist/src/common/lists.d.ts.map +1 -0
- package/dist/src/common/lists.js +185 -0
- package/dist/src/common/lists.js.map +1 -0
- package/dist/src/common/options.d.ts +108 -0
- package/dist/src/common/options.d.ts.map +1 -0
- package/dist/src/common/options.js +265 -0
- package/dist/src/common/options.js.map +1 -0
- package/dist/src/common/report.d.ts +187 -0
- package/dist/src/common/report.d.ts.map +1 -0
- package/dist/src/common/report.js +274 -0
- package/dist/src/common/report.js.map +1 -0
- package/dist/src/common/temporal.d.ts +71 -0
- package/dist/src/common/temporal.d.ts.map +1 -0
- package/dist/src/common/temporal.js +266 -0
- package/dist/src/common/temporal.js.map +1 -0
- package/dist/src/common/text.d.ts +104 -0
- package/dist/src/common/text.d.ts.map +1 -0
- package/dist/src/common/text.js +255 -0
- package/dist/src/common/text.js.map +1 -0
- package/dist/src/common/weights.d.ts +77 -0
- package/dist/src/common/weights.d.ts.map +1 -0
- package/dist/src/common/weights.js +156 -0
- package/dist/src/common/weights.js.map +1 -0
- package/dist/src/common/writer.d.ts +51 -0
- package/dist/src/common/writer.d.ts.map +1 -0
- package/dist/src/common/writer.js +108 -0
- package/dist/src/common/writer.js.map +1 -0
- package/dist/src/common/xml.d.ts +245 -0
- package/dist/src/common/xml.d.ts.map +1 -0
- package/dist/src/common/xml.js +942 -0
- package/dist/src/common/xml.js.map +1 -0
- package/dist/src/formats/csv/exporter.d.ts +70 -0
- package/dist/src/formats/csv/exporter.d.ts.map +1 -0
- package/dist/src/formats/csv/exporter.js +682 -0
- package/dist/src/formats/csv/exporter.js.map +1 -0
- package/dist/src/formats/csv/header.d.ts +66 -0
- package/dist/src/formats/csv/header.d.ts.map +1 -0
- package/dist/src/formats/csv/header.js +152 -0
- package/dist/src/formats/csv/header.js.map +1 -0
- package/dist/src/formats/csv/importer.d.ts +82 -0
- package/dist/src/formats/csv/importer.d.ts.map +1 -0
- package/dist/src/formats/csv/importer.js +849 -0
- package/dist/src/formats/csv/importer.js.map +1 -0
- package/dist/src/formats/csv/index.d.ts +60 -0
- package/dist/src/formats/csv/index.d.ts.map +1 -0
- package/dist/src/formats/csv/index.js +63 -0
- package/dist/src/formats/csv/index.js.map +1 -0
- package/dist/src/formats/csv/records.d.ts +188 -0
- package/dist/src/formats/csv/records.d.ts.map +1 -0
- package/dist/src/formats/csv/records.js +702 -0
- package/dist/src/formats/csv/records.js.map +1 -0
- package/dist/src/formats/csv/values.d.ts +105 -0
- package/dist/src/formats/csv/values.d.ts.map +1 -0
- package/dist/src/formats/csv/values.js +192 -0
- package/dist/src/formats/csv/values.js.map +1 -0
- package/dist/src/formats/dot/exporter.d.ts +52 -0
- package/dist/src/formats/dot/exporter.d.ts.map +1 -0
- package/dist/src/formats/dot/exporter.js +836 -0
- package/dist/src/formats/dot/exporter.js.map +1 -0
- package/dist/src/formats/dot/importer.d.ts +102 -0
- package/dist/src/formats/dot/importer.d.ts.map +1 -0
- package/dist/src/formats/dot/importer.js +1291 -0
- package/dist/src/formats/dot/importer.js.map +1 -0
- package/dist/src/formats/dot/index.d.ts +7 -0
- package/dist/src/formats/dot/index.d.ts.map +1 -0
- package/dist/src/formats/dot/index.js +7 -0
- package/dist/src/formats/dot/index.js.map +1 -0
- package/dist/src/formats/dot/names.d.ts +29 -0
- package/dist/src/formats/dot/names.d.ts.map +1 -0
- package/dist/src/formats/dot/names.js +28 -0
- package/dist/src/formats/dot/names.js.map +1 -0
- package/dist/src/formats/dot/tokenizer.d.ts +114 -0
- package/dist/src/formats/dot/tokenizer.d.ts.map +1 -0
- package/dist/src/formats/dot/tokenizer.js +341 -0
- package/dist/src/formats/dot/tokenizer.js.map +1 -0
- package/dist/src/formats/gexf/exporter.d.ts +56 -0
- package/dist/src/formats/gexf/exporter.d.ts.map +1 -0
- package/dist/src/formats/gexf/exporter.js +1395 -0
- package/dist/src/formats/gexf/exporter.js.map +1 -0
- package/dist/src/formats/gexf/importer.d.ts +73 -0
- package/dist/src/formats/gexf/importer.d.ts.map +1 -0
- package/dist/src/formats/gexf/importer.js +1880 -0
- package/dist/src/formats/gexf/importer.js.map +1 -0
- package/dist/src/formats/gexf/index.d.ts +96 -0
- package/dist/src/formats/gexf/index.d.ts.map +1 -0
- package/dist/src/formats/gexf/index.js +97 -0
- package/dist/src/formats/gexf/index.js.map +1 -0
- package/dist/src/formats/gexf/schema.d.ts +135 -0
- package/dist/src/formats/gexf/schema.d.ts.map +1 -0
- package/dist/src/formats/gexf/schema.js +323 -0
- package/dist/src/formats/gexf/schema.js.map +1 -0
- package/dist/src/formats/gml/exporter.d.ts +69 -0
- package/dist/src/formats/gml/exporter.d.ts.map +1 -0
- package/dist/src/formats/gml/exporter.js +1093 -0
- package/dist/src/formats/gml/exporter.js.map +1 -0
- package/dist/src/formats/gml/importer.d.ts +66 -0
- package/dist/src/formats/gml/importer.d.ts.map +1 -0
- package/dist/src/formats/gml/importer.js +1331 -0
- package/dist/src/formats/gml/importer.js.map +1 -0
- package/dist/src/formats/gml/index.d.ts +85 -0
- package/dist/src/formats/gml/index.d.ts.map +1 -0
- package/dist/src/formats/gml/index.js +88 -0
- package/dist/src/formats/gml/index.js.map +1 -0
- package/dist/src/formats/gml/syntax.d.ts +186 -0
- package/dist/src/formats/gml/syntax.d.ts.map +1 -0
- package/dist/src/formats/gml/syntax.js +467 -0
- package/dist/src/formats/gml/syntax.js.map +1 -0
- package/dist/src/formats/graphml/constants.d.ts +169 -0
- package/dist/src/formats/graphml/constants.d.ts.map +1 -0
- package/dist/src/formats/graphml/constants.js +165 -0
- package/dist/src/formats/graphml/constants.js.map +1 -0
- package/dist/src/formats/graphml/exporter.d.ts +34 -0
- package/dist/src/formats/graphml/exporter.d.ts.map +1 -0
- package/dist/src/formats/graphml/exporter.js +1176 -0
- package/dist/src/formats/graphml/exporter.js.map +1 -0
- package/dist/src/formats/graphml/importer.d.ts +31 -0
- package/dist/src/formats/graphml/importer.d.ts.map +1 -0
- package/dist/src/formats/graphml/importer.js +1607 -0
- package/dist/src/formats/graphml/importer.js.map +1 -0
- package/dist/src/formats/graphml/index.d.ts +8 -0
- package/dist/src/formats/graphml/index.d.ts.map +1 -0
- package/dist/src/formats/graphml/index.js +8 -0
- package/dist/src/formats/graphml/index.js.map +1 -0
- package/dist/src/formats/graphml/tree.d.ts +72 -0
- package/dist/src/formats/graphml/tree.d.ts.map +1 -0
- package/dist/src/formats/graphml/tree.js +290 -0
- package/dist/src/formats/graphml/tree.js.map +1 -0
- package/dist/src/formats/json/dialect.d.ts +125 -0
- package/dist/src/formats/json/dialect.d.ts.map +1 -0
- package/dist/src/formats/json/dialect.js +262 -0
- package/dist/src/formats/json/dialect.js.map +1 -0
- package/dist/src/formats/json/exporter.d.ts +89 -0
- package/dist/src/formats/json/exporter.d.ts.map +1 -0
- package/dist/src/formats/json/exporter.js +1358 -0
- package/dist/src/formats/json/exporter.js.map +1 -0
- package/dist/src/formats/json/importer.d.ts +108 -0
- package/dist/src/formats/json/importer.d.ts.map +1 -0
- package/dist/src/formats/json/importer.js +1838 -0
- package/dist/src/formats/json/importer.js.map +1 -0
- package/dist/src/formats/json/index.d.ts +8 -0
- package/dist/src/formats/json/index.d.ts.map +1 -0
- package/dist/src/formats/json/index.js +8 -0
- package/dist/src/formats/json/index.js.map +1 -0
- package/dist/src/formats/neo4j/exporter.d.ts +68 -0
- package/dist/src/formats/neo4j/exporter.d.ts.map +1 -0
- package/dist/src/formats/neo4j/exporter.js +1055 -0
- package/dist/src/formats/neo4j/exporter.js.map +1 -0
- package/dist/src/formats/neo4j/header.d.ts +52 -0
- package/dist/src/formats/neo4j/header.d.ts.map +1 -0
- package/dist/src/formats/neo4j/header.js +131 -0
- package/dist/src/formats/neo4j/header.js.map +1 -0
- package/dist/src/formats/neo4j/importer.d.ts +73 -0
- package/dist/src/formats/neo4j/importer.d.ts.map +1 -0
- package/dist/src/formats/neo4j/importer.js +932 -0
- package/dist/src/formats/neo4j/importer.js.map +1 -0
- package/dist/src/formats/neo4j/index.d.ts +79 -0
- package/dist/src/formats/neo4j/index.d.ts.map +1 -0
- package/dist/src/formats/neo4j/index.js +83 -0
- package/dist/src/formats/neo4j/index.js.map +1 -0
- package/dist/src/formats/pajek/exporter.d.ts +58 -0
- package/dist/src/formats/pajek/exporter.d.ts.map +1 -0
- package/dist/src/formats/pajek/exporter.js +825 -0
- package/dist/src/formats/pajek/exporter.js.map +1 -0
- package/dist/src/formats/pajek/importer.d.ts +88 -0
- package/dist/src/formats/pajek/importer.d.ts.map +1 -0
- package/dist/src/formats/pajek/importer.js +1047 -0
- package/dist/src/formats/pajek/importer.js.map +1 -0
- package/dist/src/formats/pajek/index.d.ts +7 -0
- package/dist/src/formats/pajek/index.d.ts.map +1 -0
- package/dist/src/formats/pajek/index.js +7 -0
- package/dist/src/formats/pajek/index.js.map +1 -0
- package/dist/src/formats/pajek/syntax.d.ts +112 -0
- package/dist/src/formats/pajek/syntax.d.ts.map +1 -0
- package/dist/src/formats/pajek/syntax.js +269 -0
- package/dist/src/formats/pajek/syntax.js.map +1 -0
- package/dist/src/index.d.ts +35 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +39 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/registry.d.ts +207 -0
- package/dist/src/registry.d.ts.map +1 -0
- package/dist/src/registry.js +481 -0
- package/dist/src/registry.js.map +1 -0
- package/dist/src/sniff.d.ts +104 -0
- package/dist/src/sniff.d.ts.map +1 -0
- package/dist/src/sniff.js +357 -0
- package/dist/src/sniff.js.map +1 -0
- package/dist/src/types.d.ts +238 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +29 -0
- package/dist/src/types.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/package.json +122 -7
- package/src/children.ts +335 -0
- package/src/common/attributes.ts +520 -0
- package/src/common/codes.ts +153 -0
- package/src/common/declared-types.ts +374 -0
- package/src/common/direction.ts +518 -0
- package/src/common/escape.ts +231 -0
- package/src/common/export.ts +817 -0
- package/src/common/format.ts +111 -0
- package/src/common/ids.ts +176 -0
- package/src/common/input.ts +378 -0
- package/src/common/lists.ts +196 -0
- package/src/common/options.ts +377 -0
- package/src/common/report.ts +352 -0
- package/src/common/temporal.ts +302 -0
- package/src/common/text.ts +294 -0
- package/src/common/weights.ts +202 -0
- package/src/common/writer.ts +123 -0
- package/src/common/xml.ts +1053 -0
- package/src/formats/csv/exporter.ts +894 -0
- package/src/formats/csv/header.ts +172 -0
- package/src/formats/csv/importer.ts +1104 -0
- package/src/formats/csv/index.ts +88 -0
- package/src/formats/csv/records.ts +813 -0
- package/src/formats/csv/values.ts +224 -0
- package/src/formats/dot/exporter.ts +1014 -0
- package/src/formats/dot/importer.ts +1549 -0
- package/src/formats/dot/index.ts +7 -0
- package/src/formats/dot/names.ts +40 -0
- package/src/formats/dot/tokenizer.ts +384 -0
- package/src/formats/gexf/exporter.ts +1696 -0
- package/src/formats/gexf/importer.ts +2333 -0
- package/src/formats/gexf/index.ts +142 -0
- package/src/formats/gexf/schema.ts +361 -0
- package/src/formats/gml/exporter.ts +1404 -0
- package/src/formats/gml/importer.ts +1591 -0
- package/src/formats/gml/index.ts +128 -0
- package/src/formats/gml/syntax.ts +545 -0
- package/src/formats/graphml/constants.ts +225 -0
- package/src/formats/graphml/exporter.ts +1458 -0
- package/src/formats/graphml/importer.ts +2027 -0
- package/src/formats/graphml/index.ts +8 -0
- package/src/formats/graphml/tree.ts +318 -0
- package/src/formats/json/dialect.ts +317 -0
- package/src/formats/json/exporter.ts +1616 -0
- package/src/formats/json/importer.ts +2271 -0
- package/src/formats/json/index.ts +8 -0
- package/src/formats/neo4j/exporter.ts +1287 -0
- package/src/formats/neo4j/header.ts +156 -0
- package/src/formats/neo4j/importer.ts +1220 -0
- package/src/formats/neo4j/index.ts +116 -0
- package/src/formats/pajek/exporter.ts +1000 -0
- package/src/formats/pajek/importer.ts +1311 -0
- package/src/formats/pajek/index.ts +7 -0
- package/src/formats/pajek/syntax.ts +307 -0
- package/src/index.ts +244 -0
- package/src/registry.ts +617 -0
- package/src/sniff.ts +397 -0
- package/src/types.ts +262 -0
|
@@ -0,0 +1,1616 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The JSON exporter (design section 8.5): one GraphExporter with a `dialect` option writing
|
|
3
|
+
* NetworkX node-link (the default), d3 (`links`, optional index endpoints), JSON Graph Format v2,
|
|
4
|
+
* Cytoscape.js elements, graphology serialisation or vis.js. The dialect defaults to the one the
|
|
5
|
+
* importer recorded under `meta.extra.json` so a JSON file re-exported as JSON keeps its shape,
|
|
6
|
+
* and to node-link for a snapshot from any other source.
|
|
7
|
+
*
|
|
8
|
+
* check() runs the generic capability pre-flight against the dialect's table and appends the
|
|
9
|
+
* dialect-specific notes (non-finite numbers written as null, JGF id stringification / collisions
|
|
10
|
+
* / node order, direction dropped by Cytoscape and vis, mutual pairs expanded, reserved key
|
|
11
|
+
* collisions, positions written as plain attributes, a Cytoscape `parents` column) before anything
|
|
12
|
+
* is written; the same column plan drives write(), so the notes and the output agree.
|
|
13
|
+
*
|
|
14
|
+
* Every column without a structural slot in the dialect is written as a plain attribute under its
|
|
15
|
+
* column name; an f32 value is written as the shortest decimal that round-trips through
|
|
16
|
+
* Math.fround (design section 3.7); a multi-component value is an array; a list is an array; a json
|
|
17
|
+
* value is its JSON text; an unset row writes no key (design section 5.3). Non-finite numbers,
|
|
18
|
+
* which JSON cannot carry, become null and are reported.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { type Column, GraphFormatError, type GraphSnapshot, INVALID_INDEX, type NodeId } from "@graphty/graph-format";
|
|
22
|
+
|
|
23
|
+
import { type PairFolding, pairFolding } from "../../common/direction.js";
|
|
24
|
+
import { checkCapabilities, countMixedEdges, LOSS } from "../../common/export.js";
|
|
25
|
+
import { formatF32 } from "../../common/format.js";
|
|
26
|
+
import { type ResolvedExportOptions, resolveExportOptions } from "../../common/options.js";
|
|
27
|
+
import { explicitWeights } from "../../common/weights.js";
|
|
28
|
+
import { encodeChunks, joinText } from "../../common/writer.js";
|
|
29
|
+
import { type CommonExportOptions, type ExportCapabilities, type GraphExporter, type LossNote } from "../../types.js";
|
|
30
|
+
import {
|
|
31
|
+
CYTOSCAPE_ELEMENT_KEYS,
|
|
32
|
+
DIALECT_DEFAULT_DIRECTED,
|
|
33
|
+
dialectCapabilities,
|
|
34
|
+
isJsonDialect,
|
|
35
|
+
JSON_DIALECTS,
|
|
36
|
+
type JsonDialect,
|
|
37
|
+
type JsonShapeMeta,
|
|
38
|
+
shapeMetaOf,
|
|
39
|
+
SUFFIX,
|
|
40
|
+
} from "./dialect.js";
|
|
41
|
+
|
|
42
|
+
/** The format-specific options of the JSON exporter. */
|
|
43
|
+
export interface JsonExportOptions {
|
|
44
|
+
/** The dialect to write; default: the dialect the importer recorded, else "node-link". */
|
|
45
|
+
dialect?: JsonDialect | undefined;
|
|
46
|
+
/** Spaces per indentation level; 0 (default) writes compact JSON. */
|
|
47
|
+
indent?: number | undefined;
|
|
48
|
+
/** node-link / d3: the key of the edge array; default: the recorded key, else "edges" (d3: "links"). */
|
|
49
|
+
edgesKey?: string | undefined;
|
|
50
|
+
/** node-link / d3 / vis: the node id key; default: the recorded key, else "id". */
|
|
51
|
+
nodeIdKey?: string | undefined;
|
|
52
|
+
/** node-link / d3: write endpoints as node array positions; default: the recorded flag, else false. */
|
|
53
|
+
indexLinks?: boolean | undefined;
|
|
54
|
+
/** node-link / d3 / vis: the source key; default: the recorded key, else "source" (vis: "from"). */
|
|
55
|
+
sourceKey?: string | undefined;
|
|
56
|
+
/** node-link / d3 / vis: the target key; default: the recorded key, else "target" (vis: "to"). */
|
|
57
|
+
targetKey?: string | undefined;
|
|
58
|
+
/** The key the weight is written under; default: the key the JSON importer read it from, else "weight". */
|
|
59
|
+
weightKey?: string | undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The LossNote codes of the JSON exporter's check(): the dialect-specific ones and, aliased, the
|
|
64
|
+
* shared ones it records (`LOSS` holds the rest of the generic pre-flight's codes). A key is the
|
|
65
|
+
* code without its severity prefix.
|
|
66
|
+
*/
|
|
67
|
+
export const JSON_LOSS = Object.freeze({
|
|
68
|
+
/** Non-finite numbers (columns, weights) are written as null. */
|
|
69
|
+
NONFINITE_AS_NULL: "W_NONFINITE_AS_NULL",
|
|
70
|
+
/** Cytoscape, vis and d3 carry no direction; the file re-imports with the dialect's default direction. */
|
|
71
|
+
DIRECTION_DROPPED: "W_DIRECTION_DROPPED",
|
|
72
|
+
/** GEXF mutual pairs are written as two directed edges. */
|
|
73
|
+
MUTUAL_EXPANDED: LOSS.MUTUAL_EXPANDED,
|
|
74
|
+
/** JGF keys its nodes by string; numeric ids re-import as text unless ids: "canonical". */
|
|
75
|
+
NUMERIC_IDS_STRINGIFIED: "W_NUMERIC_IDS_STRINGIFIED",
|
|
76
|
+
/** JGF: two ids have the same text; export() throws E_INVALID_ID. */
|
|
77
|
+
ID_TEXT_COLLISION: LOSS.ID_TEXT_COLLISION,
|
|
78
|
+
/** JGF: integer-like id keys are enumerated first and ascending by JSON parsers; node order changes on re-import. */
|
|
79
|
+
NODE_ORDER: "W_NODE_ORDER",
|
|
80
|
+
/** A column named like a reserved key of the dialect (id, source, target, ...) is skipped. */
|
|
81
|
+
RESERVED_KEY: "W_RESERVED_KEY",
|
|
82
|
+
/** A plain edge column named like the weight key reads back as THE weight (or is skipped when weights are written). */
|
|
83
|
+
WEIGHT_KEY_CLASH: LOSS.WEIGHT_KEY_CLASH,
|
|
84
|
+
/** A position column without a slot (every dialect but Cytoscape) is a plain array attribute; the role is lost. */
|
|
85
|
+
POSITIONS_DROPPED: LOSS.POSITIONS,
|
|
86
|
+
/** Cytoscape positions are 2D; non-zero z values are dropped. */
|
|
87
|
+
POSITION_Z_DROPPED: "W_POSITION_Z_DROPPED",
|
|
88
|
+
/** Cytoscape has a single parent; a `parents` list column cannot be written. */
|
|
89
|
+
PARENTS_DROPPED: LOSS.PARENTS,
|
|
90
|
+
/** node-link / d3 have no edge id slot; the id column is written as a plain attribute. */
|
|
91
|
+
EDGE_IDS_DROPPED: LOSS.EDGE_IDS_DROPPED,
|
|
92
|
+
/** An f64 column of integral values reads back as i32 (JSON declares no types). */
|
|
93
|
+
INTEGRAL_F64_AS_I32: LOSS.INTEGRAL_F64,
|
|
94
|
+
/** A role column the dialect has no slot for is a plain attribute; the role is lost. */
|
|
95
|
+
ROLE_DROPPED: LOSS.ROLE,
|
|
96
|
+
/** A column without a set cell is not written (JSON declares no columns). */
|
|
97
|
+
EMPTY_COLUMN_DROPPED: LOSS.EMPTY_COLUMN,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/** The roles each dialect has a slot for (every other role column is a plain attribute, reported by checkCapabilities()). */
|
|
101
|
+
const SLOT_ROLES: Readonly<Record<JsonDialect, ReadonlySet<string>>> = Object.freeze({
|
|
102
|
+
"node-link": new Set<string>(),
|
|
103
|
+
d3: new Set<string>(),
|
|
104
|
+
jgf: new Set(["label", "kind"]),
|
|
105
|
+
cytoscape: new Set(["classes"]),
|
|
106
|
+
graphology: new Set<string>(),
|
|
107
|
+
vis: new Set<string>(),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/** The element domains. */
|
|
111
|
+
type Domain = "node" | "edge" | "graph";
|
|
112
|
+
|
|
113
|
+
/** A note-recording callback. */
|
|
114
|
+
type NoteFn = (code: string, message: string, column?: string | null, count?: number | null) => void;
|
|
115
|
+
|
|
116
|
+
/** The roles the pre-flight treats structurally; never written as attributes. */
|
|
117
|
+
const STRUCTURAL_ROLES: ReadonlySet<string> = new Set([
|
|
118
|
+
"directed",
|
|
119
|
+
"pair",
|
|
120
|
+
"mutual",
|
|
121
|
+
"weight",
|
|
122
|
+
"timeText",
|
|
123
|
+
"originalId",
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
/** The roles no JSON dialect can carry; skipped (checkCapabilities reports them). */
|
|
127
|
+
const TEMPORAL_ROLES: ReadonlySet<string> = new Set(["start", "end", "timestamp", "timestamps", "spells", "open"]);
|
|
128
|
+
|
|
129
|
+
/** The dialects whose attributes live in a nested dict (data / metadata / attributes). */
|
|
130
|
+
const NESTED_DIALECTS: ReadonlySet<JsonDialect> = new Set(["jgf", "cytoscape", "graphology"]);
|
|
131
|
+
|
|
132
|
+
/** A mutable counter of the non-finite numbers written as null. */
|
|
133
|
+
interface Counter {
|
|
134
|
+
count: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** How one column is written. */
|
|
138
|
+
type Slot = "attribute" | "element" | "position" | "cytoscapePosition" | "parent" | "classes" | "label" | "relation";
|
|
139
|
+
|
|
140
|
+
/** One column's place in the output. */
|
|
141
|
+
interface ColumnPlan {
|
|
142
|
+
readonly column: Column;
|
|
143
|
+
/** The output key (attribute or element-level key). */
|
|
144
|
+
readonly key: string;
|
|
145
|
+
readonly slot: Slot;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The resolved exporter options. */
|
|
149
|
+
interface Resolved {
|
|
150
|
+
readonly common: ResolvedExportOptions;
|
|
151
|
+
readonly dialect: JsonDialect;
|
|
152
|
+
readonly indent: number;
|
|
153
|
+
readonly edgesKey: string;
|
|
154
|
+
readonly nodeIdKey: string | null;
|
|
155
|
+
readonly indexLinks: boolean;
|
|
156
|
+
readonly sourceKey: string;
|
|
157
|
+
readonly targetKey: string;
|
|
158
|
+
readonly weightKey: string;
|
|
159
|
+
readonly shape: JsonShapeMeta;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Everything write() needs, computed once by plan(). */
|
|
163
|
+
interface Plan {
|
|
164
|
+
readonly resolved: Resolved;
|
|
165
|
+
readonly notes: LossNote[];
|
|
166
|
+
readonly nodes: readonly ColumnPlan[];
|
|
167
|
+
readonly edges: readonly ColumnPlan[];
|
|
168
|
+
readonly graph: readonly ColumnPlan[];
|
|
169
|
+
/** The id-role edge column, or null. */
|
|
170
|
+
readonly edgeIds: Column | null;
|
|
171
|
+
/** The weight of an edge as JSON text, or null when the edge has no explicit weight. */
|
|
172
|
+
readonly weights: (e: number) => string | null;
|
|
173
|
+
/** The pair folding (design section 3.6): mirrors skipped, source directions. */
|
|
174
|
+
readonly folding: PairFolding;
|
|
175
|
+
/** The file-level direction. */
|
|
176
|
+
readonly directed: boolean;
|
|
177
|
+
/** Whether the file declares a multigraph. */
|
|
178
|
+
readonly multigraph: boolean;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Resolve the options against the snapshot's recorded shape.
|
|
183
|
+
* @param snapshot - the snapshot
|
|
184
|
+
* @param options - the caller's options
|
|
185
|
+
* @returns the resolved options; E_UNSUPPORTED for a bad value
|
|
186
|
+
*/
|
|
187
|
+
function resolve(snapshot: GraphSnapshot, options: (JsonExportOptions & CommonExportOptions) | undefined): Resolved {
|
|
188
|
+
const o = options ?? {};
|
|
189
|
+
const common = resolveExportOptions(options);
|
|
190
|
+
const shape = shapeMetaOf(snapshot.meta);
|
|
191
|
+
let dialect: JsonDialect;
|
|
192
|
+
if (o.dialect === undefined) {
|
|
193
|
+
dialect = shape.dialect ?? "node-link";
|
|
194
|
+
} else if (isJsonDialect(o.dialect)) {
|
|
195
|
+
({ dialect } = o);
|
|
196
|
+
} else {
|
|
197
|
+
throw unsupported("dialect", o.dialect, JSON_DIALECTS);
|
|
198
|
+
}
|
|
199
|
+
const indent = o.indent ?? 0;
|
|
200
|
+
if (!Number.isInteger(indent) || indent < 0 || indent > 16) {
|
|
201
|
+
throw unsupported("indent", indent, ["an integer 0..16"]);
|
|
202
|
+
}
|
|
203
|
+
const sameDialect = shape.dialect === dialect;
|
|
204
|
+
const recorded = <T>(value: T | undefined, fallback: T): T =>
|
|
205
|
+
sameDialect && value !== undefined ? value : fallback;
|
|
206
|
+
const edgesKey =
|
|
207
|
+
keyOption("edgesKey", o.edgesKey) ?? recorded(shape.edgesKey, dialect === "d3" ? "links" : "edges");
|
|
208
|
+
let nodeIdKey: string | null;
|
|
209
|
+
if (o.nodeIdKey !== undefined) {
|
|
210
|
+
nodeIdKey = keyOption("nodeIdKey", o.nodeIdKey);
|
|
211
|
+
} else if (sameDialect && shape.nodeIdKey !== undefined) {
|
|
212
|
+
({ nodeIdKey } = shape);
|
|
213
|
+
} else {
|
|
214
|
+
nodeIdKey = "id";
|
|
215
|
+
}
|
|
216
|
+
let indexLinks = o.indexLinks ?? recorded(shape.indexLinks, false);
|
|
217
|
+
if (typeof indexLinks !== "boolean") {
|
|
218
|
+
throw unsupported("indexLinks", indexLinks, ["true", "false"]);
|
|
219
|
+
}
|
|
220
|
+
if (nodeIdKey === null) {
|
|
221
|
+
// positional nodes have no id to write; endpoints must be positions
|
|
222
|
+
indexLinks = true;
|
|
223
|
+
}
|
|
224
|
+
const sourceKey =
|
|
225
|
+
keyOption("sourceKey", o.sourceKey) ?? recorded(shape.sourceKey, dialect === "vis" ? "from" : "source");
|
|
226
|
+
const targetKey =
|
|
227
|
+
keyOption("targetKey", o.targetKey) ?? recorded(shape.targetKey, dialect === "vis" ? "to" : "target");
|
|
228
|
+
const { weightOrigin } = snapshot.meta;
|
|
229
|
+
const weightKey =
|
|
230
|
+
keyOption("weightKey", o.weightKey) ??
|
|
231
|
+
(weightOrigin !== null && weightOrigin.format === "json" && weightOrigin.id !== null
|
|
232
|
+
? weightOrigin.id
|
|
233
|
+
: "weight");
|
|
234
|
+
return { common, dialect, indent, edgesKey, nodeIdKey, indexLinks, sourceKey, targetKey, weightKey, shape };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Check a key-valued option.
|
|
239
|
+
* @param name - the option name
|
|
240
|
+
* @param value - the caller's value
|
|
241
|
+
* @returns the key, or null when absent
|
|
242
|
+
*/
|
|
243
|
+
function keyOption(name: string, value: unknown): string | null {
|
|
244
|
+
if (value === undefined) {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
248
|
+
throw unsupported(name, value, ["a non-empty key"]);
|
|
249
|
+
}
|
|
250
|
+
return value;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The E_UNSUPPORTED error of a bad option.
|
|
255
|
+
* @param name - the option name
|
|
256
|
+
* @param found - the value
|
|
257
|
+
* @param supported - what is accepted
|
|
258
|
+
* @returns the error
|
|
259
|
+
*/
|
|
260
|
+
function unsupported(name: string, found: unknown, supported: readonly string[]): GraphFormatError {
|
|
261
|
+
const shown = typeof found === "string" ? JSON.stringify(found) : String(found);
|
|
262
|
+
return new GraphFormatError("E_UNSUPPORTED", `option ${name}: ${shown} is not one of ${supported.join(", ")}`, {
|
|
263
|
+
option: name,
|
|
264
|
+
found: typeof found === "string" || typeof found === "number" ? found : typeof found,
|
|
265
|
+
supported: [...supported],
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ============================================================ value formatting
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The JSON text of a finite or non-finite number; non-finite values become null and are counted.
|
|
273
|
+
* @param value - the number
|
|
274
|
+
* @param f32 - whether the value came from an f32 store (shortest fround-round-trip text)
|
|
275
|
+
* @param nonfinite - the counter of null-ed values
|
|
276
|
+
* @returns the JSON text
|
|
277
|
+
*/
|
|
278
|
+
function numberText(value: number, f32: boolean, nonfinite: Counter): string {
|
|
279
|
+
if (!Number.isFinite(value)) {
|
|
280
|
+
nonfinite.count++;
|
|
281
|
+
return "null";
|
|
282
|
+
}
|
|
283
|
+
if (f32) {
|
|
284
|
+
return formatF32(value);
|
|
285
|
+
}
|
|
286
|
+
return Object.is(value, -0) ? "0" : String(value);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The JSON text of any cell value: a number per its dtype, a boolean, a string, an array (a
|
|
291
|
+
* multi-component subarray or a list row), or a json value.
|
|
292
|
+
* @param value - the value
|
|
293
|
+
* @param f32 - whether numbers come from an f32 store
|
|
294
|
+
* @param nonfinite - the counter of null-ed values
|
|
295
|
+
* @returns the JSON text
|
|
296
|
+
*/
|
|
297
|
+
function valueText(value: unknown, f32: boolean, nonfinite: Counter): string {
|
|
298
|
+
switch (typeof value) {
|
|
299
|
+
case "number":
|
|
300
|
+
return numberText(value, f32, nonfinite);
|
|
301
|
+
case "boolean":
|
|
302
|
+
return value ? "true" : "false";
|
|
303
|
+
case "string":
|
|
304
|
+
return JSON.stringify(value);
|
|
305
|
+
case "undefined":
|
|
306
|
+
return "null";
|
|
307
|
+
default:
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
if (value === null) {
|
|
311
|
+
return "null";
|
|
312
|
+
}
|
|
313
|
+
if (Array.isArray(value) || ArrayBuffer.isView(value)) {
|
|
314
|
+
const items = Array.from(value as ArrayLike<unknown>, (item) => valueText(item, f32, nonfinite));
|
|
315
|
+
return `[${items.join(",")}]`;
|
|
316
|
+
}
|
|
317
|
+
const text = JSON.stringify(value);
|
|
318
|
+
return text === undefined ? "null" : text;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The JSON text of one set cell.
|
|
323
|
+
* @param column - the column
|
|
324
|
+
* @param row - the row
|
|
325
|
+
* @param ids - the snapshot's id map (for refersTo node columns)
|
|
326
|
+
* @param nonfinite - the counter of null-ed values
|
|
327
|
+
* @returns the JSON text
|
|
328
|
+
*/
|
|
329
|
+
function cellText(column: Column, row: number, ids: GraphSnapshot["ids"], nonfinite: Counter): string {
|
|
330
|
+
switch (column.dtype) {
|
|
331
|
+
case "list":
|
|
332
|
+
return valueText(column.sliceOf(row), column.child.dtype === "f32", nonfinite);
|
|
333
|
+
case "json":
|
|
334
|
+
return valueText(column.values[row], false, nonfinite);
|
|
335
|
+
case "u32":
|
|
336
|
+
if (column.meta.refersTo === "node" && column.meta.components === 1) {
|
|
337
|
+
const index = column.data[row];
|
|
338
|
+
return index === INVALID_INDEX ? "null" : JSON.stringify(ids.idOf(index));
|
|
339
|
+
}
|
|
340
|
+
return valueText(column.value(row), false, nonfinite);
|
|
341
|
+
case "f32":
|
|
342
|
+
return valueText(column.value(row), true, nonfinite);
|
|
343
|
+
default:
|
|
344
|
+
return valueText(column.value(row), false, nonfinite);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* How many cells of a column hold a non-finite number (f32 / f64 columns, their components and
|
|
350
|
+
* lists of them).
|
|
351
|
+
* @param column - the column
|
|
352
|
+
* @returns the count
|
|
353
|
+
*/
|
|
354
|
+
function countNonFinite(column: Column): number {
|
|
355
|
+
let data: ArrayLike<number>;
|
|
356
|
+
switch (column.dtype) {
|
|
357
|
+
case "f32":
|
|
358
|
+
case "f64":
|
|
359
|
+
({ data } = column);
|
|
360
|
+
break;
|
|
361
|
+
case "list":
|
|
362
|
+
if (column.child.dtype !== "f32" && column.child.dtype !== "f64") {
|
|
363
|
+
return 0;
|
|
364
|
+
}
|
|
365
|
+
({ data } = column.child);
|
|
366
|
+
break;
|
|
367
|
+
default:
|
|
368
|
+
return 0;
|
|
369
|
+
}
|
|
370
|
+
let count = 0;
|
|
371
|
+
for (let i = 0; i < data.length; i++) {
|
|
372
|
+
if (!Number.isFinite(data[i])) {
|
|
373
|
+
count++;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return count;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ============================================================ the writer
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Emits JSON tokens with optional indentation, accumulating text parts the generator yields one
|
|
383
|
+
* element at a time.
|
|
384
|
+
*/
|
|
385
|
+
class JsonWriter {
|
|
386
|
+
private parts: string[] = [];
|
|
387
|
+
|
|
388
|
+
private depth = 0;
|
|
389
|
+
|
|
390
|
+
private readonly firstAtDepth: boolean[] = [];
|
|
391
|
+
|
|
392
|
+
private readonly indent: number;
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Create a writer.
|
|
396
|
+
* @param indent - spaces per level; 0 for compact output
|
|
397
|
+
*/
|
|
398
|
+
constructor(indent: number) {
|
|
399
|
+
this.indent = indent;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Open an object or an array.
|
|
404
|
+
* @param bracket - "{" or "["
|
|
405
|
+
*/
|
|
406
|
+
open(bracket: "{" | "["): void {
|
|
407
|
+
this.parts.push(bracket);
|
|
408
|
+
this.depth++;
|
|
409
|
+
this.firstAtDepth.push(true);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Close the innermost object or array.
|
|
414
|
+
* @param bracket - "}" or "]"
|
|
415
|
+
*/
|
|
416
|
+
close(bracket: "}" | "]"): void {
|
|
417
|
+
const wasEmpty = this.firstAtDepth.pop() ?? true;
|
|
418
|
+
this.depth--;
|
|
419
|
+
if (!wasEmpty) {
|
|
420
|
+
this.parts.push(this.newline());
|
|
421
|
+
}
|
|
422
|
+
this.parts.push(bracket);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Start an object member: the separator and the quoted key.
|
|
427
|
+
* @param name - the key
|
|
428
|
+
*/
|
|
429
|
+
key(name: string): void {
|
|
430
|
+
this.separator();
|
|
431
|
+
this.parts.push(JSON.stringify(name), this.indent > 0 ? ": " : ":");
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Start an array element: the separator only. */
|
|
435
|
+
item(): void {
|
|
436
|
+
this.separator();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Append raw JSON text (a value).
|
|
441
|
+
* @param text - the text
|
|
442
|
+
*/
|
|
443
|
+
raw(text: string): void {
|
|
444
|
+
this.parts.push(text);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Write a member with a value in one call.
|
|
449
|
+
* @param name - the key
|
|
450
|
+
* @param text - the value's JSON text
|
|
451
|
+
*/
|
|
452
|
+
member(name: string, text: string): void {
|
|
453
|
+
this.key(name);
|
|
454
|
+
this.parts.push(text);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Hand out the text accumulated since the last take.
|
|
459
|
+
* @returns the text
|
|
460
|
+
*/
|
|
461
|
+
take(): string {
|
|
462
|
+
const text = this.parts.length === 1 ? this.parts[0] : this.parts.join("");
|
|
463
|
+
this.parts = [];
|
|
464
|
+
return text;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** The comma and newline before a member or element. */
|
|
468
|
+
private separator(): void {
|
|
469
|
+
const top = this.firstAtDepth.length - 1;
|
|
470
|
+
if (this.firstAtDepth[top]) {
|
|
471
|
+
this.firstAtDepth[top] = false;
|
|
472
|
+
} else {
|
|
473
|
+
this.parts.push(",");
|
|
474
|
+
}
|
|
475
|
+
this.parts.push(this.newline());
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* A newline plus the current indentation, or nothing in compact mode.
|
|
480
|
+
* @returns the text
|
|
481
|
+
*/
|
|
482
|
+
private newline(): string {
|
|
483
|
+
return this.indent > 0 ? `\n${" ".repeat(this.depth * this.indent)}` : "";
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ============================================================ planning
|
|
488
|
+
|
|
489
|
+
/** What plan() and its helpers share. */
|
|
490
|
+
interface PlanContext {
|
|
491
|
+
readonly snapshot: GraphSnapshot;
|
|
492
|
+
readonly resolved: Resolved;
|
|
493
|
+
readonly dialect: JsonDialect;
|
|
494
|
+
readonly caps: ExportCapabilities;
|
|
495
|
+
readonly notes: LossNote[];
|
|
496
|
+
readonly note: NoteFn;
|
|
497
|
+
/** The reserved output keys per table. */
|
|
498
|
+
readonly reserved: Readonly<Record<Domain, ReadonlySet<string>>>;
|
|
499
|
+
readonly nonfinite: Counter;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Build the column plan and the notes for one dialect.
|
|
504
|
+
* @param snapshot - the snapshot
|
|
505
|
+
* @param resolved - the resolved options
|
|
506
|
+
* @returns the plan
|
|
507
|
+
*/
|
|
508
|
+
function plan(snapshot: GraphSnapshot, resolved: Resolved): Plan {
|
|
509
|
+
const { dialect } = resolved;
|
|
510
|
+
const caps = dialectCapabilities(dialect);
|
|
511
|
+
// the generic position and edge-id notes are replaced by planTable()'s (both are written as
|
|
512
|
+
// plain attributes and read back without their role)
|
|
513
|
+
const notes = checkCapabilities(snapshot, caps, resolved.common, {
|
|
514
|
+
roles: SLOT_ROLES[dialect],
|
|
515
|
+
positionDtype: "f32",
|
|
516
|
+
}).filter((n) => n.code !== LOSS.POSITIONS && n.code !== LOSS.EDGE_IDS_DROPPED);
|
|
517
|
+
const note: NoteFn = (code, message, column = null, count = null): void => {
|
|
518
|
+
notes.push(Object.freeze({ code, message, column, count }));
|
|
519
|
+
};
|
|
520
|
+
const ctx: PlanContext = {
|
|
521
|
+
snapshot,
|
|
522
|
+
resolved,
|
|
523
|
+
dialect,
|
|
524
|
+
caps,
|
|
525
|
+
notes,
|
|
526
|
+
note,
|
|
527
|
+
reserved: reservedKeys(snapshot, resolved),
|
|
528
|
+
nonfinite: { count: 0 },
|
|
529
|
+
};
|
|
530
|
+
const nodes = planTable(ctx, snapshot.nodes, "node");
|
|
531
|
+
const edges = planTable(ctx, snapshot.edges, "edge");
|
|
532
|
+
const graph = caps.graphAttributes ? planTable(ctx, snapshot.graph, "graph") : [];
|
|
533
|
+
withdrawSlotNotes(notes, [...nodes, ...edges]);
|
|
534
|
+
const weights = planWeights(ctx, edges);
|
|
535
|
+
if (ctx.nonfinite.count > 0) {
|
|
536
|
+
note(
|
|
537
|
+
JSON_LOSS.NONFINITE_AS_NULL,
|
|
538
|
+
`${ctx.nonfinite.count} non-finite number(s) are written as null`,
|
|
539
|
+
null,
|
|
540
|
+
ctx.nonfinite.count,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
const { folding, directed } = planDirection(ctx);
|
|
544
|
+
const multigraph = snapshot.flags.multigraph || snapshot.meta.declaredMultigraph === true;
|
|
545
|
+
if (dialect === "jgf") {
|
|
546
|
+
jgfIdNotes(snapshot, note);
|
|
547
|
+
}
|
|
548
|
+
const edgeIds = snapshot.edges.byRole("id");
|
|
549
|
+
if (dialect === "cytoscape" && edgeIds !== null && edgeIds.nullCount > 0) {
|
|
550
|
+
note(
|
|
551
|
+
LOSS.EDGE_IDS_GENERATED,
|
|
552
|
+
`${edgeIds.nullCount} edge(s) have no id; canonical e<index> ids are generated for them`,
|
|
553
|
+
edgeIds.meta.name,
|
|
554
|
+
edgeIds.nullCount,
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
return { resolved, notes, nodes, edges, graph, edgeIds, weights, folding, directed, multigraph };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Withdraw the generic list note of a Cytoscape classes column: the slot writes the list as the
|
|
562
|
+
* `classes` string and the importer reads it back as the list it was.
|
|
563
|
+
* @param notes - the notes so far
|
|
564
|
+
* @param plans - the node and edge plans
|
|
565
|
+
*/
|
|
566
|
+
function withdrawSlotNotes(notes: LossNote[], plans: readonly ColumnPlan[]): void {
|
|
567
|
+
for (const plan of plans) {
|
|
568
|
+
if (plan.slot !== "classes") {
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
const at = notes.findIndex((n) => n.code === LOSS.LIST && n.column === plan.column.meta.name);
|
|
572
|
+
if (at >= 0) {
|
|
573
|
+
notes.splice(at, 1);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* The output keys each dialect reserves for its structure (a column of that name cannot be
|
|
580
|
+
* written as an attribute): the id and endpoint keys, and the weight key of a weighted snapshot.
|
|
581
|
+
* @param snapshot - the snapshot
|
|
582
|
+
* @param resolved - the resolved options
|
|
583
|
+
* @returns the reserved keys per table
|
|
584
|
+
*/
|
|
585
|
+
function reservedKeys(snapshot: GraphSnapshot, resolved: Resolved): Readonly<Record<Domain, ReadonlySet<string>>> {
|
|
586
|
+
const { dialect } = resolved;
|
|
587
|
+
const node = new Set<string>();
|
|
588
|
+
const edge = new Set<string>(snapshot.flags.weighted ? [resolved.weightKey] : []);
|
|
589
|
+
const graph = new Set<string>();
|
|
590
|
+
switch (dialect) {
|
|
591
|
+
case "node-link":
|
|
592
|
+
case "d3":
|
|
593
|
+
if (resolved.nodeIdKey !== null) {
|
|
594
|
+
node.add(resolved.nodeIdKey);
|
|
595
|
+
}
|
|
596
|
+
edge.add(resolved.sourceKey).add(resolved.targetKey);
|
|
597
|
+
break;
|
|
598
|
+
case "vis":
|
|
599
|
+
node.add(resolved.nodeIdKey ?? "id");
|
|
600
|
+
edge.add(resolved.sourceKey).add(resolved.targetKey);
|
|
601
|
+
break;
|
|
602
|
+
case "cytoscape":
|
|
603
|
+
node.add("id").add("parent");
|
|
604
|
+
edge.add("id").add("source").add("target");
|
|
605
|
+
break;
|
|
606
|
+
case "jgf":
|
|
607
|
+
case "graphology":
|
|
608
|
+
break;
|
|
609
|
+
default: {
|
|
610
|
+
const name: string = dialect;
|
|
611
|
+
throw new GraphFormatError("E_UNSUPPORTED", `unknown dialect ${name}`, { option: "dialect", found: name });
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return { node, edge, graph };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Classify the columns of one table: the slot each is written into and what it loses on the way
|
|
619
|
+
* (JSON declares no types, so the notes name what the importer's inference reads back).
|
|
620
|
+
* @param ctx - the plan context
|
|
621
|
+
* @param table - the table
|
|
622
|
+
* @param domain - its domain
|
|
623
|
+
* @returns the plans of the written columns, in declaration order
|
|
624
|
+
*/
|
|
625
|
+
function planTable(ctx: PlanContext, table: Iterable<Column>, domain: Domain): ColumnPlan[] {
|
|
626
|
+
const { dialect, note } = ctx;
|
|
627
|
+
const plans: ColumnPlan[] = [];
|
|
628
|
+
const reserved = ctx.reserved[domain];
|
|
629
|
+
const used = new Set<string>();
|
|
630
|
+
for (const column of table) {
|
|
631
|
+
const { meta } = column;
|
|
632
|
+
const { role } = meta;
|
|
633
|
+
const setRows = column.length - column.nullCount;
|
|
634
|
+
if (role !== null && (STRUCTURAL_ROLES.has(role) || TEMPORAL_ROLES.has(role))) {
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
if (domain !== "graph") {
|
|
638
|
+
ctx.nonfinite.count += countNonFinite(column);
|
|
639
|
+
}
|
|
640
|
+
const slotted = planSlot(ctx, column, domain);
|
|
641
|
+
if (slotted !== null) {
|
|
642
|
+
if (slotted !== SKIPPED) {
|
|
643
|
+
plans.push(slotted);
|
|
644
|
+
}
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
let key = meta.name;
|
|
648
|
+
let slot: Slot = "attribute";
|
|
649
|
+
if (NESTED_DIALECTS.has(dialect) && domain !== "graph") {
|
|
650
|
+
if (key.endsWith(SUFFIX.element)) {
|
|
651
|
+
key = key.slice(0, -SUFFIX.element.length);
|
|
652
|
+
slot = "element";
|
|
653
|
+
} else if (dialect === "cytoscape" && CYTOSCAPE_ELEMENT_KEYS.has(key)) {
|
|
654
|
+
slot = "element";
|
|
655
|
+
} else if (key.endsWith(SUFFIX.data)) {
|
|
656
|
+
key = key.slice(0, -SUFFIX.data.length);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
if (slot === "attribute" && reserved.has(key)) {
|
|
660
|
+
note(
|
|
661
|
+
JSON_LOSS.RESERVED_KEY,
|
|
662
|
+
`${domain} column "${meta.name}" cannot be written: "${key}" is a reserved key of ${dialect}`,
|
|
663
|
+
meta.name,
|
|
664
|
+
setRows,
|
|
665
|
+
);
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
const slotKey = `${slot}:${key}`;
|
|
669
|
+
if (used.has(slotKey)) {
|
|
670
|
+
note(
|
|
671
|
+
JSON_LOSS.RESERVED_KEY,
|
|
672
|
+
`${domain} column "${meta.name}" cannot be written: key "${key}" is already used by another column`,
|
|
673
|
+
meta.name,
|
|
674
|
+
setRows,
|
|
675
|
+
);
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
used.add(slotKey);
|
|
679
|
+
if (setRows === 0 && domain !== "graph") {
|
|
680
|
+
note(
|
|
681
|
+
LOSS.EMPTY_COLUMN,
|
|
682
|
+
`${domain} column "${meta.name}" has no set cell and is not written: JSON writes values, never declarations`,
|
|
683
|
+
meta.name,
|
|
684
|
+
0,
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
inferenceNotes(ctx, column, domain, setRows);
|
|
688
|
+
plans.push({ column, key, slot });
|
|
689
|
+
}
|
|
690
|
+
return plans;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** planSlot()'s answer for a role column handled (or dropped) without a plan of its own. */
|
|
694
|
+
const SKIPPED = Symbol("skipped");
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* The structural slot of a role column in the dialect, when it has one: Cytoscape's parent,
|
|
698
|
+
* position and classes, JGF's label and relation; the id-role edge column is written
|
|
699
|
+
* structurally (data.id / key / id) where the dialect has an edge id and as a plain attribute
|
|
700
|
+
* elsewhere; a position column without a slot is a plain array attribute.
|
|
701
|
+
* @param ctx - the plan context
|
|
702
|
+
* @param column - the column
|
|
703
|
+
* @param domain - its domain
|
|
704
|
+
* @returns the plan, SKIPPED for a column written structurally or dropped, null for a plain attribute
|
|
705
|
+
*/
|
|
706
|
+
function planSlot(ctx: PlanContext, column: Column, domain: Domain): ColumnPlan | typeof SKIPPED | null {
|
|
707
|
+
const { dialect, note } = ctx;
|
|
708
|
+
const { meta } = column;
|
|
709
|
+
const { role } = meta;
|
|
710
|
+
const setRows = column.length - column.nullCount;
|
|
711
|
+
if (role === "parent" || role === "parents") {
|
|
712
|
+
if (dialect === "cytoscape" && domain === "node") {
|
|
713
|
+
if (role === "parent" && column.dtype === "u32" && meta.refersTo === "node") {
|
|
714
|
+
return { column, key: "parent", slot: "parent" };
|
|
715
|
+
}
|
|
716
|
+
note(
|
|
717
|
+
LOSS.PARENTS,
|
|
718
|
+
`node column "${meta.name}" (${role}) cannot be written: Cytoscape has a single parent`,
|
|
719
|
+
meta.name,
|
|
720
|
+
setRows,
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
return SKIPPED;
|
|
724
|
+
}
|
|
725
|
+
if (role === "position" && domain === "node") {
|
|
726
|
+
if (dialect === "cytoscape" && (column.dtype === "f32" || column.dtype === "f64") && meta.components >= 2) {
|
|
727
|
+
const z = countNonZeroZ(column);
|
|
728
|
+
if (z > 0) {
|
|
729
|
+
note(
|
|
730
|
+
JSON_LOSS.POSITION_Z_DROPPED,
|
|
731
|
+
`${z} position(s) have a non-zero z; Cytoscape positions are 2D`,
|
|
732
|
+
meta.name,
|
|
733
|
+
z,
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
return { column, key: "position", slot: "cytoscapePosition" };
|
|
737
|
+
}
|
|
738
|
+
note(
|
|
739
|
+
LOSS.POSITIONS,
|
|
740
|
+
`node column "${meta.name}" (position) is written as a plain array attribute; its role is lost on re-import`,
|
|
741
|
+
meta.name,
|
|
742
|
+
setRows,
|
|
743
|
+
);
|
|
744
|
+
return { column, key: meta.name, slot: "position" };
|
|
745
|
+
}
|
|
746
|
+
if (role === "id" && domain === "edge") {
|
|
747
|
+
if (dialect === "node-link" || dialect === "d3") {
|
|
748
|
+
note(
|
|
749
|
+
LOSS.EDGE_IDS_DROPPED,
|
|
750
|
+
`edge column "${meta.name}" (id) is written as a plain attribute; ${dialect} has no edge id`,
|
|
751
|
+
meta.name,
|
|
752
|
+
setRows,
|
|
753
|
+
);
|
|
754
|
+
return null;
|
|
755
|
+
}
|
|
756
|
+
return SKIPPED;
|
|
757
|
+
}
|
|
758
|
+
if (dialect === "cytoscape" && role === "classes" && column.dtype === "list" && column.child.dtype === "string") {
|
|
759
|
+
return { column, key: "classes", slot: "classes" };
|
|
760
|
+
}
|
|
761
|
+
if (dialect === "jgf" && role === "label" && (column.dtype === "string" || column.dtype === "dict")) {
|
|
762
|
+
return { column, key: "label", slot: "label" };
|
|
763
|
+
}
|
|
764
|
+
if (
|
|
765
|
+
dialect === "jgf" &&
|
|
766
|
+
role === "kind" &&
|
|
767
|
+
domain === "edge" &&
|
|
768
|
+
(column.dtype === "string" || column.dtype === "dict")
|
|
769
|
+
) {
|
|
770
|
+
return { column, key: "relation", slot: "relation" };
|
|
771
|
+
}
|
|
772
|
+
return null;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* What the importer's inference (design section 5.1: JSON numbers are i32 when integral, else
|
|
777
|
+
* f64; strings are strings; arrays and objects are json) changes about a written attribute
|
|
778
|
+
* column beyond what checkCapabilities() already reported for its dtype: an f64 column whose set
|
|
779
|
+
* values are all integers reads back as i32.
|
|
780
|
+
* @param ctx - the plan context
|
|
781
|
+
* @param column - the column
|
|
782
|
+
* @param domain - its domain
|
|
783
|
+
* @param setRows - the set rows
|
|
784
|
+
*/
|
|
785
|
+
function inferenceNotes(ctx: PlanContext, column: Column, domain: Domain, setRows: number): void {
|
|
786
|
+
if (column.dtype !== "f64" || column.meta.components > 1 || setRows === 0) {
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
const { data } = column;
|
|
790
|
+
for (let r = 0; r < column.length; r++) {
|
|
791
|
+
if (column.isSet(r) && !Number.isInteger(data[r])) {
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
ctx.note(
|
|
796
|
+
LOSS.INTEGRAL_F64,
|
|
797
|
+
`${domain} column "${column.meta.name}" is f64 with integral values only; JSON declares no types and it reads back as i32`,
|
|
798
|
+
column.meta.name,
|
|
799
|
+
setRows,
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* The explicit weights and their JSON text (non-finite values become null and are counted).
|
|
805
|
+
* @param ctx - the plan context
|
|
806
|
+
* @param edges - the edge column plans (for the weight-key clash note)
|
|
807
|
+
* @returns the weight text function
|
|
808
|
+
*/
|
|
809
|
+
function planWeights(ctx: PlanContext, edges: readonly ColumnPlan[]): (e: number) => string | null {
|
|
810
|
+
const { snapshot, nonfinite } = ctx;
|
|
811
|
+
const weights = explicitWeights(snapshot);
|
|
812
|
+
if (!weights.weighted) {
|
|
813
|
+
const clash = edges.find((p) => p.slot === "attribute" && p.key === ctx.resolved.weightKey);
|
|
814
|
+
if (clash !== undefined) {
|
|
815
|
+
ctx.note(
|
|
816
|
+
LOSS.WEIGHT_KEY_CLASH,
|
|
817
|
+
`edge column "${clash.column.meta.name}" is written under "${clash.key}", the key the importer reads THE weight from; it reads back as the weight, not as a column`,
|
|
818
|
+
clash.column.meta.name,
|
|
819
|
+
clash.column.length - clash.column.nullCount,
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
return (): null => null;
|
|
823
|
+
}
|
|
824
|
+
for (let e = 0; e < snapshot.edgeCount; e++) {
|
|
825
|
+
if (weights.isExplicit(e) && !Number.isFinite(weights.value(e))) {
|
|
826
|
+
nonfinite.count++;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
return (e: number): string | null => {
|
|
830
|
+
if (!weights.isExplicit(e)) {
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
return Number.isFinite(weights.value(e)) ? weights.text(e) : "null";
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* The direction decisions: the pair folding (mutual pairs are written as two directed edges and
|
|
839
|
+
* reported), the file-level direction under the onMixedDirection policy of a dialect without a
|
|
840
|
+
* per-edge flag, and the note of the dialects that carry no direction at all.
|
|
841
|
+
* @param ctx - the plan context
|
|
842
|
+
* @returns the folding and the file-level direction
|
|
843
|
+
*/
|
|
844
|
+
function planDirection(ctx: PlanContext): { readonly folding: PairFolding; readonly directed: boolean } {
|
|
845
|
+
const { snapshot, dialect, caps, note } = ctx;
|
|
846
|
+
const folding = pairFolding(snapshot);
|
|
847
|
+
if (folding.mutualCount > 0) {
|
|
848
|
+
note(
|
|
849
|
+
LOSS.MUTUAL_EXPANDED,
|
|
850
|
+
`${folding.mutualCount} mutual pair(s) are written as two directed edges; the mutual mark is lost`,
|
|
851
|
+
null,
|
|
852
|
+
folding.mutualCount,
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
let { directed } = snapshot;
|
|
856
|
+
if (
|
|
857
|
+
!caps.mixedDirection &&
|
|
858
|
+
countMixedEdges(snapshot) > 0 &&
|
|
859
|
+
ctx.resolved.common.onMixedDirection === "undirected"
|
|
860
|
+
) {
|
|
861
|
+
directed = false;
|
|
862
|
+
}
|
|
863
|
+
if (dialect === "cytoscape" || dialect === "vis" || dialect === "d3") {
|
|
864
|
+
const assumed = DIALECT_DEFAULT_DIRECTED[dialect];
|
|
865
|
+
if (directed !== assumed) {
|
|
866
|
+
note(
|
|
867
|
+
JSON_LOSS.DIRECTION_DROPPED,
|
|
868
|
+
`${dialect} has no direction flag; the ${directed ? "directed" : "undirected"} graph re-imports as ${assumed ? "directed" : "undirected"} unless defaultDirected is passed`,
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return { folding, directed };
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* How many rows of a position column have a non-zero third component.
|
|
877
|
+
* @param column - an f32 / f64 column with at least two components
|
|
878
|
+
* @returns the count
|
|
879
|
+
*/
|
|
880
|
+
function countNonZeroZ(column: Column): number {
|
|
881
|
+
if ((column.dtype !== "f32" && column.dtype !== "f64") || column.meta.components < 3) {
|
|
882
|
+
return 0;
|
|
883
|
+
}
|
|
884
|
+
const { components } = column.meta;
|
|
885
|
+
let count = 0;
|
|
886
|
+
for (let r = 0; r < column.length; r++) {
|
|
887
|
+
if (column.isSet(r) && column.data[r * components + 2] !== 0) {
|
|
888
|
+
count++;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
return count;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* The JGF id notes: numeric ids become string keys, colliding texts are refused, and integer-like
|
|
896
|
+
* keys are enumerated first and ascending by every JSON parser so the node order may change.
|
|
897
|
+
* @param snapshot - the snapshot
|
|
898
|
+
* @param note - the recorder
|
|
899
|
+
*/
|
|
900
|
+
function jgfIdNotes(
|
|
901
|
+
snapshot: GraphSnapshot,
|
|
902
|
+
note: (code: string, message: string, column?: string | null, count?: number | null) => void,
|
|
903
|
+
): void {
|
|
904
|
+
const { ids } = snapshot;
|
|
905
|
+
const seen = new Set<string>();
|
|
906
|
+
let numeric = 0;
|
|
907
|
+
let collisions = 0;
|
|
908
|
+
let indexLike = 0;
|
|
909
|
+
let lastIndex = -1;
|
|
910
|
+
let ordered = true;
|
|
911
|
+
let sawOther = false;
|
|
912
|
+
for (let i = 0; i < ids.size; i++) {
|
|
913
|
+
const id = ids.idOf(i);
|
|
914
|
+
const text = String(id);
|
|
915
|
+
if (typeof id === "number") {
|
|
916
|
+
numeric++;
|
|
917
|
+
}
|
|
918
|
+
if (seen.has(text)) {
|
|
919
|
+
collisions++;
|
|
920
|
+
}
|
|
921
|
+
seen.add(text);
|
|
922
|
+
if (isArrayIndexKey(text)) {
|
|
923
|
+
indexLike++;
|
|
924
|
+
const n = Number(text);
|
|
925
|
+
if (sawOther || n <= lastIndex) {
|
|
926
|
+
ordered = false;
|
|
927
|
+
}
|
|
928
|
+
lastIndex = n;
|
|
929
|
+
} else {
|
|
930
|
+
sawOther = true;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
if (numeric > 0) {
|
|
934
|
+
note(
|
|
935
|
+
JSON_LOSS.NUMERIC_IDS_STRINGIFIED,
|
|
936
|
+
`${numeric} numeric node id(s) become JGF object keys (strings); pass ids: "canonical" on re-import`,
|
|
937
|
+
null,
|
|
938
|
+
numeric,
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
if (collisions > 0) {
|
|
942
|
+
note(
|
|
943
|
+
JSON_LOSS.ID_TEXT_COLLISION,
|
|
944
|
+
`${collisions} node id(s) share their text with another id; export() will throw`,
|
|
945
|
+
null,
|
|
946
|
+
collisions,
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
if (!ordered) {
|
|
950
|
+
note(
|
|
951
|
+
JSON_LOSS.NODE_ORDER,
|
|
952
|
+
`${indexLike} integer-like node id(s) are enumerated first and ascending by JSON parsers; node order changes on re-import`,
|
|
953
|
+
null,
|
|
954
|
+
indexLike,
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Whether a key is an array index in the JS sense (enumerated before other keys, ascending).
|
|
961
|
+
* @param text - the key
|
|
962
|
+
* @returns true for canonical integer text below 2^32 - 1
|
|
963
|
+
*/
|
|
964
|
+
function isArrayIndexKey(text: string): boolean {
|
|
965
|
+
return /^(0|[1-9][0-9]*)$/.test(text) && Number(text) < 4294967295;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// ============================================================ writing
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* The text parts of the document.
|
|
972
|
+
* @param snapshot - the snapshot
|
|
973
|
+
* @param p - the plan
|
|
974
|
+
* @yields one part per header, node, edge and footer
|
|
975
|
+
* @returns nothing
|
|
976
|
+
*/
|
|
977
|
+
function* write(snapshot: GraphSnapshot, p: Plan): Generator<string, void, undefined> {
|
|
978
|
+
const { dialect } = p.resolved;
|
|
979
|
+
if (p.notes.some((n) => n.code === LOSS.MIXED_DIRECTION_ERROR)) {
|
|
980
|
+
throw new GraphFormatError(
|
|
981
|
+
"E_DIRECTED",
|
|
982
|
+
`${dialect} has no mixed direction; pass onMixedDirection "directed" or "undirected"`,
|
|
983
|
+
{ reason: "mixed direction", dialect },
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
if (p.notes.some((n) => n.code === JSON_LOSS.ID_TEXT_COLLISION)) {
|
|
987
|
+
throw new GraphFormatError("E_INVALID_ID", "two node ids have the same text; JGF keys nodes by text", {
|
|
988
|
+
reason: "collision",
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
switch (dialect) {
|
|
992
|
+
case "node-link":
|
|
993
|
+
case "d3":
|
|
994
|
+
yield* writeNodeLink(snapshot, p);
|
|
995
|
+
return;
|
|
996
|
+
case "vis":
|
|
997
|
+
yield* writeVis(snapshot, p);
|
|
998
|
+
return;
|
|
999
|
+
case "graphology":
|
|
1000
|
+
yield* writeGraphology(snapshot, p);
|
|
1001
|
+
return;
|
|
1002
|
+
case "jgf":
|
|
1003
|
+
yield* writeJgf(snapshot, p);
|
|
1004
|
+
return;
|
|
1005
|
+
case "cytoscape":
|
|
1006
|
+
yield* writeCytoscape(snapshot, p);
|
|
1007
|
+
return;
|
|
1008
|
+
default: {
|
|
1009
|
+
const name: string = dialect;
|
|
1010
|
+
throw new GraphFormatError("E_UNSUPPORTED", `unknown dialect ${name}`, { option: "dialect", found: name });
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** The shared per-row state of the writers. */
|
|
1016
|
+
interface Cursor {
|
|
1017
|
+
readonly w: JsonWriter;
|
|
1018
|
+
readonly ids: GraphSnapshot["ids"];
|
|
1019
|
+
readonly nonfinite: Counter;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Write the members of one row's planned columns into the current object, attributes only.
|
|
1024
|
+
* @param c - the cursor
|
|
1025
|
+
* @param plans - the table plan
|
|
1026
|
+
* @param row - the row
|
|
1027
|
+
* @param slots - which slots to write
|
|
1028
|
+
*/
|
|
1029
|
+
function writeSlots(c: Cursor, plans: readonly ColumnPlan[], row: number, slots: ReadonlySet<Slot>): void {
|
|
1030
|
+
for (const item of plans) {
|
|
1031
|
+
if (!slots.has(item.slot) || !item.column.isSet(row)) {
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
let text: string;
|
|
1035
|
+
switch (item.slot) {
|
|
1036
|
+
case "position": {
|
|
1037
|
+
const value = item.column.value(row);
|
|
1038
|
+
const dims = dimsOf(item.column);
|
|
1039
|
+
const values = Array.from(value as ArrayLike<number>).slice(0, dims);
|
|
1040
|
+
text = valueText(values, item.column.dtype === "f32", c.nonfinite);
|
|
1041
|
+
break;
|
|
1042
|
+
}
|
|
1043
|
+
case "classes":
|
|
1044
|
+
text = JSON.stringify((item.column as Extract<Column, { dtype: "list" }>).sliceOf(row).join(" "));
|
|
1045
|
+
break;
|
|
1046
|
+
default:
|
|
1047
|
+
text = cellText(item.column, row, c.ids, c.nonfinite);
|
|
1048
|
+
break;
|
|
1049
|
+
}
|
|
1050
|
+
c.w.member(item.key, text);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const ATTRIBUTE_SLOTS: ReadonlySet<Slot> = new Set(["attribute", "position"]);
|
|
1055
|
+
const ELEMENT_SLOTS: ReadonlySet<Slot> = new Set(["element", "classes"]);
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* The number of components a position column writes: `extra.sourceDims` when recorded, else all.
|
|
1059
|
+
* @param column - the position column
|
|
1060
|
+
* @returns 2 or 3 (or the component count)
|
|
1061
|
+
*/
|
|
1062
|
+
function dimsOf(column: Column): number {
|
|
1063
|
+
const dims: unknown = column.meta.extra.sourceDims;
|
|
1064
|
+
if (typeof dims === "number" && dims >= 1 && dims <= column.meta.components) {
|
|
1065
|
+
return dims;
|
|
1066
|
+
}
|
|
1067
|
+
return column.meta.components;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Whether any planned column of a slot set has a value in a row (to omit an empty nested dict).
|
|
1072
|
+
* @param plans - the table plan
|
|
1073
|
+
* @param row - the row
|
|
1074
|
+
* @param slots - the slots
|
|
1075
|
+
* @returns true when something would be written
|
|
1076
|
+
*/
|
|
1077
|
+
function anySet(plans: readonly ColumnPlan[], row: number, slots: ReadonlySet<Slot>): boolean {
|
|
1078
|
+
return plans.some((item) => slots.has(item.slot) && item.column.isSet(row));
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* The JSON text of a node id.
|
|
1083
|
+
* @param id - the id
|
|
1084
|
+
* @returns the text
|
|
1085
|
+
*/
|
|
1086
|
+
function idText(id: NodeId): string {
|
|
1087
|
+
return typeof id === "number" && Object.is(id, -0) ? "0" : JSON.stringify(id);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* The edge id text of an edge, or null when none is set.
|
|
1092
|
+
* @param p - the plan
|
|
1093
|
+
* @param e - the edge
|
|
1094
|
+
* @param c - the cursor
|
|
1095
|
+
* @returns the text, or null
|
|
1096
|
+
*/
|
|
1097
|
+
function edgeIdText(p: Plan, e: number, c: Cursor): string | null {
|
|
1098
|
+
const column = p.edgeIds;
|
|
1099
|
+
if (column === null || !column.isSet(e)) {
|
|
1100
|
+
return null;
|
|
1101
|
+
}
|
|
1102
|
+
return cellText(column, e, c.ids, c.nonfinite);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* Whether an edge is written, and as which kind, under the plan's direction policy.
|
|
1107
|
+
* @param p - the plan
|
|
1108
|
+
* @param e - the edge
|
|
1109
|
+
* @returns "skip" for a folded mirror, "undirected" for a pair primary, "directed" otherwise
|
|
1110
|
+
*/
|
|
1111
|
+
function edgeDisposition(p: Plan, e: number): "skip" | "undirected" | "directed" {
|
|
1112
|
+
const { folding } = p;
|
|
1113
|
+
if (folding.folded(e)) {
|
|
1114
|
+
return "skip";
|
|
1115
|
+
}
|
|
1116
|
+
if (!p.directed) {
|
|
1117
|
+
return "undirected";
|
|
1118
|
+
}
|
|
1119
|
+
if (folding.sourceDirected(e)) {
|
|
1120
|
+
return "directed";
|
|
1121
|
+
}
|
|
1122
|
+
// a source-undirected edge (pair primary or expanded self-loop): a dialect with a per-edge flag
|
|
1123
|
+
// keeps it; one without writes it per the policy (the pair folds to one directed edge)
|
|
1124
|
+
if (dialectCapabilities(p.resolved.dialect).mixedDirection) {
|
|
1125
|
+
return "undirected";
|
|
1126
|
+
}
|
|
1127
|
+
return p.resolved.common.onMixedDirection === "directed" ? "directed" : "undirected";
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* The graph-level attribute object.
|
|
1132
|
+
* @param c - the cursor
|
|
1133
|
+
* @param p - the plan
|
|
1134
|
+
*/
|
|
1135
|
+
function writeGraphObject(c: Cursor, p: Plan): void {
|
|
1136
|
+
c.w.open("{");
|
|
1137
|
+
for (const item of p.graph) {
|
|
1138
|
+
if (item.column.isSet(0)) {
|
|
1139
|
+
c.w.member(item.key, cellText(item.column, 0, c.ids, c.nonfinite));
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
c.w.close("}");
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* The endpoint texts of an edge: ids, or array positions under index links.
|
|
1147
|
+
* @param snapshot - the snapshot
|
|
1148
|
+
* @param p - the plan
|
|
1149
|
+
* @param e - the edge
|
|
1150
|
+
* @returns the source and target texts
|
|
1151
|
+
*/
|
|
1152
|
+
function endpoints(snapshot: GraphSnapshot, p: Plan, e: number): [string, string] {
|
|
1153
|
+
const list = snapshot.edgeList();
|
|
1154
|
+
const u = list.src[e];
|
|
1155
|
+
const v = list.dst[e];
|
|
1156
|
+
if (p.resolved.indexLinks) {
|
|
1157
|
+
return [String(u), String(v)];
|
|
1158
|
+
}
|
|
1159
|
+
return [idText(snapshot.ids.idOf(u)), idText(snapshot.ids.idOf(v))];
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* Write a node-link / d3 document.
|
|
1164
|
+
* @param snapshot - the snapshot
|
|
1165
|
+
* @param p - the plan
|
|
1166
|
+
* @yields the parts
|
|
1167
|
+
* @returns nothing
|
|
1168
|
+
*/
|
|
1169
|
+
function* writeNodeLink(snapshot: GraphSnapshot, p: Plan): Generator<string, void, undefined> {
|
|
1170
|
+
const { resolved } = p;
|
|
1171
|
+
const c: Cursor = { w: new JsonWriter(resolved.indent), ids: snapshot.ids, nonfinite: { count: 0 } };
|
|
1172
|
+
const { w } = c;
|
|
1173
|
+
w.open("{");
|
|
1174
|
+
if (resolved.dialect !== "d3") {
|
|
1175
|
+
// d3 is the bare shape: no direction, multigraph or graph keys (the importer sniffs it by their absence)
|
|
1176
|
+
w.member("directed", p.directed ? "true" : "false");
|
|
1177
|
+
w.member("multigraph", p.multigraph ? "true" : "false");
|
|
1178
|
+
w.key("graph");
|
|
1179
|
+
writeGraphObject(c, p);
|
|
1180
|
+
}
|
|
1181
|
+
w.key("nodes");
|
|
1182
|
+
w.open("[");
|
|
1183
|
+
yield w.take();
|
|
1184
|
+
for (let i = 0; i < snapshot.nodeCount; i++) {
|
|
1185
|
+
w.item();
|
|
1186
|
+
w.open("{");
|
|
1187
|
+
if (resolved.nodeIdKey !== null) {
|
|
1188
|
+
w.member(resolved.nodeIdKey, idText(snapshot.ids.idOf(i)));
|
|
1189
|
+
}
|
|
1190
|
+
writeSlots(c, p.nodes, i, ATTRIBUTE_SLOTS);
|
|
1191
|
+
w.close("}");
|
|
1192
|
+
yield w.take();
|
|
1193
|
+
}
|
|
1194
|
+
w.close("]");
|
|
1195
|
+
w.key(resolved.edgesKey);
|
|
1196
|
+
w.open("[");
|
|
1197
|
+
for (let e = 0; e < snapshot.edgeCount; e++) {
|
|
1198
|
+
const disposition = edgeDisposition(p, e);
|
|
1199
|
+
if (disposition === "skip") {
|
|
1200
|
+
continue;
|
|
1201
|
+
}
|
|
1202
|
+
const [s, t] = endpoints(snapshot, p, e);
|
|
1203
|
+
w.item();
|
|
1204
|
+
w.open("{");
|
|
1205
|
+
w.member(resolved.sourceKey, s);
|
|
1206
|
+
w.member(resolved.targetKey, t);
|
|
1207
|
+
const weight = p.weights(e);
|
|
1208
|
+
if (weight !== null) {
|
|
1209
|
+
w.member(resolved.weightKey, weight);
|
|
1210
|
+
}
|
|
1211
|
+
writeSlots(c, p.edges, e, ATTRIBUTE_SLOTS);
|
|
1212
|
+
w.close("}");
|
|
1213
|
+
yield w.take();
|
|
1214
|
+
}
|
|
1215
|
+
w.close("]");
|
|
1216
|
+
w.close("}");
|
|
1217
|
+
yield w.take();
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* Write a vis.js document.
|
|
1222
|
+
* @param snapshot - the snapshot
|
|
1223
|
+
* @param p - the plan
|
|
1224
|
+
* @yields the parts
|
|
1225
|
+
* @returns nothing
|
|
1226
|
+
*/
|
|
1227
|
+
function* writeVis(snapshot: GraphSnapshot, p: Plan): Generator<string, void, undefined> {
|
|
1228
|
+
const { resolved } = p;
|
|
1229
|
+
const c: Cursor = { w: new JsonWriter(resolved.indent), ids: snapshot.ids, nonfinite: { count: 0 } };
|
|
1230
|
+
const { w } = c;
|
|
1231
|
+
const nodeIdKey = resolved.nodeIdKey ?? "id";
|
|
1232
|
+
w.open("{");
|
|
1233
|
+
w.key("nodes");
|
|
1234
|
+
w.open("[");
|
|
1235
|
+
yield w.take();
|
|
1236
|
+
for (let i = 0; i < snapshot.nodeCount; i++) {
|
|
1237
|
+
w.item();
|
|
1238
|
+
w.open("{");
|
|
1239
|
+
w.member(nodeIdKey, idText(snapshot.ids.idOf(i)));
|
|
1240
|
+
writeSlots(c, p.nodes, i, ATTRIBUTE_SLOTS);
|
|
1241
|
+
w.close("}");
|
|
1242
|
+
yield w.take();
|
|
1243
|
+
}
|
|
1244
|
+
w.close("]");
|
|
1245
|
+
w.key("edges");
|
|
1246
|
+
w.open("[");
|
|
1247
|
+
for (let e = 0; e < snapshot.edgeCount; e++) {
|
|
1248
|
+
if (edgeDisposition(p, e) === "skip") {
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
const list = snapshot.edgeList();
|
|
1252
|
+
w.item();
|
|
1253
|
+
w.open("{");
|
|
1254
|
+
const id = edgeIdText(p, e, c);
|
|
1255
|
+
if (id !== null) {
|
|
1256
|
+
w.member("id", id);
|
|
1257
|
+
}
|
|
1258
|
+
w.member(resolved.sourceKey, idText(snapshot.ids.idOf(list.src[e])));
|
|
1259
|
+
w.member(resolved.targetKey, idText(snapshot.ids.idOf(list.dst[e])));
|
|
1260
|
+
const weight = p.weights(e);
|
|
1261
|
+
if (weight !== null) {
|
|
1262
|
+
w.member(resolved.weightKey, weight);
|
|
1263
|
+
}
|
|
1264
|
+
writeSlots(c, p.edges, e, ATTRIBUTE_SLOTS);
|
|
1265
|
+
w.close("}");
|
|
1266
|
+
yield w.take();
|
|
1267
|
+
}
|
|
1268
|
+
w.close("]");
|
|
1269
|
+
w.close("}");
|
|
1270
|
+
yield w.take();
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* Write a graphology serialisation.
|
|
1275
|
+
* @param snapshot - the snapshot
|
|
1276
|
+
* @param p - the plan
|
|
1277
|
+
* @yields the parts
|
|
1278
|
+
* @returns nothing
|
|
1279
|
+
*/
|
|
1280
|
+
function* writeGraphology(snapshot: GraphSnapshot, p: Plan): Generator<string, void, undefined> {
|
|
1281
|
+
const { resolved } = p;
|
|
1282
|
+
const c: Cursor = { w: new JsonWriter(resolved.indent), ids: snapshot.ids, nonfinite: { count: 0 } };
|
|
1283
|
+
const { w } = c;
|
|
1284
|
+
let type: string;
|
|
1285
|
+
if (!p.directed) {
|
|
1286
|
+
type = "undirected";
|
|
1287
|
+
} else {
|
|
1288
|
+
type = countMixedEdges(snapshot) > 0 ? "mixed" : "directed";
|
|
1289
|
+
}
|
|
1290
|
+
w.open("{");
|
|
1291
|
+
w.key("attributes");
|
|
1292
|
+
writeGraphObject(c, p);
|
|
1293
|
+
w.key("options");
|
|
1294
|
+
w.open("{");
|
|
1295
|
+
w.member("type", JSON.stringify(type));
|
|
1296
|
+
// the shape the importer read is written back as read: `multi` and `allowSelfLoops` only when
|
|
1297
|
+
// the source declared them (or the graph needs them)
|
|
1298
|
+
if (snapshot.meta.declaredMultigraph !== null || p.multigraph) {
|
|
1299
|
+
w.member("multi", p.multigraph ? "true" : "false");
|
|
1300
|
+
}
|
|
1301
|
+
if (resolved.shape.allowSelfLoops !== undefined) {
|
|
1302
|
+
w.member("allowSelfLoops", !resolved.shape.allowSelfLoops && snapshot.selfLoopCount === 0 ? "false" : "true");
|
|
1303
|
+
}
|
|
1304
|
+
w.close("}");
|
|
1305
|
+
w.key("nodes");
|
|
1306
|
+
w.open("[");
|
|
1307
|
+
yield w.take();
|
|
1308
|
+
for (let i = 0; i < snapshot.nodeCount; i++) {
|
|
1309
|
+
w.item();
|
|
1310
|
+
w.open("{");
|
|
1311
|
+
w.member("key", idText(snapshot.ids.idOf(i)));
|
|
1312
|
+
if (anySet(p.nodes, i, ATTRIBUTE_SLOTS)) {
|
|
1313
|
+
w.key("attributes");
|
|
1314
|
+
w.open("{");
|
|
1315
|
+
writeSlots(c, p.nodes, i, ATTRIBUTE_SLOTS);
|
|
1316
|
+
w.close("}");
|
|
1317
|
+
}
|
|
1318
|
+
writeSlots(c, p.nodes, i, ELEMENT_SLOTS);
|
|
1319
|
+
w.close("}");
|
|
1320
|
+
yield w.take();
|
|
1321
|
+
}
|
|
1322
|
+
w.close("]");
|
|
1323
|
+
w.key("edges");
|
|
1324
|
+
w.open("[");
|
|
1325
|
+
for (let e = 0; e < snapshot.edgeCount; e++) {
|
|
1326
|
+
const disposition = edgeDisposition(p, e);
|
|
1327
|
+
if (disposition === "skip") {
|
|
1328
|
+
continue;
|
|
1329
|
+
}
|
|
1330
|
+
const list = snapshot.edgeList();
|
|
1331
|
+
w.item();
|
|
1332
|
+
w.open("{");
|
|
1333
|
+
const id = edgeIdText(p, e, c);
|
|
1334
|
+
if (id !== null) {
|
|
1335
|
+
w.member("key", id);
|
|
1336
|
+
}
|
|
1337
|
+
w.member("source", idText(snapshot.ids.idOf(list.src[e])));
|
|
1338
|
+
w.member("target", idText(snapshot.ids.idOf(list.dst[e])));
|
|
1339
|
+
if (type === "mixed" && disposition === "undirected") {
|
|
1340
|
+
w.member("undirected", "true");
|
|
1341
|
+
}
|
|
1342
|
+
const weight = p.weights(e);
|
|
1343
|
+
if (weight !== null || anySet(p.edges, e, ATTRIBUTE_SLOTS)) {
|
|
1344
|
+
w.key("attributes");
|
|
1345
|
+
w.open("{");
|
|
1346
|
+
if (weight !== null) {
|
|
1347
|
+
w.member(resolved.weightKey, weight);
|
|
1348
|
+
}
|
|
1349
|
+
writeSlots(c, p.edges, e, ATTRIBUTE_SLOTS);
|
|
1350
|
+
w.close("}");
|
|
1351
|
+
}
|
|
1352
|
+
writeSlots(c, p.edges, e, ELEMENT_SLOTS);
|
|
1353
|
+
w.close("}");
|
|
1354
|
+
yield w.take();
|
|
1355
|
+
}
|
|
1356
|
+
w.close("]");
|
|
1357
|
+
w.close("}");
|
|
1358
|
+
yield w.take();
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/**
|
|
1362
|
+
* Write a JSON Graph Format v2 document.
|
|
1363
|
+
* @param snapshot - the snapshot
|
|
1364
|
+
* @param p - the plan
|
|
1365
|
+
* @yields the parts
|
|
1366
|
+
* @returns nothing
|
|
1367
|
+
*/
|
|
1368
|
+
function* writeJgf(snapshot: GraphSnapshot, p: Plan): Generator<string, void, undefined> {
|
|
1369
|
+
const { resolved } = p;
|
|
1370
|
+
const c: Cursor = { w: new JsonWriter(resolved.indent), ids: snapshot.ids, nonfinite: { count: 0 } };
|
|
1371
|
+
const { w } = c;
|
|
1372
|
+
const labelSlots: ReadonlySet<Slot> = new Set(["label"]);
|
|
1373
|
+
const relationSlots: ReadonlySet<Slot> = new Set(["relation"]);
|
|
1374
|
+
w.open("{");
|
|
1375
|
+
w.key("graph");
|
|
1376
|
+
w.open("{");
|
|
1377
|
+
if (resolved.shape.id !== undefined) {
|
|
1378
|
+
w.member("id", JSON.stringify(resolved.shape.id));
|
|
1379
|
+
}
|
|
1380
|
+
if (snapshot.meta.name !== null) {
|
|
1381
|
+
w.member("label", JSON.stringify(snapshot.meta.name));
|
|
1382
|
+
}
|
|
1383
|
+
if (resolved.shape.type !== undefined) {
|
|
1384
|
+
w.member("type", JSON.stringify(resolved.shape.type));
|
|
1385
|
+
}
|
|
1386
|
+
w.member("directed", p.directed ? "true" : "false");
|
|
1387
|
+
if (p.graph.some((item) => item.column.isSet(0))) {
|
|
1388
|
+
w.key("metadata");
|
|
1389
|
+
writeGraphObject(c, p);
|
|
1390
|
+
}
|
|
1391
|
+
w.key("nodes");
|
|
1392
|
+
w.open("{");
|
|
1393
|
+
yield w.take();
|
|
1394
|
+
for (let i = 0; i < snapshot.nodeCount; i++) {
|
|
1395
|
+
w.key(String(snapshot.ids.idOf(i)));
|
|
1396
|
+
w.open("{");
|
|
1397
|
+
writeSlots(c, p.nodes, i, labelSlots);
|
|
1398
|
+
if (anySet(p.nodes, i, ATTRIBUTE_SLOTS)) {
|
|
1399
|
+
w.key("metadata");
|
|
1400
|
+
w.open("{");
|
|
1401
|
+
writeSlots(c, p.nodes, i, ATTRIBUTE_SLOTS);
|
|
1402
|
+
w.close("}");
|
|
1403
|
+
}
|
|
1404
|
+
writeSlots(c, p.nodes, i, ELEMENT_SLOTS);
|
|
1405
|
+
w.close("}");
|
|
1406
|
+
yield w.take();
|
|
1407
|
+
}
|
|
1408
|
+
w.close("}");
|
|
1409
|
+
w.key("edges");
|
|
1410
|
+
w.open("[");
|
|
1411
|
+
for (let e = 0; e < snapshot.edgeCount; e++) {
|
|
1412
|
+
const disposition = edgeDisposition(p, e);
|
|
1413
|
+
if (disposition === "skip") {
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1416
|
+
const list = snapshot.edgeList();
|
|
1417
|
+
w.item();
|
|
1418
|
+
w.open("{");
|
|
1419
|
+
const id = edgeIdText(p, e, c);
|
|
1420
|
+
if (id !== null) {
|
|
1421
|
+
w.member("id", id);
|
|
1422
|
+
}
|
|
1423
|
+
w.member("source", JSON.stringify(String(snapshot.ids.idOf(list.src[e]))));
|
|
1424
|
+
w.member("target", JSON.stringify(String(snapshot.ids.idOf(list.dst[e]))));
|
|
1425
|
+
writeSlots(c, p.edges, e, relationSlots);
|
|
1426
|
+
if (p.directed && disposition === "undirected") {
|
|
1427
|
+
w.member("directed", "false");
|
|
1428
|
+
}
|
|
1429
|
+
writeSlots(c, p.edges, e, labelSlots);
|
|
1430
|
+
const weight = p.weights(e);
|
|
1431
|
+
if (weight !== null || anySet(p.edges, e, ATTRIBUTE_SLOTS)) {
|
|
1432
|
+
w.key("metadata");
|
|
1433
|
+
w.open("{");
|
|
1434
|
+
if (weight !== null) {
|
|
1435
|
+
w.member(resolved.weightKey, weight);
|
|
1436
|
+
}
|
|
1437
|
+
writeSlots(c, p.edges, e, ATTRIBUTE_SLOTS);
|
|
1438
|
+
w.close("}");
|
|
1439
|
+
}
|
|
1440
|
+
writeSlots(c, p.edges, e, ELEMENT_SLOTS);
|
|
1441
|
+
w.close("}");
|
|
1442
|
+
yield w.take();
|
|
1443
|
+
}
|
|
1444
|
+
w.close("]");
|
|
1445
|
+
w.close("}");
|
|
1446
|
+
w.close("}");
|
|
1447
|
+
yield w.take();
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/**
|
|
1451
|
+
* Write Cytoscape.js elements.
|
|
1452
|
+
* @param snapshot - the snapshot
|
|
1453
|
+
* @param p - the plan
|
|
1454
|
+
* @yields the parts
|
|
1455
|
+
* @returns nothing
|
|
1456
|
+
*/
|
|
1457
|
+
function* writeCytoscape(snapshot: GraphSnapshot, p: Plan): Generator<string, void, undefined> {
|
|
1458
|
+
const { resolved } = p;
|
|
1459
|
+
const c: Cursor = { w: new JsonWriter(resolved.indent), ids: snapshot.ids, nonfinite: { count: 0 } };
|
|
1460
|
+
const { w } = c;
|
|
1461
|
+
const parentSlots: ReadonlySet<Slot> = new Set(["parent"]);
|
|
1462
|
+
const positionSlots: ReadonlySet<Slot> = new Set(["cytoscapePosition"]);
|
|
1463
|
+
w.open("{");
|
|
1464
|
+
w.key("elements");
|
|
1465
|
+
w.open("{");
|
|
1466
|
+
w.key("nodes");
|
|
1467
|
+
w.open("[");
|
|
1468
|
+
yield w.take();
|
|
1469
|
+
for (let i = 0; i < snapshot.nodeCount; i++) {
|
|
1470
|
+
w.item();
|
|
1471
|
+
w.open("{");
|
|
1472
|
+
w.key("data");
|
|
1473
|
+
w.open("{");
|
|
1474
|
+
w.member("id", idText(snapshot.ids.idOf(i)));
|
|
1475
|
+
writeSlots(c, p.nodes, i, parentSlots);
|
|
1476
|
+
writeSlots(c, p.nodes, i, ATTRIBUTE_SLOTS);
|
|
1477
|
+
w.close("}");
|
|
1478
|
+
for (const item of p.nodes) {
|
|
1479
|
+
if (positionSlots.has(item.slot) && item.column.isSet(i)) {
|
|
1480
|
+
const value = item.column.value(i) as ArrayLike<number>;
|
|
1481
|
+
const f32 = item.column.dtype === "f32";
|
|
1482
|
+
w.key("position");
|
|
1483
|
+
w.open("{");
|
|
1484
|
+
w.member("x", numberText(value[0], f32, c.nonfinite));
|
|
1485
|
+
w.member("y", numberText(value[1], f32, c.nonfinite));
|
|
1486
|
+
w.close("}");
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
writeSlots(c, p.nodes, i, ELEMENT_SLOTS);
|
|
1490
|
+
w.close("}");
|
|
1491
|
+
yield w.take();
|
|
1492
|
+
}
|
|
1493
|
+
w.close("]");
|
|
1494
|
+
w.key("edges");
|
|
1495
|
+
w.open("[");
|
|
1496
|
+
const generated = edgeIdGenerator(snapshot, p);
|
|
1497
|
+
for (let e = 0; e < snapshot.edgeCount; e++) {
|
|
1498
|
+
if (edgeDisposition(p, e) === "skip") {
|
|
1499
|
+
continue;
|
|
1500
|
+
}
|
|
1501
|
+
const list = snapshot.edgeList();
|
|
1502
|
+
w.item();
|
|
1503
|
+
w.open("{");
|
|
1504
|
+
w.key("data");
|
|
1505
|
+
w.open("{");
|
|
1506
|
+
w.member("id", edgeIdText(p, e, c) ?? generated(e));
|
|
1507
|
+
w.member("source", idText(snapshot.ids.idOf(list.src[e])));
|
|
1508
|
+
w.member("target", idText(snapshot.ids.idOf(list.dst[e])));
|
|
1509
|
+
const weight = p.weights(e);
|
|
1510
|
+
if (weight !== null) {
|
|
1511
|
+
w.member(resolved.weightKey, weight);
|
|
1512
|
+
}
|
|
1513
|
+
writeSlots(c, p.edges, e, ATTRIBUTE_SLOTS);
|
|
1514
|
+
w.close("}");
|
|
1515
|
+
writeSlots(c, p.edges, e, ELEMENT_SLOTS);
|
|
1516
|
+
w.close("}");
|
|
1517
|
+
yield w.take();
|
|
1518
|
+
}
|
|
1519
|
+
w.close("]");
|
|
1520
|
+
w.close("}");
|
|
1521
|
+
if (p.graph.some((item) => item.column.isSet(0))) {
|
|
1522
|
+
w.key("data");
|
|
1523
|
+
writeGraphObject(c, p);
|
|
1524
|
+
}
|
|
1525
|
+
const extra = resolved.shape.cytoscape;
|
|
1526
|
+
if (extra !== undefined) {
|
|
1527
|
+
for (const key of Object.keys(extra)) {
|
|
1528
|
+
if (key !== "elements" && key !== "data") {
|
|
1529
|
+
w.member(key, valueText(extra[key], false, c.nonfinite));
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
w.close("}");
|
|
1534
|
+
yield w.take();
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/**
|
|
1538
|
+
* A generator of canonical `e<index>` edge ids for the edges without one (design section 4.6),
|
|
1539
|
+
* skipping any text a node id or an explicit edge id already uses (Cytoscape ids share one
|
|
1540
|
+
* namespace).
|
|
1541
|
+
* @param snapshot - the snapshot
|
|
1542
|
+
* @param p - the plan
|
|
1543
|
+
* @returns the id text of an edge without an explicit id
|
|
1544
|
+
*/
|
|
1545
|
+
function edgeIdGenerator(snapshot: GraphSnapshot, p: Plan): (e: number) => string {
|
|
1546
|
+
const used = new Set<string>();
|
|
1547
|
+
for (let i = 0; i < snapshot.nodeCount; i++) {
|
|
1548
|
+
used.add(String(snapshot.ids.idOf(i)));
|
|
1549
|
+
}
|
|
1550
|
+
const column = p.edgeIds;
|
|
1551
|
+
if (column !== null) {
|
|
1552
|
+
for (let e = 0; e < snapshot.edgeCount; e++) {
|
|
1553
|
+
if (column.isSet(e)) {
|
|
1554
|
+
used.add(String(column.value(e)));
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
return (e: number): string => {
|
|
1559
|
+
let candidate = `e${e}`;
|
|
1560
|
+
for (let k = 2; used.has(candidate); k++) {
|
|
1561
|
+
candidate = `e${e}_${k}`;
|
|
1562
|
+
}
|
|
1563
|
+
used.add(candidate);
|
|
1564
|
+
return JSON.stringify(candidate);
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// ============================================================ the plugin
|
|
1569
|
+
|
|
1570
|
+
/**
|
|
1571
|
+
* The JSON exporter plugin (design section 8.5). `capabilities` is the node-link table (the
|
|
1572
|
+
* default dialect); check() applies the table of the dialect actually selected.
|
|
1573
|
+
*/
|
|
1574
|
+
export const jsonExporter: GraphExporter<JsonExportOptions> = Object.freeze({
|
|
1575
|
+
format: "json",
|
|
1576
|
+
capabilities: dialectCapabilities("node-link"),
|
|
1577
|
+
|
|
1578
|
+
/**
|
|
1579
|
+
* Pre-flight: every loss of the selected dialect, without writing anything.
|
|
1580
|
+
* @param snapshot - the snapshot
|
|
1581
|
+
* @param options - format-specific and common options
|
|
1582
|
+
* @returns the notes, empty when the export is exact
|
|
1583
|
+
*/
|
|
1584
|
+
check(snapshot: GraphSnapshot, options?: JsonExportOptions & CommonExportOptions): readonly LossNote[] {
|
|
1585
|
+
return Object.freeze([...plan(snapshot, resolve(snapshot, options)).notes]);
|
|
1586
|
+
},
|
|
1587
|
+
|
|
1588
|
+
/**
|
|
1589
|
+
* Write the document as UTF-8 chunks.
|
|
1590
|
+
* @param snapshot - the snapshot
|
|
1591
|
+
* @param options - format-specific and common options
|
|
1592
|
+
* @returns the chunks
|
|
1593
|
+
*/
|
|
1594
|
+
export(snapshot: GraphSnapshot, options?: JsonExportOptions & CommonExportOptions): AsyncIterable<Uint8Array> {
|
|
1595
|
+
return encodeChunks(write(snapshot, plan(snapshot, resolve(snapshot, options))));
|
|
1596
|
+
},
|
|
1597
|
+
|
|
1598
|
+
/**
|
|
1599
|
+
* Write the document as one string.
|
|
1600
|
+
* @param snapshot - the snapshot
|
|
1601
|
+
* @param options - format-specific and common options
|
|
1602
|
+
* @returns the document
|
|
1603
|
+
*/
|
|
1604
|
+
exportToString(snapshot: GraphSnapshot, options?: JsonExportOptions & CommonExportOptions): Promise<string> {
|
|
1605
|
+
return joinText(write(snapshot, plan(snapshot, resolve(snapshot, options))));
|
|
1606
|
+
},
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
/**
|
|
1610
|
+
* The capability table of one dialect, for callers that pick a dialect before check().
|
|
1611
|
+
* @param dialect - the dialect
|
|
1612
|
+
* @returns the frozen table
|
|
1613
|
+
*/
|
|
1614
|
+
export function jsonCapabilities(dialect: JsonDialect): ExportCapabilities {
|
|
1615
|
+
return dialectCapabilities(dialect);
|
|
1616
|
+
}
|