@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/lazy.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Loading the parts of the package that are fetched on first use (the generators and datasets, the file formats, the
3
+ * WebGPU code), with a warning when one never arrives.
4
+ */
5
+
6
+ /** How long a part may take to load before the call warns that it may never load. */
7
+ export const STALL_MS = 5000;
8
+
9
+ /**
10
+ * A part of the package loaded on first use, warning on the console when it has not loaded after STALL_MS.
11
+ *
12
+ * In a Vite 6 or 7 (Rollup) production build, the part's chunk imports the chunk that holds the consumer's entry
13
+ * module, because both use @graphty/graph-format and Rollup puts shared code in the chunk that loads first. When that
14
+ * entry module awaits the call at its top level, the entry chunk is still evaluating, so the part can never run and
15
+ * the await never settles. ES modules report nothing, and no package layout avoids it (the package's synchronous
16
+ * methods need @graphty/graph-format up front), so the warning names the cause. A slow network shows it too; the
17
+ * call then carries on.
18
+ * @param load - the dynamic import
19
+ * @param call - what is waiting, as the reader wrote it ("graphtyDataset")
20
+ * @returns the import
21
+ */
22
+ export function loadPart<T>(load: Promise<T>, call: string): Promise<T> {
23
+ const timer = setTimeout(() => {
24
+ console.warn(
25
+ `graphty: ${call} has waited ${String(STALL_MS / 1000)} s for part of @graphty/cytoscape-extensions to load. ` +
26
+ `If a module awaits ${call} at its top level, a Vite 6 or 7 production build never loads it: call it ` +
27
+ "inside an async function, or with .then(), instead. On a slow network the call carries on.",
28
+ );
29
+ }, STALL_MS);
30
+ const clear = (): void => {
31
+ clearTimeout(timer);
32
+ };
33
+ load.then(clear, clear);
34
+ return load;
35
+ }
package/src/samples.ts ADDED
@@ -0,0 +1,273 @@
1
+ /**
2
+ * `@graphty/cytoscape-extensions/samples`: the seeded generators and the sample datasets of @graphty/graph-samples
3
+ * as Cytoscape elements. cy.graphtyGenerate() and cy.graphtyDataset() load this module on their first call, and
4
+ * each bundled dataset is a module of its own, loaded only when it is asked for.
5
+ */
6
+
7
+ import { fromEdgeArrays, type GraphSnapshot } from "@graphty/graph-format";
8
+ import { DATASET_NAMES, fetchDataset, type FetchDatasetOptions, type SampleGraph } from "@graphty/graph-samples";
9
+ import * as g from "@graphty/graph-samples/generators";
10
+ import type { ElementDefinition } from "cytoscape";
11
+
12
+ import { GENERATOR_OPTION_NAMES } from "./algorithm-options.js";
13
+ import { flipY, snapshotToElements } from "./elements.js";
14
+
15
+ /**
16
+ * Every generator by its name: the graph-samples function name in kebab case without "Graph". Each takes that
17
+ * function's options; the random ones take `seed` (default 0) and give the same graph for the same options on
18
+ * every platform.
19
+ */
20
+ export const GENERATORS = {
21
+ ak: g.akGraph,
22
+ "balanced-tree": g.balancedTreeGraph,
23
+ "barabasi-albert": g.barabasiAlbertGraph,
24
+ barbell: g.barbellGraph,
25
+ "bianconi-barabasi": g.bianconiBarabasiGraph,
26
+ "bipartite-configuration-model": g.bipartiteConfigurationModelGraph,
27
+ caveman: g.cavemanGraph,
28
+ "chung-lu": g.chungLuGraph,
29
+ "circular-ladder": g.circularLadderGraph,
30
+ complete: g.completeGraph,
31
+ "complete-bipartite": g.completeBipartiteGraph,
32
+ "complete-multipartite": g.completeMultipartiteGraph,
33
+ "configuration-model": g.configurationModelGraph,
34
+ "connected-caveman": g.connectedCavemanGraph,
35
+ cycle: g.cycleGraph,
36
+ "degree-corrected-sbm": g.degreeCorrectedSbmGraph,
37
+ "directed-configuration-model": g.directedConfigurationModelGraph,
38
+ "duplication-divergence": g.duplicationDivergenceGraph,
39
+ empty: g.emptyGraph,
40
+ "erdos-renyi": g.erdosRenyiGraph,
41
+ "erdos-renyi-gnm": g.erdosRenyiGnmGraph,
42
+ "forest-fire": g.forestFireGraph,
43
+ genrmf: g.genrmfGraph,
44
+ grid: g.gridGraph,
45
+ "grid-3d": g.grid3dGraph,
46
+ "grid-flow-network": g.gridFlowNetwork,
47
+ "hexagonal-lattice": g.hexagonalLatticeGraph,
48
+ hyperbolic: g.hyperbolicGraph,
49
+ hypercube: g.hypercubeGraph,
50
+ knn: g.knnGraph,
51
+ kronecker: g.kroneckerGraph,
52
+ ladder: g.ladderGraph,
53
+ "layered-flow-network": g.layeredFlowNetwork,
54
+ lfr: g.lfrGraph,
55
+ lollipop: g.lollipopGraph,
56
+ "mobius-ladder": g.mobiusLadderGraph,
57
+ named: (o: { name: g.NamedGraphName } & g.WeightOptions) => {
58
+ // checked here: graph-samples' own error points at a list this package's readers cannot see
59
+ if (!(g.NAMED_GRAPH_NAMES as readonly string[]).includes(o.name)) {
60
+ throw unknownName("named graph", o.name, g.NAMED_GRAPH_NAMES);
61
+ }
62
+ return g.namedGraph(o.name, o);
63
+ },
64
+ "newman-watts": g.newmanWattsGraph,
65
+ path: g.pathGraph,
66
+ petersen: g.petersenGraph,
67
+ "planted-partition": g.plantedPartitionGraph,
68
+ price: g.priceGraph,
69
+ "random-apollonian": g.randomApollonianGraph,
70
+ "random-bipartite": g.randomBipartiteGraph,
71
+ "random-dag": g.randomDagGraph,
72
+ "random-geometric": g.randomGeometricGraph,
73
+ "random-order-dag": g.randomOrderDagGraph,
74
+ "random-recursive-tree": g.randomRecursiveTreeGraph,
75
+ "random-regular": g.randomRegularGraph,
76
+ "random-tree": g.randomTreeGraph,
77
+ "ring-of-cliques": g.ringOfCliquesGraph,
78
+ rmat: g.rmatGraph,
79
+ star: g.starGraph,
80
+ "stochastic-block-model": g.stochasticBlockModelGraph,
81
+ "triangular-lattice": g.triangularLatticeGraph,
82
+ "watts-strogatz": g.wattsStrogatzGraph,
83
+ waxman: g.waxmanGraph,
84
+ wheel: g.wheelGraph,
85
+ "wilson-maze": g.wilsonMazeGraph,
86
+ } as const;
87
+
88
+ /** A generator name. */
89
+ export type GeneratorName = keyof typeof GENERATORS;
90
+
91
+ /** The graphs the "named" generator knows, for `graphtyGenerate("named", { name })`. */
92
+ export { NAMED_GRAPH_NAMES } from "@graphty/graph-samples/generators";
93
+
94
+ /** The options of a generator. */
95
+ export type GeneratorOptions<N extends GeneratorName> = Parameters<(typeof GENERATORS)[N]>[0];
96
+
97
+ // One static specifier per dataset, so a bundler splits each into a chunk of its own.
98
+ const BUNDLED: Record<string, () => Promise<SampleGraph>> = {
99
+ karate: () => import("@graphty/graph-samples/datasets/karate").then((m) => m.karate()),
100
+ "florentine-families": () =>
101
+ import("@graphty/graph-samples/datasets/florentine-families").then((m) => m.florentineFamilies()),
102
+ "davis-southern-women": () =>
103
+ import("@graphty/graph-samples/datasets/davis-southern-women").then((m) => m.davisSouthernWomen()),
104
+ "les-miserables": () => import("@graphty/graph-samples/datasets/les-miserables").then((m) => m.lesMiserables()),
105
+ football: () => import("@graphty/graph-samples/datasets/football").then((m) => m.football()),
106
+ "political-books": () => import("@graphty/graph-samples/datasets/political-books").then((m) => m.politicalBooks()),
107
+ dolphins: () => import("@graphty/graph-samples/datasets/dolphins").then((m) => m.dolphins()),
108
+ "contiguous-usa": () => import("@graphty/graph-samples/datasets/contiguous-usa").then((m) => m.contiguousUsa()),
109
+ "knuth-miles": () => import("@graphty/graph-samples/datasets/knuth-miles").then((m) => m.knuthMiles()),
110
+ "celegans-neural": () => import("@graphty/graph-samples/datasets/celegans-neural").then((m) => m.celegansNeural()),
111
+ "political-blogs": () => import("@graphty/graph-samples/datasets/political-blogs").then((m) => m.politicalBlogs()),
112
+ openflights: () => import("@graphty/graph-samples/datasets/openflights").then((m) => m.openflights()),
113
+ "yeast-perturbation": () =>
114
+ import("@graphty/graph-samples/datasets/yeast-perturbation").then((m) => m.yeastPerturbation()),
115
+ "stelzl-interactome": () =>
116
+ import("@graphty/graph-samples/datasets/stelzl-interactome").then((m) => m.stelzlInteractome()),
117
+ "wikipathways-senescence-autophagy": () =>
118
+ import("@graphty/graph-samples/datasets/wikipathways-senescence-autophagy").then((m) =>
119
+ m.wikipathwaysSenescenceAutophagy(),
120
+ ),
121
+ "go-slim-generic": () => import("@graphty/graph-samples/datasets/go-slim-generic").then((m) => m.goSlimGeneric()),
122
+ };
123
+
124
+ /** The datasets that ship inside @graphty/graph-samples; the hosted ones (graph-samples' DATASETS) are fetched. */
125
+ export const BUNDLED_DATASET_NAMES: readonly string[] = Object.keys(BUNDLED);
126
+
127
+ /**
128
+ * The number of single-character edits that turn one string into the other.
129
+ * @param a - one string
130
+ * @param b - the other
131
+ * @returns the Levenshtein distance
132
+ */
133
+ function editDistance(a: string, b: string): number {
134
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
135
+ for (let i = 1; i <= a.length; i++) {
136
+ const row = [i];
137
+ for (let j = 1; j <= b.length; j++) {
138
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
139
+ }
140
+ prev = row;
141
+ }
142
+ return prev[b.length];
143
+ }
144
+
145
+ /**
146
+ * The error for an unknown name, naming the closest known one when one is close and listing them all.
147
+ * @param kind - what the name names ("dataset", "generator", "named graph")
148
+ * @param name - the unknown name
149
+ * @param names - the known names
150
+ * @returns a RangeError
151
+ */
152
+ function unknownName(kind: string, name: string, names: readonly string[]): RangeError {
153
+ let best = "";
154
+ let bestDistance = Infinity;
155
+ for (const known of names) {
156
+ const d = editDistance(name, known);
157
+ if (d < bestDistance) {
158
+ best = known;
159
+ bestDistance = d;
160
+ }
161
+ }
162
+ const hint = bestDistance <= Math.max(2, name.length / 3) ? ` (did you mean ${JSON.stringify(best)}?)` : "";
163
+ return new RangeError(`unknown ${kind} ${JSON.stringify(name)}${hint}; the ${kind}s are ${names.join(", ")}`);
164
+ }
165
+
166
+ /** A generated graph or a dataset as Cytoscape element definitions. */
167
+ export interface SampleElements {
168
+ /** The nodes, then the edges. Node ids are the dataset's names, or "0", "1", ... for a generated graph. */
169
+ readonly elements: ElementDefinition[];
170
+ /** Whether the graph is directed: pass it as `directed` to the algorithms. */
171
+ readonly directed: boolean;
172
+ }
173
+
174
+ /**
175
+ * The elements of a graph-samples graph.
176
+ * @param graph - the graph
177
+ * @returns the elements and the direction
178
+ */
179
+ function fromSample(graph: SampleGraph | GraphSnapshot): SampleElements {
180
+ const snapshot = "src" in graph ? fromEdgeArrays(graph) : graph;
181
+ return { elements: snapshotToElements(snapshot), directed: snapshot.directed };
182
+ }
183
+
184
+ /**
185
+ * A generated graph with its coordinate columns `x` and `y` (the lattices with `positions: true`, and the geometric
186
+ * generators) as one position column, so they become node positions; `z` stays a data field.
187
+ * @param graph - the generated graph
188
+ * @returns the graph, its x and y as a position
189
+ */
190
+ function withPosition(graph: SampleGraph): SampleGraph {
191
+ const { x, y, ...rest } = graph.nodeColumns ?? {};
192
+ if (!ArrayBuffer.isView(x) || !ArrayBuffer.isView(y)) {
193
+ return graph;
194
+ }
195
+ const xs = x as unknown as ArrayLike<number>;
196
+ const ys = y as unknown as ArrayLike<number>;
197
+ const xy = new Float64Array(2 * graph.nodeCount);
198
+ for (let i = 0; i < graph.nodeCount; i++) {
199
+ xy[2 * i] = xs[i];
200
+ xy[2 * i + 1] = ys[i];
201
+ }
202
+ return {
203
+ ...graph,
204
+ nodeColumns: { ...rest, position: { data: xy, decl: { dtype: "f64", components: 2, role: "position" } } },
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Generates a graph. Ground-truth columns (`community`, `side`, `layer`, ...) become node data fields; weights
210
+ * become `data.weight`; coordinates (`x` and `y`) become node positions. Edge k gets the id "e<k>", so the same
211
+ * options and seed give the same element ids as well as the same graph.
212
+ * @param name - the generator
213
+ * @param options - the generator's options
214
+ * @returns the elements and the direction
215
+ * @throws RangeError for an unknown generator name, or an option the generator does not take
216
+ */
217
+ export function generateElements<N extends GeneratorName>(name: N, options: GeneratorOptions<N>): SampleElements {
218
+ const make = (GENERATORS as Record<string, ((o: unknown) => SampleGraph) | undefined>)[name];
219
+ if (make === undefined) {
220
+ throw unknownName("generator", name, Object.keys(GENERATORS));
221
+ }
222
+ // a misspelled option (seeed, position) would otherwise run with the default and give a plausible graph
223
+ const known = GENERATOR_OPTION_NAMES[name] ?? [];
224
+ for (const [k, v] of Object.entries(options ?? {})) {
225
+ if (v !== undefined && !known.includes(k)) {
226
+ throw new RangeError(
227
+ `generator ${JSON.stringify(name)}: unknown option ${k}; the options are ${known.join(", ")}`,
228
+ );
229
+ }
230
+ }
231
+ const r = fromSample(withPosition(make(options)));
232
+ let k = 0;
233
+ for (const el of r.elements) {
234
+ // node ids are "0", "1", ..., so "e<k>" never collides with one
235
+ if (el.group === "edges") {
236
+ el.data.id = `e${String(k++)}`;
237
+ }
238
+ }
239
+ return r;
240
+ }
241
+
242
+ /**
243
+ * Loads a sample dataset: a bundled one from its own module, any other from graphty.app (see graph-samples'
244
+ * `DATASETS` for the list, with each one's source, citation and license). A drawn dataset's saved `x` and `y` become
245
+ * node positions, placed where Cytoscape drew them.
246
+ * @param name - the dataset name, e.g. "karate"
247
+ * @param options - for a hosted dataset: the base URL and fetch implementation; for any dataset, an abort signal
248
+ * @returns the elements and the direction
249
+ * @throws the signal's reason (an AbortError) when it is aborted before the dataset is decoded
250
+ * @throws RangeError for a name that is not a known dataset, unless `baseUrl` is given
251
+ */
252
+ export async function datasetElements(name: string, options: FetchDatasetOptions = {}): Promise<SampleElements> {
253
+ // checked here too, not only by fetch: a bundled dataset is not fetched, and a custom fetch may ignore the signal
254
+ options.signal?.throwIfAborted();
255
+ const load = BUNDLED[name];
256
+ // only graphty.app's own list is checked: a custom baseUrl may serve any name
257
+ if (load === undefined && options.baseUrl === undefined && !DATASET_NAMES.includes(name)) {
258
+ throw unknownName("dataset", name, DATASET_NAMES);
259
+ }
260
+ const graph = load === undefined ? await fetchDataset(name, options) : await load();
261
+ options.signal?.throwIfAborted();
262
+ const r = fromSample(graph);
263
+ // a drawn dataset's saved x and y (y growing upward) become the position Cytoscape drew (y growing downward)
264
+ for (const el of r.elements) {
265
+ const { x, y } = el.data;
266
+ if (typeof x === "number" && typeof y === "number") {
267
+ el.position = { x, y: flipY(y) };
268
+ delete el.data.x;
269
+ delete el.data.y;
270
+ }
271
+ }
272
+ return r;
273
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Conversion between Cytoscape elements and the graph-format snapshot every graphty layout and algorithm takes.
3
+ *
4
+ * A snapshot addresses nodes and edges by dense index. Here node index i is `nodes[i]` and edge index e is
5
+ * `edges[e]`, in the collection order Cytoscape gave, so a result array maps back to elements by position.
6
+ */
7
+
8
+ import { fromEdgeArrays, type GraphSnapshot } from "@graphty/graph-format";
9
+ import type { Collection, Core, EdgeCollection, EdgeSingular, NodeCollection } from "cytoscape";
10
+
11
+ /** How to read a Cytoscape collection as a graph. */
12
+ export interface SnapshotOptions {
13
+ /** Build a directed snapshot. Default false. */
14
+ readonly directed?: boolean | undefined;
15
+ /**
16
+ * The edge weight: an edge data field (a missing or non-numeric value counts as 1), or a function of the edge
17
+ * as Cytoscape's built-in algorithms take it. Default: unweighted. A function result is not cached.
18
+ */
19
+ readonly weight?: string | ((edge: EdgeSingular) => number) | undefined;
20
+ }
21
+
22
+ /** A node set given the Cytoscape way: a selector over the collection's nodes, or a collection. */
23
+ export type NodeSelection = string | Collection | NodeCollection;
24
+
25
+ /** A snapshot of a Cytoscape collection plus the element arrays its indices refer to. */
26
+ export interface CytoscapeSnapshot {
27
+ readonly snapshot: GraphSnapshot;
28
+ /** Node index i is `nodes[i]`. */
29
+ readonly nodes: NodeCollection;
30
+ /** Edge index e is `edges[e]`: the collection's edges whose two endpoints are both in `nodes`. */
31
+ readonly edges: EdgeCollection;
32
+ }
33
+
34
+ interface CacheEntry {
35
+ readonly key: string;
36
+ readonly eles: Collection;
37
+ readonly version: number;
38
+ readonly value: CytoscapeSnapshot;
39
+ }
40
+
41
+ interface CoreState {
42
+ version: number;
43
+ entries: CacheEntry[];
44
+ /** Told about every snapshot the cache lets go of (a GPU device releases its upload of it). */
45
+ dropListeners: Set<(dropped: readonly GraphSnapshot[]) => void>;
46
+ }
47
+
48
+ const state = new WeakMap<Core, CoreState>();
49
+ /** Runs still using each snapshot; a snapshot the cache drops while it has holds is released when the last ends. */
50
+ const holds = new Map<GraphSnapshot, number>();
51
+ /** Snapshots the cache dropped while held, and the core state whose listeners release them. */
52
+ const deferred = new Map<GraphSnapshot, CoreState>();
53
+
54
+ /**
55
+ * The core of a collection. Every collection has `cy()` at run time; Cytoscape's typings declare it only on single
56
+ * elements.
57
+ * @param eles - the collection
58
+ * @returns its core
59
+ */
60
+ export function coreOf(eles: Collection | NodeCollection | EdgeCollection): Core {
61
+ return (eles as unknown as { cy(): Core }).cy();
62
+ }
63
+
64
+ /**
65
+ * The per-core cache state; the first call subscribes to the events that change the graph.
66
+ * @param cy - the core
67
+ * @returns its cache state
68
+ */
69
+ function coreState(cy: Core): CoreState {
70
+ let s = state.get(cy);
71
+ if (s === undefined) {
72
+ const created: CoreState = { version: 0, entries: [], dropListeners: new Set() };
73
+ // ponytail: "data" fires for any field, so writing a result into data also invalidates; a per-field
74
+ // check would need Cytoscape to say which field changed, and it does not.
75
+ cy.on("add remove data move", () => {
76
+ created.version++;
77
+ drop(created, created.entries);
78
+ created.entries = [];
79
+ });
80
+ state.set(cy, created);
81
+ s = created;
82
+ }
83
+ return s;
84
+ }
85
+
86
+ /**
87
+ * Tells the drop listeners about entries leaving the cache.
88
+ * @param cs - the core's cache state
89
+ * @param gone - the entries
90
+ */
91
+ function drop(cs: CoreState, gone: readonly CacheEntry[]): void {
92
+ const now: GraphSnapshot[] = [];
93
+ for (const { value } of gone) {
94
+ if (holds.has(value.snapshot)) {
95
+ deferred.set(value.snapshot, cs);
96
+ } else {
97
+ now.push(value.snapshot);
98
+ }
99
+ }
100
+ notify(cs, now);
101
+ }
102
+
103
+ /**
104
+ * Tells a core's drop listeners about snapshots to release.
105
+ * @param cs - the core's cache state
106
+ * @param snapshots - the snapshots
107
+ */
108
+ function notify(cs: CoreState, snapshots: readonly GraphSnapshot[]): void {
109
+ if (snapshots.length > 0) {
110
+ for (const listener of cs.dropListeners) {
111
+ listener(snapshots);
112
+ }
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Keeps a snapshot from being released while a run uses it: when the cache drops it (a data change, say) during
118
+ * the run, its release waits until every hold on it is let go.
119
+ * @param s - the snapshot
120
+ * @returns lets go of the hold; calling it again does nothing
121
+ */
122
+ export function hold(s: GraphSnapshot): () => void {
123
+ holds.set(s, (holds.get(s) ?? 0) + 1);
124
+ let held = true;
125
+ return () => {
126
+ if (!held) {
127
+ return;
128
+ }
129
+ held = false;
130
+ const left = (holds.get(s) ?? 1) - 1;
131
+ if (left > 0) {
132
+ holds.set(s, left);
133
+ return;
134
+ }
135
+ holds.delete(s);
136
+ const cs = deferred.get(s);
137
+ if (cs !== undefined) {
138
+ deferred.delete(s);
139
+ notify(cs, [s]);
140
+ }
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Subscribes to the snapshots a core's cache lets go of.
146
+ * @param cy - the core
147
+ * @param listener - called with the dropped snapshots
148
+ * @returns the unsubscriber
149
+ */
150
+ export function onSnapshotsDropped(cy: Core, listener: (dropped: readonly GraphSnapshot[]) => void): () => void {
151
+ const cs = coreState(cy);
152
+ cs.dropListeners.add(listener);
153
+ return () => {
154
+ cs.dropListeners.delete(listener);
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Builds (or returns the cached) snapshot of a collection. The cache holds one snapshot per option set per core and
160
+ * is dropped when an element is added, removed, moved to other endpoints, or has its data changed.
161
+ * @param eles - the collection to read: its nodes, and its edges whose endpoints are both in it
162
+ * @param options - directed flag and weight field
163
+ * @returns the snapshot and the element arrays its indices refer to
164
+ */
165
+ export function toSnapshot(
166
+ eles: Collection | NodeCollection | EdgeCollection,
167
+ options: SnapshotOptions = {},
168
+ ): CytoscapeSnapshot {
169
+ const directed = options.directed ?? false;
170
+ const all = eles as Collection;
171
+ if (typeof options.weight === "function") {
172
+ return build(all, directed, options.weight);
173
+ }
174
+ const key = `${String(directed)}|${options.weight ?? ""}`;
175
+ const cs = coreState(coreOf(eles));
176
+ // the same elements in the same order: indices follow the collection's order, so a reordered one is another snapshot
177
+ const hit = cs.entries.find((e) => e.key === key && e.version === cs.version && sameOrder(e.eles, all));
178
+ if (hit !== undefined) {
179
+ return hit.value;
180
+ }
181
+ const value = build(all, directed, options.weight);
182
+ drop(
183
+ cs,
184
+ cs.entries.filter((e) => e.key === key),
185
+ );
186
+ cs.entries = [...cs.entries.filter((e) => e.key !== key), { key, eles: all, version: cs.version, value }];
187
+ return value;
188
+ }
189
+
190
+ /**
191
+ * Whether two collections hold the same elements in the same order.
192
+ * @param a - one collection
193
+ * @param b - the other
194
+ * @returns true when they match element by element
195
+ */
196
+ function sameOrder(a: Collection, b: Collection): boolean {
197
+ if (a.length !== b.length) {
198
+ return false;
199
+ }
200
+ for (let i = 0; i < a.length; i++) {
201
+ if (a[i] !== b[i]) {
202
+ return false;
203
+ }
204
+ }
205
+ return true;
206
+ }
207
+
208
+ /**
209
+ * Builds a snapshot without the cache.
210
+ * @param eles - the collection
211
+ * @param directed - whether the snapshot is directed
212
+ * @param weight - edge data field or function of the weight, or undefined for none
213
+ * @returns the snapshot and element arrays
214
+ */
215
+ function build(eles: Collection, directed: boolean, weight: SnapshotOptions["weight"]): CytoscapeSnapshot {
216
+ const nodes = eles.nodes();
217
+ const ids: string[] = [];
218
+ const index = new Map<string, number>();
219
+ nodes.forEach((n, i) => {
220
+ ids.push(n.id());
221
+ index.set(n.id(), i);
222
+ });
223
+ const edges = eles.edges().filter((e) => index.has(e.source().id()) && index.has(e.target().id()));
224
+ const src = new Uint32Array(edges.length);
225
+ const dst = new Uint32Array(edges.length);
226
+ const weights = weight === undefined ? undefined : new Float64Array(edges.length);
227
+ edges.forEach((e, k) => {
228
+ src[k] = index.get(e.source().id()) ?? 0;
229
+ dst[k] = index.get(e.target().id()) ?? 0;
230
+ if (weights !== undefined && weight !== undefined) {
231
+ const w: unknown = typeof weight === "function" ? weight(e) : e.data(weight);
232
+ weights[k] = typeof w === "number" && Number.isFinite(w) ? w : 1;
233
+ }
234
+ });
235
+ const snapshot = fromEdgeArrays({ directed, ids, src, dst, weights });
236
+ return { snapshot, nodes, edges };
237
+ }
238
+
239
+ /**
240
+ * Writes one value per element into its data, in one batch (one style pass).
241
+ * @param elements - `nodes` or `edges` of a CytoscapeSnapshot, in index order
242
+ * @param values - one value per element
243
+ * @param field - the data field to write
244
+ */
245
+ export function writeData(elements: NodeCollection | EdgeCollection, values: ArrayLike<number>, field: string): void {
246
+ coreOf(elements).batch(() => {
247
+ elements.forEach((ele, i) => {
248
+ ele.data(field, values[i]);
249
+ });
250
+ });
251
+ }
252
+
253
+ /**
254
+ * Indices of the snapshot's nodes in a selection.
255
+ * @param cs - the snapshot
256
+ * @param sel - a selector or a collection
257
+ * @returns the node indices, in collection order
258
+ */
259
+ export function indicesOf(cs: CytoscapeSnapshot, sel: NodeSelection): number[] {
260
+ const picked = typeof sel === "string" ? cs.nodes.filter(sel) : cs.nodes.intersection(sel);
261
+ return picked.map((n) => cs.snapshot.ids.requireIndex(n.id()));
262
+ }
263
+
264
+ /**
265
+ * The index of the first node of a selection.
266
+ * @param cs - the snapshot
267
+ * @param sel - a selector or a collection, or undefined
268
+ * @param what - the option name, for the error
269
+ * @returns the index, or undefined when no selection was given
270
+ * @throws Error when the selection matches no node of the snapshot
271
+ */
272
+ export function indexOf(cs: CytoscapeSnapshot, sel: NodeSelection | undefined, what: string): number | undefined {
273
+ if (sel === undefined) {
274
+ return undefined;
275
+ }
276
+ const [i] = indicesOf(cs, sel);
277
+ if (i === undefined) {
278
+ throw new Error(`${what} matches no node of the collection`);
279
+ }
280
+ return i;
281
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The device provider both platform builds (browser and Node) return: probe, request a context, check that the
3
+ * device computes correctly, build the accelerator. Everything here happens before any graph work, so a refusal is
4
+ * detection (the CPU runs and the result says why), never a fallback.
5
+ *
6
+ * Only the lazily loaded "#gpu-platform" chunk imports this module, so @graphty/webgpu-graph-algorithms stays out of
7
+ * the main chunk.
8
+ */
9
+
10
+ import { createAccelerator, type GpuContext, type ProbeResult, verifyDevice } from "@graphty/webgpu-graph-algorithms";
11
+
12
+ import { type GpuProvider, GpuUnavailableError, type WebGpuOptions } from "./gpu.js";
13
+
14
+ // The GPU package warns once when more than 2 snapshots are resident (its default); the adapter caches one snapshot
15
+ // per directed/weight combination per core and releases each when the cache drops it, so a few are normal.
16
+ const RESIDENT_SNAPSHOTS = 8;
17
+
18
+ /** How one platform probes for a device and opens a context on it. */
19
+ export interface Platform {
20
+ probe(rejectSoftware: boolean): Promise<ProbeResult>;
21
+ open(probe: ProbeResult, rejectSoftware: boolean, residentSnapshots: number): Promise<GpuContext>;
22
+ /** What the user can do about a refusal code on this platform; codes it does not name have no fix. */
23
+ readonly fixes: Readonly<Record<string, string>>;
24
+ }
25
+
26
+ /** The fix of a refused software adapter, on every platform. */
27
+ const SOFTWARE_FIX = "call configureWebGpu({ acceptSoftware: true }) to accept the software adapter";
28
+
29
+ /**
30
+ * A provider for a platform.
31
+ * @param platform - the platform's probe, context factory and fixes
32
+ * @param options - the options
33
+ * @returns the provider
34
+ */
35
+ export function providerFor(platform: Platform, options: WebGpuOptions): GpuProvider {
36
+ return {
37
+ acquire: async () => {
38
+ const rejectSoftware = options.acceptSoftware !== true;
39
+ const probe = await platform.probe(rejectSoftware);
40
+ if (!probe.ok) {
41
+ const fix = probe.code === "E_SOFTWARE_ONLY" ? SOFTWARE_FIX : (platform.fixes[probe.code] ?? null);
42
+ throw new GpuUnavailableError(probe.code, probe.reason ?? probe.code, fix);
43
+ }
44
+ const ctx = await platform.open(probe, rejectSoftware, RESIDENT_SNAPSHOTS);
45
+ try {
46
+ // The GPU package's compute entry points refuse a device that fails this check, mid-run. Checking
47
+ // here decides up front, so such a device means the CPU, not an error from the first call.
48
+ const check = await verifyDevice(ctx);
49
+ if (check.mismatch !== null) {
50
+ throw new GpuUnavailableError(
51
+ "E_DEVICE_INCORRECT",
52
+ `the ${check.vendor} device computes a known prefix sum incorrectly (${check.mismatch.where})`,
53
+ );
54
+ }
55
+ const { caps } = ctx;
56
+ return {
57
+ accelerator: createAccelerator(ctx),
58
+ device: `${caps.vendor} ${caps.device === "" ? caps.description : caps.device}`,
59
+ lost: ctx.lost.then((info) => (info.message === "" ? info.reason : info.message)),
60
+ };
61
+ } catch (error) {
62
+ ctx.dispose();
63
+ throw error;
64
+ }
65
+ },
66
+ };
67
+ }