@graphty/graph-io 0.3.19 → 0.3.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -4
- package/dist/chunks/{escape-CWExcecC.js → escape-B-tkk_Cl.js} +74 -16
- package/dist/chunks/escape-B-tkk_Cl.js.map +1 -0
- package/dist/chunks/{writer-DQiKgQJc.js → export-Bh60Dv-n.js} +5 -79
- package/dist/chunks/export-Bh60Dv-n.js.map +1 -0
- package/dist/chunks/{exporter-CbMZyVt-.js → exporter-BGrsWamJ.js} +7 -6
- package/dist/chunks/{exporter-CbMZyVt-.js.map → exporter-BGrsWamJ.js.map} +1 -1
- package/dist/chunks/exporter-DIeGJXAZ.js +834 -0
- package/dist/chunks/exporter-DIeGJXAZ.js.map +1 -0
- package/dist/chunks/{importer-BNFuV1-K.js → importer-BWY2FFc5.js} +28 -7
- package/dist/chunks/importer-BWY2FFc5.js.map +1 -0
- package/dist/chunks/importer-DD-xv9_Y.js +1471 -0
- package/dist/chunks/importer-DD-xv9_Y.js.map +1 -0
- package/dist/chunks/{importer-BAP4PrxR.js → importer-DET1tg6s.js} +30 -20
- package/dist/chunks/importer-DET1tg6s.js.map +1 -0
- package/dist/chunks/{importer-ByPGO-09.js → importer-DEcKhsmQ.js} +2 -2
- package/dist/chunks/{importer-ByPGO-09.js.map → importer-DEcKhsmQ.js.map} +1 -1
- package/dist/chunks/{importer-BW-Ft2ps.js → importer-DIrbbnAf.js} +6 -5
- package/dist/chunks/{importer-BW-Ft2ps.js.map → importer-DIrbbnAf.js.map} +1 -1
- package/dist/chunks/importer-D_e7e7LX.js +3055 -0
- package/dist/chunks/importer-D_e7e7LX.js.map +1 -0
- package/dist/chunks/{importer-Bm1_5vPS.js → importer-eUREhBGj.js} +11 -8
- package/dist/chunks/importer-eUREhBGj.js.map +1 -0
- package/dist/chunks/{importer-DOepkgnG.js → importer-vi3bSdtw.js} +4 -4
- package/dist/chunks/{importer-DOepkgnG.js.map → importer-vi3bSdtw.js.map} +1 -1
- package/dist/chunks/{importer-BVGtU1NA.js → importer-zGLo8Dg_.js} +6 -5
- package/dist/chunks/{importer-BVGtU1NA.js.map → importer-zGLo8Dg_.js.map} +1 -1
- package/dist/chunks/{json-elements-CZY1wiZh.js → json-elements-DDS8N2Dd.js} +2 -2
- package/dist/chunks/{json-elements-CZY1wiZh.js.map → json-elements-DDS8N2Dd.js.map} +1 -1
- package/dist/chunks/{records-BzNicMsf.js → records-dbRkxwaq.js} +2 -2
- package/dist/chunks/{records-BzNicMsf.js.map → records-dbRkxwaq.js.map} +1 -1
- package/dist/chunks/{report-BcWboivV.js → report-B1z4WT9e.js} +143 -111
- package/dist/chunks/{report-BcWboivV.js.map → report-B1z4WT9e.js.map} +1 -1
- package/dist/chunks/{weights-CwISIpCP.js → weights-Dzba96G3.js} +5 -5
- package/dist/chunks/{weights-CwISIpCP.js.map → weights-Dzba96G3.js.map} +1 -1
- package/dist/chunks/writer-C7flM-Ih.js +77 -0
- package/dist/chunks/writer-C7flM-Ih.js.map +1 -0
- package/dist/csv.js +6 -5
- package/dist/csv.js.map +1 -1
- package/dist/cx.js +1 -1
- package/dist/cx2.js +2 -2
- package/dist/cys.d.ts +1 -0
- package/dist/cys.js +6 -0
- package/dist/cys.js.map +1 -0
- package/dist/dot.js +1 -1
- package/dist/gexf.js +5 -4
- package/dist/gexf.js.map +1 -1
- package/dist/gml.js +12 -14
- package/dist/gml.js.map +1 -1
- package/dist/graph-io.js +288 -141
- package/dist/graph-io.js.map +1 -1
- package/dist/graphml.js +1 -1
- package/dist/json.js +1 -1
- package/dist/neo4j.js +6 -5
- package/dist/neo4j.js.map +1 -1
- package/dist/obo.js +1 -1
- package/dist/pajek.js +1 -1
- package/dist/src/common/cell-budget.d.ts +84 -0
- package/dist/src/common/cell-budget.d.ts.map +1 -0
- package/dist/src/common/cell-budget.js +138 -0
- package/dist/src/common/cell-budget.js.map +1 -0
- package/dist/src/common/input.d.ts +10 -0
- package/dist/src/common/input.d.ts.map +1 -1
- package/dist/src/common/input.js +39 -0
- package/dist/src/common/input.js.map +1 -1
- package/dist/src/common/options.d.ts +6 -0
- package/dist/src/common/options.d.ts.map +1 -1
- package/dist/src/common/options.js +1 -1
- package/dist/src/common/options.js.map +1 -1
- package/dist/src/common/xml.d.ts +37 -3
- package/dist/src/common/xml.d.ts.map +1 -1
- package/dist/src/common/xml.js +86 -6
- package/dist/src/common/xml.js.map +1 -1
- package/dist/src/common/zip.d.ts +92 -0
- package/dist/src/common/zip.d.ts.map +1 -0
- package/dist/src/common/zip.js +399 -0
- package/dist/src/common/zip.js.map +1 -0
- package/dist/src/formats/cx/importer.d.ts.map +1 -1
- package/dist/src/formats/cx/importer.js +26 -17
- package/dist/src/formats/cx/importer.js.map +1 -1
- package/dist/src/formats/cys/constants.d.ts +68 -0
- package/dist/src/formats/cys/constants.d.ts.map +1 -0
- package/dist/src/formats/cys/constants.js +76 -0
- package/dist/src/formats/cys/constants.js.map +1 -0
- package/dist/src/formats/cys/importer.d.ts +38 -0
- package/dist/src/formats/cys/importer.d.ts.map +1 -0
- package/dist/src/formats/cys/importer.js +830 -0
- package/dist/src/formats/cys/importer.js.map +1 -0
- package/dist/src/formats/cys/index.d.ts +7 -0
- package/dist/src/formats/cys/index.d.ts.map +1 -0
- package/dist/src/formats/cys/index.js +7 -0
- package/dist/src/formats/cys/index.js.map +1 -0
- package/dist/src/formats/cys/session.d.ts +132 -0
- package/dist/src/formats/cys/session.d.ts.map +1 -0
- package/dist/src/formats/cys/session.js +315 -0
- package/dist/src/formats/cys/session.js.map +1 -0
- package/dist/src/formats/cys/tables.d.ts +90 -0
- package/dist/src/formats/cys/tables.d.ts.map +1 -0
- package/dist/src/formats/cys/tables.js +293 -0
- package/dist/src/formats/cys/tables.js.map +1 -0
- package/dist/src/formats/dot/exporter.d.ts +2 -0
- package/dist/src/formats/dot/exporter.d.ts.map +1 -1
- package/dist/src/formats/dot/exporter.js +12 -0
- package/dist/src/formats/dot/exporter.js.map +1 -1
- package/dist/src/formats/dot/importer.js +6 -2
- package/dist/src/formats/dot/importer.js.map +1 -1
- package/dist/src/formats/gexf/importer.d.ts +2 -2
- package/dist/src/formats/gexf/importer.d.ts.map +1 -1
- package/dist/src/formats/gexf/importer.js +1 -1
- package/dist/src/formats/gexf/importer.js.map +1 -1
- package/dist/src/formats/gml/exporter.d.ts +0 -8
- package/dist/src/formats/gml/exporter.d.ts.map +1 -1
- package/dist/src/formats/gml/exporter.js +7 -17
- package/dist/src/formats/gml/exporter.js.map +1 -1
- package/dist/src/formats/json/exporter.d.ts.map +1 -1
- package/dist/src/formats/json/exporter.js +3 -1
- package/dist/src/formats/json/exporter.js.map +1 -1
- package/dist/src/formats/json/importer.d.ts.map +1 -1
- package/dist/src/formats/json/importer.js +3 -2
- package/dist/src/formats/json/importer.js.map +1 -1
- package/dist/src/formats/xgmml/columns.d.ts +152 -0
- package/dist/src/formats/xgmml/columns.d.ts.map +1 -0
- package/dist/src/formats/xgmml/columns.js +593 -0
- package/dist/src/formats/xgmml/columns.js.map +1 -0
- package/dist/src/formats/xgmml/constants.d.ts +196 -0
- package/dist/src/formats/xgmml/constants.d.ts.map +1 -0
- package/dist/src/formats/xgmml/constants.js +197 -0
- package/dist/src/formats/xgmml/constants.js.map +1 -0
- package/dist/src/formats/xgmml/document.d.ts +361 -0
- package/dist/src/formats/xgmml/document.d.ts.map +1 -0
- package/dist/src/formats/xgmml/document.js +695 -0
- package/dist/src/formats/xgmml/document.js.map +1 -0
- package/dist/src/formats/xgmml/emit.d.ts +341 -0
- package/dist/src/formats/xgmml/emit.d.ts.map +1 -0
- package/dist/src/formats/xgmml/emit.js +1275 -0
- package/dist/src/formats/xgmml/emit.js.map +1 -0
- package/dist/src/formats/xgmml/exporter.d.ts +24 -0
- package/dist/src/formats/xgmml/exporter.d.ts.map +1 -0
- package/dist/src/formats/xgmml/exporter.js +999 -0
- package/dist/src/formats/xgmml/exporter.js.map +1 -0
- package/dist/src/formats/xgmml/importer.d.ts +58 -0
- package/dist/src/formats/xgmml/importer.d.ts.map +1 -0
- package/dist/src/formats/xgmml/importer.js +360 -0
- package/dist/src/formats/xgmml/importer.js.map +1 -0
- package/dist/src/formats/xgmml/index.d.ts +8 -0
- package/dist/src/formats/xgmml/index.d.ts.map +1 -0
- package/dist/src/formats/xgmml/index.js +8 -0
- package/dist/src/formats/xgmml/index.js.map +1 -0
- package/dist/src/formats/xgmml/values.d.ts +60 -0
- package/dist/src/formats/xgmml/values.d.ts.map +1 -0
- package/dist/src/formats/xgmml/values.js +148 -0
- package/dist/src/formats/xgmml/values.js.map +1 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/registry.d.ts +7 -0
- package/dist/src/registry.d.ts.map +1 -1
- package/dist/src/registry.js +25 -10
- package/dist/src/registry.js.map +1 -1
- package/dist/src/sniff.d.ts +1 -1
- package/dist/src/sniff.d.ts.map +1 -1
- package/dist/src/sniff.js +2 -0
- package/dist/src/sniff.js.map +1 -1
- package/dist/xgmml.d.ts +1 -0
- package/dist/xgmml.js +9 -0
- package/dist/xgmml.js.map +1 -0
- package/package.json +15 -2
- package/src/common/cell-budget.ts +170 -0
- package/src/common/input.ts +40 -0
- package/src/common/options.ts +1 -1
- package/src/common/xml.ts +127 -6
- package/src/common/zip.ts +472 -0
- package/src/formats/cx/importer.ts +27 -19
- package/src/formats/cys/constants.ts +99 -0
- package/src/formats/cys/importer.ts +1100 -0
- package/src/formats/cys/index.ts +7 -0
- package/src/formats/cys/session.ts +428 -0
- package/src/formats/cys/tables.ts +400 -0
- package/src/formats/dot/exporter.ts +17 -0
- package/src/formats/dot/importer.ts +6 -2
- package/src/formats/gexf/importer.ts +0 -1
- package/src/formats/gml/exporter.ts +7 -18
- package/src/formats/json/exporter.ts +3 -1
- package/src/formats/json/importer.ts +3 -2
- package/src/formats/xgmml/columns.ts +747 -0
- package/src/formats/xgmml/constants.ts +265 -0
- package/src/formats/xgmml/document.ts +975 -0
- package/src/formats/xgmml/emit.ts +1563 -0
- package/src/formats/xgmml/exporter.ts +1215 -0
- package/src/formats/xgmml/importer.ts +491 -0
- package/src/formats/xgmml/index.ts +8 -0
- package/src/formats/xgmml/values.ts +183 -0
- package/src/index.ts +9 -0
- package/src/registry.ts +41 -15
- package/src/sniff.ts +5 -1
- package/dist/chunks/escape-CWExcecC.js.map +0 -1
- package/dist/chunks/importer-BAP4PrxR.js.map +0 -1
- package/dist/chunks/importer-BNFuV1-K.js.map +0 -1
- package/dist/chunks/importer-Bm1_5vPS.js.map +0 -1
- package/dist/chunks/writer-DQiKgQJc.js.map +0 -1
|
@@ -0,0 +1,1275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolving a parsed XGMML document (document.ts) into a sink: which nodes and edges a graph
|
|
3
|
+
* holds, their ids, endpoints, direction, weights, containment, positions, graphics and attribute
|
|
4
|
+
* columns. The `.cys` importer calls the same code with the session's tables and views merged into
|
|
5
|
+
* the records first, so both readers share every rule.
|
|
6
|
+
*/
|
|
7
|
+
import { INVALID_INDEX } from "@graphty/graph-format";
|
|
8
|
+
import { declareResolved } from "../../common/attributes.js";
|
|
9
|
+
import { DirectionResolver } from "../../common/direction.js";
|
|
10
|
+
import { IdCoercer } from "../../common/ids.js";
|
|
11
|
+
import { throwIfAborted } from "../../common/input.js";
|
|
12
|
+
import { parseWeightText } from "../../common/weights.js";
|
|
13
|
+
import { ColumnSet } from "./columns.js";
|
|
14
|
+
import { CYTOSCAPE_ORIGIN_NAMESPACE, EDGE_ID_COLUMN, FORMAT, GRAPHICS_COLUMN, INTERACTION_COLUMN, LABEL_COLUMN, META_KEY, NESTED_NETWORK_COLUMN, NETWORK_POINTER_COLUMN, NETWORKS_COLUMN, PARENT_COLUMN, PARENTS_COLUMN, POSITION_COLUMN, SUBGRAPH_COLUMN, XGMML_ISSUE, XGMML_ORIGIN_NAMESPACE, Z_COLUMN, } from "./constants.js";
|
|
15
|
+
import { attJson, isCyTrue, } from "./document.js";
|
|
16
|
+
/** The graph atts that are document metadata, not columns. */
|
|
17
|
+
const META_ATTS = new Set(["documentVersion", "networkMetadata"]);
|
|
18
|
+
/** The root XML attributes that are structure, not graph columns. */
|
|
19
|
+
const GRAPH_STRUCTURE = new Set([
|
|
20
|
+
"id",
|
|
21
|
+
"label",
|
|
22
|
+
"directed",
|
|
23
|
+
"xlink:href",
|
|
24
|
+
"cy:documentVersion",
|
|
25
|
+
"cy:view",
|
|
26
|
+
"cy:registered",
|
|
27
|
+
"cy:networkId",
|
|
28
|
+
"cy:visualStyle",
|
|
29
|
+
"cy:rendererId",
|
|
30
|
+
]);
|
|
31
|
+
/** Elements between two checks of the cancellation signal. */
|
|
32
|
+
const ABORT_CHECK_INTERVAL = 64;
|
|
33
|
+
/** Cytoscape's label alias shape: `source (interaction) target`. */
|
|
34
|
+
const ALIAS_SPLIT = /[()]/;
|
|
35
|
+
/**
|
|
36
|
+
* The dialect of a document (`research-xgmml.md` section 2).
|
|
37
|
+
* @param doc - the document
|
|
38
|
+
* @param report - the report the unparseable-version warning goes to
|
|
39
|
+
* @returns the dialect
|
|
40
|
+
*/
|
|
41
|
+
export function dialectOf(doc, report) {
|
|
42
|
+
const { root } = doc;
|
|
43
|
+
let versionText = root.attrs.get("cy:documentVersion") ?? null;
|
|
44
|
+
for (const att of root.atts) {
|
|
45
|
+
if (att.name === "documentVersion" && att.value !== null) {
|
|
46
|
+
versionText ?? (versionText = att.value);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
let version = 0;
|
|
50
|
+
if (versionText !== null) {
|
|
51
|
+
version = Number(versionText.trim());
|
|
52
|
+
if (!Number.isFinite(version) || versionText.trim().length === 0) {
|
|
53
|
+
report.warning("validation-error", XGMML_ISSUE.DOCUMENT_VERSION, `documentVersion "${versionText}" does not parse; the dialect is chosen from the content`, { line: root.line });
|
|
54
|
+
version = 0;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const registered = root.subgraphs.some((g) => isCyTrue(g.registered));
|
|
58
|
+
return {
|
|
59
|
+
versionText,
|
|
60
|
+
version,
|
|
61
|
+
cytoscape: doc.cytoscape,
|
|
62
|
+
cy2: doc.cytoscape && version < 3,
|
|
63
|
+
session: root.registered !== null && !isCyTrue(root.registered) && registered,
|
|
64
|
+
view: isCyTrue(root.attrs.get("cy:view")),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The graphs a document offers: the registered subnetworks of a session network document, else
|
|
69
|
+
* the root.
|
|
70
|
+
* @param doc - the document
|
|
71
|
+
* @param dialect - its dialect
|
|
72
|
+
* @returns the graphs, in document order
|
|
73
|
+
*/
|
|
74
|
+
export function graphsOf(doc, dialect) {
|
|
75
|
+
return dialect.session ? doc.root.subgraphs.filter((g) => isCyTrue(g.registered)) : [doc.root];
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The id a `#id` reference names, or null for a cross-file or malformed reference.
|
|
79
|
+
* @param href - the xlink:href text
|
|
80
|
+
* @returns the id
|
|
81
|
+
*/
|
|
82
|
+
function localRef(href) {
|
|
83
|
+
return href.startsWith("#") ? href.slice(1) : null;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The nodes and edges a graph holds: its direct members, references resolved.
|
|
87
|
+
* @param doc - the document
|
|
88
|
+
* @param graph - the graph (a registered subnetwork), or null for the whole document
|
|
89
|
+
* @returns the declared node and edge records it holds, references resolved, in order
|
|
90
|
+
*/
|
|
91
|
+
export function membersOf(doc, graph) {
|
|
92
|
+
if (graph === null) {
|
|
93
|
+
return { nodes: doc.nodes, edges: doc.edges, dangling: 0 };
|
|
94
|
+
}
|
|
95
|
+
const nodeById = indexById(doc.nodes);
|
|
96
|
+
const edgeById = indexById(doc.edges);
|
|
97
|
+
const nodes = [];
|
|
98
|
+
const edges = [];
|
|
99
|
+
let dangling = 0;
|
|
100
|
+
for (const member of graph.members) {
|
|
101
|
+
if (member.href === null) {
|
|
102
|
+
if (member.kind === "node") {
|
|
103
|
+
nodes.push(member);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
edges.push(member);
|
|
107
|
+
}
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const id = localRef(member.href);
|
|
111
|
+
let target;
|
|
112
|
+
if (id !== null) {
|
|
113
|
+
target = member.kind === "node" ? nodeById.get(id) : edgeById.get(id);
|
|
114
|
+
}
|
|
115
|
+
if (target === undefined) {
|
|
116
|
+
dangling++;
|
|
117
|
+
}
|
|
118
|
+
else if (target.kind === "node") {
|
|
119
|
+
nodes.push(target);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
edges.push(target);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { nodes, edges, dangling };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The first declaration of each id.
|
|
129
|
+
* @param records - node or edge records
|
|
130
|
+
* @returns id to record
|
|
131
|
+
*/
|
|
132
|
+
function indexById(records) {
|
|
133
|
+
const out = new Map();
|
|
134
|
+
for (const record of records) {
|
|
135
|
+
if (record.id !== null && !out.has(record.id)) {
|
|
136
|
+
out.set(record.id, record);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Pushes one graph of a document into a sink.
|
|
143
|
+
*/
|
|
144
|
+
export class XgmmlEmitter {
|
|
145
|
+
/**
|
|
146
|
+
* Create an emitter for one graph.
|
|
147
|
+
* @param doc - the document
|
|
148
|
+
* @param dialect - its dialect
|
|
149
|
+
* @param sink - the sink
|
|
150
|
+
* @param report - the report
|
|
151
|
+
* @param options - the resolved common options
|
|
152
|
+
* @param settings - the XGMML options
|
|
153
|
+
* @param extras - what a session adds
|
|
154
|
+
*/
|
|
155
|
+
constructor(doc, dialect, sink, report, options, settings, extras = {}) {
|
|
156
|
+
this.rows = new Map();
|
|
157
|
+
this.rowList = [];
|
|
158
|
+
this.edgeIndex = [];
|
|
159
|
+
this.sinceCheck = 0;
|
|
160
|
+
this.aliasResolved = 0;
|
|
161
|
+
this.aliasInteractions = 0;
|
|
162
|
+
this.weighted = false;
|
|
163
|
+
/** The direction of each edge, decided once (its warning is recorded once). */
|
|
164
|
+
this.directions = new Map();
|
|
165
|
+
/** Node labels to rows, for label aliases; null marks a label two nodes share. */
|
|
166
|
+
this.labelRows = null;
|
|
167
|
+
this.doc = doc;
|
|
168
|
+
this.dialect = dialect;
|
|
169
|
+
this.sink = sink;
|
|
170
|
+
this.report = report;
|
|
171
|
+
this.options = options;
|
|
172
|
+
this.settings = settings;
|
|
173
|
+
this.extras = extras;
|
|
174
|
+
this.coercer = new IdCoercer(options.ids);
|
|
175
|
+
this.direction = new DirectionResolver(sink, report, options.onMixedDirection);
|
|
176
|
+
const base = { unescape: settings.cytoscapeEscapes, long: options.long };
|
|
177
|
+
this.nodeColumns = new ColumnSet("node", report, {
|
|
178
|
+
...base,
|
|
179
|
+
reserved: new Set([
|
|
180
|
+
LABEL_COLUMN,
|
|
181
|
+
POSITION_COLUMN,
|
|
182
|
+
Z_COLUMN,
|
|
183
|
+
GRAPHICS_COLUMN,
|
|
184
|
+
PARENT_COLUMN,
|
|
185
|
+
PARENTS_COLUMN,
|
|
186
|
+
SUBGRAPH_COLUMN,
|
|
187
|
+
NESTED_NETWORK_COLUMN,
|
|
188
|
+
NETWORK_POINTER_COLUMN,
|
|
189
|
+
NETWORKS_COLUMN,
|
|
190
|
+
]),
|
|
191
|
+
});
|
|
192
|
+
this.edgeColumns = new ColumnSet("edge", report, {
|
|
193
|
+
...base,
|
|
194
|
+
reserved: new Set([EDGE_ID_COLUMN, LABEL_COLUMN, GRAPHICS_COLUMN]),
|
|
195
|
+
});
|
|
196
|
+
this.graphColumns = new ColumnSet("graph", report, { ...base, reserved: new Set([GRAPHICS_COLUMN]) });
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Push the graph: nodes, containment, positions, edges, columns, metadata.
|
|
200
|
+
* @param graph - the subnetwork, or null for the whole document
|
|
201
|
+
*/
|
|
202
|
+
emit(graph) {
|
|
203
|
+
const members = membersOf(this.doc, graph);
|
|
204
|
+
if (members.dangling > 0) {
|
|
205
|
+
this.report.warning("validation-error", XGMML_ISSUE.DANGLING_REFERENCE, `${this.prefix()}${members.dangling} xlink:href member reference(s) name no node or edge of the document`, { line: graph?.line ?? null });
|
|
206
|
+
}
|
|
207
|
+
const header = this.headerDirected();
|
|
208
|
+
this.direction.setHeader(this.sinkDirection(members.edges, header), { line: this.doc.root.line });
|
|
209
|
+
this.addNodes(members.nodes);
|
|
210
|
+
if (graph === null) {
|
|
211
|
+
this.checkReferences();
|
|
212
|
+
}
|
|
213
|
+
const containment = new Containment(this);
|
|
214
|
+
containment.resolve(this.rowList, this.groupTest());
|
|
215
|
+
this.writeNodeExtras(graph);
|
|
216
|
+
this.addEdges(members.edges, header);
|
|
217
|
+
this.finishAliases();
|
|
218
|
+
this.writeGraph(graph);
|
|
219
|
+
const nodeIndex = (row) => this.rowList[row].index;
|
|
220
|
+
this.nodeColumns.write(this.sink, nodeIndex);
|
|
221
|
+
this.edgeColumns.write(this.sink, (row) => this.edgeIndex[row] ?? -1);
|
|
222
|
+
containment.write();
|
|
223
|
+
this.writeMeta(graph);
|
|
224
|
+
throwIfAborted(this.options.signal);
|
|
225
|
+
}
|
|
226
|
+
// ------------------------------------------------------------------ shared helpers (also used by Containment)
|
|
227
|
+
/**
|
|
228
|
+
* The sink.
|
|
229
|
+
* @returns the sink
|
|
230
|
+
*/
|
|
231
|
+
get target() {
|
|
232
|
+
return this.sink;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* The report.
|
|
236
|
+
* @returns the report
|
|
237
|
+
*/
|
|
238
|
+
get issues() {
|
|
239
|
+
return this.report;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* The row of a node id in this graph.
|
|
243
|
+
* @param id - the id text
|
|
244
|
+
* @returns the row, or undefined
|
|
245
|
+
*/
|
|
246
|
+
rowOf(id) {
|
|
247
|
+
return this.rows.get(id);
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The network name a cross-file pointer names, when a session can tell.
|
|
251
|
+
* @param href - the pointer
|
|
252
|
+
* @returns the name, or null
|
|
253
|
+
*/
|
|
254
|
+
resolvePointer(href) {
|
|
255
|
+
return this.extras.resolvePointer?.(href) ?? null;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Hand a group member the graph does not hold to the session, when one is reading.
|
|
259
|
+
* @param group - the group node's id
|
|
260
|
+
* @param member - the member's id
|
|
261
|
+
* @returns true when the session took it
|
|
262
|
+
*/
|
|
263
|
+
missingMember(group, member) {
|
|
264
|
+
if (this.extras.onMissingMember === undefined) {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
this.extras.onMissingMember(group, member);
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* The first graph of the document with an id.
|
|
272
|
+
* @param id - the id
|
|
273
|
+
* @returns the graph, or undefined
|
|
274
|
+
*/
|
|
275
|
+
graphById(id) {
|
|
276
|
+
return this.doc.graphs.find((g) => g.id === id);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The prefix of every message (the session entry), or "".
|
|
280
|
+
* @returns the prefix
|
|
281
|
+
*/
|
|
282
|
+
prefix() {
|
|
283
|
+
return this.extras.entry === undefined ? "" : `${this.extras.entry}: `;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Check the cancellation signal every few dozen elements.
|
|
287
|
+
*/
|
|
288
|
+
checkAbort() {
|
|
289
|
+
if (++this.sinceCheck >= ABORT_CHECK_INTERVAL) {
|
|
290
|
+
this.sinceCheck = 0;
|
|
291
|
+
throwIfAborted(this.options.signal);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Declare a column the importer owns on first use (the 5.6 rename rule applies).
|
|
296
|
+
* @param domain - node or edge
|
|
297
|
+
* @param decl - the declaration
|
|
298
|
+
* @returns the handle, or null when the sink refused it (recorded)
|
|
299
|
+
*/
|
|
300
|
+
declare(domain, decl) {
|
|
301
|
+
try {
|
|
302
|
+
return declareResolved(this.sink, domain, decl, this.report, { element: decl.name }).handle;
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
this.report.recordError(err, { element: decl.name });
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// ------------------------------------------------------------------ nodes
|
|
310
|
+
/**
|
|
311
|
+
* The header direction: the root's `directed` (0 by the DTD), else the defaultDirected option.
|
|
312
|
+
* @returns whether the graph is directed by default
|
|
313
|
+
*/
|
|
314
|
+
headerDirected() {
|
|
315
|
+
const text = this.doc.root.attrs.get("directed");
|
|
316
|
+
if (text === undefined) {
|
|
317
|
+
return this.options.defaultDirected;
|
|
318
|
+
}
|
|
319
|
+
return this.flag(text, this.options.defaultDirected, { line: this.doc.root.line, element: "directed" });
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* The direction of one edge: its cy:directed, else the header.
|
|
323
|
+
* @param record - the edge
|
|
324
|
+
* @param header - the header direction
|
|
325
|
+
* @returns whether the edge is directed
|
|
326
|
+
*/
|
|
327
|
+
edgeDirection(record, header) {
|
|
328
|
+
let directed = this.directions.get(record);
|
|
329
|
+
if (directed === undefined) {
|
|
330
|
+
directed =
|
|
331
|
+
record.directed === null
|
|
332
|
+
? header
|
|
333
|
+
: this.flag(record.directed, header, { line: record.line, element: record.id ?? record.label });
|
|
334
|
+
this.directions.set(record, directed);
|
|
335
|
+
}
|
|
336
|
+
return directed;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* The direction the sink starts with: the one every edge has when they all agree (a
|
|
340
|
+
* Cytoscape file writes cy:directed on every edge and may omit the root attribute), else the
|
|
341
|
+
* header, so a graph of one direction is never expanded into a mixed one.
|
|
342
|
+
* @param edges - the edges of the graph
|
|
343
|
+
* @param header - the header direction
|
|
344
|
+
* @returns the sink direction
|
|
345
|
+
*/
|
|
346
|
+
sinkDirection(edges, header) {
|
|
347
|
+
let first = null;
|
|
348
|
+
for (const edge of edges) {
|
|
349
|
+
const directed = this.edgeDirection(edge, header);
|
|
350
|
+
if (first === null) {
|
|
351
|
+
first = directed;
|
|
352
|
+
}
|
|
353
|
+
else if (first !== directed) {
|
|
354
|
+
return header;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return first ?? header;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* A 0 / 1 direction flag: true, false, yes and no are accepted with a warning; anything else
|
|
361
|
+
* falls back.
|
|
362
|
+
* @param text - the text
|
|
363
|
+
* @param fallback - the direction for an unreadable value
|
|
364
|
+
* @param where - the location
|
|
365
|
+
* @returns the direction
|
|
366
|
+
*/
|
|
367
|
+
flag(text, fallback, where) {
|
|
368
|
+
const t = text.trim();
|
|
369
|
+
if (t === "1" || t === "0") {
|
|
370
|
+
return t === "1";
|
|
371
|
+
}
|
|
372
|
+
const lower = t.toLowerCase();
|
|
373
|
+
const readable = lower === "true" || lower === "yes" || lower === "false" || lower === "no";
|
|
374
|
+
this.report.warnOnce("validation-error", XGMML_ISSUE.BAD_DIRECTED, readable
|
|
375
|
+
? `${this.prefix()}directed="${text}" is not 0 or 1; read as ${lower === "true" || lower === "yes" ? "1" : "0"}`
|
|
376
|
+
: `${this.prefix()}directed="${text}" is not 0 or 1; read as ${fallback ? "directed" : "undirected"}`, where, `${XGMML_ISSUE.BAD_DIRECTED}:${text}`);
|
|
377
|
+
return readable ? lower === "true" || lower === "yes" : fallback;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Add every node record to the sink (one node per id; later declarations merge).
|
|
381
|
+
* @param records - the node records
|
|
382
|
+
*/
|
|
383
|
+
addNodes(records) {
|
|
384
|
+
for (const record of records) {
|
|
385
|
+
const { id } = record;
|
|
386
|
+
if (id === null) {
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
const existing = this.rows.get(id);
|
|
390
|
+
if (existing !== undefined) {
|
|
391
|
+
existing.records.push(record);
|
|
392
|
+
this.report.warning("validation-error", XGMML_ISSUE.DUPLICATE_NODE, `${this.prefix()}node "${id}" is declared twice; the declarations are merged`, { line: record.line, element: id });
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
// -1 (not INVALID_INDEX, which is positive) marks a node the sink refused
|
|
396
|
+
const row = { id, records: [record], index: -1, row: this.rowList.length };
|
|
397
|
+
try {
|
|
398
|
+
row.index = this.sink.addNode(this.nodeId(id, record.line));
|
|
399
|
+
this.report.counts.nodes++;
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
this.report.counts.skippedNodes++;
|
|
403
|
+
this.report.recordError(err, { line: record.line, element: id });
|
|
404
|
+
}
|
|
405
|
+
this.rows.set(id, row);
|
|
406
|
+
this.rowList.push(row);
|
|
407
|
+
this.checkAbort();
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* The coerced id of a node text.
|
|
412
|
+
* @param text - the id text
|
|
413
|
+
* @param line - the line
|
|
414
|
+
* @returns the id
|
|
415
|
+
*/
|
|
416
|
+
nodeId(text, line) {
|
|
417
|
+
const id = this.coercer.text(text);
|
|
418
|
+
const merge = this.coercer.lastMerge;
|
|
419
|
+
if (merge !== null) {
|
|
420
|
+
this.report.warning("coercion", XGMML_ISSUE.ID_MERGED, `id "${merge.text}" merged with "${merge.previousText}" as ${merge.id} under ids: "number"`, { line, element: text });
|
|
421
|
+
}
|
|
422
|
+
return id;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Whether a node's nested graph is a group (containment) rather than a nested-network
|
|
426
|
+
* pointer: any graph in the 1.0 draft dialect, a 2.x node with `__groupState`, a 3.x node with
|
|
427
|
+
* `__isGroup` true, or a node the session lists.
|
|
428
|
+
* @returns the test
|
|
429
|
+
*/
|
|
430
|
+
groupTest() {
|
|
431
|
+
const { groupNodes } = this.extras;
|
|
432
|
+
const { cytoscape } = this.dialect;
|
|
433
|
+
return (node) => {
|
|
434
|
+
if (!cytoscape) {
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
if (node.id !== null && groupNodes?.has(node.id) === true) {
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
return node.atts.some((a) => a.name === "__groupState" || (a.name === "__isGroup" && isCyTrue(a.value)));
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* W_DANGLING_REFERENCE for the `xlink:href` members of a generic document's non-group graphs
|
|
445
|
+
* that name no node or edge (a group's are E_UNKNOWN_PARENT, reported by the containment).
|
|
446
|
+
*/
|
|
447
|
+
checkReferences() {
|
|
448
|
+
const isGroup = this.groupTest();
|
|
449
|
+
const edgeIds = new Set(this.doc.edges.map((e) => e.id));
|
|
450
|
+
let dangling = 0;
|
|
451
|
+
for (const graph of this.doc.graphs) {
|
|
452
|
+
if (graph.owner !== null && isGroup(graph.owner)) {
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
for (const member of graph.members) {
|
|
456
|
+
if (member.href === null) {
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
const id = localRef(member.href);
|
|
460
|
+
const known = id !== null && (member.kind === "node" ? this.rows.has(id) : edgeIds.has(id));
|
|
461
|
+
if (!known) {
|
|
462
|
+
dangling++;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (dangling > 0) {
|
|
467
|
+
this.report.warning("validation-error", XGMML_ISSUE.DANGLING_REFERENCE, `${this.prefix()}${dangling} xlink:href reference(s) name no node or edge of the document`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Write the per-node values: label, attributes, XML attributes, graphics, positions, nested
|
|
472
|
+
* network pointers and subgraph memberships.
|
|
473
|
+
* @param graph - the subnetwork, or null
|
|
474
|
+
*/
|
|
475
|
+
writeNodeExtras(graph) {
|
|
476
|
+
var _a;
|
|
477
|
+
const labels = [];
|
|
478
|
+
const positions = new Positions(this, this.dialect.cytoscape ? this.settings.zAs : "position");
|
|
479
|
+
const graphics = new JsonColumn(this, "node", GRAPHICS_COLUMN, XGMML_ORIGIN_NAMESPACE, "graphics");
|
|
480
|
+
const pointers = new Pointers(this, this.groupTest());
|
|
481
|
+
for (const row of this.rowList) {
|
|
482
|
+
const where = { line: row.records[0].line, element: row.id };
|
|
483
|
+
const seen = new Set();
|
|
484
|
+
for (const record of row.records) {
|
|
485
|
+
labels[_a = row.row] ?? (labels[_a] = record.label ?? undefined);
|
|
486
|
+
this.addElementColumns(this.nodeColumns, row.row, record, seen, where);
|
|
487
|
+
positions.add(row, record);
|
|
488
|
+
if (record.graphics !== null && Object.keys(record.graphics).length > 0) {
|
|
489
|
+
graphics.set(row.index, record.graphics);
|
|
490
|
+
}
|
|
491
|
+
pointers.add(row, record);
|
|
492
|
+
}
|
|
493
|
+
this.checkAbort();
|
|
494
|
+
}
|
|
495
|
+
this.writeLabels("node", labels, (row) => this.rowList[row].index);
|
|
496
|
+
positions.write();
|
|
497
|
+
pointers.write();
|
|
498
|
+
if (graph === null) {
|
|
499
|
+
this.writeNetworks();
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Add an element's XML attributes (text grammar) and atts to a column set; atts of a later
|
|
504
|
+
* duplicate declaration only fill names the earlier ones left unset.
|
|
505
|
+
* @param columns - the column set
|
|
506
|
+
* @param row - the row
|
|
507
|
+
* @param record - the node or edge
|
|
508
|
+
* @param seen - the names set by earlier declarations of the same node
|
|
509
|
+
* @param where - the location
|
|
510
|
+
*/
|
|
511
|
+
addElementColumns(columns, row, record, seen, where) {
|
|
512
|
+
const merging = seen.size > 0;
|
|
513
|
+
const names = new Set();
|
|
514
|
+
if (record.name !== null) {
|
|
515
|
+
if (!(merging && seen.has("name"))) {
|
|
516
|
+
columns.addText(row, "name", record.name, where);
|
|
517
|
+
}
|
|
518
|
+
names.add("name");
|
|
519
|
+
}
|
|
520
|
+
for (const [key, value] of record.xmlAttrs) {
|
|
521
|
+
if (!(merging && seen.has(key))) {
|
|
522
|
+
columns.addText(row, key, value, where);
|
|
523
|
+
}
|
|
524
|
+
names.add(key);
|
|
525
|
+
}
|
|
526
|
+
for (const att of record.atts) {
|
|
527
|
+
if (this.skipAtt(record, att) || (merging && att.name !== null && seen.has(att.name))) {
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
if (record.kind === "node" && att.name === "__isGroup" && record.nested.length > 0 && isCyTrue(att.value)) {
|
|
531
|
+
// the group is the containment itself (parent / parents and xgmml.subgraph)
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (att.hasGraph && att.name === null) {
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
columns.addAtt(row, att, where);
|
|
538
|
+
if (att.name !== null) {
|
|
539
|
+
names.add(att.name);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
for (const name of names) {
|
|
543
|
+
seen.add(name);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Whether an att is not a column: an edge's weight att under weightFrom.
|
|
548
|
+
* @param record - the element
|
|
549
|
+
* @param att - the att
|
|
550
|
+
* @returns true to skip it
|
|
551
|
+
*/
|
|
552
|
+
skipAtt(record, att) {
|
|
553
|
+
return record.kind === "edge" && record.weight === null && att.name === this.options.weightFrom;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Write a label column (role label).
|
|
557
|
+
* @param domain - node or edge
|
|
558
|
+
* @param labels - the label of each row
|
|
559
|
+
* @param index - the sink index of a row
|
|
560
|
+
*/
|
|
561
|
+
writeLabels(domain, labels, index) {
|
|
562
|
+
if (!labels.some((l) => l !== undefined)) {
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
const handle = this.declare(domain, {
|
|
566
|
+
name: LABEL_COLUMN,
|
|
567
|
+
dtype: "string",
|
|
568
|
+
role: "label",
|
|
569
|
+
nullable: true,
|
|
570
|
+
origin: { format: FORMAT, id: null, title: null, type: "label", namespace: null },
|
|
571
|
+
});
|
|
572
|
+
if (handle === null) {
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
labels.forEach((label, row) => {
|
|
576
|
+
const i = index(row);
|
|
577
|
+
if (label !== undefined && i >= 0) {
|
|
578
|
+
if (domain === "node") {
|
|
579
|
+
this.sink.setNodeValue(handle, i, label);
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
this.sink.setEdgeValue(handle, i, label);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
/** The `xgmml.networks` membership of nodes in root-level subgraphs of a generic document. */
|
|
588
|
+
writeNetworks() {
|
|
589
|
+
const { subgraphs } = this.doc.root;
|
|
590
|
+
if (subgraphs.length === 0) {
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const member = new Map();
|
|
594
|
+
for (const graph of subgraphs) {
|
|
595
|
+
const name = graph.id ?? graph.label ?? "";
|
|
596
|
+
for (const m of graph.members) {
|
|
597
|
+
const id = m.kind === "node" ? (m.id ?? (m.href === null ? null : localRef(m.href))) : null;
|
|
598
|
+
const row = id === null ? undefined : this.rows.get(id);
|
|
599
|
+
if (row !== undefined && row.index >= 0) {
|
|
600
|
+
const list = member.get(row.index) ?? [];
|
|
601
|
+
list.push(name);
|
|
602
|
+
member.set(row.index, list);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (member.size === 0) {
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
const handle = this.declare("node", {
|
|
610
|
+
name: NETWORKS_COLUMN,
|
|
611
|
+
dtype: "list",
|
|
612
|
+
itemDtype: "string",
|
|
613
|
+
nullable: true,
|
|
614
|
+
origin: { format: FORMAT, id: null, title: null, type: "graph", namespace: XGMML_ORIGIN_NAMESPACE },
|
|
615
|
+
});
|
|
616
|
+
if (handle !== null) {
|
|
617
|
+
for (const [index, list] of member) {
|
|
618
|
+
this.sink.setNodeValue(handle, index, list);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
// ------------------------------------------------------------------ edges
|
|
623
|
+
/**
|
|
624
|
+
* Push every edge record.
|
|
625
|
+
* @param records - the edge records
|
|
626
|
+
* @param header - the header direction
|
|
627
|
+
*/
|
|
628
|
+
addEdges(records, header) {
|
|
629
|
+
const ids = new Set();
|
|
630
|
+
const dedupe = this.dialect.cy2 && this.doc.nodes.some((n) => n.nested.length > 0) ? new Set() : null;
|
|
631
|
+
const labels = [];
|
|
632
|
+
const edgeIds = [];
|
|
633
|
+
const graphics = [];
|
|
634
|
+
let row = 0;
|
|
635
|
+
for (const record of records) {
|
|
636
|
+
const where = { line: record.line, element: record.id ?? record.label };
|
|
637
|
+
const key = record.id ?? record.label;
|
|
638
|
+
if (dedupe !== null && key !== null) {
|
|
639
|
+
if (dedupe.has(key)) {
|
|
640
|
+
this.report.warnOnce("merged", XGMML_ISSUE.GROUP_DUPLICATE_EDGE, `${this.prefix()}the Cytoscape 2.x writer repeats the edges of a group; each repeat is dropped`, where);
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
dedupe.add(key);
|
|
644
|
+
}
|
|
645
|
+
const index = this.addEdge(record, header, ids, where);
|
|
646
|
+
this.checkAbort();
|
|
647
|
+
if (index < 0) {
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
this.edgeIndex[row] = index;
|
|
651
|
+
labels[row] = record.label ?? undefined;
|
|
652
|
+
edgeIds[row] = record.id ?? undefined;
|
|
653
|
+
this.addElementColumns(this.edgeColumns, row, record, new Set(), where);
|
|
654
|
+
this.fillInteraction(record, row, where);
|
|
655
|
+
if (record.graphics !== null && Object.keys(record.graphics).length > 0) {
|
|
656
|
+
graphics.push([index, record.graphics]);
|
|
657
|
+
}
|
|
658
|
+
row++;
|
|
659
|
+
}
|
|
660
|
+
this.writeEdgeIds(edgeIds);
|
|
661
|
+
this.writeLabels("edge", labels, (r) => this.edgeIndex[r] ?? -1);
|
|
662
|
+
const column = new JsonColumn(this, "edge", GRAPHICS_COLUMN, XGMML_ORIGIN_NAMESPACE, "graphics");
|
|
663
|
+
for (const [index, value] of graphics) {
|
|
664
|
+
column.set(index, value);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Push one edge: endpoints (with label aliases), duplicate id check, direction and weight.
|
|
669
|
+
* @param record - the edge
|
|
670
|
+
* @param header - the header direction
|
|
671
|
+
* @param ids - the edge ids used so far
|
|
672
|
+
* @param where - the location
|
|
673
|
+
* @returns the edge index, or -1 when it was skipped
|
|
674
|
+
*/
|
|
675
|
+
addEdge(record, header, ids, where) {
|
|
676
|
+
const alias = this.settings.labelAliases ? aliasesOf(record.label) : null;
|
|
677
|
+
let source;
|
|
678
|
+
let target;
|
|
679
|
+
try {
|
|
680
|
+
source = this.endpoint(record.source, alias?.[0] ?? null, "source", where);
|
|
681
|
+
target = this.endpoint(record.target, alias?.[2] ?? null, "target", where);
|
|
682
|
+
}
|
|
683
|
+
catch (err) {
|
|
684
|
+
// an endpoint the id rule or the sink refuses (ids: "number", a caller's sink)
|
|
685
|
+
this.report.counts.skippedEdges++;
|
|
686
|
+
this.report.recordError(err, where);
|
|
687
|
+
return -1;
|
|
688
|
+
}
|
|
689
|
+
if (source === null || target === null) {
|
|
690
|
+
this.report.counts.skippedEdges++;
|
|
691
|
+
return -1;
|
|
692
|
+
}
|
|
693
|
+
if (record.id !== null && ids.has(record.id)) {
|
|
694
|
+
this.report.counts.skippedEdges++;
|
|
695
|
+
this.report.error("validation-error", XGMML_ISSUE.DUPLICATE_EDGE_ID, `${this.prefix()}edge id "${record.id}" is used twice; the second edge is skipped`, where);
|
|
696
|
+
return -1;
|
|
697
|
+
}
|
|
698
|
+
const directed = this.edgeDirection(record, header);
|
|
699
|
+
const kind = directed ? "directed" : "undirected";
|
|
700
|
+
let weight;
|
|
701
|
+
try {
|
|
702
|
+
weight = this.weightOf(record);
|
|
703
|
+
const before = this.sink.edgeCount;
|
|
704
|
+
const e = this.direction.addEdge(source, target, kind, weight, where);
|
|
705
|
+
this.report.counts.edges += this.sink.edgeCount - before;
|
|
706
|
+
if (record.id !== null) {
|
|
707
|
+
ids.add(record.id);
|
|
708
|
+
}
|
|
709
|
+
return e;
|
|
710
|
+
}
|
|
711
|
+
catch (err) {
|
|
712
|
+
this.report.counts.skippedEdges++;
|
|
713
|
+
this.report.recordError(err, where);
|
|
714
|
+
return -1;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* The weight of an edge: its `weight` XML attribute (or the att weightFrom names).
|
|
719
|
+
* @param record - the edge
|
|
720
|
+
* @returns the weight, or undefined
|
|
721
|
+
*/
|
|
722
|
+
weightOf(record) {
|
|
723
|
+
const from = this.options.weightFrom;
|
|
724
|
+
if (from === null) {
|
|
725
|
+
return undefined;
|
|
726
|
+
}
|
|
727
|
+
let text = from === "weight" ? record.weight : null;
|
|
728
|
+
if (text === null) {
|
|
729
|
+
text = record.atts.find((a) => a.name === from && a.children.length === 0)?.value ?? null;
|
|
730
|
+
}
|
|
731
|
+
if (text === null) {
|
|
732
|
+
return undefined;
|
|
733
|
+
}
|
|
734
|
+
const weight = parseWeightText(text);
|
|
735
|
+
this.weighted || (this.weighted = weight !== undefined);
|
|
736
|
+
return weight;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Resolve one endpoint: a node of this graph by id, else (under labelAliases) the label alias
|
|
740
|
+
* by id or by unique node label; a missing endpoint is E_MISSING_ENDPOINT, an unknown one
|
|
741
|
+
* E_UNKNOWN_NODE unless addMissingNodes adds it.
|
|
742
|
+
* @param text - the source or target text, or null
|
|
743
|
+
* @param alias - the label alias, or null
|
|
744
|
+
* @param side - which end
|
|
745
|
+
* @param where - the location
|
|
746
|
+
* @returns the node id, or null when the edge cannot be added
|
|
747
|
+
*/
|
|
748
|
+
endpoint(text, alias, side, where) {
|
|
749
|
+
if (text !== null) {
|
|
750
|
+
const row = this.rows.get(text);
|
|
751
|
+
if (row !== undefined && row.index >= 0) {
|
|
752
|
+
return this.coercer.text(text);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
if (alias !== null) {
|
|
756
|
+
const resolved = this.aliasRow(alias);
|
|
757
|
+
if (resolved !== null) {
|
|
758
|
+
this.aliasResolved++;
|
|
759
|
+
return this.coercer.text(resolved.id);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
if (text === null) {
|
|
763
|
+
this.report.error("missing-value", XGMML_ISSUE.MISSING_ENDPOINT, `${this.prefix()}<edge> without a ${side}`, where);
|
|
764
|
+
return null;
|
|
765
|
+
}
|
|
766
|
+
if (this.options.addMissingNodes) {
|
|
767
|
+
const id = this.coercer.text(text);
|
|
768
|
+
if (this.sink.indexOf(id) === INVALID_INDEX) {
|
|
769
|
+
this.sink.addNode(id);
|
|
770
|
+
this.report.counts.nodes++;
|
|
771
|
+
}
|
|
772
|
+
return id;
|
|
773
|
+
}
|
|
774
|
+
this.report.error("validation-error", XGMML_ISSUE.UNKNOWN_NODE, `${this.prefix()}edge ${side} "${text}" is not a node of this graph; the edge is skipped`, where);
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* The node a label alias names: by id, else by a label only one node has.
|
|
779
|
+
* @param alias - the alias
|
|
780
|
+
* @returns the row, or null
|
|
781
|
+
*/
|
|
782
|
+
aliasRow(alias) {
|
|
783
|
+
const byId = this.rows.get(alias);
|
|
784
|
+
if (byId !== undefined && byId.index >= 0) {
|
|
785
|
+
return byId;
|
|
786
|
+
}
|
|
787
|
+
if (this.labelRows === null) {
|
|
788
|
+
this.labelRows = new Map();
|
|
789
|
+
for (const row of this.rowList) {
|
|
790
|
+
const { label } = row.records[0];
|
|
791
|
+
if (label !== null && row.index >= 0) {
|
|
792
|
+
this.labelRows.set(label, this.labelRows.has(label) ? null : row);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
return this.labelRows.get(alias) ?? null;
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Fill the interaction of an edge without one from its label alias, as Cytoscape does.
|
|
800
|
+
* @param record - the edge
|
|
801
|
+
* @param row - the edge row
|
|
802
|
+
* @param where - the location
|
|
803
|
+
*/
|
|
804
|
+
fillInteraction(record, row, where) {
|
|
805
|
+
if (!this.settings.labelAliases ||
|
|
806
|
+
this.dialect.session ||
|
|
807
|
+
record.atts.some((a) => a.name === INTERACTION_COLUMN)) {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
const alias = aliasesOf(record.label);
|
|
811
|
+
if (alias !== null) {
|
|
812
|
+
this.edgeColumns.addText(row, INTERACTION_COLUMN, alias[1], where);
|
|
813
|
+
this.aliasInteractions++;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
/** Report the label alias uses, once. */
|
|
817
|
+
finishAliases() {
|
|
818
|
+
if (this.aliasResolved === 0 && this.aliasInteractions === 0) {
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
this.report.warning("coercion", XGMML_ISSUE.LABEL_ALIAS, `${this.prefix()}Cytoscape label aliases ("a (pp) b"): ${this.aliasResolved} endpoint(s) resolved, ${this.aliasInteractions} interaction(s) filled from edge labels`);
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Write the edge `id` column (role id, unique).
|
|
825
|
+
* @param ids - the id of each row
|
|
826
|
+
*/
|
|
827
|
+
writeEdgeIds(ids) {
|
|
828
|
+
if (!ids.some((id) => id !== undefined)) {
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
const handle = this.declare("edge", {
|
|
832
|
+
name: EDGE_ID_COLUMN,
|
|
833
|
+
dtype: "string",
|
|
834
|
+
role: "id",
|
|
835
|
+
unique: true,
|
|
836
|
+
nullable: true,
|
|
837
|
+
origin: { format: FORMAT, id: null, title: null, type: "id", namespace: null },
|
|
838
|
+
});
|
|
839
|
+
if (handle === null) {
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
ids.forEach((id, row) => {
|
|
843
|
+
const e = this.edgeIndex[row];
|
|
844
|
+
if (id !== undefined && e !== undefined) {
|
|
845
|
+
try {
|
|
846
|
+
this.sink.setEdgeValue(handle, e, id);
|
|
847
|
+
}
|
|
848
|
+
catch (err) {
|
|
849
|
+
this.report.recordError(err, { element: id });
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
// ------------------------------------------------------------------ graph and metadata
|
|
855
|
+
/**
|
|
856
|
+
* The graph table: the graph's atts and other XML attributes, and its graphics.
|
|
857
|
+
* @param graph - the subnetwork, or null for the root
|
|
858
|
+
*/
|
|
859
|
+
writeGraph(graph) {
|
|
860
|
+
const g = graph ?? this.doc.root;
|
|
861
|
+
const where = { line: g.line, element: g.id };
|
|
862
|
+
if (graph === null) {
|
|
863
|
+
for (const [key, value] of g.attrs) {
|
|
864
|
+
if (!GRAPH_STRUCTURE.has(key) && key !== "xmlns" && !key.startsWith("xmlns:")) {
|
|
865
|
+
this.graphColumns.addText(0, key, value, where);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
for (const att of g.atts) {
|
|
870
|
+
if (att.name !== null && META_ATTS.has(att.name)) {
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
if (att.hasGraph && att.name === null) {
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
this.graphColumns.addAtt(0, att, where);
|
|
877
|
+
}
|
|
878
|
+
this.graphColumns.write(this.sink, () => 0);
|
|
879
|
+
const json = { ...(this.extras.graphJson ?? {}) };
|
|
880
|
+
if (g.graphics !== null && Object.keys(g.graphics).length > 0) {
|
|
881
|
+
json[GRAPHICS_COLUMN] = g.graphics;
|
|
882
|
+
}
|
|
883
|
+
for (const [name, value] of Object.entries(json)) {
|
|
884
|
+
this.sink.setGraphValue(name, value, {
|
|
885
|
+
name,
|
|
886
|
+
dtype: "json",
|
|
887
|
+
nullable: true,
|
|
888
|
+
origin: { format: FORMAT, id: null, title: null, type: name, namespace: XGMML_ORIGIN_NAMESPACE },
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* The graph metadata: name, description and date from the RDF metadata, the format, version
|
|
894
|
+
* and what the exporter needs.
|
|
895
|
+
* @param graph - the subnetwork, or null
|
|
896
|
+
*/
|
|
897
|
+
writeMeta(graph) {
|
|
898
|
+
const g = graph ?? this.doc.root;
|
|
899
|
+
const { rdf } = this.doc;
|
|
900
|
+
const name = this.extras.graphName ?? g.label ?? rdf.title ?? g.id ?? undefined;
|
|
901
|
+
this.sink.setMeta({
|
|
902
|
+
name: name ?? undefined,
|
|
903
|
+
description: rdf.description ?? undefined,
|
|
904
|
+
created: rdf.date ?? undefined,
|
|
905
|
+
sourceFormat: this.extras.sourceFormat ?? FORMAT,
|
|
906
|
+
sourceVersion: this.dialect.versionText ?? undefined,
|
|
907
|
+
weightOrigin: this.weighted
|
|
908
|
+
? { format: FORMAT, id: "weight", title: null, type: "weight", namespace: null }
|
|
909
|
+
: undefined,
|
|
910
|
+
extra: {
|
|
911
|
+
[META_KEY]: {
|
|
912
|
+
graphId: g.id,
|
|
913
|
+
directed: this.doc.root.attrs.get("directed") ?? null,
|
|
914
|
+
rdf: Object.keys(rdf).length > 0 ? { ...rdf } : null,
|
|
915
|
+
},
|
|
916
|
+
...(this.extras.metaExtra ?? {}),
|
|
917
|
+
},
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* The three parts of a Cytoscape edge label (`source (interaction) target`).
|
|
923
|
+
* @param label - the label, or null
|
|
924
|
+
* @returns source alias, interaction, target alias; null when the label has another shape
|
|
925
|
+
*/
|
|
926
|
+
export function aliasesOf(label) {
|
|
927
|
+
if (label === null) {
|
|
928
|
+
return null;
|
|
929
|
+
}
|
|
930
|
+
const parts = label.split(ALIAS_SPLIT);
|
|
931
|
+
return parts.length === 3 ? [parts[0].trim(), parts[1], parts[2].trim()] : null;
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* A json column the importer owns, declared on first value.
|
|
935
|
+
*/
|
|
936
|
+
class JsonColumn {
|
|
937
|
+
/**
|
|
938
|
+
* Create the column.
|
|
939
|
+
* @param emitter - the emitter
|
|
940
|
+
* @param domain - node or edge
|
|
941
|
+
* @param name - the column name
|
|
942
|
+
* @param namespace - its origin namespace
|
|
943
|
+
* @param type - its origin type
|
|
944
|
+
* @param dtype - json (default) or string
|
|
945
|
+
*/
|
|
946
|
+
constructor(emitter, domain, name, namespace, type, dtype = "json") {
|
|
947
|
+
this.emitter = emitter;
|
|
948
|
+
this.domain = domain;
|
|
949
|
+
this.name = name;
|
|
950
|
+
this.namespace = namespace;
|
|
951
|
+
this.type = type;
|
|
952
|
+
this.dtype = dtype;
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Set one value.
|
|
956
|
+
* @param index - the node or edge index
|
|
957
|
+
* @param value - the value
|
|
958
|
+
*/
|
|
959
|
+
set(index, value) {
|
|
960
|
+
if (index < 0) {
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
if (this.handle === undefined) {
|
|
964
|
+
this.handle = this.emitter.declare(this.domain, {
|
|
965
|
+
name: this.name,
|
|
966
|
+
dtype: this.dtype,
|
|
967
|
+
nullable: true,
|
|
968
|
+
origin: { format: FORMAT, id: null, title: null, type: this.type, namespace: this.namespace },
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
if (this.handle === null) {
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const sink = this.emitter.target;
|
|
975
|
+
if (this.domain === "node") {
|
|
976
|
+
sink.setNodeValue(this.handle, index, value);
|
|
977
|
+
}
|
|
978
|
+
else {
|
|
979
|
+
sink.setEdgeValue(this.handle, index, value);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Node positions: x and y (y negated: Cytoscape's screen y grows down, graph-io's grows up), z
|
|
985
|
+
* as a coordinate in the draft dialect or under zAs "position", else as the `z` column.
|
|
986
|
+
*/
|
|
987
|
+
class Positions {
|
|
988
|
+
/**
|
|
989
|
+
* Create the collector.
|
|
990
|
+
* @param emitter - the emitter
|
|
991
|
+
* @param zAs - where z goes
|
|
992
|
+
*/
|
|
993
|
+
constructor(emitter, zAs) {
|
|
994
|
+
this.emitter = emitter;
|
|
995
|
+
this.zAs = zAs;
|
|
996
|
+
this.values = new Map();
|
|
997
|
+
this.zValues = new Map();
|
|
998
|
+
this.hasZ = false;
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Read one node record's coordinates.
|
|
1002
|
+
* @param row - the node row
|
|
1003
|
+
* @param record - the record
|
|
1004
|
+
*/
|
|
1005
|
+
add(row, record) {
|
|
1006
|
+
if (row.index < 0 || (record.x === null && record.y === null && record.z === null)) {
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
const where = { line: record.line, element: row.id };
|
|
1010
|
+
const x = this.number(record.x, where);
|
|
1011
|
+
const y = this.number(record.y, where);
|
|
1012
|
+
const z = this.number(record.z, where);
|
|
1013
|
+
if (z !== null) {
|
|
1014
|
+
this.hasZ = true;
|
|
1015
|
+
if (this.zAs === "column") {
|
|
1016
|
+
this.zValues.set(row.index, z);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
if (x !== null && y !== null) {
|
|
1020
|
+
const flipped = y === 0 ? 0 : -y;
|
|
1021
|
+
this.values.set(row.index, [x, flipped, this.zAs === "position" ? (z ?? 0) : 0]);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* A coordinate.
|
|
1026
|
+
* @param text - the text, or null
|
|
1027
|
+
* @param where - the location
|
|
1028
|
+
* @returns the number, or null when absent or invalid (recorded)
|
|
1029
|
+
*/
|
|
1030
|
+
number(text, where) {
|
|
1031
|
+
if (text === null) {
|
|
1032
|
+
return null;
|
|
1033
|
+
}
|
|
1034
|
+
const n = Number(text.trim());
|
|
1035
|
+
if (text.trim().length === 0 || !Number.isFinite(n)) {
|
|
1036
|
+
this.emitter.issues.error("validation-error", XGMML_ISSUE.BAD_VALUE, `${this.emitter.prefix()}graphics coordinate "${text}" is not a number`, where);
|
|
1037
|
+
return null;
|
|
1038
|
+
}
|
|
1039
|
+
return n;
|
|
1040
|
+
}
|
|
1041
|
+
/** Declare and write the position and z columns. */
|
|
1042
|
+
write() {
|
|
1043
|
+
const sink = this.emitter.target;
|
|
1044
|
+
if (this.values.size > 0) {
|
|
1045
|
+
const handle = this.emitter.declare("node", {
|
|
1046
|
+
name: POSITION_COLUMN,
|
|
1047
|
+
dtype: "f32",
|
|
1048
|
+
components: 3,
|
|
1049
|
+
role: "position",
|
|
1050
|
+
nullable: true,
|
|
1051
|
+
origin: { format: FORMAT, id: null, title: null, type: "graphics", namespace: XGMML_ORIGIN_NAMESPACE },
|
|
1052
|
+
extra: { sourceDims: this.zAs === "position" && this.hasZ ? 3 : 2, units: "file" },
|
|
1053
|
+
});
|
|
1054
|
+
if (handle !== null) {
|
|
1055
|
+
for (const [index, xyz] of this.values) {
|
|
1056
|
+
sink.setNodeValue(handle, index, xyz);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
if (this.zValues.size > 0) {
|
|
1061
|
+
const handle = this.emitter.declare("node", {
|
|
1062
|
+
name: Z_COLUMN,
|
|
1063
|
+
dtype: "f64",
|
|
1064
|
+
nullable: true,
|
|
1065
|
+
origin: {
|
|
1066
|
+
format: FORMAT,
|
|
1067
|
+
id: null,
|
|
1068
|
+
title: null,
|
|
1069
|
+
type: "graphics",
|
|
1070
|
+
namespace: CYTOSCAPE_ORIGIN_NAMESPACE,
|
|
1071
|
+
},
|
|
1072
|
+
});
|
|
1073
|
+
if (handle !== null) {
|
|
1074
|
+
for (const [index, z] of this.zValues) {
|
|
1075
|
+
sink.setNodeValue(handle, index, z);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Nested-network pointers: a node-nested graph that is not a group names a network.
|
|
1083
|
+
*/
|
|
1084
|
+
class Pointers {
|
|
1085
|
+
/**
|
|
1086
|
+
* Create the collector.
|
|
1087
|
+
* @param emitter - the emitter
|
|
1088
|
+
* @param isGroup - the group test
|
|
1089
|
+
*/
|
|
1090
|
+
constructor(emitter, isGroup) {
|
|
1091
|
+
this.emitter = emitter;
|
|
1092
|
+
this.isGroup = isGroup;
|
|
1093
|
+
this.names = new Map();
|
|
1094
|
+
this.crossFile = new Map();
|
|
1095
|
+
this.dangling = 0;
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Read one node record's pointers.
|
|
1099
|
+
* @param row - the node row
|
|
1100
|
+
* @param record - the record
|
|
1101
|
+
*/
|
|
1102
|
+
add(row, record) {
|
|
1103
|
+
if (record.nested.length === 0 || this.isGroup(record) || row.index < 0) {
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
for (const graph of record.nested) {
|
|
1107
|
+
if (graph.href === null) {
|
|
1108
|
+
this.names.set(row.index, graph.label ?? graph.id ?? "");
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
const id = localRef(graph.href);
|
|
1112
|
+
if (id === null) {
|
|
1113
|
+
const resolved = this.emitter.resolvePointer(graph.href);
|
|
1114
|
+
if (resolved !== null) {
|
|
1115
|
+
this.names.set(row.index, resolved);
|
|
1116
|
+
}
|
|
1117
|
+
else {
|
|
1118
|
+
this.crossFile.set(row.index, graph.href);
|
|
1119
|
+
}
|
|
1120
|
+
continue;
|
|
1121
|
+
}
|
|
1122
|
+
const target = this.emitter.graphById(id);
|
|
1123
|
+
if (target === undefined) {
|
|
1124
|
+
this.dangling++;
|
|
1125
|
+
}
|
|
1126
|
+
else {
|
|
1127
|
+
this.names.set(row.index, target.label ?? target.id ?? id);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
/** Write the pointer columns and their warnings. */
|
|
1132
|
+
write() {
|
|
1133
|
+
const prefix = this.emitter.prefix();
|
|
1134
|
+
if (this.dangling > 0) {
|
|
1135
|
+
this.emitter.issues.warning("validation-error", XGMML_ISSUE.DANGLING_REFERENCE, `${prefix}${this.dangling} nested-network pointer(s) name no graph of the document`);
|
|
1136
|
+
}
|
|
1137
|
+
const names = new JsonColumn(this.emitter, "node", NESTED_NETWORK_COLUMN, CYTOSCAPE_ORIGIN_NAMESPACE, "nestedNetwork", "string");
|
|
1138
|
+
for (const [index, name] of this.names) {
|
|
1139
|
+
names.set(index, name);
|
|
1140
|
+
}
|
|
1141
|
+
if (this.crossFile.size > 0) {
|
|
1142
|
+
this.emitter.issues.warning("unsupported", XGMML_ISSUE.CROSS_FILE_REFERENCE, `${prefix}${this.crossFile.size} nested-network pointer(s) point into another file; each is kept as text in ${NETWORK_POINTER_COLUMN}`);
|
|
1143
|
+
const column = new JsonColumn(this.emitter, "node", NETWORK_POINTER_COLUMN, XGMML_ORIGIN_NAMESPACE, "xlink:href", "string");
|
|
1144
|
+
for (const [index, href] of this.crossFile) {
|
|
1145
|
+
column.set(index, href);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Group containment: every node (declared or referenced) in a group's nested graph has the group
|
|
1152
|
+
* node as a parent; a node in several groups gets the `parents` list instead of `parent`.
|
|
1153
|
+
*/
|
|
1154
|
+
class Containment {
|
|
1155
|
+
/**
|
|
1156
|
+
* Create the resolver.
|
|
1157
|
+
* @param emitter - the emitter
|
|
1158
|
+
*/
|
|
1159
|
+
constructor(emitter) {
|
|
1160
|
+
this.emitter = emitter;
|
|
1161
|
+
this.parents = new Map();
|
|
1162
|
+
this.subgraphs = new Map();
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Collect the memberships of every group node of the graph.
|
|
1166
|
+
* @param rows - the node rows
|
|
1167
|
+
* @param isGroup - the group test
|
|
1168
|
+
*/
|
|
1169
|
+
resolve(rows, isGroup) {
|
|
1170
|
+
let unknown = 0;
|
|
1171
|
+
for (const row of rows) {
|
|
1172
|
+
for (const record of row.records) {
|
|
1173
|
+
if (row.index < 0 || record.nested.length === 0 || !isGroup(record)) {
|
|
1174
|
+
continue;
|
|
1175
|
+
}
|
|
1176
|
+
for (const graph of record.nested) {
|
|
1177
|
+
const atts = graph.atts.filter((a) => a.name !== null);
|
|
1178
|
+
if (graph.id !== null || graph.label !== null || atts.length > 0) {
|
|
1179
|
+
this.subgraphs.set(row.index, {
|
|
1180
|
+
id: graph.id,
|
|
1181
|
+
label: graph.label,
|
|
1182
|
+
atts: Object.fromEntries(atts.map((a) => [a.name, attJson(a)])),
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
for (const member of graph.members) {
|
|
1186
|
+
if (member.kind !== "node") {
|
|
1187
|
+
continue;
|
|
1188
|
+
}
|
|
1189
|
+
const id = member.id ?? (member.href === null ? null : localRef(member.href));
|
|
1190
|
+
const child = id === null ? undefined : this.emitter.rowOf(id);
|
|
1191
|
+
if (child === undefined) {
|
|
1192
|
+
if (id === null || !this.emitter.missingMember(row.id, id)) {
|
|
1193
|
+
unknown++;
|
|
1194
|
+
}
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
if (child.index >= 0) {
|
|
1198
|
+
this.link(child.index, row.index, member.line);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
if (unknown > 0) {
|
|
1205
|
+
this.emitter.issues.error("missing-value", XGMML_ISSUE.UNKNOWN_PARENT, `${this.emitter.prefix()}${unknown} group member(s) name no node of this graph`);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Add one membership unless it closes a parent cycle.
|
|
1210
|
+
* @param child - the member's index
|
|
1211
|
+
* @param parent - the group node's index
|
|
1212
|
+
* @param line - the line
|
|
1213
|
+
*/
|
|
1214
|
+
link(child, parent, line) {
|
|
1215
|
+
if (child === parent || this.isAncestor(child, parent)) {
|
|
1216
|
+
this.emitter.issues.error("validation-error", XGMML_ISSUE.PARENT_CYCLE, `${this.emitter.prefix()}a group membership would make a node its own ancestor; that membership is dropped`, { line });
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
const list = this.parents.get(child) ?? [];
|
|
1220
|
+
if (!list.includes(parent)) {
|
|
1221
|
+
list.push(parent);
|
|
1222
|
+
}
|
|
1223
|
+
this.parents.set(child, list);
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* Whether a node is an ancestor of another through the memberships so far.
|
|
1227
|
+
* @param ancestor - the candidate ancestor
|
|
1228
|
+
* @param node - the node
|
|
1229
|
+
* @returns true when following parents from node reaches ancestor
|
|
1230
|
+
*/
|
|
1231
|
+
isAncestor(ancestor, node) {
|
|
1232
|
+
const stack = [node];
|
|
1233
|
+
const seen = new Set();
|
|
1234
|
+
while (stack.length > 0) {
|
|
1235
|
+
const n = stack.pop();
|
|
1236
|
+
if (n === ancestor) {
|
|
1237
|
+
return true;
|
|
1238
|
+
}
|
|
1239
|
+
if (seen.has(n)) {
|
|
1240
|
+
continue;
|
|
1241
|
+
}
|
|
1242
|
+
seen.add(n);
|
|
1243
|
+
stack.push(...(this.parents.get(n) ?? []));
|
|
1244
|
+
}
|
|
1245
|
+
return false;
|
|
1246
|
+
}
|
|
1247
|
+
/** Declare and write the parent (or parents) and subgraph columns. */
|
|
1248
|
+
write() {
|
|
1249
|
+
const sink = this.emitter.target;
|
|
1250
|
+
if (this.parents.size > 0) {
|
|
1251
|
+
const multi = [...this.parents.values()].some((list) => list.length > 1);
|
|
1252
|
+
const handle = this.emitter.declare("node", {
|
|
1253
|
+
name: multi ? PARENTS_COLUMN : PARENT_COLUMN,
|
|
1254
|
+
dtype: multi ? "list" : "u32",
|
|
1255
|
+
itemDtype: multi ? "u32" : undefined,
|
|
1256
|
+
role: multi ? "parents" : "parent",
|
|
1257
|
+
refersTo: "node",
|
|
1258
|
+
nullable: true,
|
|
1259
|
+
origin: { format: FORMAT, id: null, title: null, type: "graph", namespace: null },
|
|
1260
|
+
});
|
|
1261
|
+
if (handle !== null) {
|
|
1262
|
+
for (const [child, list] of this.parents) {
|
|
1263
|
+
sink.setNodeValue(handle, child, multi ? list : list[0]);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
if (this.subgraphs.size > 0) {
|
|
1268
|
+
const column = new JsonColumn(this.emitter, "node", SUBGRAPH_COLUMN, XGMML_ORIGIN_NAMESPACE, "graph");
|
|
1269
|
+
for (const [index, value] of this.subgraphs) {
|
|
1270
|
+
column.set(index, value);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
//# sourceMappingURL=emit.js.map
|