@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/layouts.ts ADDED
@@ -0,0 +1,886 @@
1
+ /**
2
+ * Every @graphty/layout layout as a Cytoscape.js 3.x layout extension, named "graphty-<name>".
3
+ *
4
+ * Two kinds:
5
+ * - static layouts compute once and hand their positions to Cytoscape's `layoutPositions`, which supplies the
6
+ * events, `fit`, `spacingFactor`, `transform` and the tween for any truthy `animate`;
7
+ * - the force simulations (ForceAtlas2, Fruchterman-Reingold, spring-electrical) step a `LayoutSimulation`. With
8
+ * `animate: true` they draw every frame and own their events; otherwise they run to the end and finish through
9
+ * `layoutPositions` like a static layout.
10
+ *
11
+ * Positions are 2D. A 3D run (`dim: 3`) is projected onto the x-y plane.
12
+ */
13
+
14
+ import { type GraphSnapshot, makeMask, maskSet, maskTest, type U32 } from "@graphty/graph-format";
15
+ import {
16
+ arf,
17
+ type ArfOptions,
18
+ bfs,
19
+ bipartite,
20
+ type BipartiteLayoutOptions,
21
+ circular,
22
+ createSimulation,
23
+ type ForceAtlas2Options,
24
+ type FruchtermanReingoldOptions,
25
+ grid,
26
+ type GridLayoutOptions,
27
+ kamadaKawai,
28
+ type KamadaKawaiOptions,
29
+ type LayoutAccelerator,
30
+ type LayoutResult,
31
+ type LayoutSimulation,
32
+ Lcg,
33
+ multipartite,
34
+ planar,
35
+ radial,
36
+ random,
37
+ rescaleInPlace,
38
+ shell,
39
+ type SimulationType,
40
+ spectral,
41
+ spiral,
42
+ type SpiralLayoutOptions,
43
+ type SpringElectricalOptions,
44
+ } from "@graphty/layout";
45
+ import type {
46
+ BoundingBox12,
47
+ BoundingBoxWH,
48
+ Collection,
49
+ Core,
50
+ LayoutHandler,
51
+ LayoutPositionOptions,
52
+ Layouts as CytoscapeLayouts,
53
+ NodeCollection,
54
+ NodeSingular,
55
+ Position,
56
+ } from "cytoscape";
57
+
58
+ import { type Backend, backendOf, checkGpuMode, cpuWithoutAsking, gpuFor, type GpuMode, warnIfFixable } from "./gpu.js";
59
+ import { type CytoscapeSnapshot, hold, indexOf, indicesOf, type NodeSelection, toSnapshot } from "./snapshot.js";
60
+
61
+ /**
62
+ * Every option this package defines for the "graphty-*" layouts, shared and per layout. A layout takes the shared ones
63
+ * and its own (see GraphtyLayoutOptionsByName); its other options go to the @graphty/layout function unchanged.
64
+ * Exported for scripts/reference.ts, which documents every layout option from it.
65
+ * @internal
66
+ */
67
+ export interface LayoutOptionFields {
68
+ readonly name: GraphtyLayoutName;
69
+ /** Set by `cy.layout()` / `eles.layout()`. */
70
+ readonly eles?: Collection;
71
+ /** false: jump; "end" or any truthy value on a static layout: tween to the result; true on a simulation: draw every frame. */
72
+ readonly animate?: boolean | "end";
73
+ /** Length of the tween, in milliseconds. */
74
+ readonly animationDuration?: number;
75
+ /** Easing of the tween, as Cytoscape's built-in layouts take it (for example "ease-out"). */
76
+ readonly animationEasing?: string;
77
+ /** Tween only the nodes for which this returns true; the others jump to their positions. */
78
+ readonly animateFilter?: (node: NodeSingular, i: number) => boolean;
79
+ /** Fit the viewport to the result. Default true. */
80
+ readonly fit?: boolean;
81
+ /** Space around the result when `fit` is true, in pixels. */
82
+ readonly padding?: number;
83
+ /**
84
+ * Where to place the result. Default: the viewport, which is 1 x 1 pixel on a headless core. The result keeps its
85
+ * shape: its centroid goes to the center of the box, and it is scaled so the node farthest from the centroid is
86
+ * half the box's shorter side away. A box wider than it is tall is therefore not filled from side to side. A
87
+ * simulation with locked nodes keeps them in place and scales the free nodes around them instead.
88
+ */
89
+ readonly boundingBox?: BoundingBox12 | BoundingBoxWH;
90
+ /**
91
+ * Expands (above 1) or compresses (below 1) the area the result takes up, after it is fitted to `boundingBox`, so
92
+ * a value above 1 can put nodes outside the box.
93
+ */
94
+ readonly spacingFactor?: number;
95
+ /** Changes each final position: called with the node and its computed position, returns the position to use. */
96
+ readonly transform?: (node: NodeSingular, position: Position) => Position;
97
+ /** Called on layoutready. */
98
+ readonly ready?: LayoutHandler;
99
+ /** Called on layoutstop. */
100
+ readonly stop?: LayoutHandler;
101
+ /**
102
+ * 2 (default) or 3. With 3, graphty-circular puts the nodes on a sphere, and graphty-random, graphty-kamada-kawai,
103
+ * graphty-arf and the simulations place them in 3D; z is then dropped, so the drawing is that 3D result seen from
104
+ * above and no longer looks like the 2D layout (circular is no longer a circle). The other layouts ignore it.
105
+ * Cytoscape draws in 2D, so 3 only gives a different flattened drawing; it is there because the underlying layout
106
+ * functions take it.
107
+ */
108
+ readonly dim?: 2 | 3;
109
+ /**
110
+ * Seed of the layouts that draw random numbers: graphty-random, graphty-spectral, graphty-planar, graphty-arf and
111
+ * the three simulations. The same seed gives the same positions; without one, positions that depend on random
112
+ * numbers differ from run to run. The other layouts ignore it.
113
+ */
114
+ readonly seed?: number;
115
+ /**
116
+ * Edge data field holding the weight; a function of the edge also works at runtime. An edge without a number
117
+ * counts as 1. Only graphty-forceatlas2 and graphty-kamada-kawai read it, in opposite directions: forceatlas2
118
+ * treats it as attraction (higher pulls the ends closer), kamada-kawai as edge length (higher pushes them
119
+ * apart). Default: unweighted.
120
+ */
121
+ readonly weight?: string;
122
+ /** Simulations: start from random positions (true, default) or from the current ones. Locked nodes never move. */
123
+ readonly randomize?: boolean;
124
+ /** Simulations with `animate: true`: iterations per frame. Default 1. */
125
+ readonly refresh?: number;
126
+ /**
127
+ * Simulations: "auto" (default) runs on the core's GPU when the runtime has a usable WebGPU device, else on the
128
+ * CPU. In Node or a browser with `navigator.gpu`, a run that has to look for a device finishes after `run()`
129
+ * returns, whichever it picks, so listen for layoutstop. "off" runs on the CPU, synchronously when `animate` is false; "require" emits layouterror instead of running on the CPU. `layout.backend` says which ran. Any other value makes `run()` throw a TypeError.
130
+ */
131
+ readonly gpu?: GpuMode;
132
+ /**
133
+ * Simulations: an accelerator the caller built and owns (for example the `createAccelerator` of
134
+ * `@graphty/webgpu-graph-algorithms`); overrides `gpu`. null forces the CPU.
135
+ */
136
+ readonly accelerator?: LayoutAccelerator | null;
137
+ /** shell: the shells, innermost first. */
138
+ readonly nlist?: readonly NodeSelection[];
139
+ /**
140
+ * multipartite: a node data field whose value names the layer (default "subset"), or the layers in order. Layers
141
+ * named by a field are ordered by value, numbers ascending and other values alphabetically. A node with no value in
142
+ * the field is not placed and keeps its position, so on a graph where no node has the field nothing moves.
143
+ */
144
+ readonly subsets?: string | readonly NodeSelection[];
145
+ /**
146
+ * bipartite: the nodes of the first line, the left column when `align` is "vertical" and the top row when it is
147
+ * "horizontal". Absent: the first, third, fifth and so on of the laid-out nodes.
148
+ */
149
+ readonly top?: NodeSelection;
150
+ /**
151
+ * multipartite, bipartite and bfs: "vertical" (default) puts each layer in a column, its nodes sharing one x;
152
+ * "horizontal" puts each layer in a row, its nodes sharing one y.
153
+ */
154
+ readonly align?: "vertical" | "horizontal";
155
+ /**
156
+ * bfs: the start node, default the first node; radial: the center node, default the node with the most neighbors.
157
+ */
158
+ readonly root?: NodeSelection;
159
+ /**
160
+ * forceatlas2: each node's mass. The name of a node data field (a node without a number there gets the default),
161
+ * an object keyed by node id, or one number per node in node order (an array or a typed array; see Per-node
162
+ * arrays). Default: the node's degree + 1.
163
+ */
164
+ readonly nodeMass?: string | ArrayLike<number> | Readonly<Record<string, number>>;
165
+ /** forceatlas2: accepted in the same forms as `nodeMass` and not used yet: nodes are treated as points. */
166
+ readonly nodeSize?: string | ArrayLike<number> | Readonly<Record<string, number>>;
167
+ }
168
+
169
+ /** The options of LayoutOptionFields that only some layouts take. */
170
+ type PerLayoutField = "nlist" | "subsets" | "top" | "align" | "root" | "nodeMass" | "nodeSize";
171
+ /** The options every layout takes. */
172
+ type Shared = Omit<LayoutOptionFields, "name" | PerLayoutField>;
173
+ /** Some of the per-layout options. */
174
+ type Own<K extends PerLayoutField> = Pick<LayoutOptionFields, K>;
175
+ /**
176
+ * A @graphty/layout options type without what this package sets itself (the frame and the stepping, `fixed` from
177
+ * locked nodes) or spells its own way (bfs `start` is `root` here).
178
+ */
179
+ type Library<T, Drop extends string = never> = Omit<
180
+ T,
181
+ | keyof LayoutOptionFields
182
+ | "scale"
183
+ | "center"
184
+ | "iterationsPerStep"
185
+ | "fixed"
186
+ | "start"
187
+ | "weights"
188
+ | "weighted"
189
+ | Drop
190
+ >;
191
+
192
+ /**
193
+ * The options of each "graphty-*" layout, by name. A simulation's `pos` is the nodes' current positions, so only the
194
+ * static layouts that take a start (arf, kamada-kawai) list it.
195
+ */
196
+ export interface GraphtyLayoutOptionsByName {
197
+ "graphty-random": Shared;
198
+ "graphty-circular": Shared;
199
+ "graphty-spiral": Shared & Library<SpiralLayoutOptions>;
200
+ "graphty-grid": Shared & Library<GridLayoutOptions>;
201
+ "graphty-spectral": Shared;
202
+ "graphty-planar": Shared;
203
+ "graphty-arf": Shared & Library<ArfOptions>;
204
+ "graphty-kamada-kawai": Shared & Library<KamadaKawaiOptions>;
205
+ "graphty-shell": Shared & Own<"nlist">;
206
+ "graphty-multipartite": Shared & Own<"subsets" | "align">;
207
+ "graphty-bipartite": Shared & Own<"top" | "align"> & Library<BipartiteLayoutOptions>;
208
+ "graphty-bfs": Shared & Own<"root" | "align">;
209
+ "graphty-radial": Shared & Own<"root">;
210
+ "graphty-forceatlas2": Shared & Own<"nodeMass" | "nodeSize"> & Library<ForceAtlas2Options>;
211
+ "graphty-fruchterman-reingold": Shared & Library<FruchtermanReingoldOptions, "pos">;
212
+ "graphty-spring-electrical": Shared & Library<SpringElectricalOptions, "pos">;
213
+ }
214
+
215
+ /** Every layout this package registers, by its registered name. */
216
+ export type GraphtyLayoutName = keyof GraphtyLayoutOptionsByName;
217
+
218
+ /**
219
+ * The options of a "graphty-*" layout: a union over the layout names, so a variable annotated with it keeps its
220
+ * layout's options type, and a misspelled name or option does not compile. `GraphtyLayoutOptions<"graphty-bfs">` is
221
+ * one layout's.
222
+ */
223
+ export type GraphtyLayoutOptions<N extends GraphtyLayoutName = GraphtyLayoutName> = N extends GraphtyLayoutName
224
+ ? GraphtyLayoutOptionsByName[N] & { readonly name: N }
225
+ : never;
226
+
227
+ /**
228
+ * The layout a "graphty-*" layout name gives. `backend` is set on a simulation (forceatlas2, fruchterman-reingold,
229
+ * spring-electrical) once it has decided between GPU and CPU, before layoutready; static layouts leave it unset.
230
+ */
231
+ export type GraphtyLayouts = CytoscapeLayouts & { readonly backend?: Backend };
232
+
233
+ // Cytoscape types layout() over a closed union whose catch-all, BaseLayoutOptions, rejects every option it does not
234
+ // list, so `cy.layout({ name: "graphty-circular", boundingBox })` written inline would not compile. These overloads
235
+ // take a "graphty-*" layout's own options; any other layout name still resolves to Cytoscape's signature.
236
+ declare module "cytoscape" {
237
+ interface CoreLayout {
238
+ layout(options: GraphtyLayoutOptions): GraphtyLayouts;
239
+ makeLayout(options: GraphtyLayoutOptions): GraphtyLayouts;
240
+ createLayout(options: GraphtyLayoutOptions): GraphtyLayouts;
241
+ }
242
+ interface CollectionLayout {
243
+ layout(options: GraphtyLayoutOptions): GraphtyLayouts;
244
+ makeLayout(options: GraphtyLayoutOptions): GraphtyLayouts;
245
+ createLayout(options: GraphtyLayoutOptions): GraphtyLayouts;
246
+ }
247
+ }
248
+
249
+ /** The registry hands the constructor the options plus `cy`; options this package does not define pass through. */
250
+ interface ConstructorOptions extends Omit<LayoutOptionFields, "name"> {
251
+ readonly name: string;
252
+ readonly cy: Core;
253
+ readonly eles: Collection;
254
+ readonly [option: string]: unknown;
255
+ }
256
+
257
+ /** The layout object the Cytoscape registry builds around a registrant (its emitter methods are added by Cytoscape). */
258
+ interface LayoutThis {
259
+ options: ConstructorOptions;
260
+ stopped: boolean;
261
+ looping: boolean;
262
+ /** Simulations: which implementation ran and why, set before layoutready. */
263
+ backend?: Backend;
264
+ emit(event: string | { type: string; layout: LayoutThis }, params?: unknown[]): LayoutThis;
265
+ one(events: string, handler: LayoutHandler): LayoutThis;
266
+ }
267
+
268
+ /** A static layout over the snapshot, given the options with element selections already turned into indices. */
269
+ type StaticLayout = (s: GraphSnapshot, o: Record<string, unknown>, cs: CytoscapeSnapshot) => LayoutResult;
270
+
271
+ export const DEFAULTS = {
272
+ animate: false,
273
+ animationDuration: 500,
274
+ fit: true,
275
+ padding: 30,
276
+ randomize: true,
277
+ refresh: 1,
278
+ } as const;
279
+
280
+ /**
281
+ * Groups the nodes by the value of a data field, in ascending value order; nodes without the field are left out.
282
+ * @param cs - the layout's snapshot
283
+ * @param field - the node data field
284
+ * @returns node indices per value
285
+ */
286
+ function groupsByData(cs: CytoscapeSnapshot, field: string): number[][] {
287
+ const groups = new Map<string | number, number[]>();
288
+ cs.nodes.forEach((node, i) => {
289
+ const v: unknown = node.data(field);
290
+ if (typeof v === "number" || typeof v === "string") {
291
+ const g = groups.get(v);
292
+ if (g === undefined) {
293
+ groups.set(v, [i]);
294
+ } else {
295
+ g.push(i);
296
+ }
297
+ }
298
+ });
299
+ const keys = [...groups.keys()].sort((a, b) =>
300
+ typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b)),
301
+ );
302
+ return keys.map((k) => groups.get(k) ?? []);
303
+ }
304
+
305
+ // The @graphty/layout one-shot functions take index arrays, masks and column names where Cytoscape users hold
306
+ // selectors and collections; these entries translate the few options that differ and pass the rest through.
307
+ const STATIC: Readonly<Record<string, StaticLayout>> = {
308
+ random: (s, o) => random(s, o),
309
+ circular: (s, o) => circular(s, o),
310
+ spiral: (s, o) => spiral(s, o),
311
+ grid: (s, o) => grid(s, o),
312
+ spectral: (s, o) => spectral(s, o),
313
+ planar: (s, o) => planar(s, o),
314
+ arf: (s, o) => arf(s, o),
315
+ "kamada-kawai": (s, o) => kamadaKawai(s, o),
316
+ shell: (s, o, cs) => {
317
+ const nlist = o.nlist as readonly NodeSelection[] | undefined;
318
+ return shell(s, { ...o, nlist: nlist?.map((sel) => indicesOf(cs, sel)) });
319
+ },
320
+ multipartite: (s, o, cs) => {
321
+ const subsets = (o.subsets ?? "subset") as string | readonly NodeSelection[];
322
+ const layers = typeof subsets === "string" ? groupsByData(cs, subsets) : subsets.map((x) => indicesOf(cs, x));
323
+ return multipartite(s, { ...o, subsets: layers });
324
+ },
325
+ bipartite: (s, o, cs) => {
326
+ if (o.top === undefined) {
327
+ return bipartite(s, o);
328
+ }
329
+ const mask = makeMask(s.nodeCount);
330
+ for (const i of indicesOf(cs, o.top as NodeSelection)) {
331
+ maskSet(mask, i, true);
332
+ }
333
+ return bipartite(s, { ...o, top: mask });
334
+ },
335
+ bfs: (s, { root, ...o }, cs) =>
336
+ bfs(s, { ...o, start: indexOf(cs, root as NodeSelection | undefined, "graphty layout: root") }),
337
+ radial: (s, o, cs) =>
338
+ radial(s, { ...o, root: indexOf(cs, o.root as NodeSelection | undefined, "graphty layout: root") }),
339
+ };
340
+
341
+ // This package's own iteration budgets for the simulations, passed explicitly so a change of a @graphty/layout
342
+ // default does not change what a Cytoscape user gets; the caller's option overrides them.
343
+ export const SIMULATION_DEFAULTS: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {
344
+ forceatlas2: { maxIter: 100 },
345
+ fruchtermanReingold: { iterations: 50 },
346
+ };
347
+
348
+ /** Registered name suffix -> @graphty/layout simulation type. */
349
+ export const SIMULATIONS: Readonly<Record<string, SimulationType>> = {
350
+ forceatlas2: "forceatlas2",
351
+ "fruchterman-reingold": "fruchtermanReingold",
352
+ "spring-electrical": "spring-electrical",
353
+ };
354
+
355
+ /** Every layout name this package registers, without the "graphty-" prefix. */
356
+ export const LAYOUT_NAMES: readonly string[] = [...Object.keys(STATIC), ...Object.keys(SIMULATIONS)];
357
+
358
+ /**
359
+ * The bounding box in x1/y1/w/h form; default the viewport.
360
+ * @param bb - the option
361
+ * @param cy - the core
362
+ * @returns the box
363
+ */
364
+ function box(bb: BoundingBox12 | BoundingBoxWH | undefined, cy: Core): BoundingBoxWH {
365
+ if (bb === undefined) {
366
+ return { x1: 0, y1: 0, w: cy.width(), h: cy.height() };
367
+ }
368
+ return "w" in bb ? bb : { x1: bb.x1, y1: bb.y1, w: bb.x2 - bb.x1, h: bb.y2 - bb.y1 };
369
+ }
370
+
371
+ /**
372
+ * The radius and centre the layout fills inside a box.
373
+ * @param b - the box
374
+ * @returns half the shorter side (at least 1) and the centre
375
+ */
376
+ function frame(b: BoundingBoxWH): { radius: number; center: [number, number, number] } {
377
+ return { radius: Math.max(Math.min(b.w, b.h) / 2, 1), center: [b.x1 + b.w / 2, b.y1 + b.h / 2, 0] };
378
+ }
379
+
380
+ /**
381
+ * The x-y pairs of `n` rows of `stride` values, rescaled so the farthest placed node is `radius` from the centre. NaN
382
+ * rows (unplaced nodes) stay NaN.
383
+ * @param rows - the rows
384
+ * @param stride - values per row (2 or 3)
385
+ * @param n - row count
386
+ * @param b - the target box
387
+ * @returns 2n values
388
+ */
389
+ function fitRows(rows: ArrayLike<number>, stride: number, n: number, b: BoundingBoxWH): Float64Array {
390
+ const xy = new Float64Array(2 * n);
391
+ for (let i = 0; i < n; i++) {
392
+ xy[2 * i] = rows[stride * i];
393
+ xy[2 * i + 1] = rows[stride * i + 1];
394
+ }
395
+ const { radius, center } = frame(b);
396
+ return rescaleInPlace(xy, 2, radius, center);
397
+ }
398
+
399
+ /**
400
+ * The x-y pairs of a simulation with locked nodes: each locked node where it stands, each free node scaled about
401
+ * the locked nodes' centroid by the largest factor that keeps every free node inside the box (by the factor that
402
+ * puts the farthest at half the box's shorter side when the centroid is outside the box).
403
+ * @param pos - the stride-3 positions, in pixels
404
+ * @param locked - the locked nodes' mask
405
+ * @param n - node count
406
+ * @param b - the target box
407
+ * @returns 2n values
408
+ */
409
+ function fitAroundLocked(pos: ArrayLike<number>, locked: U32, n: number, b: BoundingBoxWH): Float64Array {
410
+ let cx = 0;
411
+ let cy = 0;
412
+ let count = 0;
413
+ for (let i = 0; i < n; i++) {
414
+ if (maskTest(locked, i)) {
415
+ cx += pos[3 * i];
416
+ cy += pos[3 * i + 1];
417
+ count++;
418
+ }
419
+ }
420
+ cx /= count;
421
+ cy /= count;
422
+ const inside = cx >= b.x1 && cx <= b.x1 + b.w && cy >= b.y1 && cy <= b.y1 + b.h;
423
+ // the largest factor keeping one coordinate inside [lo, hi], seen from c
424
+ const limit = (c: number, d: number, lo: number, hi: number): number => {
425
+ if (d === 0) {
426
+ return Infinity;
427
+ }
428
+ return ((d > 0 ? hi : lo) - c) / d;
429
+ };
430
+ let scale = Infinity;
431
+ let farthest = 0;
432
+ for (let i = 0; i < n; i++) {
433
+ if (!maskTest(locked, i)) {
434
+ const dx = pos[3 * i] - cx;
435
+ const dy = pos[3 * i + 1] - cy;
436
+ scale = Math.min(scale, limit(cx, dx, b.x1, b.x1 + b.w), limit(cy, dy, b.y1, b.y1 + b.h));
437
+ farthest = Math.max(farthest, Math.hypot(dx, dy));
438
+ }
439
+ }
440
+ if (!inside) {
441
+ scale = farthest > 0 ? frame(b).radius / farthest : 1;
442
+ } else if (!Number.isFinite(scale)) {
443
+ scale = 1;
444
+ }
445
+ const xy = new Float64Array(2 * n);
446
+ for (let i = 0; i < n; i++) {
447
+ const s = maskTest(locked, i) ? 1 : scale;
448
+ xy[2 * i] = cx + s * (pos[3 * i] - cx);
449
+ xy[2 * i + 1] = cy + s * (pos[3 * i + 1] - cy);
450
+ }
451
+ return xy;
452
+ }
453
+
454
+ /**
455
+ * The per-node options as @graphty/layout takes them: a node data field (`nodeMass: "mass"`) becomes an id-keyed
456
+ * record, where a node whose field is missing or not a finite number gets the layout's default; an array or a typed
457
+ * array other than a Float32Array becomes a Float32Array in node order (@graphty/layout would read a plain array as
458
+ * an object keyed by id, so `[2, 5]` would set nodes "0" and "1").
459
+ * @param cs - the layout's snapshot
460
+ * @param o - the layout options
461
+ * @returns the converted options
462
+ */
463
+ function nodeFields(
464
+ cs: CytoscapeSnapshot,
465
+ o: ConstructorOptions,
466
+ ): Record<string, Record<string, number> | Float32Array> {
467
+ const out: Record<string, Record<string, number> | Float32Array> = {};
468
+ for (const key of ["nodeMass", "nodeSize"] as const) {
469
+ const field = o[key];
470
+ if (Array.isArray(field) || (ArrayBuffer.isView(field) && !(field instanceof Float32Array))) {
471
+ out[key] = Float32Array.from(field as ArrayLike<number>);
472
+ } else if (typeof field === "string") {
473
+ const values: Record<string, number> = {};
474
+ cs.nodes.forEach((node) => {
475
+ const v: unknown = node.data(field);
476
+ if (typeof v === "number" && Number.isFinite(v)) {
477
+ values[node.id()] = v;
478
+ }
479
+ });
480
+ out[key] = values;
481
+ }
482
+ }
483
+ return out;
484
+ }
485
+
486
+ /**
487
+ * The position function `layoutPositions` and `positions()` take: an unplaced node keeps its position.
488
+ * @param xy - 2n values by node index
489
+ * @returns the position of node i
490
+ */
491
+ function positionOf(xy: ArrayLike<number>): (node: NodeSingular, i: number) => Position {
492
+ return (node, i) => (Number.isNaN(xy[2 * i]) ? node.position() : { x: xy[2 * i], y: xy[2 * i + 1] });
493
+ }
494
+
495
+ /**
496
+ * Ends a discrete run through Cytoscape's `layoutPositions` (events, fit, spacing, transform, tween).
497
+ * @param layout - the layout
498
+ * @param nodes - the nodes in index order
499
+ * @param xy - 2n values by node index
500
+ */
501
+ function finishDiscrete(layout: LayoutThis, nodes: NodeCollection, xy: ArrayLike<number>): void {
502
+ // layoutPositions memoizes by node id and calls back with ITS index (non-parent nodes in collection order),
503
+ // which is ours because the snapshot was built from the same non-parent nodes
504
+ const at = positionOf(xy);
505
+ // Cytoscape's typings declare the first parameter a string; it is the layout object (index.d.ts defect)
506
+ (
507
+ nodes.layoutPositions as unknown as (
508
+ l: LayoutThis,
509
+ o: LayoutPositionOptions,
510
+ fn: (n: NodeSingular, i: number) => Position,
511
+ ) => void
512
+ )(layout, layout.options as unknown as LayoutPositionOptions, at);
513
+ }
514
+
515
+ /**
516
+ * The options handed to a @graphty/layout function: the caller's, with the layout's own frame and weight flag.
517
+ * @param o - the layout options
518
+ * @returns the options
519
+ */
520
+ function graphtyOptions(o: ConstructorOptions): Record<string, unknown> {
521
+ return { ...o, dim: o.dim ?? 2, scale: 1, center: undefined, seed: o.seed ?? null, weight: o.weight !== undefined };
522
+ }
523
+
524
+ /**
525
+ * The snapshot of the layout's nodes (compound parents are positioned by Cytoscape from their children).
526
+ * @param o - the layout options
527
+ * @returns the snapshot
528
+ */
529
+ function layoutSnapshot(o: ConstructorOptions): CytoscapeSnapshot {
530
+ return toSnapshot(o.eles.not(":parent"), { weight: o.weight });
531
+ }
532
+
533
+ /**
534
+ * Throws when the run would tween on a core that cannot: a headless core without `styleEnabled: true`, where
535
+ * Cytoscape's own tween fails with "ani.play is not a function".
536
+ * @param o - the layout options
537
+ * @param simulation - whether the layout is a simulation, which draws `animate: true` frame by frame instead
538
+ */
539
+ function checkTween(o: ConstructorOptions, simulation: boolean): void {
540
+ const tweens = simulation ? o.animate !== true && Boolean(o.animate) : Boolean(o.animate);
541
+ // styleEnabled() is public Cytoscape API that its typings leave out
542
+ if (tweens && !(o.cy as Core & { styleEnabled(): boolean }).styleEnabled()) {
543
+ throw new Error(
544
+ `${o.name}: animate needs a core that renders; create a headless core with styleEnabled: true, or pass animate: false`,
545
+ );
546
+ }
547
+ }
548
+
549
+ /**
550
+ * Runs a static layout.
551
+ * @param layout - the layout
552
+ * @param fn - the layout function
553
+ */
554
+ function runStatic(layout: LayoutThis, fn: StaticLayout): void {
555
+ const o = layout.options;
556
+ checkTween(o, false);
557
+ const cs = layoutSnapshot(o);
558
+ const r = fn(cs.snapshot, graphtyOptions(o), cs);
559
+ finishDiscrete(layout, cs.nodes, fitRows(r.positions, r.dim, r.n, box(o.boundingBox, o.cy)));
560
+ }
561
+
562
+ /**
563
+ * Steps until settled or stopped. Synchronous for a CPU simulation; a GPU simulation's step is a promise, and then
564
+ * so is this.
565
+ * @param sim - the simulation
566
+ * @param layout - the layout (its `stopped` flag ends the run)
567
+ * @returns a promise only when the simulation is asynchronous
568
+ */
569
+ function stepToEnd(sim: LayoutSimulation, layout: LayoutThis): void | Promise<void> {
570
+ while (!sim.settled && !layout.stopped) {
571
+ const r = sim.step(50);
572
+ if (r instanceof Promise) {
573
+ return r.then(() => stepToEnd(sim, layout));
574
+ }
575
+ }
576
+ return undefined;
577
+ }
578
+
579
+ /**
580
+ * Runs a simulation layout: on the caller's accelerator, on the CPU when no GPU is enabled (synchronously), or on
581
+ * the core's GPU once one is acquired. The decision is made before the simulation starts; a GPU failure after that
582
+ * is reported as layouterror, never finished on the CPU.
583
+ * @param layout - the layout
584
+ * @param type - the simulation type
585
+ */
586
+ function runSimulation(layout: LayoutThis, type: SimulationType): void {
587
+ const o = layout.options;
588
+ checkTween(o, true);
589
+ checkGpuMode(o.name, o.gpu);
590
+ layout.stopped = false;
591
+ if (o.accelerator !== undefined) {
592
+ layout.backend =
593
+ o.accelerator === null
594
+ ? { ran: "cpu", reason: "accelerator: null was passed", device: null }
595
+ : { ran: "gpu", reason: null, device: o.accelerator.kind };
596
+ simulate(layout, type, o.accelerator);
597
+ return;
598
+ }
599
+ // "require" goes through gpuFor, which turns a known CPU decision into the error layouterror reports
600
+ const known = o.gpu === "require" ? null : cpuWithoutAsking(o.gpu);
601
+ if (known !== null) {
602
+ layout.backend = backendOf(known, false, "");
603
+ warnIfFixable(known, o.eles.nodes().length);
604
+ simulate(layout, type, null);
605
+ return;
606
+ }
607
+ layout.looping = true;
608
+ gpuFor(o.cy, o.gpu)
609
+ .then((d) => {
610
+ layout.looping = false;
611
+ layout.backend = backendOf(d, d.gpu !== null, "");
612
+ warnIfFixable(d, o.eles.nodes().length);
613
+ if (layout.stopped) {
614
+ layout.emit({ type: "layoutstop", layout });
615
+ return;
616
+ }
617
+ simulate(layout, type, d.gpu?.accelerator ?? null);
618
+ })
619
+ .catch((error: unknown) => {
620
+ layout.looping = false;
621
+ layout.emit("layouterror", [error]);
622
+ layout.emit({ type: "layoutstop", layout });
623
+ });
624
+ }
625
+
626
+ /** Per core and simulation: pixels per simulation unit of its last run, which a randomize: false run continues. */
627
+ const DRAWN_SCALE = new WeakMap<Core, Map<SimulationType, number>>();
628
+
629
+ /**
630
+ * The x-y centroid of stride-3 rows.
631
+ * @param pos - the rows
632
+ * @param n - row count
633
+ * @returns the centroid, z 0
634
+ */
635
+ function centroidOf(pos: ArrayLike<number>, n: number): [number, number, number] {
636
+ let x = 0;
637
+ let y = 0;
638
+ for (let i = 0; i < n; i++) {
639
+ x += pos[3 * i];
640
+ y += pos[3 * i + 1];
641
+ }
642
+ return n === 0 ? [0, 0, 0] : [x / n, y / n, 0];
643
+ }
644
+
645
+ /**
646
+ * Runs a simulation on an accelerator (null: the CPU).
647
+ * @param layout - the layout
648
+ * @param type - the simulation type
649
+ * @param accelerator - the accelerator
650
+ */
651
+ function simulate(layout: LayoutThis, type: SimulationType, accelerator: LayoutAccelerator | null): void {
652
+ const o = layout.options;
653
+ if (accelerator === null && type === "spring-electrical") {
654
+ const reason = layout.backend?.reason ?? "none was available";
655
+ // nothing ran, so no backend: "cpu" would say the layout ran there (as with gpu: "require")
656
+ layout.backend = undefined;
657
+ const error = new Error(
658
+ `graphty-spring-electrical has no CPU simulation and runs only on the GPU; no GPU ran because ${reason}`,
659
+ );
660
+ // reported as gpu: "require" reports it, after run() returns, whether or not the GPU was asked for
661
+ layout.looping = true;
662
+ void Promise.resolve().then(() => {
663
+ layout.looping = false;
664
+ layout.emit("layouterror", [error]);
665
+ layout.emit({ type: "layoutstop", layout });
666
+ });
667
+ return;
668
+ }
669
+ const cs = layoutSnapshot(o);
670
+ const s = cs.snapshot;
671
+ const n = s.nodeCount;
672
+ const b = box(o.boundingBox, o.cy);
673
+ const { radius, center } = frame(b);
674
+ const dim = o.dim ?? 2;
675
+
676
+ // The simulation reads and writes this stride-3 array in "scene" units; scene units are pixels here, so a
677
+ // locked node is pinned exactly where it stands. A randomized node starts anywhere in the box (Cytoscape's
678
+ // convention). Not left NaN for @graphty/layout's seedPositions to fill: with exactly one finite row (one locked
679
+ // node) its draw box has zero width and every seeded node lands on the locked one; Fruchterman-Reingold then
680
+ // never separates them, and ForceAtlas2Simulation.load does not seed NaN rows at all.
681
+ const pos = new Float32Array(3 * n);
682
+ const fixed = makeMask(n);
683
+ const rng = new Lcg(o.seed ?? null);
684
+ let anyLocked = false;
685
+ cs.nodes.forEach((node, i) => {
686
+ const locked = node.locked();
687
+ anyLocked ||= locked;
688
+ maskSet(fixed, i, locked);
689
+ const p = node.position();
690
+ const keep = locked || o.randomize === false;
691
+ pos[3 * i] = keep ? p.x : b.x1 + rng.next() * b.w;
692
+ pos[3 * i + 1] = keep ? p.y : b.y1 + rng.next() * b.h;
693
+ pos[3 * i + 2] = keep || dim === 2 ? 0 : (rng.next() * 2 - 1) * radius;
694
+ });
695
+ // A continuation (randomize: false) reads the positions at the scale the last run of this simulation on this core
696
+ // drew them at, about their centroid. Read at the box's scale instead, the graph would start far smaller than the
697
+ // simulation's own size, and the first steps would jolt it back out.
698
+ const drawn = o.randomize === false && !anyLocked ? DRAWN_SCALE.get(o.cy)?.get(type) : undefined;
699
+ const scale = drawn ?? radius;
700
+ const sim = createSimulation(
701
+ type,
702
+ {
703
+ ...SIMULATION_DEFAULTS[type],
704
+ ...graphtyOptions(o),
705
+ ...nodeFields(cs, o),
706
+ scale,
707
+ center: drawn === undefined ? center : centroidOf(pos, n),
708
+ iterationsPerStep: 1,
709
+ },
710
+ accelerator,
711
+ );
712
+ sim.load(s, pos);
713
+ if (anyLocked) {
714
+ sim.setFixed(fixed);
715
+ }
716
+ // a data change while the simulation runs must not release the snapshot its device upload came from
717
+ const unhold = hold(s);
718
+ const dispose = (): void => {
719
+ sim.dispose();
720
+ unhold();
721
+ if (!anyLocked) {
722
+ // pixels per simulation unit once fitted to the box: fitRows scales the farthest node to `radius`
723
+ const [cx, cy] = centroidOf(pos, n);
724
+ let farthest = 0;
725
+ for (let i = 0; i < n; i++) {
726
+ farthest = Math.max(farthest, Math.hypot(pos[3 * i] - cx, pos[3 * i + 1] - cy));
727
+ }
728
+ if (farthest > 0) {
729
+ const scales = DRAWN_SCALE.get(o.cy) ?? new Map<SimulationType, number>();
730
+ scales.set(type, (scale * radius) / farthest);
731
+ DRAWN_SCALE.set(o.cy, scales);
732
+ }
733
+ }
734
+ };
735
+
736
+ // Fitted to the box as a static result is; with a locked node, the free nodes are scaled about the locked ones
737
+ const xy = (): ArrayLike<number> => (anyLocked ? fitAroundLocked(pos, fixed, n, b) : fitRows(pos, 3, n, b));
738
+ const fail = (error: unknown): void => {
739
+ layout.looping = false;
740
+ dispose();
741
+ layout.emit("layouterror", [error]);
742
+ layout.emit({ type: "layoutstop", layout });
743
+ };
744
+
745
+ if (o.animate !== true) {
746
+ const done = (): void => {
747
+ layout.looping = false;
748
+ dispose();
749
+ finishDiscrete(layout, cs.nodes, xy());
750
+ };
751
+ const r = stepToEnd(sim, layout);
752
+ if (r === undefined) {
753
+ done();
754
+ } else {
755
+ layout.looping = true;
756
+ r.then(done, fail);
757
+ }
758
+ return;
759
+ }
760
+
761
+ // Continuous: one frame per animation frame, `refresh` iterations each
762
+ layout.looping = true;
763
+ layout.emit({ type: "layoutstart", layout });
764
+ let first = true;
765
+ const schedule =
766
+ typeof requestAnimationFrame === "function"
767
+ ? requestAnimationFrame
768
+ : (f: () => void): unknown => setTimeout(f, 16);
769
+ const draw = (): void => {
770
+ cs.nodes.positions(positionOf(xy()));
771
+ if (o.fit !== false) {
772
+ o.cy.fit(o.eles, o.padding);
773
+ }
774
+ if (first) {
775
+ first = false;
776
+ if (o.ready !== undefined) {
777
+ layout.one("layoutready", o.ready);
778
+ }
779
+ layout.emit({ type: "layoutready", layout });
780
+ }
781
+ if (sim.settled || layout.stopped) {
782
+ layout.looping = false;
783
+ dispose();
784
+ if (o.stop !== undefined) {
785
+ layout.one("layoutstop", o.stop);
786
+ }
787
+ layout.emit({ type: "layoutstop", layout });
788
+ } else {
789
+ schedule(tick);
790
+ }
791
+ };
792
+ const tick = (): void => {
793
+ if (layout.stopped) {
794
+ draw();
795
+ return;
796
+ }
797
+ let r: void | Promise<void>;
798
+ try {
799
+ r = sim.step(o.refresh ?? 1);
800
+ } catch (error) {
801
+ fail(error);
802
+ return;
803
+ }
804
+ if (r instanceof Promise) {
805
+ r.then(draw, fail);
806
+ } else {
807
+ draw();
808
+ }
809
+ };
810
+ tick();
811
+ }
812
+
813
+ /** Each layout object's run body, set by its registrant's constructor. */
814
+ const RUN_BODIES = new WeakMap<LayoutThis, (layout: LayoutThis) => void>();
815
+
816
+ /**
817
+ * A registrant's run(): runs the layout's body.
818
+ * @returns the layout
819
+ */
820
+ function runLayout(this: LayoutThis): LayoutThis {
821
+ RUN_BODIES.get(this)?.(this);
822
+ return this;
823
+ }
824
+
825
+ /**
826
+ * A simulation registrant's stop(). A running loop ends itself on its next frame and emits layoutstop; otherwise
827
+ * act as the registry would.
828
+ * @returns the layout
829
+ */
830
+ function stopLayout(this: LayoutThis): LayoutThis {
831
+ if (this.looping) {
832
+ this.stopped = true;
833
+ } else {
834
+ this.emit({ type: "layoutstop", layout: this });
835
+ }
836
+ return this;
837
+ }
838
+
839
+ /**
840
+ * A Cytoscape layout registrant. It must be a function constructor: the registry calls it as
841
+ * `registrant.call(this, options)`, which a native class rejects.
842
+ * @param run - the run body
843
+ * @param withStop - whether the registrant defines stop(); without one the registry emits layoutstop itself
844
+ * @returns the registrant
845
+ */
846
+ function registrant(run: (layout: LayoutThis) => void, withStop: boolean): unknown {
847
+ function GraphtyLayout(this: LayoutThis, options: ConstructorOptions): void {
848
+ this.options = { ...DEFAULTS, ...options };
849
+ this.stopped = false;
850
+ this.looping = false;
851
+ RUN_BODIES.set(this, run);
852
+ }
853
+ GraphtyLayout.prototype.run = runLayout;
854
+ if (withStop) {
855
+ GraphtyLayout.prototype.stop = stopLayout;
856
+ }
857
+ return GraphtyLayout;
858
+ }
859
+
860
+ /** The registration function Cytoscape's `use()` calls. */
861
+ type Register = (type: string, name: string, registrant: unknown) => void;
862
+
863
+ /**
864
+ * Registers every graphty layout as "graphty-<name>": `cytoscape.use(graphtyCytoscape)`.
865
+ * @param cytoscape - the cytoscape function
866
+ */
867
+ export function registerLayouts(cytoscape: Register): void {
868
+ for (const [name, fn] of Object.entries(STATIC)) {
869
+ cytoscape(
870
+ "layout",
871
+ `graphty-${name}`,
872
+ registrant((l) => {
873
+ runStatic(l, fn);
874
+ }, false),
875
+ );
876
+ }
877
+ for (const [name, type] of Object.entries(SIMULATIONS)) {
878
+ cytoscape(
879
+ "layout",
880
+ `graphty-${name}`,
881
+ registrant((l) => {
882
+ runSimulation(l, type);
883
+ }, true),
884
+ );
885
+ }
886
+ }