@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graphty/cytoscape-extensions",
3
- "version": "0.0.22",
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.42",
75
- "@graphty/graph-samples": "^0.1.35",
76
- "@graphty/webgpu-graph-algorithms": "^0.6.57"
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.10",
107
- "@graphty/graph-format": "^1.4.3",
108
- "@graphty/layout": "^2.5.6"
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
- return [...nodeElements(snapshot, ids, onRename), ...edgeElements(snapshot, ids, onRename, onIdDropped)];
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(kept);
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(kept);
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.edges().filter((e) => index.has(e.source().id()) && index.has(e.target().id()));
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 xy = new Float64Array(nodes.length * 2);
364
+ const xyz = new Float64Array(nodes.length * 3);
326
365
  nodes.forEach((n, i) => {
327
366
  const p = n.position();
328
- xy[2 * i] = p.x;
329
- xy[2 * i + 1] = flipY(p.y);
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 (y negated for the formats whose y grows upward: GEXF, GML, DOT, Pajek). Throws, adding
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 r = await importElements(input, format, options);
154
- return { elements: addTo(this, r.elements), directed: r.directed, format: r.format, report: r.report };
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 { ExportFormat, ExportOptions, ImportFormat, ImportOptions, LossNote } from "./io.js";
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 ExportGraphOptions,
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 type { Collection, ElementDefinition } from "cytoscape";
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
- /** Options of graphtyImport: graph-io's import options (`filename`, `graphIndex`, format-specific ones, ...). */
50
- export type ImportOptions = Omit<ImportGraphOptions, "format">;
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 ExportGraphOptions {
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 r = await importGraph(input instanceof ArrayBuffer ? new Uint8Array(input) : input, { ...options, format });
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
- // 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);
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 = checkExport(snapshot, format, graphOptions).filter(lostForCytoscape);
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
  }