@graphty/cytoscape-extensions 0.0.0-placeholder.0 → 0.0.2

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/src/io.ts ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * `@graphty/cytoscape-extensions/io`: reading and writing Cytoscape graphs in the file formats of @graphty/graph-io.
3
+ * cy.graphtyImport() and cy.graphtyExport() load this module on their first call, so a page that never imports or
4
+ * exports a file never downloads the parsers.
5
+ */
6
+
7
+ import {
8
+ checkExport,
9
+ type ExportGraphOptions,
10
+ exportGraphToString,
11
+ ImportError,
12
+ importGraph,
13
+ type ImportGraphOptions,
14
+ type ImportInput,
15
+ type ImportIssue,
16
+ type ImportReport,
17
+ type LossNote,
18
+ } from "@graphty/graph-io";
19
+ import type { Collection, ElementDefinition } from "cytoscape";
20
+
21
+ import { elementsToSnapshot, flipY, snapshotToElements } from "./elements.js";
22
+
23
+ /** One thing graphtyExport's format cannot hold: `code`, `message`, the data field (`column`) and how many values. */
24
+ export type { LossNote } from "@graphty/graph-io";
25
+
26
+ /**
27
+ * The formats graphtyImport reads; "auto" sniffs the format from the content (and `filename` when given). "cys" is a
28
+ * Cytoscape desktop session file, which must be passed as bytes.
29
+ */
30
+ export type ImportFormat =
31
+ | "auto"
32
+ | "gexf"
33
+ | "graphml"
34
+ | "gml"
35
+ | "dot"
36
+ | "pajek"
37
+ | "csv"
38
+ | "json"
39
+ | "neo4j"
40
+ | "xgmml"
41
+ | "cx2"
42
+ | "cx"
43
+ | "obo"
44
+ | "cys";
45
+
46
+ /** The formats graphtyExport writes. XGMML is the one Cytoscape desktop opens as a network file. */
47
+ export type ExportFormat = "gexf" | "graphml" | "gml" | "dot" | "pajek" | "csv" | "json" | "neo4j" | "xgmml" | "cx2";
48
+
49
+ /** Options of graphtyImport: graph-io's import options (`filename`, `graphIndex`, format-specific ones, ...). */
50
+ export type ImportOptions = Omit<ImportGraphOptions, "format">;
51
+
52
+ /**
53
+ * Options of graphtyExport: graph-io's export options for the format, plus whether to write a directed graph.
54
+ * `sanitizeIds` defaults to "mangle" here (graph-io's default is "error"): GML and CX2 take only integer ids, so
55
+ * when a node id is not a plain non-negative integer ("a", "-1", "007") the nodes are numbered and the original
56
+ * kept in an attribute graphtyImport restores. When every id is one ("0", "17"), the ids are written as they are,
57
+ * and "error" accepts them too.
58
+ * JSON is written as Cytoscape JSON (`dialect: "cytoscape"`, the shape `cy.json()` and `cy.add()` use), which keeps
59
+ * edge ids, compound parents and positions; pass `dialect: "node-link"` for the NetworkX shape.
60
+ */
61
+ export interface ExportOptions extends ExportGraphOptions {
62
+ /** Write the graph as directed. Default false. */
63
+ readonly directed?: boolean | undefined;
64
+ /**
65
+ * Called before writing with what the format cannot hold (positions in GraphML, lists and objects in DOT,
66
+ * ...), one note per column and kind of loss; not called when nothing is lost. Without it a loss is silent.
67
+ */
68
+ readonly onLoss?: ((notes: readonly LossNote[]) => void) | undefined;
69
+ }
70
+
71
+ /** A file read into Cytoscape element definitions. */
72
+ export interface ImportedElements {
73
+ /** The nodes, then the edges. */
74
+ readonly elements: ElementDefinition[];
75
+ /** Whether the file's graph is directed: pass it as `directed` to the algorithms. */
76
+ readonly directed: boolean;
77
+ /** The format the input was read as. */
78
+ readonly format: string;
79
+ /** graph-io's report: counts, warnings, and what could not be represented. */
80
+ readonly report: ImportReport;
81
+ }
82
+
83
+ /**
84
+ * Reads a graph file into Cytoscape element definitions.
85
+ * @param input - the file as text, bytes, a stream or chunks
86
+ * @param format - the format, or "auto" to sniff it
87
+ * @param options - graph-io's import options
88
+ * @returns the elements, the direction, the format and graph-io's report
89
+ * @throws ImportError (from graph-io) when the file cannot be read, or when it yields no node and reports an error
90
+ */
91
+ export async function importElements(
92
+ input: ImportInput | ArrayBuffer,
93
+ format: ImportFormat = "auto",
94
+ options: ImportOptions = {},
95
+ ): Promise<ImportedElements> {
96
+ // fetch().arrayBuffer() and File.arrayBuffer() give an ArrayBuffer, which graph-io does not take
97
+ const r = await importGraph(input instanceof ArrayBuffer ? new Uint8Array(input) : input, { ...options, format });
98
+ // most text sniffs as CSV, so a file that is not a graph at all reads as an empty one with errors
99
+ if (r.snapshot.nodeCount === 0 && r.report.errorCount > 0) {
100
+ const first = r.report.issues.find((i) => i.severity === "error");
101
+ throw new ImportError(
102
+ `nothing could be read from the input as ${r.format}: ${first?.code ?? "E_IMPORT"}: ${first?.message ?? ""}`,
103
+ r.report,
104
+ { format: r.format },
105
+ );
106
+ }
107
+ const warnings: ImportIssue[] = [];
108
+ const elements = snapshotToElements(
109
+ r.snapshot,
110
+ ({ domain, from, to }) =>
111
+ warnings.push({
112
+ category: "coercion",
113
+ severity: "warning",
114
+ code: "W_COLUMN_RENAMED",
115
+ message: `${domain} attribute "${from}" renamed to "${to}": Cytoscape reserves the data field "${from}"`,
116
+ line: null,
117
+ element: from,
118
+ }),
119
+ (id, takenBy) =>
120
+ warnings.push({
121
+ category: "coercion",
122
+ severity: "warning",
123
+ code: "W_EDGE_ID_DROPPED",
124
+ message: `edge id "${id}" is already the id of ${takenBy === "node" ? "a node" : "an earlier edge"}; Cytoscape gives this edge a new id`,
125
+ line: null,
126
+ element: id,
127
+ }),
128
+ );
129
+ // graph-io stores positions with y growing upward; Cytoscape's y grows downward
130
+ for (const el of elements) {
131
+ if (el.position !== undefined) {
132
+ el.position.y = flipY(el.position.y);
133
+ }
134
+ }
135
+ const report =
136
+ warnings.length === 0
137
+ ? r.report
138
+ : {
139
+ ...r.report,
140
+ issues: [...r.report.issues, ...warnings],
141
+ warningCount: r.report.warningCount + warnings.length,
142
+ };
143
+ return { elements, directed: r.snapshot.directed, format: r.format, report };
144
+ }
145
+
146
+ /**
147
+ * Whether a graph-io loss note is a loss for a Cytoscape graph. graph-io's notes describe its own snapshot read back
148
+ * by itself; graphtyImport restores some of what they report:
149
+ * - W_ID_TEXT_TYPE: every id is turned back into a string;
150
+ * - W_COLUMN_NAME_CHANGED on the edge `id`, the `parent` or the `position` (DOT reads them back as "key",
151
+ * "graphty.parent" and "pos"): they are read back by their role, as the edge id, `data.parent` and the position;
152
+ * - W_ROLE_DROPPED on `label`: the values read back as `data.label`, and Cytoscape has no label role to lose.
153
+ * @param n - the note
154
+ * @returns false for a note about something graphtyImport gives back
155
+ */
156
+ function lostForCytoscape(n: LossNote): boolean {
157
+ if (n.code === "W_ID_TEXT_TYPE") {
158
+ return false;
159
+ }
160
+ if (n.code === "W_COLUMN_NAME_CHANGED" && (n.column === "id" || n.column === "parent" || n.column === "position")) {
161
+ return false;
162
+ }
163
+ return !(n.code === "W_ROLE_DROPPED" && n.column === "label");
164
+ }
165
+
166
+ /** The formats that hold only integer node ids: a graph whose ids are all integers is written with them as they are. */
167
+ const INTEGER_ID_FORMATS: ReadonlySet<ExportFormat> = new Set(["gml", "cx2"]);
168
+
169
+ /**
170
+ * Writes a collection as a graph file: every data field, each node's position and compound parent, and each
171
+ * edge's id, as far as the format holds them (`onLoss` hears about the rest).
172
+ * @param eles - the nodes and the edges between them
173
+ * @param format - the format
174
+ * @param options - graph-io's export options for the format, plus `directed`
175
+ * @returns the file's text; rejects (a TypeError) when `eles` is not a collection
176
+ */
177
+ export async function exportElements(
178
+ eles: Collection,
179
+ format: ExportFormat,
180
+ options: ExportOptions = {},
181
+ ): Promise<string> {
182
+ // importElements returns definitions, which this cannot read without a core to build them
183
+ if (typeof (eles as Partial<Collection> | null)?.nodes !== "function") {
184
+ throw new TypeError(
185
+ "exportElements needs a Cytoscape collection; wrap element definitions with cytoscape({ headless: true, elements }).elements()",
186
+ );
187
+ }
188
+ const { directed, onLoss, ...rest } = options;
189
+ const snapshot = elementsToSnapshot(eles, {
190
+ directed,
191
+ integerIds: INTEGER_ID_FORMATS.has(format),
192
+ });
193
+ const graphOptions = {
194
+ sanitizeIds: "mangle" as const,
195
+ ...(format === "json" ? { dialect: "cytoscape" } : {}),
196
+ ...rest,
197
+ };
198
+ if (onLoss !== undefined) {
199
+ const notes = checkExport(snapshot, format, graphOptions).filter(lostForCytoscape);
200
+ if (notes.length > 0) {
201
+ onLoss(notes);
202
+ }
203
+ }
204
+ return exportGraphToString(snapshot, format, graphOptions);
205
+ }