@graphty/cytoscape-extensions 0.0.10 → 0.0.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graphty/cytoscape-extensions",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "description": "Graphty layouts, algorithms, graph generators, sample datasets and file import/export as Cytoscape.js extensions",
5
5
  "author": "Adam Powers <apowers@ato.ms>",
6
6
  "type": "module",
@@ -71,9 +71,9 @@
71
71
  },
72
72
  "homepage": "https://github.com/graphty-org/graphty-monorepo/tree/master/cytoscape-extensions#readme",
73
73
  "dependencies": {
74
- "@graphty/graph-io": "^0.3.35",
75
- "@graphty/webgpu-graph-algorithms": "^0.6.45",
76
- "@graphty/graph-samples": "^0.1.27"
74
+ "@graphty/graph-io": "^0.3.36",
75
+ "@graphty/graph-samples": "^0.1.28",
76
+ "@graphty/webgpu-graph-algorithms": "^0.6.47"
77
77
  },
78
78
  "peerDependencies": {
79
79
  "@graphty/algorithms": "^3.0.0",
@@ -102,9 +102,9 @@
102
102
  "typescript": "^5.9.3",
103
103
  "vite": "^7.0.5",
104
104
  "vitest": "4.1.11",
105
- "@graphty/graph-format": "^1.3.12",
106
- "@graphty/layout": "^2.4.0",
107
- "@graphty/algorithms": "^3.4.2"
105
+ "@graphty/algorithms": "^3.4.3",
106
+ "@graphty/graph-format": "^1.3.13",
107
+ "@graphty/layout": "^2.4.2"
108
108
  },
