@graphty/cytoscape-extensions 0.0.22 → 0.0.24
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/package.json +7 -7
- package/src/elements.ts +53 -7
- package/src/graph-data.ts +69 -5
- package/src/index.ts +8 -1
- package/src/io.ts +219 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@graphty/cytoscape-extensions",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.24",
|
|
4
4
|
"description": "Graphty layouts, algorithms, graph generators, sample datasets and file import/export as Cytoscape.js extensions",
|
|
5
5
|
"author": "Adam Powers <apowers@ato.ms>",
|
|
6
6
|
"type": "module",
|
|
@@ -71,9 +71,9 @@
|
|
|
71
71
|
},
|
|
72
72
|
"homepage": "https://graphty.app/docs/cytoscape-extensions/",
|
|
73
73
|
"dependencies": {
|
|
74
|
-
"@graphty/graph-io": "^0.3.
|
|
75
|
-
"@graphty/graph-
|
|
76
|
-
"@graphty/
|
|
74
|
+
"@graphty/graph-io": "^0.3.44",
|
|
75
|
+
"@graphty/webgpu-graph-algorithms": "^0.6.59",
|
|
76
|
+
"@graphty/graph-samples": "^0.1.37"
|
|
77
77
|
},
|
|
78
78
|
"peerDependencies": {
|
|
79
79
|
"@graphty/algorithms": "^3.0.0",
|
|
@@ -103,9 +103,9 @@
|
|
|
103
103
|
"typescript": "^5.9.3",
|
|
104
104
|
"vite": "^7.0.5",
|
|
105
105
|
"vitest": "4.1.11",
|
|
106
|
-
"@graphty/algorithms": "^3.4.
|
|
107
|
-
"@graphty/graph-format": "^1.4.
|
|
108
|
-
"@graphty/layout": "^2.5.
|
|
106
|
+
"@graphty/algorithms": "^3.4.12",
|
|
107
|
+
"@graphty/graph-format": "^1.4.5",
|
|
108
|
+
"@graphty/layout": "^2.5.8"
|
|
109
109
|
},
|
|
110
110
|
"scripts": {
|
|
111
111
|
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && vite build --config vite.bundle.config.ts --mode cdn --logLevel warn && vite build --config vite.bundle.config.ts --logLevel warn",
|
package/src/elements.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { type Column, type ColumnInput, fromEdgeArrays, type GraphSnapshot } from "@graphty/graph-format";
|
|
8
|
+
import { writtenPositions } from "@graphty/graph-io/positions";
|
|
8
9
|
import type { Collection, ElementDefinition } from "cytoscape";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -54,18 +55,26 @@ interface RenamedColumn {
|
|
|
54
55
|
* @param snapshot - the snapshot
|
|
55
56
|
* @param onRename - told about each column renamed because its name is reserved
|
|
56
57
|
* @param onIdDropped - told about each edge id dropped, and whether a node or an earlier edge has it
|
|
58
|
+
* @param styled - whether the file's styles are read as a stylesheet: then the columns that hold styles (a GEXF
|
|
59
|
+
* `viz:color`, `viz:size`, `viz:shape`, its image and `viz:thickness`) are left out of data, because the
|
|
60
|
+
* stylesheet carries them
|
|
57
61
|
* @returns the nodes, then the edges
|
|
58
62
|
*/
|
|
59
63
|
export function snapshotToElements(
|
|
60
64
|
snapshot: GraphSnapshot,
|
|
61
65
|
onRename?: (renamed: RenamedColumn) => void,
|
|
62
66
|
onIdDropped?: (id: string, takenBy: "node" | "edge") => void,
|
|
67
|
+
styled = false,
|
|
63
68
|
): ElementDefinition[] {
|
|
64
69
|
const ids: string[] = [];
|
|
65
70
|
for (let i = 0; i < snapshot.nodeCount; i++) {
|
|
66
71
|
ids.push(String(snapshot.ids.idOf(i)));
|
|
67
72
|
}
|
|
68
|
-
|
|
73
|
+
const keep = styled ? (c: Column): boolean => kept(c) && !holdsStyle(c) : kept;
|
|
74
|
+
return [
|
|
75
|
+
...nodeElements(snapshot, ids, onRename, keep),
|
|
76
|
+
...edgeElements(snapshot, ids, onRename, onIdDropped, keep),
|
|
77
|
+
];
|
|
69
78
|
}
|
|
70
79
|
|
|
71
80
|
/**
|
|
@@ -77,6 +86,19 @@ function kept(c: Column): boolean {
|
|
|
77
86
|
return c.meta.role !== "id" && c.meta.role !== "parent";
|
|
78
87
|
}
|
|
79
88
|
|
|
89
|
+
/** The roles of the columns that hold styles, which graph-io reads as a file's styles. */
|
|
90
|
+
const STYLE_ROLES: ReadonlySet<string> = new Set(["color", "size", "shape", "thickness"]);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Whether a column holds styles: one with a style role, or GEXF's image of an `image` shape.
|
|
94
|
+
* @param c - the column
|
|
95
|
+
* @returns true for a style column
|
|
96
|
+
*/
|
|
97
|
+
function holdsStyle(c: Column): boolean {
|
|
98
|
+
const { role, name, origin } = c.meta;
|
|
99
|
+
return (role !== null && STYLE_ROLES.has(role)) || (name === "shape.uri" && origin?.namespace === "viz");
|
|
100
|
+
}
|
|
101
|
+
|
|
80
102
|
/**
|
|
81
103
|
* The data field name of each column: its own, or for a reserved name `<name>#<origin id>` (`<name>#2`, `#3`, ...
|
|
82
104
|
* without one, or when that is taken), the rule graph-io follows when two attributes share a name.
|
|
@@ -137,14 +159,16 @@ function rowData(cols: readonly Column[], row: number, fieldOf: (c: Column) => s
|
|
|
137
159
|
* @param snapshot - the snapshot
|
|
138
160
|
* @param ids - the node ids as strings, by node index
|
|
139
161
|
* @param onRename - told about each renamed column
|
|
162
|
+
* @param keep - whether a column becomes a data field
|
|
140
163
|
* @returns one element per node
|
|
141
164
|
*/
|
|
142
165
|
function nodeElements(
|
|
143
166
|
snapshot: GraphSnapshot,
|
|
144
167
|
ids: readonly string[],
|
|
145
168
|
onRename: ((renamed: RenamedColumn) => void) | undefined,
|
|
169
|
+
keep: (c: Column) => boolean,
|
|
146
170
|
): ElementDefinition[] {
|
|
147
|
-
const nodeCols = [...snapshot.nodes].filter(
|
|
171
|
+
const nodeCols = [...snapshot.nodes].filter(keep);
|
|
148
172
|
const position = nodeCols.find((c) => c.meta.role === "position" && c.meta.components >= 2);
|
|
149
173
|
const nodeData = nodeCols.filter((c) => c !== position);
|
|
150
174
|
const names = fieldNames(nodeData, "node", onRename);
|
|
@@ -173,6 +197,7 @@ function nodeElements(
|
|
|
173
197
|
* @param ids - the node ids as strings, by node index
|
|
174
198
|
* @param onRename - told about each renamed column
|
|
175
199
|
* @param onIdDropped - told about each dropped edge id
|
|
200
|
+
* @param keep - whether a column becomes a data field
|
|
176
201
|
* @returns one element per edge
|
|
177
202
|
*/
|
|
178
203
|
function edgeElements(
|
|
@@ -180,8 +205,9 @@ function edgeElements(
|
|
|
180
205
|
ids: readonly string[],
|
|
181
206
|
onRename: ((renamed: RenamedColumn) => void) | undefined,
|
|
182
207
|
onIdDropped: ((id: string, takenBy: "node" | "edge") => void) | undefined,
|
|
208
|
+
keep: (c: Column) => boolean,
|
|
183
209
|
): ElementDefinition[] {
|
|
184
|
-
const edgeCols = [...snapshot.edges].filter(
|
|
210
|
+
const edgeCols = [...snapshot.edges].filter(keep);
|
|
185
211
|
const named = edgeCols.some((c) => c.meta.name === "weight");
|
|
186
212
|
const weightColumn = edgeCols.find((c) => c.meta.role === "weight");
|
|
187
213
|
const names = fieldNames(edgeCols, "edge", onRename);
|
|
@@ -286,6 +312,16 @@ function columns(records: readonly Record<string, unknown>[]): Record<string, Co
|
|
|
286
312
|
return out;
|
|
287
313
|
}
|
|
288
314
|
|
|
315
|
+
/**
|
|
316
|
+
* The edges of a collection that elementsToSnapshot() writes, in its order: those whose two ends are in it.
|
|
317
|
+
* @param eles - the collection
|
|
318
|
+
* @returns the edges
|
|
319
|
+
*/
|
|
320
|
+
export function exportedEdges(eles: Collection): Collection {
|
|
321
|
+
const ids = new Set(eles.nodes().map((n) => n.id()));
|
|
322
|
+
return eles.edges().filter((e) => ids.has(e.source().id()) && ids.has(e.target().id()));
|
|
323
|
+
}
|
|
324
|
+
|
|
289
325
|
/**
|
|
290
326
|
* A snapshot of a collection with every data field as a column and every node's position as a "position" column
|
|
291
327
|
* (y negated: graph-io's y grows upward), for an exporter. Edges whose endpoints are not both in the collection are left out. A numeric edge field named
|
|
@@ -298,6 +334,8 @@ function columns(records: readonly Record<string, unknown>[]): Record<string, Co
|
|
|
298
334
|
* @param options.directed - write a directed graph (default false)
|
|
299
335
|
* @param options.integerIds - when every node id is a non-negative integer written plainly ("0", "17"), give the
|
|
300
336
|
* snapshot number ids, so a format that holds only integer ids (GML, CX2) writes them as they are (default false)
|
|
337
|
+
* @param options.format - the format the snapshot is written in: the positions are spelled as its writer expects
|
|
338
|
+
* them (GML and Pajek draw y downward); without it they are graph-io's neutral form, y up
|
|
301
339
|
* @returns the snapshot
|
|
302
340
|
*/
|
|
303
341
|
export function elementsToSnapshot(
|
|
@@ -305,6 +343,7 @@ export function elementsToSnapshot(
|
|
|
305
343
|
options: {
|
|
306
344
|
readonly directed?: boolean | undefined;
|
|
307
345
|
readonly integerIds?: boolean | undefined;
|
|
346
|
+
readonly format?: string | undefined;
|
|
308
347
|
} = {},
|
|
309
348
|
): GraphSnapshot {
|
|
310
349
|
const nodes = eles.nodes();
|
|
@@ -315,19 +354,26 @@ export function elementsToSnapshot(
|
|
|
315
354
|
});
|
|
316
355
|
const integers = options.integerIds === true && names.every((id) => /^(?:0|[1-9]\d{0,14})$/.test(id));
|
|
317
356
|
const ids: (string | number)[] = integers ? names.map(Number) : names;
|
|
318
|
-
const edges = eles
|
|
357
|
+
const edges = exportedEdges(eles);
|
|
319
358
|
const src = new Uint32Array(edges.length);
|
|
320
359
|
const dst = new Uint32Array(edges.length);
|
|
321
360
|
edges.forEach((e, k) => {
|
|
322
361
|
src[k] = index.get(e.source().id()) ?? 0;
|
|
323
362
|
dst[k] = index.get(e.target().id()) ?? 0;
|
|
324
363
|
});
|
|
325
|
-
const
|
|
364
|
+
const xyz = new Float64Array(nodes.length * 3);
|
|
326
365
|
nodes.forEach((n, i) => {
|
|
327
366
|
const p = n.position();
|
|
328
|
-
|
|
329
|
-
|
|
367
|
+
xyz[3 * i] = p.x;
|
|
368
|
+
xyz[3 * i + 1] = flipY(p.y);
|
|
330
369
|
});
|
|
370
|
+
// spelled as the format's writer expects its position column (GML and Pajek: y down)
|
|
371
|
+
const written = options.format === undefined ? xyz : writtenPositions(options.format, xyz);
|
|
372
|
+
const xy = new Float64Array(nodes.length * 2);
|
|
373
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
374
|
+
xy[2 * i] = written[3 * i];
|
|
375
|
+
xy[2 * i + 1] = written[3 * i + 1];
|
|
376
|
+
}
|
|
331
377
|
const parents = nodes.map((n) => index.get(n.parent().first().id()) ?? null);
|
|
332
378
|
const edgeColumns = columns(edges.map((e) => e.data() as Record<string, unknown>));
|
|
333
379
|
let weights: Float64Array<ArrayBuffer> | undefined;
|
package/src/graph-data.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { FetchDatasetOptions } from "@graphty/graph-samples";
|
|
|
9
9
|
import type { Collection, CollectionReturnValue, Core, ElementDefinition } from "cytoscape";
|
|
10
10
|
|
|
11
11
|
import { coreDestroyedError } from "./gpu.js";
|
|
12
|
-
import type { ExportFormat, ExportOptions, ImportFormat, ImportOptions } from "./io.js";
|
|
12
|
+
import type { CytoscapeStylesheetRule, ExportFormat, ExportOptions, ImportFormat, ImportOptions } from "./io.js";
|
|
13
13
|
import { loadPart } from "./lazy.js";
|
|
14
14
|
import type { GeneratorName, GeneratorOptions } from "./samples.js";
|
|
15
15
|
|
|
@@ -21,12 +21,26 @@ export interface AddedGraph {
|
|
|
21
21
|
readonly directed: boolean;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** The part of a core's live style graphtyImport appends rules with, which Cytoscape's typings leave out. */
|
|
25
|
+
interface AppendableStyle {
|
|
26
|
+
selector(s: string): { style(props: object): AppendableStyle };
|
|
27
|
+
update(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
24
30
|
/** What graphtyImport adds to the core. */
|
|
25
31
|
export interface ImportedGraph extends AddedGraph {
|
|
26
32
|
/** The format the input was read as. */
|
|
27
33
|
readonly format: string;
|
|
28
34
|
/** graph-io's report: counts, warnings, and what could not be represented. */
|
|
29
35
|
readonly report: ImportReport;
|
|
36
|
+
/**
|
|
37
|
+
* With `styles: true`, the file's styles as a Cytoscape.js stylesheet whose every rule applies only to this
|
|
38
|
+
* import's elements (they hold its id in `data.graphty_import`). `applyStyles: true` has already appended it to
|
|
39
|
+
* the core's stylesheet; otherwise append it yourself, rule by rule:
|
|
40
|
+
* `for (const r of stylesheet) cy.style().selector(r.selector).style(r.style); cy.style().update()`.
|
|
41
|
+
* Absent without `styles`.
|
|
42
|
+
*/
|
|
43
|
+
readonly stylesheet?: CytoscapeStylesheetRule[];
|
|
30
44
|
}
|
|
31
45
|
|
|
32
46
|
/** The core methods `cytoscape.use(graphtyCytoscape)` adds for getting graphs in and out. */
|
|
@@ -49,8 +63,14 @@ export interface GraphtyGraphData {
|
|
|
49
63
|
/**
|
|
50
64
|
* Adds the graph in a file: GEXF, GraphML, GML, DOT, Pajek, CSV, JSON, Neo4j, XGMML, CX2, CX, OBO or a
|
|
51
65
|
* Cytoscape session (`.cys`, as bytes). Every attribute becomes a data field, and positions in the file become
|
|
52
|
-
* node positions
|
|
66
|
+
* node positions, the way up the program that wrote the file draws them (a yEd box's top-left corner is
|
|
67
|
+
* moved to its center). Throws, adding
|
|
53
68
|
* nothing, when the core already holds one of the graph's node ids.
|
|
69
|
+
* `{ styles: true }` also reads the file's colors, sizes, shapes, ... and returns them as `stylesheet`;
|
|
70
|
+
* `{ styles: true, applyStyles: true }` draws them too (the core needs `styleEnabled` when headless).
|
|
71
|
+
* Without `styles`, a GEXF `viz:color`, `viz:size`, `viz:shape` and `viz:thickness` also become data fields;
|
|
72
|
+
* these fields are deprecated and are removed in the next major release: use `styles: true`, which leaves
|
|
73
|
+
* them out of data and draws them through the stylesheet.
|
|
54
74
|
*/
|
|
55
75
|
graphtyImport(
|
|
56
76
|
input: ImportInput | ArrayBuffer,
|
|
@@ -60,7 +80,8 @@ export interface GraphtyGraphData {
|
|
|
60
80
|
/**
|
|
61
81
|
* Writes the graph as a file's text: every data field, each node's position and parent, and each edge's id, as
|
|
62
82
|
* far as the format holds them (`options.onLoss` hears about the rest). Hidden elements are written too. On a
|
|
63
|
-
* collection: its nodes, and those of its edges whose two ends are among them.
|
|
83
|
+
* collection: its nodes, and those of its edges whose two ends are among them. `{ styles: true }` also writes
|
|
84
|
+
* how the core draws each element, so graphtyImport with `styles: true` draws the file the same.
|
|
64
85
|
*/
|
|
65
86
|
graphtyExport(format: ExportFormat, options?: ExportOptions): Promise<string>;
|
|
66
87
|
}
|
|
@@ -129,6 +150,20 @@ function addTo(cy: Core, elements: ElementDefinition[]): CollectionReturnValue {
|
|
|
129
150
|
return cy.add(elements);
|
|
130
151
|
}
|
|
131
152
|
|
|
153
|
+
/**
|
|
154
|
+
* An import id no element of the core holds yet.
|
|
155
|
+
* @param cy - the core
|
|
156
|
+
* @returns the id
|
|
157
|
+
*/
|
|
158
|
+
function freeImportId(cy: Core): string {
|
|
159
|
+
const taken = new Set(cy.elements("[graphty_import]").map((e) => String(e.data("graphty_import"))));
|
|
160
|
+
let k = taken.size + 1;
|
|
161
|
+
while (taken.has(String(k))) {
|
|
162
|
+
k++;
|
|
163
|
+
}
|
|
164
|
+
return String(k);
|
|
165
|
+
}
|
|
166
|
+
|
|
132
167
|
/**
|
|
133
168
|
* Registers graphtyGenerate, graphtyDataset, graphtyImport and graphtyExport.
|
|
134
169
|
* @param cytoscape - the cytoscape function
|
|
@@ -150,8 +185,37 @@ export function registerGraphData(cytoscape: Register): void {
|
|
|
150
185
|
"graphtyImport",
|
|
151
186
|
async function (this: Core, input: ImportInput | ArrayBuffer, format?: ImportFormat, options?: ImportOptions) {
|
|
152
187
|
const { importElements } = await loadPart(import("./io.js"), "graphtyImport");
|
|
153
|
-
const
|
|
154
|
-
|
|
188
|
+
const styled = options?.styles !== undefined && options.styles !== false;
|
|
189
|
+
const r = await importElements(input, format, {
|
|
190
|
+
...options,
|
|
191
|
+
...(styled && options.importId === undefined ? { importId: freeImportId(this) } : {}),
|
|
192
|
+
});
|
|
193
|
+
const apply = options?.applyStyles === true && r.stylesheet !== undefined;
|
|
194
|
+
if (apply) {
|
|
195
|
+
r.bypass?.forEach((b, i) => {
|
|
196
|
+
if (b !== null) {
|
|
197
|
+
r.elements[i].style = b;
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
const elements = addTo(this, r.elements);
|
|
202
|
+
const style = this.style() as unknown as AppendableStyle | undefined;
|
|
203
|
+
if (apply && r.stylesheet !== undefined && style !== undefined) {
|
|
204
|
+
// appended rule by rule: a round trip through style.json() turns the core's own function-valued
|
|
205
|
+
// properties into the string "fn", which Cytoscape then rejects
|
|
206
|
+
let s = style;
|
|
207
|
+
for (const rule of r.stylesheet) {
|
|
208
|
+
s = s.selector(rule.selector).style(rule.style);
|
|
209
|
+
}
|
|
210
|
+
s.update();
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
elements,
|
|
214
|
+
directed: r.directed,
|
|
215
|
+
format: r.format,
|
|
216
|
+
report: r.report,
|
|
217
|
+
...(r.stylesheet === undefined ? {} : { stylesheet: r.stylesheet }),
|
|
218
|
+
};
|
|
155
219
|
},
|
|
156
220
|
);
|
|
157
221
|
const exportFn = async (eles: Collection, format: ExportFormat, options?: ExportOptions): Promise<string> => {
|
package/src/index.ts
CHANGED
|
@@ -40,7 +40,14 @@ export {
|
|
|
40
40
|
} from "./algorithms.js";
|
|
41
41
|
export { type Backend, configureWebGpu, GPU_SIZE_FLOOR, type GpuMode, type WebGpuOptions } from "./gpu.js";
|
|
42
42
|
export { type AddedGraph, type GraphtyGraphData, IdTakenError, type ImportedGraph } from "./graph-data.js";
|
|
43
|
-
export type {
|
|
43
|
+
export type {
|
|
44
|
+
CytoscapeStylesheetRule,
|
|
45
|
+
ExportFormat,
|
|
46
|
+
ExportOptions,
|
|
47
|
+
ImportFormat,
|
|
48
|
+
ImportOptions,
|
|
49
|
+
LossNote,
|
|
50
|
+
} from "./io.js";
|
|
44
51
|
export {
|
|
45
52
|
type GraphtyLayoutName,
|
|
46
53
|
type GraphtyLayoutOptions,
|
package/src/io.ts
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* exports a file never downloads the parsers.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { GraphSnapshot } from "@graphty/graph-format";
|
|
7
8
|
import {
|
|
8
9
|
checkExport,
|
|
9
|
-
type
|
|
10
|
+
type CommonExportOptions,
|
|
10
11
|
exportGraphToString,
|
|
11
12
|
ImportError,
|
|
12
13
|
importGraph,
|
|
@@ -15,10 +16,20 @@ import {
|
|
|
15
16
|
type ImportIssue,
|
|
16
17
|
type ImportReport,
|
|
17
18
|
type LossNote,
|
|
19
|
+
registry,
|
|
18
20
|
} from "@graphty/graph-io";
|
|
19
|
-
import
|
|
21
|
+
import { extractPositions } from "@graphty/graph-io/positions";
|
|
22
|
+
import {
|
|
23
|
+
type CytoscapeStylesheetRule,
|
|
24
|
+
type ExtractedStyles,
|
|
25
|
+
readCytoscapeStyles,
|
|
26
|
+
STYLE_ISSUE,
|
|
27
|
+
type StyleNote,
|
|
28
|
+
toCytoscapeStylesheet,
|
|
29
|
+
} from "@graphty/graph-io/styles";
|
|
30
|
+
import type { Collection, Core, ElementDefinition } from "cytoscape";
|
|
20
31
|
|
|
21
|
-
import { elementsToSnapshot, flipY, snapshotToElements } from "./elements.js";
|
|
32
|
+
import { elementsToSnapshot, exportedEdges, flipY, snapshotToElements } from "./elements.js";
|
|
22
33
|
|
|
23
34
|
/** One thing graphtyExport's format cannot hold: `code`, `message`, the data field (`column`) and how many values. */
|
|
24
35
|
export type { LossNote } from "@graphty/graph-io";
|
|
@@ -46,8 +57,28 @@ export type ImportFormat =
|
|
|
46
57
|
/** The formats graphtyExport writes. XGMML is the one Cytoscape desktop opens as a network file. */
|
|
47
58
|
export type ExportFormat = "gexf" | "graphml" | "gml" | "dot" | "pajek" | "csv" | "json" | "neo4j" | "xgmml" | "cx2";
|
|
48
59
|
|
|
49
|
-
/**
|
|
50
|
-
export type
|
|
60
|
+
/** One rule of a Cytoscape.js stylesheet: a selector and its properties. */
|
|
61
|
+
export type { CytoscapeStylesheetRule } from "@graphty/graph-io/styles";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Options of graphtyImport: graph-io's import options (`filename`, `graphIndex`, format-specific ones, ...).
|
|
65
|
+
* `styles: true` also reads the file's styles (its colors, sizes, shapes, ...) as a Cytoscape.js stylesheet,
|
|
66
|
+
* returned as `stylesheet`; a property the file leaves to the program that wrote it gets that program's default
|
|
67
|
+
* (`styles: { toolDefaults: false }` leaves them out).
|
|
68
|
+
*/
|
|
69
|
+
export type ImportOptions = Omit<ImportGraphOptions, "format"> & {
|
|
70
|
+
/**
|
|
71
|
+
* With `styles`, graphtyImport also appends the file's stylesheet to the core's own and gives each element the
|
|
72
|
+
* per-element overrides that make it exact. Default false: the stylesheet is only returned.
|
|
73
|
+
*/
|
|
74
|
+
readonly applyStyles?: boolean | undefined;
|
|
75
|
+
/**
|
|
76
|
+
* With `styles`, the value of the `graphty_import` data field every imported element gets and every rule of the
|
|
77
|
+
* stylesheet tests, so a second import into the same core never restyles the first one's elements. Default: a
|
|
78
|
+
* new one for each import.
|
|
79
|
+
*/
|
|
80
|
+
readonly importId?: string | undefined;
|
|
81
|
+
};
|
|
51
82
|
|
|
52
83
|
/**
|
|
53
84
|
* Options of graphtyExport: graph-io's export options for the format, plus whether to write a directed graph.
|
|
@@ -58,7 +89,17 @@ export type ImportOptions = Omit<ImportGraphOptions, "format">;
|
|
|
58
89
|
* JSON is written as Cytoscape JSON (`dialect: "cytoscape"`, the shape `cy.json()` and `cy.add()` use), which keeps
|
|
59
90
|
* edge ids, compound parents and positions; pass `dialect: "node-link"` for the NetworkX shape.
|
|
60
91
|
*/
|
|
61
|
-
export interface ExportOptions extends
|
|
92
|
+
export interface ExportOptions extends CommonExportOptions {
|
|
93
|
+
/**
|
|
94
|
+
* Write the core's styles too: its stylesheet's rules, with what the core draws for each element as the truth
|
|
95
|
+
* (a function-valued property is written per element and reported as W_STYLE_LAYER_FLATTENED). Each format
|
|
96
|
+
* keeps as much of them as it can, so importing the file with `styles: true` draws the same; `onLoss` hears
|
|
97
|
+
* about the rest (W_STYLE_ codes). Default false: without it `onLoss` hears W_STYLES_NOT_EXPORTED when the core
|
|
98
|
+
* has a stylesheet of its own.
|
|
99
|
+
*/
|
|
100
|
+
readonly styles?: boolean | undefined;
|
|
101
|
+
/** Format-specific options, passed to graph-io as they are. */
|
|
102
|
+
readonly [formatOption: string]: unknown;
|
|
62
103
|
/** Write the graph as directed. Default false. */
|
|
63
104
|
readonly directed?: boolean | undefined;
|
|
64
105
|
/**
|
|
@@ -78,10 +119,44 @@ export interface ImportedElements {
|
|
|
78
119
|
readonly format: string;
|
|
79
120
|
/** graph-io's report: counts, warnings, and what could not be represented. */
|
|
80
121
|
readonly report: ImportReport;
|
|
122
|
+
/**
|
|
123
|
+
* With `styles`, the file's styles as a Cytoscape.js stylesheet scoped to this import; append it to the core's
|
|
124
|
+
* rule by rule (`cy.style().selector(r.selector).style(r.style)` for each, then `cy.style().update()`). Absent
|
|
125
|
+
* without `styles`.
|
|
126
|
+
*/
|
|
127
|
+
readonly stylesheet?: CytoscapeStylesheetRule[];
|
|
128
|
+
/**
|
|
129
|
+
* With `styles`, per element (in the order of `elements`) the style overrides that make it draw exactly as the
|
|
130
|
+
* file says where the stylesheet alone does not, or null; give them as each definition's `style`.
|
|
131
|
+
*/
|
|
132
|
+
readonly bypass?: (Readonly<Record<string, string | number>> | null)[];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** How many imports have read styles: the default import ids. */
|
|
136
|
+
let imports = 0;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A style note as a report warning.
|
|
140
|
+
* @param n - the note
|
|
141
|
+
* @returns the issue
|
|
142
|
+
*/
|
|
143
|
+
function styleIssue(n: StyleNote): ImportIssue {
|
|
144
|
+
return {
|
|
145
|
+
category: "unsupported",
|
|
146
|
+
severity: "warning",
|
|
147
|
+
code: n.code,
|
|
148
|
+
message: n.message,
|
|
149
|
+
line: null,
|
|
150
|
+
element: n.params.property,
|
|
151
|
+
};
|
|
81
152
|
}
|
|
82
153
|
|
|
83
154
|
/**
|
|
84
155
|
* Reads a graph file into Cytoscape element definitions.
|
|
156
|
+
* Without `styles`, a GEXF `viz:color`, `viz:size`, `viz:shape` and `viz:thickness` also become data fields
|
|
157
|
+
* (`data.color` as four numbers from 0 to 1, `data.size`, `data.shape`, `data.thickness`); these fields are
|
|
158
|
+
* deprecated and are removed in the next major release: import with `styles: true`, whose stylesheet draws them.
|
|
159
|
+
* With `styles: true` they are left out of data.
|
|
85
160
|
* @param input - the file as text, bytes, a stream or chunks
|
|
86
161
|
* @param format - the format, or "auto" to sniff it
|
|
87
162
|
* @param options - graph-io's import options
|
|
@@ -94,7 +169,11 @@ export async function importElements(
|
|
|
94
169
|
options: ImportOptions = {},
|
|
95
170
|
): Promise<ImportedElements> {
|
|
96
171
|
// fetch().arrayBuffer() and File.arrayBuffer() give an ArrayBuffer, which graph-io does not take
|
|
97
|
-
const
|
|
172
|
+
const { applyStyles: _apply, importId, ...graphOptions } = options;
|
|
173
|
+
const r = await importGraph(input instanceof ArrayBuffer ? new Uint8Array(input) : input, {
|
|
174
|
+
...graphOptions,
|
|
175
|
+
format,
|
|
176
|
+
});
|
|
98
177
|
// most text sniffs as CSV, so a file that is not a graph at all reads as an empty one with errors
|
|
99
178
|
if (r.snapshot.nodeCount === 0 && r.report.errorCount > 0) {
|
|
100
179
|
const first = r.report.issues.find((i) => i.severity === "error");
|
|
@@ -125,13 +204,32 @@ export async function importElements(
|
|
|
125
204
|
line: null,
|
|
126
205
|
element: id,
|
|
127
206
|
}),
|
|
207
|
+
r.styles !== undefined && r.styles !== null,
|
|
128
208
|
);
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
209
|
+
// each format spells its coordinates its own way (yEd: a box's top-left corner, y down; Gephi: the
|
|
210
|
+
// center, y up); graph-io's extractPositions reads them all as the center with y up, and Cytoscape's y
|
|
211
|
+
// grows downward
|
|
212
|
+
const positions = extractPositions(registry.importer(r.format), r.snapshot, r.report);
|
|
213
|
+
for (let i = 0; i < r.snapshot.nodeCount; i++) {
|
|
214
|
+
const el = elements[i];
|
|
215
|
+
const x = positions?.usable === true ? positions.coords[3 * i] : Number.NaN;
|
|
216
|
+
if (Number.isNaN(x)) {
|
|
217
|
+
delete el.position;
|
|
218
|
+
} else {
|
|
219
|
+
el.position = { x, y: flipY(positions?.coords[3 * i + 1] ?? 0) };
|
|
133
220
|
}
|
|
134
221
|
}
|
|
222
|
+
let styled: Pick<ImportedElements, "stylesheet" | "bypass"> = {};
|
|
223
|
+
if (r.styles !== undefined && r.styles !== null) {
|
|
224
|
+
imports++;
|
|
225
|
+
const em = toCytoscapeStylesheet(r.styles, { importId: importId ?? String(imports) });
|
|
226
|
+
const n = r.snapshot.nodeCount;
|
|
227
|
+
const at = <T>(per: { node: readonly T[]; edge: readonly T[] }, i: number): T =>
|
|
228
|
+
i < n ? per.node[i] : per.edge[i - n];
|
|
229
|
+
elements.forEach((el, i) => Object.assign(el.data, at(em.elementData, i)));
|
|
230
|
+
styled = { stylesheet: em.stylesheet, bypass: elements.map((_el, i) => at(em.bypass, i)) };
|
|
231
|
+
warnings.push(...em.notes.map(styleIssue));
|
|
232
|
+
}
|
|
135
233
|
const report =
|
|
136
234
|
warnings.length === 0
|
|
137
235
|
? r.report
|
|
@@ -140,7 +238,7 @@ export async function importElements(
|
|
|
140
238
|
issues: [...r.report.issues, ...warnings],
|
|
141
239
|
warningCount: r.report.warningCount + warnings.length,
|
|
142
240
|
};
|
|
143
|
-
return { elements, directed: r.snapshot.directed, format: r.format, report };
|
|
241
|
+
return { elements, directed: r.snapshot.directed, format: r.format, report, ...styled };
|
|
144
242
|
}
|
|
145
243
|
|
|
146
244
|
/** The loss notes about something graphtyImport always gives back (see lostForCytoscape). */
|
|
@@ -174,6 +272,107 @@ function lostForCytoscape(n: LossNote): boolean {
|
|
|
174
272
|
return !(n.code === "W_ROLE_DROPPED" && n.column === "label");
|
|
175
273
|
}
|
|
176
274
|
|
|
275
|
+
/** What this module reads of a core's style, which Cytoscape's typings leave out. */
|
|
276
|
+
interface LiveStyle {
|
|
277
|
+
json(): unknown[];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The core a collection belongs to (`eles.cy()`, which Cytoscape's typings leave out).
|
|
282
|
+
* @param eles - the collection
|
|
283
|
+
* @returns the core
|
|
284
|
+
*/
|
|
285
|
+
function coreOf(eles: Collection): Core {
|
|
286
|
+
return (eles as unknown as { cy(): Core }).cy();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* A core's style.
|
|
291
|
+
* @param cy - the core
|
|
292
|
+
* @returns the style, or undefined when the core has styles disabled (headless without `styleEnabled`)
|
|
293
|
+
*/
|
|
294
|
+
function styleOf(cy: Core): LiveStyle | undefined {
|
|
295
|
+
return cy.style() as unknown as LiveStyle | undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The styles of a collection as its core draws them: the core's stylesheet, with each element's `ele.style()` as
|
|
300
|
+
* the truth.
|
|
301
|
+
* @param eles - the collection
|
|
302
|
+
* @param snapshot - the collection as elementsToSnapshot() wrote it
|
|
303
|
+
* @returns the styles, or null when the core has styles disabled (a headless core without `styleEnabled`)
|
|
304
|
+
*/
|
|
305
|
+
function coreStyles(eles: Collection, snapshot: GraphSnapshot): ExtractedStyles | null {
|
|
306
|
+
const style = styleOf(coreOf(eles));
|
|
307
|
+
if (style === undefined) {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
const nodes = eles.nodes();
|
|
311
|
+
const edges = exportedEdges(eles);
|
|
312
|
+
return readCytoscapeStyles({
|
|
313
|
+
elements: { nodes: nodes.jsons(), edges: edges.jsons() },
|
|
314
|
+
style: style.json(),
|
|
315
|
+
rendered: [...nodes.map((n) => n.style()), ...edges.map((e) => e.style())],
|
|
316
|
+
snapshot,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* W_STYLE_LAYER_FLATTENED for each property written per element instead of as a rule (a function-valued one).
|
|
322
|
+
* @param extracted - the core's styles
|
|
323
|
+
* @returns the notes
|
|
324
|
+
*/
|
|
325
|
+
function flattened(extracted: ExtractedStyles): LossNote[] {
|
|
326
|
+
return extracted.styles.notes
|
|
327
|
+
.filter((n) => n.code === STYLE_ISSUE.LAYER_FLATTENED)
|
|
328
|
+
.map((n) => ({
|
|
329
|
+
code: n.code,
|
|
330
|
+
message: n.message,
|
|
331
|
+
column: null,
|
|
332
|
+
count: n.params.count,
|
|
333
|
+
...(n.params.target === "node" || n.params.target === "edge"
|
|
334
|
+
? { style: { target: n.params.target, property: n.params.property as never } }
|
|
335
|
+
: {}),
|
|
336
|
+
}));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* W_STYLES_NOT_EXPORTED when the core has a stylesheet of its own (one a new core does not have), or when styles were
|
|
341
|
+
* asked for and the core has none to give (a headless core without `styleEnabled`).
|
|
342
|
+
* @param cy - the core
|
|
343
|
+
* @param asked - whether the export asked for the styles
|
|
344
|
+
* @returns the note, or none
|
|
345
|
+
*/
|
|
346
|
+
function notExported(cy: Core, asked: boolean): LossNote[] {
|
|
347
|
+
const style = styleOf(cy);
|
|
348
|
+
if (style === undefined) {
|
|
349
|
+
return asked
|
|
350
|
+
? [
|
|
351
|
+
{
|
|
352
|
+
code: STYLE_ISSUE.NOT_EXPORTED,
|
|
353
|
+
message:
|
|
354
|
+
"no styles are written: the core has styles disabled; create it with { styleEnabled: true }",
|
|
355
|
+
column: null,
|
|
356
|
+
count: null,
|
|
357
|
+
},
|
|
358
|
+
]
|
|
359
|
+
: [];
|
|
360
|
+
}
|
|
361
|
+
const fresh = new (cy.constructor as new (options: object) => Core)({ headless: true, styleEnabled: true });
|
|
362
|
+
const own = JSON.stringify(style.json()) !== JSON.stringify(styleOf(fresh)?.json());
|
|
363
|
+
fresh.destroy();
|
|
364
|
+
return own
|
|
365
|
+
? [
|
|
366
|
+
{
|
|
367
|
+
code: STYLE_ISSUE.NOT_EXPORTED,
|
|
368
|
+
message: "the core's stylesheet is not written; export with { styles: true } to write it",
|
|
369
|
+
column: null,
|
|
370
|
+
count: null,
|
|
371
|
+
},
|
|
372
|
+
]
|
|
373
|
+
: [];
|
|
374
|
+
}
|
|
375
|
+
|
|
177
376
|
/** The formats that hold only integer node ids: a graph whose ids are all integers is written with them as they are. */
|
|
178
377
|
const INTEGER_ID_FORMATS: ReadonlySet<ExportFormat> = new Set(["gml", "cx2"]);
|
|
179
378
|
|
|
@@ -196,18 +395,24 @@ export async function exportElements(
|
|
|
196
395
|
"exportElements needs a Cytoscape collection; wrap element definitions with cytoscape({ headless: true, elements }).elements()",
|
|
197
396
|
);
|
|
198
397
|
}
|
|
199
|
-
const { directed, onLoss, ...rest } = options;
|
|
398
|
+
const { directed, onLoss, styles, ...rest } = options;
|
|
200
399
|
const snapshot = elementsToSnapshot(eles, {
|
|
201
400
|
directed,
|
|
401
|
+
format,
|
|
202
402
|
integerIds: INTEGER_ID_FORMATS.has(format),
|
|
203
403
|
});
|
|
404
|
+
const extracted = styles === true ? coreStyles(eles, snapshot) : null;
|
|
204
405
|
const graphOptions = {
|
|
205
406
|
sanitizeIds: "mangle" as const,
|
|
206
407
|
...(format === "json" ? { dialect: "cytoscape" } : {}),
|
|
207
408
|
...rest,
|
|
409
|
+
...(extracted === null ? {} : { styles: extracted }),
|
|
208
410
|
};
|
|
209
411
|
if (onLoss !== undefined) {
|
|
210
|
-
const notes =
|
|
412
|
+
const notes = [
|
|
413
|
+
...(extracted === null ? notExported(coreOf(eles), styles === true) : flattened(extracted)),
|
|
414
|
+
...checkExport(snapshot, format, graphOptions).filter(lostForCytoscape),
|
|
415
|
+
];
|
|
211
416
|
if (notes.length > 0) {
|
|
212
417
|
onLoss(notes);
|
|
213
418
|
}
|