@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/LICENSE +21 -0
- package/NOTICE +10 -0
- package/README.md +73 -7
- package/package.json +124 -14
- package/src/algorithm-options.ts +277 -0
- package/src/algorithms.ts +1911 -0
- package/src/elements.ts +357 -0
- package/src/gpu-platform-browser.ts +29 -0
- package/src/gpu-platform-node.ts +35 -0
- package/src/gpu.ts +407 -0
- package/src/graph-data.ts +165 -0
- package/src/index.ts +73 -0
- package/src/io.ts +205 -0
- package/src/layouts.ts +886 -0
- package/src/lazy.ts +35 -0
- package/src/samples.ts +273 -0
- package/src/snapshot.ts +281 -0
- package/src/webgpu-provider.ts +67 -0
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
|
+
}
|
package/src/snapshot.ts
ADDED
|
@@ -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
|
+
}
|