109
109
  "scripts": {
110
110
  "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && vite build --config vite.bundle.config.ts --mode cdn --logLevel warn && vite build --config vite.bundle.config.ts --logLevel warn",
package/src/algorithms.ts CHANGED
@@ -136,7 +136,16 @@ import type {
136
136
  } from "cytoscape";
137
137
 
138
138
  import { OPTION_NAMES } from "./algorithm-options.js";
139
- import { type Backend, backendOf, checkGpuMode, gpuFor, type GpuMode, recording, warnIfFixable } from "./gpu.js";
139
+ import {
140
+ type Backend,
141
+ backendOf,
142
+ checkGpuMode,
143
+ coreDestroyedError,
144
+ gpuFor,
145
+ type GpuMode,
146
+ recording,
147
+ warnIfFixable,
148
+ } from "./gpu.js";
140
149
  import {
141
150
  coreOf,
142
151
  type CytoscapeSnapshot,
@@ -1859,7 +1868,12 @@ function runSync(eles: Collection, key: keyof Impls, options: AlgorithmOptions =
1859
1868
  */
1860
1869
  async function runAsync(eles: Collection, key: keyof Impls, options: AlgorithmOptions = {}): Promise<unknown> {
1861
1870
  checkGpuMode(`${methodName(key)}Async`, options?.gpu);
1862
- const d = await gpuFor(coreOf(eles), options.gpu);
1871
+ const cy = coreOf(eles);
1872
+ const d = await gpuFor(cy, options.gpu);
1873
+ // destroyed while the device was acquired: its device is disposed and its elements are gone with it
1874
+ if (cy.destroyed()) {
1875
+ throw coreDestroyedError();
1876
+ }
1863
1877
  const rec = d.gpu === null ? null : recording(d.gpu.accelerator);
1864
1878
  warnIfFixable(d, eles.nodes().length);
1865
1879
  const { result, snapshot } = run(eles, key, options, accelerated(rec?.accelerator ?? null));
@@ -1868,6 +1882,9 @@ async function runAsync(eles: Collection, key: keyof Impls, options: AlgorithmOp
1868
1882
  let value: object;
1869
1883
  try {
1870
1884
  value = (await result) as object;
1885
+ } catch (e) {
1886
+ // destroyed mid-run, the device fails with "GpuContext is disposed"; the cause is the destroy
1887
+ throw cy.destroyed() ? coreDestroyedError() : e;
1871
1888
  } finally {
1872
1889
  unhold();
1873
1890
  if (d.gpu !== null && typeof options.weight === "function") {
package/src/elements.ts CHANGED
@@ -23,29 +23,6 @@ function plain(v: unknown): unknown {
23
23
  return ArrayBuffer.isView(v) ? Array.from(v as unknown as ArrayLike<number>) : v;
24
24
  }
25
25
 
26
- /**
27
- * RGB(A) channels in 0..1 as a CSS color: `#rrggbb` when opaque, `rgba(r, g, b, a)` otherwise.
28
- * @param c - the channels
29
- * @returns the color
30
- */
31
- function cssColor(c: ArrayLike<number>): string {
32
- const ch = (i: number): number => Math.max(0, Math.min(255, Math.round(c[i] * 255)));
33
- const a = c.length > 3 ? Math.round(c[3] * 1000) / 1000 : 1;
34
- if (a === 1) {
35
- return `#${[0, 1, 2].map((i) => ch(i).toString(16).padStart(2, "0")).join("")}`;
36
- }
37
- return `rgba(${ch(0)}, ${ch(1)}, ${ch(2)}, ${a})`;
38
- }
39
-
40
- /**
41
- * Whether a column is a color: the role "color" with 3 or 4 numeric components (GEXF's viz:color).
42
- * @param c - the column
43
- * @returns true for a color column
44
- */
45
- function isColor(c: Column): boolean {
46
- return c.meta.role === "color" && (c.meta.components === 3 || c.meta.components === 4) && c.dtype !== "json";
47
- }
48
-
49
26
  /**
50
27
  * A y coordinate between Cytoscape (y grows downward) and graph-io (y grows upward, so a Cytoscape-family file's
51
28
  * screen y is negated on import and negated back on export). Never yields -0.
@@ -56,9 +33,6 @@ export function flipY(y: number): number {
56
33
  return y === 0 ? 0 : -y;
57
34
  }
58
35
 
59
- /** Reads a CSS color as RGBA channels in 0..1, or undefined when it cannot. */
60
- type ColorParser = (text: string) => [number, number, number, number] | undefined;
61
-
62
36
  /** A column that arrives under another data field name, because Cytoscape reserves its own. */
63
37
  interface RenamedColumn {
64
38
  readonly domain: "node" | "edge";
@@ -70,7 +44,6 @@ interface RenamedColumn {
70
44
  * Cytoscape element definitions for a snapshot. Node ids are the snapshot's ids as strings. Every node and edge
71
45
  * column becomes a data field of the same name, except that one named `id`, `source`, `target` or `parent` (which
72
46
  * Cytoscape reserves) becomes `<name>#<the attribute's id in the file>`, or `<name>#2` when it has none, and:
73
- * - a column with the role "color" (GEXF's viz:color) becomes a CSS color string, `#rrggbb` or `rgba(r, g, b, a)`;
74
47
  * - a node column with the role "position" becomes the node's position (x and y; a z is dropped);
75
48
  * - a node column with the role "parent" (a compound parent, as Cytoscape JSON, GraphML nested graphs and DOT
76
49
  * clusters carry it) becomes `data.parent`;
@@ -153,8 +126,7 @@ function rowData(cols: readonly Column[], row: number, fieldOf: (c: Column) => s
153
126
  const data: Record<string, unknown> = {};
154
127
  for (const c of cols) {
155
128
  if (c.isSet(row)) {
156
- const v = c.value(row);
157
- data[fieldOf(c)] = isColor(c) ? cssColor(v as ArrayLike<number>) : plain(v);
129
+ data[fieldOf(c)] = plain(c.value(row));
158
130
  }
159
131
  }
160
132
  return data;
@@ -287,30 +259,12 @@ function column(values: unknown[], label: boolean): ColumnInput | undefined {
287
259
  return { data: values.map((v) => v ?? undefined), decl: { dtype: "json" } };
288
260
  }
289
261
 
290
- /**
291
- * A string column of CSS colors as a color column (role "color", RGBA in 0..1), which GEXF writes as viz:color.
292
- * @param c - the column
293
- * @param parse - reads one color
294
- * @returns the color column, or undefined when it is not a string column or a value is not a color `parse` reads
295
- */
296
- function colorColumn(c: ColumnInput, parse: ColorParser): ColumnInput | undefined {
297
- if (c.decl.dtype !== "string") {
298
- return undefined;
299
- }
300
- const rows = (c.data as (string | null)[]).map((v) => (v === null ? null : parse(v)));
301
- if (rows.includes(undefined)) {
302
- return undefined;
303
- }
304
- return { data: rows, decl: { dtype: "f32", components: 4, role: "color" } };
305
- }
306
-
307
262
  /**
308
263
  * The columns of a set of elements' data, one per field, skipping the reserved ones.
309
264
  * @param records - each element's data
310
- * @param colors - when given, a `color` field whose every value it reads is written as the role "color"
311
265
  * @returns the columns by name
312
266
  */
313
- function columns(records: readonly Record<string, unknown>[], colors?: ColorParser): Record<string, ColumnInput> {
267
+ function columns(records: readonly Record<string, unknown>[]): Record<string, ColumnInput> {
314
268
  const names = new Set<string>();
315
269
  for (const r of records) {
316
270
  for (const k of Object.keys(r)) {
@@ -326,7 +280,7 @@ function columns(records: readonly Record<string, unknown>[], colors?: ColorPars
326
280
  name === "label",
327
281
  );
328
282
  if (c !== undefined) {
329
- out[name] = colors !== undefined && name === "color" ? (colorColumn(c, colors) ?? c) : c;
283
+ out[name] = c;
330
284
  }
331
285
  }
332
286
  return out;
@@ -342,8 +296,6 @@ function columns(records: readonly Record<string, unknown>[], colors?: ColorPars
342
296
  * @param eles - the collection
343
297
  * @param options - the direction and the id type
344
298
  * @param options.directed - write a directed graph (default false)
345
- * @param options.colors - reads a CSS color as RGBA in 0..1; when given, a `color` data field whose every value it
346
- * reads is written as the role "color", for a format with a color slot of its own (GEXF's viz:color)
347
299
  * @param options.integerIds - when every node id is a non-negative integer written plainly ("0", "17"), give the
348
300
  * snapshot number ids, so a format that holds only integer ids (GML, CX2) writes them as they are (default false)
349
301
  * @returns the snapshot
@@ -353,7 +305,6 @@ export function elementsToSnapshot(
353
305
  options: {
354
306
  readonly directed?: boolean | undefined;
355
307
  readonly integerIds?: boolean | undefined;
356
- readonly colors?: ColorParser | undefined;
357
308
  } = {},
358
309
  ): GraphSnapshot {
359
310
  const nodes = eles.nodes();
@@ -378,10 +329,7 @@ export function elementsToSnapshot(
378
329
  xy[2 * i + 1] = flipY(p.y);
379
330
  });
380
331
  const parents = nodes.map((n) => index.get(n.parent().first().id()) ?? null);
381
- const edgeColumns = columns(
382
- edges.map((e) => e.data() as Record<string, unknown>),
383
- options.colors,
384
- );
332
+ const edgeColumns = columns(edges.map((e) => e.data() as Record<string, unknown>));
385
333
  let weights: Float64Array<ArrayBuffer> | undefined;
386
334
  const w = edgeColumns.weight;
387
335
  if (w?.decl.dtype === "f64" && (w.data as unknown[]).every((v) => v !== null)) {
@@ -395,10 +343,7 @@ export function elementsToSnapshot(
395
343
  dst,
396
344
  weights,
397
345
  nodeColumns: {
398
- ...columns(
399
- nodes.map((n) => n.data() as Record<string, unknown>),
400
- options.colors,
401
- ),
346
+ ...columns(nodes.map((n) => n.data() as Record<string, unknown>)),
402
347
  position: { data: xy, decl: { dtype: "f64", components: 2, role: "position" } },
403
348
  ...(parents.some((p) => p !== null)
404
349
  ? { parent: { data: parents, decl: { dtype: "u32", role: "parent", refersTo: "node" } } }
package/src/gpu.ts CHANGED
@@ -57,6 +57,14 @@ export class GpuUnavailableError extends Error {
57
57
  }
58
58
  }
59
59
 
60
+ /**
61
+ * The error a call on a destroyed Cytoscape core rejects with, or a layout run started on one emits as layouterror.
62
+ * @returns the error, `code` "E_CORE_DESTROYED"
63
+ */
64
+ export function coreDestroyedError(): Error & { readonly code: "E_CORE_DESTROYED" } {
65
+ return Object.assign(new Error("graphty: the Cytoscape core was destroyed"), { code: "E_CORE_DESTROYED" as const });
66
+ }
67
+
60
68
  /** Probes, constructs and verifies a device, or throws GpuUnavailableError. */
61
69
  export interface GpuProvider {
62
70
  acquire(): Promise<AcquiredGpu>;
@@ -333,7 +341,7 @@ export async function gpuFor(cy: Core, mode: GpuMode = "auto"): Promise<Decision
333
341
  d = { gpu: null, ...src };
334
342
  } else {
335
343
  if (g.destroyed) {
336
- throw new Error("graphty: the Cytoscape core was destroyed");
344
+ throw coreDestroyedError();
337
345
  }
338
346
  if (g.decision === null || g.by !== src) {
339
347
  disposeWhenDecided(g.decision);
package/src/graph-data.ts CHANGED
@@ -8,6 +8,7 @@ import type { ImportInput, ImportReport } from "@graphty/graph-io";
8
8
  import type { FetchDatasetOptions } from "@graphty/graph-samples";
9
9
  import type { Collection, CollectionReturnValue, Core, ElementDefinition } from "cytoscape";
10
10
 
11
+ import { coreDestroyedError } from "./gpu.js";
11
12
  import type { ExportFormat, ExportOptions, ImportFormat, ImportOptions } from "./io.js";
12
13
  import { loadPart } from "./lazy.js";
13
14
  import type { GeneratorName, GeneratorOptions } from "./samples.js";
@@ -108,8 +109,13 @@ export class IdTakenError extends Error {
108
109
  * @param elements - the definitions; edge data may be changed
109
110
  * @returns the added elements
110
111
  * @throws IdTakenError naming the first node id the core already holds; nothing is added
112
+ * @throws Error coded E_CORE_DESTROYED when the core was destroyed (before the call or while it loaded); nothing
113
+ * is added
111
114
  */
112
115
  function addTo(cy: Core, elements: ElementDefinition[]): CollectionReturnValue {
116
+ if (cy.destroyed()) {
117
+ throw coreDestroyedError();
118
+ }
113
119
  for (const el of elements) {
114
120
  if (el.group === "nodes" && el.data.id !== undefined && cy.getElementById(el.data.id).nonempty()) {
115
121
  throw new IdTakenError(el.data.id);
package/src/io.ts CHANGED
@@ -163,46 +163,6 @@ function lostForCytoscape(n: LossNote): boolean {
163
163
  return !(n.code === "W_ROLE_DROPPED" && n.column === "label");
164
164
  }
165
165
 
166
- /**
167
- * A CSS color as RGBA channels in 0..1: `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()`, `rgba()`, `hsl()` and
168
- * `hsla()` with comma-separated arguments. A named color ("red") is not read.
169
- * @param text - the color
170
- * @returns the channels, or undefined when the text is not one of those forms
171
- */
172
- function parseCssColor(text: string): [number, number, number, number] | undefined {
173
- const s = text.trim().toLowerCase();
174
- const hex = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(s)?.[1];
175
- if (hex !== undefined) {
176
- const pairs = hex.length <= 4 ? Array.from(hex, (h) => h + h) : (hex.match(/../g) ?? []);
177
- const [r, g, b, a = 255] = pairs.map((h) => Number.parseInt(h, 16) / 255);
178
- return [r, g, b, a === 255 ? 1 : a];
179
- }
180
- const fn = /^(rgba?|hsla?)\(([^)]*)\)$/.exec(s);
181
- const args = fn?.[2].split(",").map((a) => a.trim());
182
- if (fn === undefined || fn === null || args === undefined || (args.length !== 3 && args.length !== 4)) {
183
- return undefined;
184
- }
185
- // each argument: a number, or a percentage of `full`
186
- const num = (a: string, full: number): number =>
187
- a.endsWith("%") ? (Number(a.slice(0, -1)) / 100) * full : Number(a);
188
- const alpha = args.length === 4 ? num(args[3], 1) : 1;
189
- let rgb: number[];
190
- if (fn[1].startsWith("rgb")) {
191
- rgb = args.slice(0, 3).map((a) => num(a, 255) / 255);
192
- } else {
193
- const h = Number(args[0].replace(/deg$/, ""));
194
- const sat = num(args[1], 1);
195
- const l = num(args[2], 1);
196
- const k = (n: number): number => (n + h / 30) % 12;
197
- const c = sat * Math.min(l, 1 - l);
198
- rgb = [0, 8, 4].map((n) => l - c * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1)));
199
- }
200
- const out = [...rgb, alpha];
201
- return out.every((v) => Number.isFinite(v) && v >= 0 && v <= 1)
202
- ? (out as [number, number, number, number])
203
- : undefined;
204
- }
205
-
206
166
  /** The formats that hold only integer node ids: a graph whose ids are all integers is written with them as they are. */
207
167
  const INTEGER_ID_FORMATS: ReadonlySet<ExportFormat> = new Set(["gml", "cx2"]);
208
168
 
@@ -229,8 +189,6 @@ export async function exportElements(
229
189
  const snapshot = elementsToSnapshot(eles, {
230
190
  directed,
231
191
  integerIds: INTEGER_ID_FORMATS.has(format),
232
- // GEXF owns the name "color" for viz:color: a plain "color" field would read back renamed
233
- colors: format === "gexf" ? parseCssColor : undefined,
234
192
  });
235
193
  const graphOptions = {
236
194
  sanitizeIds: "mangle" as const,
package/src/layouts.ts CHANGED
@@ -55,7 +55,16 @@ import type {
55
55
  Position,
56
56
  } from "cytoscape";
57
57
 
58
- import { type Backend, backendOf, checkGpuMode, cpuWithoutAsking, gpuFor, type GpuMode, warnIfFixable } from "./gpu.js";
58
+ import {
59
+ type Backend,
60
+ backendOf,
61
+ checkGpuMode,
62
+ coreDestroyedError,
63
+ cpuWithoutAsking,
64
+ gpuFor,
65
+ type GpuMode,
66
+ warnIfFixable,
67
+ } from "./gpu.js";
59
68
  import { type CytoscapeSnapshot, hold, indexOf, indicesOf, type NodeSelection, toSnapshot } from "./snapshot.js";
60
69
 
61
70
  /**
@@ -363,7 +372,10 @@ export const LAYOUT_NAMES: readonly string[] = [...Object.keys(STATIC), ...Objec
363
372
  */
364
373
  function box(bb: BoundingBox12 | BoundingBoxWH | undefined, cy: Core): BoundingBoxWH {
365
374
  if (bb === undefined) {
366
- return { x1: 0, y1: 0, w: cy.width(), h: cy.height() };
375
+ // a container detached from the page reads NaN (its computed padding is empty); use Cytoscape's own size
376
+ // for a core without a container, 1 x 1
377
+ const finite = (v: number): number => (Number.isFinite(v) ? v : 1);
378
+ return { x1: 0, y1: 0, w: finite(cy.width()), h: finite(cy.height()) };
367
379
  }
368
380
  return "w" in bb ? bb : { x1: bb.x1, y1: bb.y1, w: bb.x2 - bb.x1, h: bb.y2 - bb.y1 };
369
381
  }
@@ -567,7 +579,7 @@ function runStatic(layout: LayoutThis, fn: StaticLayout): void {
567
579
  * @returns a promise only when the simulation is asynchronous
568
580
  */
569
581
  function stepToEnd(sim: LayoutSimulation, layout: LayoutThis): void | Promise<void> {
570
- while (!sim.settled && !layout.stopped) {
582
+ while (!sim.settled && !layout.stopped && !layout.options.cy.destroyed()) {
571
583
  const r = sim.step(50);
572
584
  if (r instanceof Promise) {
573
585
  return r.then(() => stepToEnd(sim, layout));
@@ -610,7 +622,7 @@ function runSimulation(layout: LayoutThis, type: SimulationType): void {
610
622
  layout.looping = false;
611
623
  layout.backend = backendOf(d, d.gpu !== null, "");
612
624
  warnIfFixable(d, o.eles.nodes().length);
613
- if (layout.stopped) {
625
+ if (layout.stopped || o.cy.destroyed()) {
614
626
  layout.emit({ type: "layoutstop", layout });
615
627
  return;
616
628
  }
@@ -618,7 +630,10 @@ function runSimulation(layout: LayoutThis, type: SimulationType): void {
618
630
  })
619
631
  .catch((error: unknown) => {
620
632
  layout.looping = false;
621
- layout.emit("layouterror", [error]);
633
+ // the core destroyed meanwhile ends the run as stop() would
634
+ if (!o.cy.destroyed()) {
635
+ layout.emit("layouterror", [error]);
636
+ }
622
637
  layout.emit({ type: "layoutstop", layout });
623
638
  });
624
639
  }
@@ -658,12 +673,7 @@ function simulate(layout: LayoutThis, type: SimulationType, accelerator: LayoutA
658
673
  `graphty-spring-electrical has no CPU simulation and runs only on the GPU; no GPU ran because ${reason}`,
659
674
  );
660
675
  // 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
- });
676
+ failLater(layout, error);
667
677
  return;
668
678
  }
669
679
  const cs = layoutSnapshot(o);
@@ -735,10 +745,14 @@ function simulate(layout: LayoutThis, type: SimulationType, accelerator: LayoutA
735
745
 
736
746
  // Fitted to the box as a static result is; with a locked node, the free nodes are scaled about the locked ones
737
747
  const xy = (): ArrayLike<number> => (anyLocked ? fitAroundLocked(pos, fixed, n, b) : fitRows(pos, 3, n, b));
748
+ // A core destroyed mid-run ends the run as stop() would: its device is gone with it ("GpuContext is disposed"),
749
+ // and a write to it fails in Cytoscape ("reading 'notify'" of its null renderer).
738
750
  const fail = (error: unknown): void => {
739
751
  layout.looping = false;
740
752
  dispose();
741
- layout.emit("layouterror", [error]);
753
+ if (!o.cy.destroyed()) {
754
+ layout.emit("layouterror", [error]);
755
+ }
742
756
  layout.emit({ type: "layoutstop", layout });
743
757
  };
744
758
 
@@ -746,7 +760,11 @@ function simulate(layout: LayoutThis, type: SimulationType, accelerator: LayoutA
746
760
  const done = (): void => {
747
761
  layout.looping = false;
748
762
  dispose();
749
- finishDiscrete(layout, cs.nodes, xy());
763
+ if (o.cy.destroyed()) {
764
+ layout.emit({ type: "layoutstop", layout });
765
+ } else {
766
+ finishDiscrete(layout, cs.nodes, xy());
767
+ }
750
768
  };
751
769
  const r = stepToEnd(sim, layout);
752
770
  if (r === undefined) {
@@ -766,7 +784,19 @@ function simulate(layout: LayoutThis, type: SimulationType, accelerator: LayoutA
766
784
  typeof requestAnimationFrame === "function"
767
785
  ? requestAnimationFrame
768
786
  : (f: () => void): unknown => setTimeout(f, 16);
787
+ const end = (): void => {
788
+ layout.looping = false;
789
+ dispose();
790
+ if (o.stop !== undefined) {
791
+ layout.one("layoutstop", o.stop);
792
+ }
793
+ layout.emit({ type: "layoutstop", layout });
794
+ };
769
795
  const draw = (): void => {
796
+ if (o.cy.destroyed()) {
797
+ end();
798
+ return;
799
+ }
770
800
  cs.nodes.positions(positionOf(xy()));
771
801
  if (o.fit !== false) {
772
802
  o.cy.fit(o.eles, o.padding);
@@ -779,18 +809,13 @@ function simulate(layout: LayoutThis, type: SimulationType, accelerator: LayoutA
779
809
  layout.emit({ type: "layoutready", layout });
780
810
  }
781
811
  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 });
812
+ end();
788
813
  } else {
789
814
  schedule(tick);
790
815
  }
791
816
  };
792
817
  const tick = (): void => {
793
- if (layout.stopped) {
818
+ if (layout.stopped || o.cy.destroyed()) {
794
819
  draw();
795
820
  return;
796
821
  }
@@ -810,15 +835,33 @@ function simulate(layout: LayoutThis, type: SimulationType, accelerator: LayoutA
810
835
  tick();
811
836
  }
812
837
 
838
+ /**
839
+ * Ends a run that never started: layouterror with the error, then layoutstop, after run() returns.
840
+ * @param layout - the layout
841
+ * @param error - the error
842
+ */
843
+ function failLater(layout: LayoutThis, error: Error): void {
844
+ layout.looping = true;
845
+ void Promise.resolve().then(() => {
846
+ layout.looping = false;
847
+ layout.emit("layouterror", [error]);
848
+ layout.emit({ type: "layoutstop", layout });
849
+ });
850
+ }
851
+
813
852
  /** Each layout object's run body, set by its registrant's constructor. */
814
853
  const RUN_BODIES = new WeakMap<LayoutThis, (layout: LayoutThis) => void>();
815
854
 
816
855
  /**
817
- * A registrant's run(): runs the layout's body.
856
+ * A registrant's run(): runs the layout's body, or refuses a destroyed core with E_CORE_DESTROYED.
818
857
  * @returns the layout
819
858
  */
820
859
  function runLayout(this: LayoutThis): LayoutThis {
821
- RUN_BODIES.get(this)?.(this);
860
+ if (this.options.cy.destroyed()) {
861
+ failLater(this, coreDestroyedError());
862
+ } else {
863
+ RUN_BODIES.get(this)?.(this);
864
+ }
822
865
  return this;
823
866
  }
824
867