@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/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
|
+
}
|