@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/gpu.ts
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebGPU acceleration: detection, device acquisition, reuse, device-loss recovery and disposal, per Cytoscape core.
|
|
3
|
+
*
|
|
4
|
+
* The package @graphty/webgpu-graph-algorithms is a regular dependency, but nothing here imports it statically.
|
|
5
|
+
* The first call that could use a GPU, in a runtime that has WebGPU (or might: Node, where the optional `webgpu`
|
|
6
|
+
* package brings Dawn), loads it with a dynamic import of "#gpu-platform". The package's `imports` field maps that specifier to the Node
|
|
7
|
+
* build or the browser build, so a bundler puts the GPU code in a chunk of its own that a CPU-only page never
|
|
8
|
+
* fetches, and a browser bundle never sees the Node build's `import("webgpu")`.
|
|
9
|
+
*
|
|
10
|
+
* The rule the whole module follows: the GPU-or-CPU decision is made once, up front, before any work starts.
|
|
11
|
+
* A failure of work that already started on the GPU (a lost device, an out-of-memory) is thrown to the caller,
|
|
12
|
+
* never finished quietly on the CPU. The next call acquires a fresh device.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { AlgorithmAccelerator } from "@graphty/algorithms";
|
|
16
|
+
import type { GraphSnapshot } from "@graphty/graph-format";
|
|
17
|
+
import type { LayoutAccelerator } from "@graphty/layout";
|
|
18
|
+
import type { Core } from "cytoscape";
|
|
19
|
+
|
|
20
|
+
import { loadPart } from "./lazy.js";
|
|
21
|
+
import { onSnapshotsDropped } from "./snapshot.js";
|
|
22
|
+
|
|
23
|
+
/** The accelerator a provider hands over: both CPU packages' injection seams, plus snapshot release and disposal. */
|
|
24
|
+
export type GpuAccelerator = AlgorithmAccelerator &
|
|
25
|
+
LayoutAccelerator & {
|
|
26
|
+
release(s: GraphSnapshot): void;
|
|
27
|
+
dispose(): void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** A device a provider acquired. */
|
|
31
|
+
export interface AcquiredGpu {
|
|
32
|
+
readonly accelerator: GpuAccelerator;
|
|
33
|
+
/** "vendor / description", for the backend report. */
|
|
34
|
+
readonly device: string;
|
|
35
|
+
/** Resolves (with the reason) when the device is lost, including when it is destroyed by dispose(). */
|
|
36
|
+
readonly lost: Promise<string>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Thrown by a provider that declined up front (no WebGPU, no adapter, a software-only or incorrect device). */
|
|
40
|
+
export class GpuUnavailableError extends Error {
|
|
41
|
+
/** E_NO_WEBGPU, E_NO_ADAPTER, E_SOFTWARE_ONLY, E_DEVICE_INCORRECT, ... */
|
|
42
|
+
readonly code: string;
|
|
43
|
+
/** What the user can change to get the GPU, when there is something; null when nothing they do would help. */
|
|
44
|
+
readonly fix: string | null;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Creates the error.
|
|
48
|
+
* @param code - the reason code
|
|
49
|
+
* @param message - the reason in words
|
|
50
|
+
* @param fix - what the user can change to get the GPU, or null
|
|
51
|
+
*/
|
|
52
|
+
constructor(code: string, message: string, fix: string | null = null) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = "GpuUnavailableError";
|
|
55
|
+
this.code = code;
|
|
56
|
+
this.fix = fix;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Probes, constructs and verifies a device, or throws GpuUnavailableError. */
|
|
61
|
+
export interface GpuProvider {
|
|
62
|
+
acquire(): Promise<AcquiredGpu>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Per call: "auto" (default) uses the GPU when one is available and runs the CPU otherwise; "off" always runs the CPU;
|
|
67
|
+
* "require" throws when no GPU is available instead of running the CPU.
|
|
68
|
+
*/
|
|
69
|
+
export type GpuMode = "auto" | "off" | "require";
|
|
70
|
+
|
|
71
|
+
const GPU_MODES: readonly unknown[] = ["auto", "off", "require"] satisfies GpuMode[];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Throws when a `gpu` option is not one of the modes, so a misspelled "require" cannot quietly run on the CPU.
|
|
75
|
+
* @param name - the method or layout, for the message
|
|
76
|
+
* @param mode - the caller's `gpu` option
|
|
77
|
+
* @throws TypeError when the option is set to anything but "auto", "off" or "require"
|
|
78
|
+
*/
|
|
79
|
+
export function checkGpuMode(name: string, mode: unknown): void {
|
|
80
|
+
if (mode !== undefined && !GPU_MODES.includes(mode)) {
|
|
81
|
+
// a string quoted, so "require " shows its space; anything else by its value or its type
|
|
82
|
+
const plain = mode === null || typeof mode === "number" || typeof mode === "boolean";
|
|
83
|
+
const shown = plain ? String(mode) : `a value of type ${typeof mode}`;
|
|
84
|
+
const got = typeof mode === "string" ? JSON.stringify(mode) : shown;
|
|
85
|
+
throw new TypeError(`${name}: gpu must be "auto", "off" or "require"; got ${got}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Which implementation ran, and why the CPU when it was the CPU. */
|
|
90
|
+
export interface Backend {
|
|
91
|
+
readonly ran: "gpu" | "cpu";
|
|
92
|
+
/** Why the CPU ran; null when the GPU ran. */
|
|
93
|
+
readonly reason: string | null;
|
|
94
|
+
/** The GPU device, when one was acquired (also when the options kept the work on the CPU). */
|
|
95
|
+
readonly device: string | null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Options of `configureWebGpu`, for every core. */
|
|
99
|
+
export interface WebGpuOptions {
|
|
100
|
+
/**
|
|
101
|
+
* Accept a software adapter (llvmpipe, SwiftShader, WARP). Default false: a software adapter is usually slower
|
|
102
|
+
* than the CPU implementation, so the CPU runs and the result says why.
|
|
103
|
+
*/
|
|
104
|
+
readonly acceptSoftware?: boolean;
|
|
105
|
+
/** Node only: a substring of the Dawn adapter name to pick ("llvmpipe", "4070"). Ignored in a browser. */
|
|
106
|
+
readonly adapter?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Graphs with at least this many nodes are where the GPU is expected to pay off. One of them running on the CPU for
|
|
111
|
+
* a reason the user could fix logs a one-time console warning; smaller graphs never do.
|
|
112
|
+
*/
|
|
113
|
+
export const GPU_SIZE_FLOOR = 5_000;
|
|
114
|
+
|
|
115
|
+
/** A core's GPU decision: an acquired device, or the reason there is none. */
|
|
116
|
+
interface Decision {
|
|
117
|
+
readonly gpu: AcquiredGpu | null;
|
|
118
|
+
readonly reason: string | null;
|
|
119
|
+
/** When the CPU was chosen for a reason the user could fix: the fix. */
|
|
120
|
+
readonly fix?: string | null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface CoreGpu {
|
|
124
|
+
decision: Promise<Decision> | null;
|
|
125
|
+
/** The provider the decision came from; a newly registered provider decides again. */
|
|
126
|
+
by: GpuProvider | null;
|
|
127
|
+
destroyed: boolean;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A provider, or the reason (and fix) there is none. */
|
|
131
|
+
type Source = GpuProvider | { readonly reason: string; readonly fix: string | null };
|
|
132
|
+
|
|
133
|
+
let options: WebGpuOptions = {};
|
|
134
|
+
/** Set by tests in place of the platform's provider; null disables WebGPU, undefined restores the platform's. */
|
|
135
|
+
let override: GpuProvider | null | undefined;
|
|
136
|
+
/** The provider being loaded or loaded, for the current options. */
|
|
137
|
+
let loading: Promise<Source> | null = null;
|
|
138
|
+
/** The same, once settled, so a run that cannot use the GPU can stay synchronous. */
|
|
139
|
+
let loaded: Source | null = null;
|
|
140
|
+
let warned = false;
|
|
141
|
+
const cores = new WeakMap<Core, CoreGpu>();
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Sets the WebGPU options of every core. Each core disposes its device and decides again on its next call.
|
|
145
|
+
* @param o - see WebGpuOptions
|
|
146
|
+
*/
|
|
147
|
+
export function configureWebGpu(o: WebGpuOptions = {}): void {
|
|
148
|
+
options = { ...o };
|
|
149
|
+
loading = null;
|
|
150
|
+
loaded = null;
|
|
151
|
+
warned = false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Test seam: replaces the platform's provider.
|
|
156
|
+
* @param p - the provider; null disables WebGPU; undefined restores the platform's provider
|
|
157
|
+
*/
|
|
158
|
+
export function registerGpuProvider(p: GpuProvider | null | undefined): void {
|
|
159
|
+
configureWebGpu(options);
|
|
160
|
+
override = p;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Why this runtime certainly has no WebGPU, without loading anything.
|
|
165
|
+
* @returns the reason, or null when it has WebGPU or might (Node, with the optional `webgpu` package)
|
|
166
|
+
*/
|
|
167
|
+
function noWebGpu(): string | null {
|
|
168
|
+
const nav = (globalThis as { navigator?: { gpu?: unknown } }).navigator;
|
|
169
|
+
if (nav?.gpu !== undefined && nav.gpu !== null) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
const proc = (globalThis as { process?: { versions?: { node?: string } } }).process;
|
|
173
|
+
return proc?.versions?.node === undefined ? "this runtime has no WebGPU (navigator.gpu is undefined)" : null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The provider, loading the GPU package on first use.
|
|
178
|
+
* @returns the provider, or why there is none
|
|
179
|
+
*/
|
|
180
|
+
function source(): Promise<Source> {
|
|
181
|
+
if (loading === null) {
|
|
182
|
+
const none = noWebGpu();
|
|
183
|
+
if (override !== undefined || none !== null) {
|
|
184
|
+
// known without loading anything, so known synchronously
|
|
185
|
+
loaded = override ?? { reason: none ?? "WebGPU was disabled", fix: null };
|
|
186
|
+
loading = Promise.resolve(loaded);
|
|
187
|
+
} else {
|
|
188
|
+
const p: Promise<Source> = loadPart(
|
|
189
|
+
import("#gpu-platform"),
|
|
190
|
+
"an ...Async method or a simulation layout",
|
|
191
|
+
).then(
|
|
192
|
+
(m) => m.gpuProvider(options),
|
|
193
|
+
(e: unknown) => ({
|
|
194
|
+
reason: `@graphty/webgpu-graph-algorithms did not load: ${messageOf(e)}`,
|
|
195
|
+
fix: "check that @graphty/webgpu-graph-algorithms is installed and that your bundler serves its chunk",
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
loading = p;
|
|
199
|
+
void p.then((s) => {
|
|
200
|
+
if (loading === p) {
|
|
201
|
+
loaded = s;
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return loading;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Whether a source is a provider.
|
|
211
|
+
* @param s - the source
|
|
212
|
+
* @returns true for a provider
|
|
213
|
+
*/
|
|
214
|
+
function isProvider(s: Source): s is GpuProvider {
|
|
215
|
+
return "acquire" in s;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The message of a thrown value.
|
|
220
|
+
* @param e - what was thrown
|
|
221
|
+
* @returns its message
|
|
222
|
+
*/
|
|
223
|
+
function messageOf(e: unknown): string {
|
|
224
|
+
return e instanceof Error ? e.message : String(e);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Disposes a decision's device once the decision settles. A decision that failed holds no device, and its error
|
|
229
|
+
* already reached the caller that awaited it.
|
|
230
|
+
* @param decision - the decision, or null when there is none
|
|
231
|
+
*/
|
|
232
|
+
function disposeWhenDecided(decision: Promise<Decision> | null): void {
|
|
233
|
+
decision?.then(
|
|
234
|
+
(d) => d.gpu?.accelerator.dispose(),
|
|
235
|
+
() => undefined,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The per-core state; the first call subscribes to the core's destroy event.
|
|
241
|
+
* @param cy - the core
|
|
242
|
+
* @returns its state
|
|
243
|
+
*/
|
|
244
|
+
function coreGpu(cy: Core): CoreGpu {
|
|
245
|
+
let g = cores.get(cy);
|
|
246
|
+
if (g === undefined) {
|
|
247
|
+
const created: CoreGpu = { decision: null, by: null, destroyed: false };
|
|
248
|
+
cy.one("destroy", () => {
|
|
249
|
+
created.destroyed = true;
|
|
250
|
+
disposeWhenDecided(created.decision);
|
|
251
|
+
created.decision = null;
|
|
252
|
+
});
|
|
253
|
+
cores.set(cy, created);
|
|
254
|
+
g = created;
|
|
255
|
+
}
|
|
256
|
+
return g;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Acquires a device for a core: probe, construct, verify; wires loss, snapshot release and destroy.
|
|
261
|
+
* @param cy - the core
|
|
262
|
+
* @param g - its state
|
|
263
|
+
* @param p - the provider
|
|
264
|
+
* @returns the decision
|
|
265
|
+
*/
|
|
266
|
+
async function decide(cy: Core, g: CoreGpu, p: GpuProvider): Promise<Decision> {
|
|
267
|
+
let gpu: AcquiredGpu;
|
|
268
|
+
try {
|
|
269
|
+
gpu = await p.acquire();
|
|
270
|
+
} catch (e) {
|
|
271
|
+
// Up-front detection: nothing has run yet, so the CPU is the honest answer, with the reason
|
|
272
|
+
const fix = e instanceof GpuUnavailableError ? e.fix : null;
|
|
273
|
+
return { gpu: null, reason: `no usable WebGPU device: ${messageOf(e)}`, fix };
|
|
274
|
+
}
|
|
275
|
+
if (g.destroyed) {
|
|
276
|
+
gpu.accelerator.dispose();
|
|
277
|
+
return { gpu: null, reason: "the core was destroyed" };
|
|
278
|
+
}
|
|
279
|
+
const unsubscribe = onSnapshotsDropped(cy, (dropped) => {
|
|
280
|
+
for (const s of dropped) {
|
|
281
|
+
gpu.accelerator.release(s);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
const self = g.decision;
|
|
285
|
+
void gpu.lost.then(() => {
|
|
286
|
+
unsubscribe();
|
|
287
|
+
// The next call acquires a new device. A device the core's own destroy disposed also resolves `lost`, and
|
|
288
|
+
// a decision already replaced is left alone.
|
|
289
|
+
if (g.decision === self) {
|
|
290
|
+
g.decision = null;
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
return { gpu, reason: null };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Why a run in this mode takes the CPU without asking for a device, so it can stay synchronous.
|
|
298
|
+
* @param mode - the caller's mode
|
|
299
|
+
* @returns the decision when it is already known to be the CPU, or null when a device has to be asked for
|
|
300
|
+
*/
|
|
301
|
+
export function cpuWithoutAsking(mode: GpuMode = "auto"): Decision | null {
|
|
302
|
+
if (mode === "off") {
|
|
303
|
+
return { gpu: null, reason: 'gpu: "off" was requested', fix: null };
|
|
304
|
+
}
|
|
305
|
+
if (override === null) {
|
|
306
|
+
return { gpu: null, reason: "WebGPU was disabled", fix: null };
|
|
307
|
+
}
|
|
308
|
+
if (loading === null && override === undefined) {
|
|
309
|
+
const none = noWebGpu();
|
|
310
|
+
if (none !== null) {
|
|
311
|
+
return { gpu: null, reason: none, fix: null };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return loaded === null || isProvider(loaded) ? null : { gpu: null, ...loaded };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The core's device, acquiring one on first use and again after a loss. One device per core, reused by every call.
|
|
319
|
+
* The first call in the process loads @graphty/webgpu-graph-algorithms.
|
|
320
|
+
* @param cy - the core
|
|
321
|
+
* @param mode - the caller's mode
|
|
322
|
+
* @returns the device (null for the CPU) and the reason when null
|
|
323
|
+
* @throws Error under "require" when no device is available, and when the core was destroyed
|
|
324
|
+
*/
|
|
325
|
+
export async function gpuFor(cy: Core, mode: GpuMode = "auto"): Promise<Decision> {
|
|
326
|
+
let d: Decision | null = cpuWithoutAsking(mode);
|
|
327
|
+
if (d === null) {
|
|
328
|
+
// subscribed to the core's destroy before the first await, so a destroy during the wait is seen
|
|
329
|
+
const g = coreGpu(cy);
|
|
330
|
+
const pending = source();
|
|
331
|
+
const src = loaded ?? (await pending);
|
|
332
|
+
if (!isProvider(src)) {
|
|
333
|
+
d = { gpu: null, ...src };
|
|
334
|
+
} else {
|
|
335
|
+
if (g.destroyed) {
|
|
336
|
+
throw new Error("graphty: the Cytoscape core was destroyed");
|
|
337
|
+
}
|
|
338
|
+
if (g.decision === null || g.by !== src) {
|
|
339
|
+
disposeWhenDecided(g.decision);
|
|
340
|
+
// deferred one tick so decide() sees its own promise in g.decision
|
|
341
|
+
g.decision = Promise.resolve().then(() => decide(cy, g, src));
|
|
342
|
+
g.by = src;
|
|
343
|
+
}
|
|
344
|
+
d = await g.decision;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (mode === "require" && d.gpu === null) {
|
|
348
|
+
throw new Error(`graphty: gpu: "require" but ${d.reason ?? "no device"}`);
|
|
349
|
+
}
|
|
350
|
+
return d;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Warns once per configuration when a graph at or above GPU_SIZE_FLOOR runs on the CPU for a reason the user could
|
|
355
|
+
* fix. Small graphs, gpu: "off" and reasons nobody can fix stay quiet.
|
|
356
|
+
* @param d - the decision the run started with
|
|
357
|
+
* @param nodeCount - the graph's node count
|
|
358
|
+
*/
|
|
359
|
+
export function warnIfFixable(d: Decision, nodeCount: number): void {
|
|
360
|
+
if (warned || d.gpu !== null || d.fix === undefined || d.fix === null || nodeCount < GPU_SIZE_FLOOR) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
warned = true;
|
|
364
|
+
console.warn(
|
|
365
|
+
`graphty: a ${nodeCount.toLocaleString("en-US")}-node graph ran on the CPU because ${d.reason ?? "no GPU"}. ` +
|
|
366
|
+
`To use the GPU: ${d.fix}. Pass gpu: "off" to run on the CPU without this warning.`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* An accelerator that records whether any of its methods was called, so the run can report which implementation
|
|
372
|
+
* answered. The CPU packages' dispatchers choose the CPU silently when the options or the graph need it.
|
|
373
|
+
* @param acc - the accelerator
|
|
374
|
+
* @returns the recording accelerator and the flag reader
|
|
375
|
+
*/
|
|
376
|
+
export function recording<T extends object>(acc: T): { accelerator: T; used(): boolean } {
|
|
377
|
+
let used = false;
|
|
378
|
+
const accelerator = new Proxy(acc, {
|
|
379
|
+
get(target, key, receiver): unknown {
|
|
380
|
+
const v: unknown = Reflect.get(target, key, receiver);
|
|
381
|
+
if (typeof v !== "function" || key === "release" || key === "dispose") {
|
|
382
|
+
return v;
|
|
383
|
+
}
|
|
384
|
+
return (...args: unknown[]): unknown => {
|
|
385
|
+
used = true;
|
|
386
|
+
return (v as (...a: unknown[]) => unknown).apply(target, args);
|
|
387
|
+
};
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
return { accelerator, used: () => used };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The backend report of a finished run.
|
|
395
|
+
* @param d - the decision the run started with
|
|
396
|
+
* @param usedGpu - whether the dispatcher called the accelerator
|
|
397
|
+
* @param why - the reason the dispatcher kept the work on the CPU, when it did
|
|
398
|
+
* @returns the report
|
|
399
|
+
*/
|
|
400
|
+
export function backendOf(d: Decision, usedGpu: boolean, why: string): Backend {
|
|
401
|
+
if (d.gpu === null) {
|
|
402
|
+
return { ran: "cpu", reason: d.reason, device: null };
|
|
403
|
+
}
|
|
404
|
+
return usedGpu
|
|
405
|
+
? { ran: "gpu", reason: null, device: d.gpu.device }
|
|
406
|
+
: { ran: "cpu", reason: why, device: d.gpu.device };
|
|
407
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The core methods that put graphs into Cytoscape and take them out: graphtyGenerate, graphtyDataset,
|
|
3
|
+
* graphtyImport and graphtyExport. Each loads its implementation (./samples.js or ./io.js, and with them the
|
|
4
|
+
* graph-samples or graph-io package) on its first call, so registering them costs a page nothing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ImportInput, ImportReport } from "@graphty/graph-io";
|
|
8
|
+
import type { FetchDatasetOptions } from "@graphty/graph-samples";
|
|
9
|
+
import type { Collection, CollectionReturnValue, Core, ElementDefinition } from "cytoscape";
|
|
10
|
+
|
|
11
|
+
import type { ExportFormat, ExportOptions, ImportFormat, ImportOptions } from "./io.js";
|
|
12
|
+
import { loadPart } from "./lazy.js";
|
|
13
|
+
import type { GeneratorName, GeneratorOptions } from "./samples.js";
|
|
14
|
+
|
|
15
|
+
/** What graphtyGenerate and graphtyDataset add to the core. */
|
|
16
|
+
export interface AddedGraph {
|
|
17
|
+
/** The added nodes and edges. */
|
|
18
|
+
readonly elements: CollectionReturnValue;
|
|
19
|
+
/** Whether the graph is directed: pass it as `directed` to the algorithms. */
|
|
20
|
+
readonly directed: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What graphtyImport adds to the core. */
|
|
24
|
+
export interface ImportedGraph extends AddedGraph {
|
|
25
|
+
/** The format the input was read as. */
|
|
26
|
+
readonly format: string;
|
|
27
|
+
/** graph-io's report: counts, warnings, and what could not be represented. */
|
|
28
|
+
readonly report: ImportReport;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The core methods `cytoscape.use(graphtyCytoscape)` adds for getting graphs in and out. */
|
|
32
|
+
export interface GraphtyGraphData {
|
|
33
|
+
/**
|
|
34
|
+
* Adds a generated graph: `cy.graphtyGenerate("barabasi-albert", { n: 500, m: 2, seed: 1 })`. Node ids are
|
|
35
|
+
* "0", "1", ... and edge ids "e0", "e1", ...; ground-truth columns (`community`, ...) become data fields and
|
|
36
|
+
* weights `data.weight`. Nodes are placed at the origin (run a layout next), except that a generator's
|
|
37
|
+
* coordinates (`positions: true` on the lattices; the geometric generators) become node positions, in the
|
|
38
|
+
* generator's own units. Throws, adding nothing, when the core already holds one of the graph's node ids.
|
|
39
|
+
*/
|
|
40
|
+
graphtyGenerate<N extends GeneratorName>(name: N, options: GeneratorOptions<N>): Promise<AddedGraph>;
|
|
41
|
+
/**
|
|
42
|
+
* Adds a sample dataset: `cy.graphtyDataset("karate")`. The small ones ship with the package; the large ones
|
|
43
|
+
* (road-ny, ogbn-arxiv, com-dblp) are downloaded from graphty.app. Throws, adding nothing, when the core already holds one of the graph's node ids.
|
|
44
|
+
* `options.signal` cancels it: once aborted, it rejects with the signal's reason and adds nothing, whatever
|
|
45
|
+
* `options.fetch` does with the signal.
|
|
46
|
+
*/
|
|
47
|
+
graphtyDataset(name: string, options?: FetchDatasetOptions): Promise<AddedGraph>;
|
|
48
|
+
/**
|
|
49
|
+
* Adds the graph in a file: GEXF, GraphML, GML, DOT, Pajek, CSV, JSON, Neo4j, XGMML, CX2, CX, OBO or a
|
|
50
|
+
* Cytoscape session (`.cys`, as bytes). Every attribute becomes a data field, and positions in the file become
|
|
51
|
+
* node positions (y negated for the formats whose y grows upward: GEXF, GML, DOT, Pajek). Throws, adding
|
|
52
|
+
* nothing, when the core already holds one of the graph's node ids.
|
|
53
|
+
*/
|
|
54
|
+
graphtyImport(
|
|
55
|
+
input: ImportInput | ArrayBuffer,
|
|
56
|
+
format?: ImportFormat,
|
|
57
|
+
options?: ImportOptions,
|
|
58
|
+
): Promise<ImportedGraph>;
|
|
59
|
+
/**
|
|
60
|
+
* Writes the graph as a file's text: every data field, each node's position and parent, and each edge's id, as
|
|
61
|
+
* far as the format holds them (`options.onLoss` hears about the rest). Hidden elements are written too. On a
|
|
62
|
+
* collection: its nodes, and those of its edges whose two ends are among them.
|
|
63
|
+
*/
|
|
64
|
+
graphtyExport(format: ExportFormat, options?: ExportOptions): Promise<string>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
declare module "cytoscape" {
|
|
68
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- declaration merging
|
|
69
|
+
interface Core extends GraphtyGraphData {}
|
|
70
|
+
interface CollectionAlgorithms {
|
|
71
|
+
/** Writes the collection's nodes, and its edges whose two ends are among them, as a file's text. */
|
|
72
|
+
graphtyExport: GraphtyGraphData["graphtyExport"];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type Register = (type: string, name: string, registrant: unknown) => void;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Rejected by graphtyGenerate, graphtyDataset and graphtyImport when a node id of the new graph is already in the
|
|
80
|
+
* core; nothing is added. `name` is "IdTakenError", `code` is "E_ID_TAKEN" and `id` is the first clashing id.
|
|
81
|
+
*/
|
|
82
|
+
export class IdTakenError extends Error {
|
|
83
|
+
/** Always "E_ID_TAKEN". */
|
|
84
|
+
readonly code = "E_ID_TAKEN";
|
|
85
|
+
/** The first node id of the new graph that the core already holds. */
|
|
86
|
+
readonly id: string;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Creates the error.
|
|
90
|
+
* @param id - the clashing id
|
|
91
|
+
*/
|
|
92
|
+
constructor(id: string) {
|
|
93
|
+
super(
|
|
94
|
+
`graphty: the core already has an element with id "${id}", so the new graph would merge into it; ` +
|
|
95
|
+
"remove the existing elements (cy.elements().remove()) or add the graph to an empty core",
|
|
96
|
+
);
|
|
97
|
+
this.name = "IdTakenError";
|
|
98
|
+
this.id = id;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Adds element definitions to a core. An edge whose id the core already holds loses it and gets one from
|
|
104
|
+
* Cytoscape, so a second import into the same core does not fail on edge ids the file chose. A node id the core
|
|
105
|
+
* already holds is refused: Cytoscape would skip that node and attach the new edges to the old one, merging the
|
|
106
|
+
* two graphs.
|
|
107
|
+
* @param cy - the core
|
|
108
|
+
* @param elements - the definitions; edge data may be changed
|
|
109
|
+
* @returns the added elements
|
|
110
|
+
* @throws IdTakenError naming the first node id the core already holds; nothing is added
|
|
111
|
+
*/
|
|
112
|
+
function addTo(cy: Core, elements: ElementDefinition[]): CollectionReturnValue {
|
|
113
|
+
for (const el of elements) {
|
|
114
|
+
if (el.group === "nodes" && el.data.id !== undefined && cy.getElementById(el.data.id).nonempty()) {
|
|
115
|
+
throw new IdTakenError(el.data.id);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
for (const el of elements) {
|
|
119
|
+
if (el.group === "edges" && el.data.id !== undefined && cy.getElementById(el.data.id).nonempty()) {
|
|
120
|
+
delete el.data.id;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return cy.add(elements);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Registers graphtyGenerate, graphtyDataset, graphtyImport and graphtyExport.
|
|
128
|
+
* @param cytoscape - the cytoscape function
|
|
129
|
+
*/
|
|
130
|
+
export function registerGraphData(cytoscape: Register): void {
|
|
131
|
+
cytoscape("core", "graphtyGenerate", async function (this: Core, name: GeneratorName, options: unknown) {
|
|
132
|
+
const { generateElements } = await loadPart(import("./samples.js"), "graphtyGenerate");
|
|
133
|
+
const r = generateElements(name, options as never);
|
|
134
|
+
return { elements: addTo(this, r.elements), directed: r.directed };
|
|
135
|
+
});
|
|
136
|
+
cytoscape("core", "graphtyDataset", async function (this: Core, name: string, options?: FetchDatasetOptions) {
|
|
137
|
+
const { datasetElements } = await loadPart(import("./samples.js"), "graphtyDataset");
|
|
138
|
+
const r = await datasetElements(name, options);
|
|
139
|
+
options?.signal?.throwIfAborted();
|
|
140
|
+
return { elements: addTo(this, r.elements), directed: r.directed };
|
|
141
|
+
});
|
|
142
|
+
cytoscape(
|
|
143
|
+
"core",
|
|
144
|
+
"graphtyImport",
|
|
145
|
+
async function (this: Core, input: ImportInput | ArrayBuffer, format?: ImportFormat, options?: ImportOptions) {
|
|
146
|
+
const { importElements } = await loadPart(import("./io.js"), "graphtyImport");
|
|
147
|
+
const r = await importElements(input, format, options);
|
|
148
|
+
return { elements: addTo(this, r.elements), directed: r.directed, format: r.format, report: r.report };
|
|
149
|
+
},
|
|
150
|
+
);
|
|
151
|
+
const exportFn = async (eles: Collection, format: ExportFormat, options?: ExportOptions): Promise<string> => {
|
|
152
|
+
const { exportElements } = await loadPart(import("./io.js"), "graphtyExport");
|
|
153
|
+
return exportElements(eles, format, options);
|
|
154
|
+
};
|
|
155
|
+
cytoscape("core", "graphtyExport", function (this: Core, format: ExportFormat, options?: ExportOptions) {
|
|
156
|
+
return exportFn(this.elements(), format, options);
|
|
157
|
+
});
|
|
158
|
+
cytoscape(
|
|
159
|
+
"collection",
|
|
160
|
+
"graphtyExport",
|
|
161
|
+
function (this: Collection, format: ExportFormat, options?: ExportOptions) {
|
|
162
|
+
return exportFn(this, format, options);
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Cytoscape.js adapter (package "@graphty/cytoscape-extensions"): graphty layouts and algorithms as Cytoscape.js extensions.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import cytoscape from "cytoscape";
|
|
6
|
+
* import graphtyCytoscape from "@graphty/cytoscape-extensions";
|
|
7
|
+
* cytoscape.use(graphtyCytoscape);
|
|
8
|
+
* cy.layout({ name: "graphty-forceatlas2" }).run();
|
|
9
|
+
* cy.elements().graphtyPageRank().rank("#a");
|
|
10
|
+
*
|
|
11
|
+
* // the ...Async methods and the simulations use WebGPU when the runtime has it; nothing else to import
|
|
12
|
+
* const r = await cy.elements().graphtyPageRankAsync();
|
|
13
|
+
* r.backend.ran; // "gpu" or "cpu", with r.backend.reason saying why
|
|
14
|
+
*
|
|
15
|
+
* // graphs in and out; each loads its parsers or generators on first use
|
|
16
|
+
* await cy.graphtyGenerate("barabasi-albert", { n: 500, m: 2, seed: 1 });
|
|
17
|
+
* await cy.graphtyImport(graphmlText, "graphml");
|
|
18
|
+
* const gexf = await cy.graphtyExport("gexf");
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { registerAlgorithms } from "./algorithms.js";
|
|
23
|
+
import { registerGraphData } from "./graph-data.js";
|
|
24
|
+
import { registerLayouts } from "./layouts.js";
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
ALGORITHM_NAMES,
|
|
28
|
+
type AlgorithmOptions,
|
|
29
|
+
ASYNC_ALGORITHM_NAMES,
|
|
30
|
+
type CutResult,
|
|
31
|
+
type ElementRef,
|
|
32
|
+
type GraphtyAlgorithms,
|
|
33
|
+
type NodePair,
|
|
34
|
+
type Partition,
|
|
35
|
+
type PathsResult,
|
|
36
|
+
type PointPathResult,
|
|
37
|
+
type PredictedLink,
|
|
38
|
+
type ScoreResult,
|
|
39
|
+
type SearchResult,
|
|
40
|
+
} from "./algorithms.js";
|
|
41
|
+
export { type Backend, configureWebGpu, GPU_SIZE_FLOOR, type GpuMode, type WebGpuOptions } from "./gpu.js";
|
|
42
|
+
export { type AddedGraph, type GraphtyGraphData, IdTakenError, type ImportedGraph } from "./graph-data.js";
|
|
43
|
+
export type { ExportFormat, ExportOptions, ImportFormat, ImportOptions, LossNote } from "./io.js";
|
|
44
|
+
export {
|
|
45
|
+
type GraphtyLayoutName,
|
|
46
|
+
type GraphtyLayoutOptions,
|
|
47
|
+
type GraphtyLayoutOptionsByName,
|
|
48
|
+
type GraphtyLayouts,
|
|
49
|
+
LAYOUT_NAMES,
|
|
50
|
+
} from "./layouts.js";
|
|
51
|
+
export type { GeneratorName, GeneratorOptions } from "./samples.js";
|
|
52
|
+
export { type CytoscapeSnapshot, type NodeSelection, type SnapshotOptions, toSnapshot, writeData } from "./snapshot.js";
|
|
53
|
+
|
|
54
|
+
/** Set on a cytoscape function once the extension is registered on it. */
|
|
55
|
+
const REGISTERED = Symbol.for("@graphty/cytoscape-extensions");
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The Cytoscape extension: `cytoscape.use(graphtyCytoscape)` registers every layout in LAYOUT_NAMES as
|
|
59
|
+
* "graphty-<name>", every algorithm in ALGORITHM_NAMES as a collection and core method, and the core methods
|
|
60
|
+
* graphtyGenerate, graphtyDataset, graphtyImport and graphtyExport. A second `use()` with the same cytoscape does
|
|
61
|
+
* nothing (the script-tag build has already registered itself when a page calls `use()` again).
|
|
62
|
+
* @param cytoscape - the cytoscape function `use()` passes in
|
|
63
|
+
*/
|
|
64
|
+
export default function graphtyCytoscape(cytoscape: (type: string, name: string, registrant: unknown) => void): void {
|
|
65
|
+
const marked = cytoscape as unknown as Record<symbol, boolean | undefined>;
|
|
66
|
+
if (marked[REGISTERED] === true) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
marked[REGISTERED] = true;
|
|
70
|
+
registerLayouts(cytoscape);
|
|
71
|
+
registerAlgorithms(cytoscape);
|
|
72
|
+
registerGraphData(cytoscape);
|
|
73
|
+
}
|