@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.
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Conversion between a graph-format snapshot with its attributes and Cytoscape element definitions, for the import,
3
+ * export, generator and dataset methods. Unlike toSnapshot() (topology and one weight, for the algorithms), these
4
+ * carry every data field across.
5
+ */
6
+
7
+ import { type Column, type ColumnInput, fromEdgeArrays, type GraphSnapshot } from "@graphty/graph-format";
8
+ import type { Collection, ElementDefinition } from "cytoscape";
9
+
10
+ /**
11
+ * Data fields Cytoscape gives a meaning of its own, and "__proto__", whose assignment would set the data object's
12
+ * prototype (and with it inherited id, source, target or parent fields). An element's own data fields of these names
13
+ * are not exported as columns; a column with one of these names is imported under another name (see fieldNames).
14
+ */
15
+ const RESERVED: ReadonlySet<string> = new Set(["id", "source", "target", "parent", "__proto__"]);
16
+
17
+ /**
18
+ * A column value as Cytoscape data: a typed array (a multi-component column) becomes a plain array.
19
+ * @param v - the column value
20
+ * @returns the value to store
21
+ */
22
+ function plain(v: unknown): unknown {
23
+ return ArrayBuffer.isView(v) ? Array.from(v as unknown as ArrayLike<number>) : v;
24
+ }
25
+
26
+ /**
27
+ * A y coordinate between Cytoscape (y grows downward) and graph-io (y grows upward, so a Cytoscape-family file's
28
+ * screen y is negated on import and negated back on export). Never yields -0.
29
+ * @param y - the coordinate
30
+ * @returns the negated coordinate
31
+ */
32
+ export function flipY(y: number): number {
33
+ return y === 0 ? 0 : -y;
34
+ }
35
+
36
+ /** A column that arrives under another data field name, because Cytoscape reserves its own. */
37
+ interface RenamedColumn {
38
+ readonly domain: "node" | "edge";
39
+ readonly from: string;
40
+ readonly to: string;
41
+ }
42
+
43
+ /**
44
+ * Cytoscape element definitions for a snapshot. Node ids are the snapshot's ids as strings. Every node and edge
45
+ * column becomes a data field of the same name, except that one named `id`, `source`, `target` or `parent` (which
46
+ * Cytoscape reserves) becomes `<name>#<the attribute's id in the file>`, or `<name>#2` when it has none, and:
47
+ * - a node column with the role "position" becomes the node's position (x and y; a z is dropped);
48
+ * - a node column with the role "parent" (a compound parent, as Cytoscape JSON, GraphML nested graphs and DOT
49
+ * clusters carry it) becomes `data.parent`;
50
+ * - an edge column with the role "id" becomes the edge's id when that id is not a node id and not used by an
51
+ * earlier edge; otherwise the edge gets no id, Cytoscape assigns one, and `onIdDropped` hears about it;
52
+ * - the edge weights become `data.weight`: the weight column when the file had one, else the snapshot's weights
53
+ * when it is weighted.
54
+ * @param snapshot - the snapshot
55
+ * @param onRename - told about each column renamed because its name is reserved
56
+ * @param onIdDropped - told about each edge id dropped, and whether a node or an earlier edge has it
57
+ * @returns the nodes, then the edges
58
+ */
59
+ export function snapshotToElements(
60
+ snapshot: GraphSnapshot,
61
+ onRename?: (renamed: RenamedColumn) => void,
62
+ onIdDropped?: (id: string, takenBy: "node" | "edge") => void,
63
+ ): ElementDefinition[] {
64
+ const ids: string[] = [];
65
+ for (let i = 0; i < snapshot.nodeCount; i++) {
66
+ ids.push(String(snapshot.ids.idOf(i)));
67
+ }
68
+ return [...nodeElements(snapshot, ids, onRename), ...edgeElements(snapshot, ids, onRename, onIdDropped)];
69
+ }
70
+
71
+ /**
72
+ * Whether a column becomes a data field of its own (not the id or the parent, which the element gets as such).
73
+ * @param c - the column
74
+ * @returns true to copy it into data
75
+ */
76
+ function kept(c: Column): boolean {
77
+ return c.meta.role !== "id" && c.meta.role !== "parent";
78
+ }
79
+
80
+ /**
81
+ * The data field name of each column: its own, or for a reserved name `<name>#<origin id>` (`<name>#2`, `#3`, ...
82
+ * without one, or when that is taken), the rule graph-io follows when two attributes share a name.
83
+ * @param cols - the columns copied into data
84
+ * @param domain - node or edge, for onRename
85
+ * @param onRename - told about each renamed column
86
+ * @returns the field name by column
87
+ */
88
+ function fieldNames(
89
+ cols: readonly Column[],
90
+ domain: RenamedColumn["domain"],
91
+ onRename: ((renamed: RenamedColumn) => void) | undefined,
92
+ ): Map<Column, string> {
93
+ const taken = new Set([...RESERVED, ...cols.map((c) => c.meta.name)]);
94
+ const out = new Map<Column, string>();
95
+ for (const c of cols) {
96
+ const from = c.meta.name;
97
+ if (!RESERVED.has(from)) {
98
+ out.set(c, from);
99
+ continue;
100
+ }
101
+ const id = c.meta.origin?.id ?? null;
102
+ const base = id === null ? from : `${from}#${id}`;
103
+ let to = base;
104
+ if (id === null || taken.has(base)) {
105
+ let n = 2;
106
+ while (taken.has(`${base}#${n}`)) {
107
+ n++;
108
+ }
109
+ to = `${base}#${n}`;
110
+ }
111
+ taken.add(to);
112
+ out.set(c, to);
113
+ onRename?.({ domain, from, to });
114
+ }
115
+ return out;
116
+ }
117
+
118
+ /**
119
+ * One row's set values as data fields.
120
+ * @param cols - the columns to copy
121
+ * @param row - the node or edge index
122
+ * @param fieldOf - the data field a column's value goes to
123
+ * @returns the data, one field per column with a value in this row
124
+ */
125
+ function rowData(cols: readonly Column[], row: number, fieldOf: (c: Column) => string): Record<string, unknown> {
126
+ const data: Record<string, unknown> = {};
127
+ for (const c of cols) {
128
+ if (c.isSet(row)) {
129
+ data[fieldOf(c)] = plain(c.value(row));
130
+ }
131
+ }
132
+ return data;
133
+ }
134
+
135
+ /**
136
+ * The node elements of snapshotToElements().
137
+ * @param snapshot - the snapshot
138
+ * @param ids - the node ids as strings, by node index
139
+ * @param onRename - told about each renamed column
140
+ * @returns one element per node
141
+ */
142
+ function nodeElements(
143
+ snapshot: GraphSnapshot,
144
+ ids: readonly string[],
145
+ onRename: ((renamed: RenamedColumn) => void) | undefined,
146
+ ): ElementDefinition[] {
147
+ const nodeCols = [...snapshot.nodes].filter(kept);
148
+ const position = nodeCols.find((c) => c.meta.role === "position" && c.meta.components >= 2);
149
+ const nodeData = nodeCols.filter((c) => c !== position);
150
+ const names = fieldNames(nodeData, "node", onRename);
151
+ const parent = snapshot.nodes.byRole("parent");
152
+ const out: ElementDefinition[] = [];
153
+ for (let i = 0; i < snapshot.nodeCount; i++) {
154
+ const data = rowData(nodeData, i, (c) => names.get(c) ?? c.meta.name);
155
+ data.id = ids[i];
156
+ const p = parent?.isSet(i) === true ? parent.value(i) : undefined;
157
+ if (typeof p === "number" && p !== i && p < ids.length) {
158
+ data.parent = ids[p];
159
+ }
160
+ const el: ElementDefinition = { group: "nodes", data: data };
161
+ const xy = position?.isSet(i) === true ? (position.value(i) as ArrayLike<number>) : undefined;
162
+ if (xy !== undefined && Number.isFinite(xy[0]) && Number.isFinite(xy[1])) {
163
+ el.position = { x: xy[0], y: xy[1] };
164
+ }
165
+ out.push(el);
166
+ }
167
+ return out;
168
+ }
169
+
170
+ /**
171
+ * The edge elements of snapshotToElements().
172
+ * @param snapshot - the snapshot
173
+ * @param ids - the node ids as strings, by node index
174
+ * @param onRename - told about each renamed column
175
+ * @param onIdDropped - told about each dropped edge id
176
+ * @returns one element per edge
177
+ */
178
+ function edgeElements(
179
+ snapshot: GraphSnapshot,
180
+ ids: readonly string[],
181
+ onRename: ((renamed: RenamedColumn) => void) | undefined,
182
+ onIdDropped: ((id: string, takenBy: "node" | "edge") => void) | undefined,
183
+ ): ElementDefinition[] {
184
+ const edgeCols = [...snapshot.edges].filter(kept);
185
+ const named = edgeCols.some((c) => c.meta.name === "weight");
186
+ const weightColumn = edgeCols.find((c) => c.meta.role === "weight");
187
+ const names = fieldNames(edgeCols, "edge", onRename);
188
+ const fieldOf = (c: Column): string => (c === weightColumn && !named ? "weight" : (names.get(c) ?? c.meta.name));
189
+ const edgeIds = snapshot.edges.byRole("id");
190
+ const nodeIds = new Set(ids);
191
+ const usedEdgeIds = new Set<string>();
192
+ const out: ElementDefinition[] = [];
193
+ for (let e = 0; e < snapshot.edgeCount; e++) {
194
+ const data = rowData(edgeCols, e, fieldOf);
195
+ if (weightColumn === undefined && !named && snapshot.weights !== null) {
196
+ data.weight = snapshot.weights[snapshot.edgeToArc[e]];
197
+ }
198
+ const id =
199
+ edgeIds?.isSet(e) === true
200
+ ? claimEdgeId(String(edgeIds.value(e)), nodeIds, usedEdgeIds, onIdDropped)
201
+ : undefined;
202
+ if (id !== undefined) {
203
+ data.id = id;
204
+ }
205
+ data.source = ids[snapshot.edgeSource(e)];
206
+ data.target = ids[snapshot.edgeTarget(e)];
207
+ out.push({ group: "edges", data: data });
208
+ }
209
+ return out;
210
+ }
211
+
212
+ /**
213
+ * An edge's own id, unless a node or an earlier edge already has it.
214
+ * @param id - the edge's id in the snapshot
215
+ * @param nodeIds - every node id
216
+ * @param usedEdgeIds - the edge ids handed out so far; the id is added when it is kept
217
+ * @param onIdDropped - told when the id is dropped, and what has it
218
+ * @returns the id, or undefined when it is dropped
219
+ */
220
+ function claimEdgeId(
221
+ id: string,
222
+ nodeIds: ReadonlySet<string>,
223
+ usedEdgeIds: Set<string>,
224
+ onIdDropped: ((id: string, takenBy: "node" | "edge") => void) | undefined,
225
+ ): string | undefined {
226
+ if (nodeIds.has(id) || usedEdgeIds.has(id)) {
227
+ onIdDropped?.(id, nodeIds.has(id) ? "node" : "edge");
228
+ return undefined;
229
+ }
230
+ usedEdgeIds.add(id);
231
+ return id;
232
+ }
233
+
234
+ /**
235
+ * A column of data values: f64 when every set value is a number (NaN and the infinities included), bool when every one is a boolean, string
236
+ * when every one is a string, json otherwise. A missing value (undefined or null) is an unset row.
237
+ * @param values - one value per row
238
+ * @param label - whether this is the `label` field: a string one gets the role "label", so a format with a label
239
+ * of its own (GEXF, Pajek, DOT, ...) writes it there and reads it back under the same name
240
+ * @returns the column, or undefined when no row has a value
241
+ */
242
+ function column(values: unknown[], label: boolean): ColumnInput | undefined {
243
+ const set = values.filter((v) => v !== undefined && v !== null);
244
+ if (set.length === 0) {
245
+ return undefined;
246
+ }
247
+ const rows = values.map((v) => (v === undefined ? null : v));
248
+ const all = (test: (v: unknown) => boolean): boolean => set.every(test);
249
+ if (all((v) => typeof v === "number")) {
250
+ return { data: rows, decl: { dtype: "f64" } };
251
+ }
252
+ if (all((v) => typeof v === "boolean")) {
253
+ return { data: rows, decl: { dtype: "bool" } };
254
+ }
255
+ if (all((v) => typeof v === "string")) {
256
+ return { data: rows, decl: { dtype: "string", ...(label ? { role: "label" } : {}) } };
257
+ }
258
+ // in a json column null is a set value (JSON null), which a format would write as the text "null"
259
+ return { data: values.map((v) => v ?? undefined), decl: { dtype: "json" } };
260
+ }
261
+
262
+ /**
263
+ * The columns of a set of elements' data, one per field, skipping the reserved ones.
264
+ * @param records - each element's data
265
+ * @returns the columns by name
266
+ */
267
+ function columns(records: readonly Record<string, unknown>[]): Record<string, ColumnInput> {
268
+ const names = new Set<string>();
269
+ for (const r of records) {
270
+ for (const k of Object.keys(r)) {
271
+ if (!RESERVED.has(k)) {
272
+ names.add(k);
273
+ }
274
+ }
275
+ }
276
+ const out: Record<string, ColumnInput> = {};
277
+ for (const name of names) {
278
+ const c = column(
279
+ records.map((r) => r[name]),
280
+ name === "label",
281
+ );
282
+ if (c !== undefined) {
283
+ out[name] = c;
284
+ }
285
+ }
286
+ return out;
287
+ }
288
+
289
+ /**
290
+ * A snapshot of a collection with every data field as a column and every node's position as a "position" column
291
+ * (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
292
+ * `weight` becomes the snapshot's edge weights when every edge has one. Edge ids become an "id" column, and a
293
+ * compound node's parent a "parent" column when the parent is in the collection too (a parent outside it is left
294
+ * out, so the node is written as a top-level node). Hidden elements are written like any other: pass
295
+ * `cy.elements(":visible")` to leave them out.
296
+ * @param eles - the collection
297
+ * @param options - the direction and the id type
298
+ * @param options.directed - write a directed graph (default false)
299
+ * @param options.integerIds - when every node id is a non-negative integer written plainly ("0", "17"), give the
300
+ * snapshot number ids, so a format that holds only integer ids (GML, CX2) writes them as they are (default false)
301
+ * @returns the snapshot
302
+ */
303
+ export function elementsToSnapshot(
304
+ eles: Collection,
305
+ options: {
306
+ readonly directed?: boolean | undefined;
307
+ readonly integerIds?: boolean | undefined;
308
+ } = {},
309
+ ): GraphSnapshot {
310
+ const nodes = eles.nodes();
311
+ const index = new Map<string, number>();
312
+ const names = nodes.map((n, i) => {
313
+ index.set(n.id(), i);
314
+ return n.id();
315
+ });
316
+ const integers = options.integerIds === true && names.every((id) => /^(?:0|[1-9]\d{0,14})$/.test(id));
317
+ 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()));
319
+ const src = new Uint32Array(edges.length);
320
+ const dst = new Uint32Array(edges.length);
321
+ edges.forEach((e, k) => {
322
+ src[k] = index.get(e.source().id()) ?? 0;
323
+ dst[k] = index.get(e.target().id()) ?? 0;
324
+ });
325
+ const xy = new Float64Array(nodes.length * 2);
326
+ nodes.forEach((n, i) => {
327
+ const p = n.position();
328
+ xy[2 * i] = p.x;
329
+ xy[2 * i + 1] = flipY(p.y);
330
+ });
331
+ const parents = nodes.map((n) => index.get(n.parent().first().id()) ?? null);
332
+ const edgeColumns = columns(edges.map((e) => e.data() as Record<string, unknown>));
333
+ let weights: Float64Array<ArrayBuffer> | undefined;
334
+ const w = edgeColumns.weight;
335
+ if (w?.decl.dtype === "f64" && (w.data as unknown[]).every((v) => v !== null)) {
336
+ weights = Float64Array.from(w.data as number[]);
337
+ delete edgeColumns.weight;
338
+ }
339
+ return fromEdgeArrays({
340
+ directed: options.directed ?? false,
341
+ ids,
342
+ src,
343
+ dst,
344
+ weights,
345
+ nodeColumns: {
346
+ ...columns(nodes.map((n) => n.data() as Record<string, unknown>)),
347
+ position: { data: xy, decl: { dtype: "f64", components: 2, role: "position" } },
348
+ ...(parents.some((p) => p !== null)
349
+ ? { parent: { data: parents, decl: { dtype: "u32", role: "parent", refersTo: "node" } } }
350
+ : {}),
351
+ },
352
+ edgeColumns: {
353
+ ...edgeColumns,
354
+ id: { data: edges.map((e) => e.id()), decl: { dtype: "string", role: "id", unique: true } },
355
+ },
356
+ });
357
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * What "#gpu-platform" loads in a browser (the package's `imports` field): the provider on `navigator.gpu`.
3
+ */
4
+
5
+ import { probeBrowserWebGpu, requestGpuContext } from "@graphty/webgpu-graph-algorithms/browser";
6
+
7
+ import type { GpuProvider, WebGpuOptions } from "./gpu.js";
8
+ import { providerFor } from "./webgpu-provider.js";
9
+
10
+ /**
11
+ * The browser's provider.
12
+ * @param options - the options
13
+ * @returns the provider
14
+ */
15
+ export function gpuProvider(options: WebGpuOptions): GpuProvider {
16
+ return providerFor(
17
+ {
18
+ probe: (rejectSoftware) => probeBrowserWebGpu({ rejectSoftware }),
19
+ open: (probe, rejectSoftware, residentSnapshots) =>
20
+ requestGpuContext({
21
+ adapter: probe.adapter ?? undefined,
22
+ rejectSoftware,
23
+ warnUnreleasedSnapshots: residentSnapshots,
24
+ }),
25
+ fixes: {},
26
+ },
27
+ options,
28
+ );
29
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * What "#gpu-platform" loads under Node (the package's `imports` field): the provider on Dawn. The GPU package loads
3
+ * the optional `webgpu` package at run time; without it the probe declines and every run is on the CPU, saying why.
4
+ */
5
+
6
+ import { createNodeGpuContext, probeNodeWebGpu } from "@graphty/webgpu-graph-algorithms/node";
7
+
8
+ import type { GpuProvider, WebGpuOptions } from "./gpu.js";
9
+ import { type Platform, providerFor } from "./webgpu-provider.js";
10
+
11
+ /** What a Node user can do about a refusal. */
12
+ export const NODE_FIXES: Platform["fixes"] = {
13
+ E_NO_WEBGPU: "install the optional webgpu package (npm install webgpu), which brings Dawn to Node",
14
+ };
15
+
16
+ /**
17
+ * The Node provider.
18
+ * @param options - the options; `adapter` picks the Dawn adapter by name
19
+ * @returns the provider
20
+ */
21
+ export function gpuProvider(options: WebGpuOptions): GpuProvider {
22
+ return providerFor(
23
+ {
24
+ probe: (rejectSoftware) => probeNodeWebGpu({ adapter: options.adapter, rejectSoftware }),
25
+ open: (_probe, rejectSoftware, residentSnapshots) =>
26
+ createNodeGpuContext({
27
+ adapter: options.adapter,
28
+ rejectSoftware,
29
+ warnUnreleasedSnapshots: residentSnapshots,
30
+ }),
31
+ fixes: NODE_FIXES,
32
+ },
33
+ options,
34
+ );
35
+ }