@modernrelay/orbit-engine-cosmos 0.2.0
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/README.md +122 -0
- package/dist/index.d.ts +322 -0
- package/dist/index.js +947 -0
- package/dist/index.js.map +1 -0
- package/package.json +38 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,947 @@
|
|
|
1
|
+
// src/restoreDeadline.ts
|
|
2
|
+
var RestoreDeadline = class {
|
|
3
|
+
constructor(doc, ms, onExpire) {
|
|
4
|
+
this.doc = doc;
|
|
5
|
+
this.onExpire = onExpire;
|
|
6
|
+
this.remaining = ms;
|
|
7
|
+
}
|
|
8
|
+
doc;
|
|
9
|
+
onExpire;
|
|
10
|
+
timer = null;
|
|
11
|
+
/** Unspent budget in ms; recomputed each time the timer is disarmed. */
|
|
12
|
+
remaining;
|
|
13
|
+
/** Timestamp when the current timer was armed (for banking on hide). */
|
|
14
|
+
armedAt = 0;
|
|
15
|
+
started = false;
|
|
16
|
+
/** Terminal: set on expiry or cancel; guarantees onExpire fires at most once. */
|
|
17
|
+
done = false;
|
|
18
|
+
handleVisibilityChange = () => {
|
|
19
|
+
if (this.done) return;
|
|
20
|
+
if (this.doc.visibilityState === "visible") this.arm();
|
|
21
|
+
else this.disarm();
|
|
22
|
+
};
|
|
23
|
+
start() {
|
|
24
|
+
if (this.done || this.started) return;
|
|
25
|
+
this.started = true;
|
|
26
|
+
this.doc.addEventListener("visibilitychange", this.handleVisibilityChange);
|
|
27
|
+
if (this.doc.visibilityState === "visible") this.arm();
|
|
28
|
+
}
|
|
29
|
+
cancel() {
|
|
30
|
+
if (this.done) return;
|
|
31
|
+
this.done = true;
|
|
32
|
+
if (this.timer !== null) {
|
|
33
|
+
globalThis.clearTimeout(this.timer);
|
|
34
|
+
this.timer = null;
|
|
35
|
+
}
|
|
36
|
+
if (this.started) {
|
|
37
|
+
this.doc.removeEventListener("visibilitychange", this.handleVisibilityChange);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
arm() {
|
|
41
|
+
if (this.timer !== null) return;
|
|
42
|
+
this.armedAt = Date.now();
|
|
43
|
+
this.timer = globalThis.setTimeout(() => {
|
|
44
|
+
this.timer = null;
|
|
45
|
+
this.done = true;
|
|
46
|
+
this.doc.removeEventListener("visibilitychange", this.handleVisibilityChange);
|
|
47
|
+
this.onExpire();
|
|
48
|
+
}, this.remaining);
|
|
49
|
+
}
|
|
50
|
+
disarm() {
|
|
51
|
+
if (this.timer === null) return;
|
|
52
|
+
globalThis.clearTimeout(this.timer);
|
|
53
|
+
this.timer = null;
|
|
54
|
+
this.remaining = Math.max(0, this.remaining - (Date.now() - this.armedAt));
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// src/CosmosEngine.ts
|
|
59
|
+
function clickModifiers(event) {
|
|
60
|
+
const e = event;
|
|
61
|
+
if (e && typeof e.metaKey === "boolean" && typeof e.shiftKey === "boolean") {
|
|
62
|
+
return { metaKey: e.metaKey, shiftKey: e.shiftKey };
|
|
63
|
+
}
|
|
64
|
+
return void 0;
|
|
65
|
+
}
|
|
66
|
+
var DEFAULT_SPACE_SIZE = 4096;
|
|
67
|
+
var DEFAULT_RESTORE_DEADLINE_MS = 1e4;
|
|
68
|
+
var CosmosEngine = class {
|
|
69
|
+
capabilities = {
|
|
70
|
+
// Evidence-backed S6 flip: the M0 probe measured native onLinkClick/
|
|
71
|
+
// onLinkMouseOver delivering correct link indices with ~4px perpendicular
|
|
72
|
+
// tolerance (docs/m0-conformance.json, `link-picking`).
|
|
73
|
+
linkPicking: true,
|
|
74
|
+
rangeUpdates: [],
|
|
75
|
+
trackedPositions: true,
|
|
76
|
+
simulation: true,
|
|
77
|
+
// §16.12: cosmos 3.3.0 exposes the `linkDefaultArrows` config key
|
|
78
|
+
// (config.d.ts) — instanced arrowheads toggle atomically via setConfigPartial.
|
|
79
|
+
edgeArrows: true,
|
|
80
|
+
// §8: cosmos 3.3.0 exposes setImageData(ImageData[]) +
|
|
81
|
+
// setPointImageIndices(Float32Array) (index.d.ts) — per-point sprites via
|
|
82
|
+
// an adapter-maintained slot→ImageData atlas.
|
|
83
|
+
pointImages: true,
|
|
84
|
+
// §16.3 stage 4: cosmos 3.3.0 ships a real GPU cluster force —
|
|
85
|
+
// `setPointClusters((number|undefined)[])`, `setClusterPositions(
|
|
86
|
+
// (number|undefined)[])`, `setPointClusterStrength(Float32Array)`
|
|
87
|
+
// (index.d.ts) plus the `simulationCluster` coefficient (config.d.ts,
|
|
88
|
+
// default 0.1) and the `Clusters` core module (dist/modules/Clusters).
|
|
89
|
+
// `undefined` is cosmos' documented "not in any cluster" value, which the
|
|
90
|
+
// adapter maps from the contract's NaN.
|
|
91
|
+
clusterForce: true
|
|
92
|
+
};
|
|
93
|
+
options;
|
|
94
|
+
graph = null;
|
|
95
|
+
innerDiv = null;
|
|
96
|
+
events = null;
|
|
97
|
+
/**
|
|
98
|
+
* Pre-mount/lost-context commits collapse per channel and are applied as one
|
|
99
|
+
* atomic commit once a usable graph exists.
|
|
100
|
+
*/
|
|
101
|
+
pendingCommit = null;
|
|
102
|
+
applied = null;
|
|
103
|
+
destroyed = false;
|
|
104
|
+
mounting = false;
|
|
105
|
+
/** Constructed graph whose `ready` promise has not settled yet. */
|
|
106
|
+
graphAwaitingReady = null;
|
|
107
|
+
/** Guards every graph teardown, including async init/recovery races. */
|
|
108
|
+
destroyedGraphs = /* @__PURE__ */ new WeakSet();
|
|
109
|
+
/** Sticky override from EngineConfigUpdate.seedRadius. */
|
|
110
|
+
seedRadiusOverride;
|
|
111
|
+
/** Point count of the last structure-bearing commit — the roster length a
|
|
112
|
+
* `cluster: null` clear must write an all-unclustered array for (§16.3). */
|
|
113
|
+
lastPointCount = 0;
|
|
114
|
+
/**
|
|
115
|
+
* cosmos fires `onClick` (index undefined) AND `onBackgroundClick` for the
|
|
116
|
+
* same background click; remembering the MouseEvent dedupes the null emit.
|
|
117
|
+
*/
|
|
118
|
+
lastNullClickEvent = null;
|
|
119
|
+
/** Point latched at cosmos onDragStart; cleared when the gesture ends. */
|
|
120
|
+
dragIndex = null;
|
|
121
|
+
/** Last hover reported by cosmos — fallback dragged-point source. */
|
|
122
|
+
lastHoverIndex = null;
|
|
123
|
+
// --- adapter-owned overlay/activity clock (see module header) ---
|
|
124
|
+
/** rAF id of the pending activity-clock tick; null = clock not running. */
|
|
125
|
+
frameHandle = null;
|
|
126
|
+
/** Window driving the clock, cached so stop works after the div detaches. */
|
|
127
|
+
frameWindow = null;
|
|
128
|
+
// --- WebGL context-loss recovery state (§13.1) ---
|
|
129
|
+
/** cosmos' canvas (queried post-mount) carrying the webglcontext* listeners. */
|
|
130
|
+
canvas = null;
|
|
131
|
+
contextLost = false;
|
|
132
|
+
/** Terminal: GL reinitialization failed; commits stash inertly forever. */
|
|
133
|
+
failed = false;
|
|
134
|
+
deadline = null;
|
|
135
|
+
/** Graph constructor cached at mount so recovery never re-imports cosmos. */
|
|
136
|
+
cosmosCtor = null;
|
|
137
|
+
// --- §8 image atlas (capability pointImages) ---
|
|
138
|
+
/**
|
|
139
|
+
* slot → ImageData mirror of the cosmos image atlas. ImageData is CPU-side,
|
|
140
|
+
* so the mirror survives context loss — any post-restore atlas commit
|
|
141
|
+
* re-uploads the FULL array to the fresh graph. `null` = removed (blank).
|
|
142
|
+
*/
|
|
143
|
+
imageSlots = [];
|
|
144
|
+
/** Lazily created 1×1 transparent entry filling removed/hole slots. */
|
|
145
|
+
blankImage = null;
|
|
146
|
+
/** One-shot guard for the `engine:image-channel-unavailable` diagnostic. */
|
|
147
|
+
imageChannelUnavailable = false;
|
|
148
|
+
constructor(options = {}) {
|
|
149
|
+
this.options = options;
|
|
150
|
+
}
|
|
151
|
+
async mount(container, events) {
|
|
152
|
+
if (this.destroyed) throw new Error("CosmosEngine: mount() after destroy()");
|
|
153
|
+
if (this.mounting || this.graph) throw new Error("CosmosEngine: mount() may only be called once");
|
|
154
|
+
this.mounting = true;
|
|
155
|
+
this.events = events;
|
|
156
|
+
const div = container.ownerDocument.createElement("div");
|
|
157
|
+
div.style.width = "100%";
|
|
158
|
+
div.style.height = "100%";
|
|
159
|
+
container.appendChild(div);
|
|
160
|
+
this.innerDiv = div;
|
|
161
|
+
let graph = null;
|
|
162
|
+
try {
|
|
163
|
+
const { Graph } = await import('@cosmos.gl/graph');
|
|
164
|
+
if (this.destroyed) {
|
|
165
|
+
div.remove();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
graph = new Graph(div, this.buildInitialConfig());
|
|
169
|
+
this.graphAwaitingReady = graph;
|
|
170
|
+
await graph.ready;
|
|
171
|
+
if (this.graphAwaitingReady === graph) this.graphAwaitingReady = null;
|
|
172
|
+
if (this.destroyed) {
|
|
173
|
+
this.destroyGraphOnce(graph);
|
|
174
|
+
div.remove();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
this.graph = graph;
|
|
178
|
+
this.cosmosCtor = Graph;
|
|
179
|
+
this.attachContextListeners();
|
|
180
|
+
events.onDiagnostic?.({
|
|
181
|
+
code: "engine:overlay-activity-clock",
|
|
182
|
+
severity: "info",
|
|
183
|
+
message: "CosmosEngine: cosmos exposes no draw-phase hook (postDrawFrames:false); overlays ride an adapter-owned requestAnimationFrame activity clock and may lag the canvas by one frame."
|
|
184
|
+
});
|
|
185
|
+
this.startFrameLoop();
|
|
186
|
+
const pending = this.pendingCommit;
|
|
187
|
+
this.pendingCommit = null;
|
|
188
|
+
if (pending) this.applyCommit(graph, pending);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if (graph !== null) {
|
|
191
|
+
if (this.graph === graph) {
|
|
192
|
+
this.stopFrameLoop();
|
|
193
|
+
this.detachContextListeners();
|
|
194
|
+
this.graph = null;
|
|
195
|
+
}
|
|
196
|
+
if (this.graphAwaitingReady === graph) this.graphAwaitingReady = null;
|
|
197
|
+
this.destroyGraphOnce(graph);
|
|
198
|
+
}
|
|
199
|
+
div.remove();
|
|
200
|
+
if (this.innerDiv === div) this.innerDiv = null;
|
|
201
|
+
if (this.destroyed) return;
|
|
202
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
203
|
+
events.onError?.(error);
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
commit(update) {
|
|
208
|
+
if (this.destroyed) throw new Error("CosmosEngine: commit() after destroy()");
|
|
209
|
+
const graph = this.activeGraph;
|
|
210
|
+
if (!graph) {
|
|
211
|
+
this.queueCommit(update);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
this.applyCommit(graph, update);
|
|
215
|
+
}
|
|
216
|
+
appliedRevision() {
|
|
217
|
+
return this.applied;
|
|
218
|
+
}
|
|
219
|
+
// --- camera ---
|
|
220
|
+
fitView(opts) {
|
|
221
|
+
this.activeGraph?.fitView(opts?.durationMs, opts?.padding);
|
|
222
|
+
}
|
|
223
|
+
zoom(factor, durationMs) {
|
|
224
|
+
const graph = this.activeGraph;
|
|
225
|
+
if (!graph) return;
|
|
226
|
+
graph.setZoomLevel(graph.getZoomLevel() * factor, durationMs);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* REAL pan (the former zoom-only limitation is lifted — see module header):
|
|
230
|
+
* when a target center is provided, one `setZoomTransformByPointPositions`
|
|
231
|
+
* call centers space point (x, y) at EXACTLY the requested (or current)
|
|
232
|
+
* zoom — `setViewport(p)` then `getViewport()` returns p, modulo cosmos'
|
|
233
|
+
* d3 scaleExtent clamp. A missing x or y is filled from the current
|
|
234
|
+
* viewport; zoom-only calls keep the `setZoomLevel` path (d3's scaleTo
|
|
235
|
+
* preserves the current center). Instant unless `durationMs` is given —
|
|
236
|
+
* cosmos' own default duration is 250 ms, which would animate context-
|
|
237
|
+
* recovery replays.
|
|
238
|
+
*/
|
|
239
|
+
setViewport(v, opts) {
|
|
240
|
+
const graph = this.activeGraph;
|
|
241
|
+
if (!graph) return;
|
|
242
|
+
if (v.x !== void 0 || v.y !== void 0) {
|
|
243
|
+
const current = v.x === void 0 || v.y === void 0 ? this.getViewport() : null;
|
|
244
|
+
const x = v.x ?? current?.x;
|
|
245
|
+
const y = v.y ?? current?.y;
|
|
246
|
+
if (x !== void 0 && y !== void 0) {
|
|
247
|
+
graph.setZoomTransformByPointPositions(
|
|
248
|
+
Float32Array.of(x, y),
|
|
249
|
+
opts?.durationMs ?? 0,
|
|
250
|
+
v.zoom ?? graph.getZoomLevel()
|
|
251
|
+
);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (v.zoom !== void 0) graph.setZoomLevel(v.zoom, opts?.durationMs ?? 0);
|
|
256
|
+
}
|
|
257
|
+
getViewport() {
|
|
258
|
+
const graph = this.activeGraph;
|
|
259
|
+
const div = this.innerDiv;
|
|
260
|
+
if (!graph || !div) return null;
|
|
261
|
+
const [x, y] = graph.screenToSpacePosition([div.clientWidth / 2, div.clientHeight / 2]);
|
|
262
|
+
return { x, y, zoom: graph.getZoomLevel() };
|
|
263
|
+
}
|
|
264
|
+
zoomToIndex(index, durationMs) {
|
|
265
|
+
this.activeGraph?.zoomToPointByIndex(index, durationMs);
|
|
266
|
+
}
|
|
267
|
+
// --- simulation ---
|
|
268
|
+
start(alpha) {
|
|
269
|
+
this.activeGraph?.start(alpha);
|
|
270
|
+
}
|
|
271
|
+
pause() {
|
|
272
|
+
this.activeGraph?.pause();
|
|
273
|
+
}
|
|
274
|
+
// --- built-in interaction visuals ---
|
|
275
|
+
setSelectedIndices(indices) {
|
|
276
|
+
const graph = this.activeGraph;
|
|
277
|
+
if (!graph) return;
|
|
278
|
+
graph.setConfigPartial({
|
|
279
|
+
highlightedPointIndices: indices === null ? void 0 : Array.from(indices)
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
setFocusedIndex(index) {
|
|
283
|
+
const graph = this.activeGraph;
|
|
284
|
+
if (!graph) return;
|
|
285
|
+
graph.setConfigPartial({
|
|
286
|
+
focusedPointIndex: index === null ? void 0 : index
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
// --- spatial queries & pinning (§13/§15) ---
|
|
290
|
+
pointsInPolygon(screenPolygon) {
|
|
291
|
+
const graph = this.activeGraph;
|
|
292
|
+
if (!graph) return [];
|
|
293
|
+
return graph.findPointsInPolygon(screenPolygon.map(([x, y]) => [x, y]));
|
|
294
|
+
}
|
|
295
|
+
pointsInRect(screenRect) {
|
|
296
|
+
const graph = this.activeGraph;
|
|
297
|
+
if (!graph) return [];
|
|
298
|
+
const [x0, y0, x1, y1] = screenRect;
|
|
299
|
+
return graph.findPointsInRect([
|
|
300
|
+
[Math.min(x0, x1), Math.min(y0, y1)],
|
|
301
|
+
[Math.max(x0, x1), Math.max(y0, y1)]
|
|
302
|
+
]);
|
|
303
|
+
}
|
|
304
|
+
neighborIndices(index) {
|
|
305
|
+
const graph = this.activeGraph;
|
|
306
|
+
if (!graph) return [];
|
|
307
|
+
return graph.getNeighboringPointIndices(index).filter((i) => i !== index);
|
|
308
|
+
}
|
|
309
|
+
screenToSpace(p) {
|
|
310
|
+
const graph = this.activeGraph;
|
|
311
|
+
return graph ? graph.screenToSpacePosition([p[0], p[1]]) : null;
|
|
312
|
+
}
|
|
313
|
+
spaceToScreen(p) {
|
|
314
|
+
const graph = this.activeGraph;
|
|
315
|
+
return graph ? graph.spaceToScreenPosition([p[0], p[1]]) : null;
|
|
316
|
+
}
|
|
317
|
+
setPinnedIndices(indices) {
|
|
318
|
+
this.activeGraph?.setPinnedPoints(indices === null ? null : Array.from(indices));
|
|
319
|
+
}
|
|
320
|
+
// --- readback ---
|
|
321
|
+
getPositions() {
|
|
322
|
+
const graph = this.activeGraph;
|
|
323
|
+
return graph ? Float32Array.from(graph.getPointPositions()) : null;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Captures the cosmos canvas via the M0 same-tick method (see module
|
|
327
|
+
* header): one rAF is scheduled and, inside that tick, the WebGL canvas is
|
|
328
|
+
* drawn synchronously onto an offscreen 2D canvas (cosmos renders with
|
|
329
|
+
* preserveDrawingBuffer:false, so the buffer is only readable same-tick).
|
|
330
|
+
* Resolves null on any failure or unusable lifecycle state (pre-mount,
|
|
331
|
+
* context lost/failed, destroyed, no 2D context, toBlob failure).
|
|
332
|
+
*/
|
|
333
|
+
captureScreenshot() {
|
|
334
|
+
const canvas = this.activeGraph ? this.canvas : null;
|
|
335
|
+
const win = canvas?.ownerDocument.defaultView ?? null;
|
|
336
|
+
if (!canvas || !win || typeof win.requestAnimationFrame !== "function") {
|
|
337
|
+
return Promise.resolve(null);
|
|
338
|
+
}
|
|
339
|
+
return new Promise((resolve) => {
|
|
340
|
+
win.requestAnimationFrame(() => {
|
|
341
|
+
if (this.destroyed || !this.activeGraph || this.canvas !== canvas) {
|
|
342
|
+
resolve(null);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
const off = canvas.ownerDocument.createElement("canvas");
|
|
347
|
+
off.width = canvas.width;
|
|
348
|
+
off.height = canvas.height;
|
|
349
|
+
const ctx = off.getContext("2d");
|
|
350
|
+
if (!ctx) {
|
|
351
|
+
resolve(null);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
ctx.drawImage(canvas, 0, 0);
|
|
355
|
+
off.toBlob((blob) => {
|
|
356
|
+
resolve(blob);
|
|
357
|
+
});
|
|
358
|
+
} catch {
|
|
359
|
+
resolve(null);
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
destroy() {
|
|
365
|
+
if (this.destroyed) return;
|
|
366
|
+
this.destroyed = true;
|
|
367
|
+
this.stopFrameLoop();
|
|
368
|
+
this.deadline?.cancel();
|
|
369
|
+
this.deadline = null;
|
|
370
|
+
this.detachContextListeners();
|
|
371
|
+
const graph = this.graph;
|
|
372
|
+
this.graph = null;
|
|
373
|
+
this.destroyGraphOnce(graph);
|
|
374
|
+
const awaiting = this.graphAwaitingReady;
|
|
375
|
+
this.graphAwaitingReady = null;
|
|
376
|
+
this.destroyGraphOnce(awaiting);
|
|
377
|
+
this.innerDiv?.remove();
|
|
378
|
+
this.innerDiv = null;
|
|
379
|
+
this.events = null;
|
|
380
|
+
this.pendingCommit = null;
|
|
381
|
+
this.imageSlots = [];
|
|
382
|
+
this.blankImage = null;
|
|
383
|
+
}
|
|
384
|
+
// --- overlay/activity clock (see module header) ---
|
|
385
|
+
/**
|
|
386
|
+
* One rAF tick of the activity clock. Reschedules FIRST so a throwing host
|
|
387
|
+
* callback can never kill the clock; skips the callback (but keeps ticking)
|
|
388
|
+
* while the document is hidden. Deliberately cheap: a single callback
|
|
389
|
+
* invocation — the core skips all work when nothing subscribes.
|
|
390
|
+
*/
|
|
391
|
+
frameTick = (timeMs) => {
|
|
392
|
+
const win = this.frameWindow;
|
|
393
|
+
if (!win || this.frameHandle === null) return;
|
|
394
|
+
this.frameHandle = win.requestAnimationFrame(this.frameTick);
|
|
395
|
+
if (!win.document.hidden) this.events?.onFrame?.(timeMs);
|
|
396
|
+
};
|
|
397
|
+
startFrameLoop() {
|
|
398
|
+
if (this.destroyed || this.frameHandle !== null) return;
|
|
399
|
+
const win = this.innerDiv?.ownerDocument.defaultView ?? null;
|
|
400
|
+
if (!win || typeof win.requestAnimationFrame !== "function") return;
|
|
401
|
+
this.frameWindow = win;
|
|
402
|
+
this.frameHandle = win.requestAnimationFrame(this.frameTick);
|
|
403
|
+
}
|
|
404
|
+
stopFrameLoop() {
|
|
405
|
+
if (this.frameHandle !== null) {
|
|
406
|
+
this.frameWindow?.cancelAnimationFrame(this.frameHandle);
|
|
407
|
+
this.frameHandle = null;
|
|
408
|
+
}
|
|
409
|
+
this.frameWindow = null;
|
|
410
|
+
}
|
|
411
|
+
// --- WebGL context-loss recovery (§13.1) ---
|
|
412
|
+
/** The graph, unless it is unusable (context lost / terminally failed). */
|
|
413
|
+
get activeGraph() {
|
|
414
|
+
return this.contextLost || this.failed ? null : this.graph;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* cosmos owns its canvas; we can only wire webglcontext* listeners after
|
|
418
|
+
* init by querying it. A missing canvas downgrades to an info diagnostic —
|
|
419
|
+
* the engine keeps working, just without context-loss recovery.
|
|
420
|
+
*/
|
|
421
|
+
attachContextListeners() {
|
|
422
|
+
const canvas = this.innerDiv?.querySelector("canvas") ?? null;
|
|
423
|
+
if (!canvas) {
|
|
424
|
+
this.events?.onDiagnostic?.({
|
|
425
|
+
code: "engine:context-listeners-unavailable",
|
|
426
|
+
severity: "info",
|
|
427
|
+
message: "CosmosEngine: no <canvas> found in the cosmos container; WebGL context-loss recovery is disabled."
|
|
428
|
+
});
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
this.canvas = canvas;
|
|
432
|
+
canvas.addEventListener("webglcontextlost", this.handleContextLost);
|
|
433
|
+
canvas.addEventListener("webglcontextrestored", this.handleContextRestored);
|
|
434
|
+
}
|
|
435
|
+
detachContextListeners() {
|
|
436
|
+
const canvas = this.canvas;
|
|
437
|
+
if (!canvas) return;
|
|
438
|
+
canvas.removeEventListener("webglcontextlost", this.handleContextLost);
|
|
439
|
+
canvas.removeEventListener("webglcontextrestored", this.handleContextRestored);
|
|
440
|
+
this.canvas = null;
|
|
441
|
+
}
|
|
442
|
+
/** DOM listener: nothing may throw out of it. */
|
|
443
|
+
handleContextLost = (event) => {
|
|
444
|
+
try {
|
|
445
|
+
event.preventDefault();
|
|
446
|
+
if (this.destroyed || this.failed || this.contextLost) return;
|
|
447
|
+
this.contextLost = true;
|
|
448
|
+
this.stopFrameLoop();
|
|
449
|
+
this.dragIndex = null;
|
|
450
|
+
this.lastHoverIndex = null;
|
|
451
|
+
try {
|
|
452
|
+
this.graph?.pause();
|
|
453
|
+
} catch {
|
|
454
|
+
}
|
|
455
|
+
this.startRestoreDeadline();
|
|
456
|
+
this.events?.onContextEvent?.({ type: "lost" });
|
|
457
|
+
} catch {
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
/** DOM listener: nothing may throw out of it (async work is caught below). */
|
|
461
|
+
handleContextRestored = () => {
|
|
462
|
+
if (this.destroyed || this.failed || !this.contextLost) return;
|
|
463
|
+
this.reinitializeAfterRestore().catch((err) => {
|
|
464
|
+
if (this.destroyed) return;
|
|
465
|
+
this.failed = true;
|
|
466
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
467
|
+
try {
|
|
468
|
+
this.events?.onContextEvent?.({ type: "failed", error });
|
|
469
|
+
} catch {
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
};
|
|
473
|
+
/**
|
|
474
|
+
* cosmos cannot reuse a restored context (its GPU resources are gone), so
|
|
475
|
+
* recovery = tear down the old Graph and build a fresh one in the same div,
|
|
476
|
+
* flush the stashed commit, and only then report `restored` — the core
|
|
477
|
+
* re-commits the full scene in response.
|
|
478
|
+
*/
|
|
479
|
+
async reinitializeAfterRestore() {
|
|
480
|
+
this.deadline?.cancel();
|
|
481
|
+
this.deadline = null;
|
|
482
|
+
this.detachContextListeners();
|
|
483
|
+
const oldGraph = this.graph;
|
|
484
|
+
this.graph = null;
|
|
485
|
+
this.destroyGraphOnce(oldGraph);
|
|
486
|
+
const div = this.innerDiv;
|
|
487
|
+
const Ctor = this.cosmosCtor;
|
|
488
|
+
if (!div || !Ctor) return;
|
|
489
|
+
const graph = new Ctor(div, this.buildInitialConfig());
|
|
490
|
+
this.graphAwaitingReady = graph;
|
|
491
|
+
try {
|
|
492
|
+
await graph.ready;
|
|
493
|
+
if (this.graphAwaitingReady === graph) this.graphAwaitingReady = null;
|
|
494
|
+
if (this.destroyed) {
|
|
495
|
+
this.destroyGraphOnce(graph);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
this.graph = graph;
|
|
499
|
+
this.attachContextListeners();
|
|
500
|
+
this.contextLost = false;
|
|
501
|
+
this.startFrameLoop();
|
|
502
|
+
const pending = this.pendingCommit;
|
|
503
|
+
this.pendingCommit = null;
|
|
504
|
+
if (pending) this.applyCommit(graph, pending);
|
|
505
|
+
this.events?.onContextEvent?.({ type: "restored" });
|
|
506
|
+
} catch (err) {
|
|
507
|
+
if (this.graphAwaitingReady === graph) this.graphAwaitingReady = null;
|
|
508
|
+
this.stopFrameLoop();
|
|
509
|
+
if (this.graph === graph) {
|
|
510
|
+
this.detachContextListeners();
|
|
511
|
+
this.graph = null;
|
|
512
|
+
}
|
|
513
|
+
this.destroyGraphOnce(graph);
|
|
514
|
+
throw err;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
startRestoreDeadline() {
|
|
518
|
+
const doc = this.canvas?.ownerDocument;
|
|
519
|
+
if (!doc) return;
|
|
520
|
+
const ms = this.options.restoreDeadlineMs ?? DEFAULT_RESTORE_DEADLINE_MS;
|
|
521
|
+
const deadline = new RestoreDeadline(doc, ms, () => {
|
|
522
|
+
this.deadline = null;
|
|
523
|
+
this.events?.onDiagnostic?.({
|
|
524
|
+
code: "engine:context-restore-deadline",
|
|
525
|
+
severity: "warning",
|
|
526
|
+
message: `CosmosEngine: WebGL context was not restored within ${ms}ms of visible time.`
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
this.deadline = deadline;
|
|
530
|
+
deadline.start();
|
|
531
|
+
}
|
|
532
|
+
// -------------------------------------------------------------------------
|
|
533
|
+
/**
|
|
534
|
+
* Coalesces partial commits without discarding independent channels. The
|
|
535
|
+
* newest call supplies the visible revision; structure is one atomic
|
|
536
|
+
* channel, buffers/config merge per field, and restart persists until a
|
|
537
|
+
* later explicit directive (including `false`) replaces it.
|
|
538
|
+
*/
|
|
539
|
+
queueCommit(update) {
|
|
540
|
+
const previous = this.pendingCommit;
|
|
541
|
+
if (previous === null) {
|
|
542
|
+
this.pendingCommit = update;
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const merged = { revision: update.revision };
|
|
546
|
+
const structure = update.structure ?? previous.structure;
|
|
547
|
+
if (structure !== void 0) merged.structure = structure;
|
|
548
|
+
if (previous.buffers !== void 0 || update.buffers !== void 0) {
|
|
549
|
+
merged.buffers = { ...previous.buffers, ...update.buffers };
|
|
550
|
+
}
|
|
551
|
+
if (previous.config !== void 0 || update.config !== void 0) {
|
|
552
|
+
const config = { ...previous.config, ...update.config };
|
|
553
|
+
if (previous.config?.simulation !== void 0 || update.config?.simulation !== void 0) {
|
|
554
|
+
config.simulation = {
|
|
555
|
+
...previous.config?.simulation,
|
|
556
|
+
...update.config?.simulation
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
merged.config = config;
|
|
560
|
+
}
|
|
561
|
+
if (previous.resources !== void 0 || update.resources !== void 0) {
|
|
562
|
+
merged.resources = mergeResources(previous.resources, update.resources);
|
|
563
|
+
}
|
|
564
|
+
const restart = update.restart !== void 0 && update.restart !== false ? update.restart : previous.restart;
|
|
565
|
+
if (restart !== void 0) merged.restart = restart;
|
|
566
|
+
this.pendingCommit = merged;
|
|
567
|
+
}
|
|
568
|
+
/** Invokes a graph's destructor at most once, even across async races. */
|
|
569
|
+
destroyGraphOnce(graph) {
|
|
570
|
+
if (graph === null || this.destroyedGraphs.has(graph)) return;
|
|
571
|
+
this.destroyedGraphs.add(graph);
|
|
572
|
+
try {
|
|
573
|
+
graph.destroy();
|
|
574
|
+
} catch {
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
get spaceSize() {
|
|
578
|
+
const fromInitial = this.options.initialConfig?.["spaceSize"];
|
|
579
|
+
if (typeof fromInitial === "number") return fromInitial;
|
|
580
|
+
return this.options.spaceSize ?? DEFAULT_SPACE_SIZE;
|
|
581
|
+
}
|
|
582
|
+
get seedRadius() {
|
|
583
|
+
return this.seedRadiusOverride ?? this.options.seedRadius ?? this.spaceSize / 4;
|
|
584
|
+
}
|
|
585
|
+
buildInitialConfig() {
|
|
586
|
+
const base = {
|
|
587
|
+
// Default false: the core owns the camera (fitView is driven explicitly).
|
|
588
|
+
fitViewOnInit: this.options.fitViewOnInit ?? false,
|
|
589
|
+
// Native point dragging; the core owns pin semantics on top (§16.3).
|
|
590
|
+
enableDrag: this.options.enableDrag ?? true,
|
|
591
|
+
onPointClick: (index, _position, event) => {
|
|
592
|
+
this.events?.onPointClick?.(index, clickModifiers(event));
|
|
593
|
+
},
|
|
594
|
+
onBackgroundClick: (event) => {
|
|
595
|
+
this.emitNullPointClick(event);
|
|
596
|
+
},
|
|
597
|
+
// onClick fires for every canvas click; an undefined index means no
|
|
598
|
+
// point was hit (background clicks — link clicks arrive via onLinkClick).
|
|
599
|
+
onClick: (index, _position, event) => {
|
|
600
|
+
if (index === void 0) this.emitNullPointClick(event);
|
|
601
|
+
},
|
|
602
|
+
// Unified context-menu channel: fires exactly once per gesture (desktop
|
|
603
|
+
// right-click AND touch long-press — cosmos synthesizes the latter);
|
|
604
|
+
// the per-target onPoint/onLink/onBackgroundContextMenu callbacks fire
|
|
605
|
+
// ADDITIONALLY for the same gesture, so only this one is wired (see
|
|
606
|
+
// module header). index undefined = background (or a link).
|
|
607
|
+
onContextMenu: (index, _position, event) => {
|
|
608
|
+
this.handleContextMenu(index, event);
|
|
609
|
+
},
|
|
610
|
+
onPointMouseOver: (index) => {
|
|
611
|
+
this.lastHoverIndex = index;
|
|
612
|
+
this.events?.onPointHover?.(index);
|
|
613
|
+
},
|
|
614
|
+
onPointMouseOut: () => {
|
|
615
|
+
this.lastHoverIndex = null;
|
|
616
|
+
this.events?.onPointHover?.(null);
|
|
617
|
+
},
|
|
618
|
+
// Setting any onLink* callback enables cosmos' native link hit-testing.
|
|
619
|
+
onLinkClick: (linkIndex) => {
|
|
620
|
+
this.events?.onLinkClick?.(linkIndex);
|
|
621
|
+
},
|
|
622
|
+
onLinkMouseOver: (linkIndex) => {
|
|
623
|
+
this.events?.onLinkHover?.(linkIndex);
|
|
624
|
+
},
|
|
625
|
+
onLinkMouseOut: () => {
|
|
626
|
+
this.events?.onLinkHover?.(null);
|
|
627
|
+
},
|
|
628
|
+
onDragStart: () => {
|
|
629
|
+
this.handleDragStart();
|
|
630
|
+
},
|
|
631
|
+
onDragEnd: (e) => {
|
|
632
|
+
this.handleDragEnd(e);
|
|
633
|
+
},
|
|
634
|
+
onZoom: () => {
|
|
635
|
+
this.emitViewportChange();
|
|
636
|
+
},
|
|
637
|
+
onZoomEnd: () => {
|
|
638
|
+
this.emitViewportChange();
|
|
639
|
+
},
|
|
640
|
+
onSimulationEnd: () => {
|
|
641
|
+
this.events?.onSimulationEnd?.();
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
if (this.options.spaceSize !== void 0) base.spaceSize = this.options.spaceSize;
|
|
645
|
+
return { ...base, ...this.options.initialConfig };
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Maps cosmos' unified context-menu callback to the host event: index
|
|
649
|
+
* undefined → null (background), MouseEvent client coords → container-
|
|
650
|
+
* relative CSS px (the inner div fills the host container exactly). The
|
|
651
|
+
* native event is preventDefault-ed so the browser menu never opens — but
|
|
652
|
+
* only when the host actually registered onContextMenu (it always does in
|
|
653
|
+
* Orbit: the core owns the typed 'contextMenu' event channel). cosmos'
|
|
654
|
+
* desktop `contextmenu` handler already prevents the event itself; repeating
|
|
655
|
+
* it is an idempotent no-op that also covers the touch long-press path
|
|
656
|
+
* (where cosmos forwards the originating pointerdown event instead).
|
|
657
|
+
*/
|
|
658
|
+
handleContextMenu(index, event) {
|
|
659
|
+
const events = this.events;
|
|
660
|
+
if (!events?.onContextMenu) return;
|
|
661
|
+
if (typeof event.preventDefault === "function") event.preventDefault();
|
|
662
|
+
const div = this.innerDiv;
|
|
663
|
+
if (!div) return;
|
|
664
|
+
const rect = div.getBoundingClientRect();
|
|
665
|
+
events.onContextMenu(index ?? null, [event.clientX - rect.left, event.clientY - rect.top]);
|
|
666
|
+
}
|
|
667
|
+
/** Emits onPointClick(null) once per originating click event. */
|
|
668
|
+
emitNullPointClick(event) {
|
|
669
|
+
if (event != null && event === this.lastNullClickEvent) return;
|
|
670
|
+
this.lastNullClickEvent = event ?? null;
|
|
671
|
+
this.events?.onPointClick?.(null, clickModifiers(event));
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* The D3 drag event carries no point index; cosmos assigns
|
|
675
|
+
* `store.draggingPointIndex` right before invoking onDragStart (see module
|
|
676
|
+
* header). The public onPointMouseOver stream is the fallback — cosmos only
|
|
677
|
+
* starts a drag while a point is hovered.
|
|
678
|
+
*/
|
|
679
|
+
readDraggingIndex() {
|
|
680
|
+
const store = this.graph?.store;
|
|
681
|
+
return store?.draggingPointIndex ?? store?.hoveredPoint?.index ?? this.lastHoverIndex;
|
|
682
|
+
}
|
|
683
|
+
handleDragStart() {
|
|
684
|
+
const index = this.readDraggingIndex();
|
|
685
|
+
if (index === null) return;
|
|
686
|
+
this.dragIndex = index;
|
|
687
|
+
this.events?.onDragStart?.(index);
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Reports the dragged point's final SPACE position. cosmos' drag shader
|
|
691
|
+
* pins the point to the mouse's space position every frame, so converting
|
|
692
|
+
* the event's screen coords is exact and O(1) (see module header).
|
|
693
|
+
*/
|
|
694
|
+
handleDragEnd(e) {
|
|
695
|
+
const index = this.dragIndex;
|
|
696
|
+
this.dragIndex = null;
|
|
697
|
+
if (index === null) return;
|
|
698
|
+
const graph = this.activeGraph;
|
|
699
|
+
if (!graph) return;
|
|
700
|
+
const [x, y] = graph.screenToSpacePosition([e.x, e.y]);
|
|
701
|
+
this.events?.onDragEnd?.(index, x, y);
|
|
702
|
+
}
|
|
703
|
+
emitViewportChange() {
|
|
704
|
+
const viewport = this.getViewport();
|
|
705
|
+
if (viewport) this.events?.onViewportChange?.(viewport);
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* One visibly atomic update (§13): all channels and config are staged, then
|
|
709
|
+
* exactly one render() draws them; restart reheats after the render.
|
|
710
|
+
*/
|
|
711
|
+
applyCommit(graph, update) {
|
|
712
|
+
const { config, structure, buffers, resources, restart } = update;
|
|
713
|
+
if (config) {
|
|
714
|
+
if (config.seedRadius !== void 0) this.seedRadiusOverride = config.seedRadius;
|
|
715
|
+
const partial = {};
|
|
716
|
+
if (config.backgroundColor !== void 0) partial.backgroundColor = config.backgroundColor;
|
|
717
|
+
if (config.linkArrows !== void 0) partial.linkDefaultArrows = config.linkArrows;
|
|
718
|
+
if (config.renderLinks !== void 0) partial.renderLinks = config.renderLinks;
|
|
719
|
+
if (config.defaultPointColor !== void 0) {
|
|
720
|
+
partial.pointDefaultColor = config.defaultPointColor;
|
|
721
|
+
}
|
|
722
|
+
if (config.defaultLinkColor !== void 0) {
|
|
723
|
+
partial.linkDefaultColor = config.defaultLinkColor;
|
|
724
|
+
}
|
|
725
|
+
const sim = config.simulation;
|
|
726
|
+
if (sim) {
|
|
727
|
+
if (sim.gravity !== void 0) partial.simulationGravity = sim.gravity;
|
|
728
|
+
if (sim.repulsion !== void 0) partial.simulationRepulsion = sim.repulsion;
|
|
729
|
+
if (sim.friction !== void 0) partial.simulationFriction = sim.friction;
|
|
730
|
+
if (sim.linkDistance !== void 0) partial.simulationLinkDistance = sim.linkDistance;
|
|
731
|
+
if (sim.linkSpring !== void 0) partial.simulationLinkSpring = sim.linkSpring;
|
|
732
|
+
if (sim.decay !== void 0) partial.simulationDecay = sim.decay;
|
|
733
|
+
if (sim.collision !== void 0) partial.simulationCollision = sim.collision;
|
|
734
|
+
if (sim.collisionRadius !== void 0) {
|
|
735
|
+
partial.simulationCollisionRadius = sim.collisionRadius;
|
|
736
|
+
}
|
|
737
|
+
if (sim.collisionPadding !== void 0) {
|
|
738
|
+
partial.simulationCollisionPadding = sim.collisionPadding;
|
|
739
|
+
}
|
|
740
|
+
if (sim.repulsionTheta !== void 0) {
|
|
741
|
+
partial.simulationRepulsionTheta = sim.repulsionTheta;
|
|
742
|
+
}
|
|
743
|
+
if (sim.center !== void 0) partial.simulationCenter = sim.center;
|
|
744
|
+
if (sim.repulsionFromMouse !== void 0) {
|
|
745
|
+
partial.simulationRepulsionFromMouse = sim.repulsionFromMouse;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
if (config.cluster != null && config.cluster.strength !== void 0) {
|
|
749
|
+
partial.simulationCluster = config.cluster.strength;
|
|
750
|
+
}
|
|
751
|
+
if (Object.keys(partial).length > 0) graph.setConfigPartial(partial);
|
|
752
|
+
}
|
|
753
|
+
if (structure) {
|
|
754
|
+
this.lastPointCount = structure.pointCount;
|
|
755
|
+
graph.setPointPositions(this.withSeededPositions(structure.positions));
|
|
756
|
+
graph.setLinks(Float32Array.from(structure.links));
|
|
757
|
+
}
|
|
758
|
+
if (config && config.cluster !== void 0) this.applyClusterForce(graph, config.cluster);
|
|
759
|
+
if (buffers) {
|
|
760
|
+
if (buffers.pointColor) graph.setPointColors(buffers.pointColor);
|
|
761
|
+
if (buffers.pointSize) graph.setPointSizes(buffers.pointSize);
|
|
762
|
+
if (buffers.linkColor) graph.setLinkColors(buffers.linkColor);
|
|
763
|
+
if (buffers.linkWidth) graph.setLinkWidths(buffers.linkWidth);
|
|
764
|
+
}
|
|
765
|
+
if (resources) this.applyResources(graph, resources);
|
|
766
|
+
graph.render();
|
|
767
|
+
this.applied = update.revision;
|
|
768
|
+
if (restart) graph.start(restart.alpha);
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Applies the §16.3 stage-4 cluster force (capability `clusterForce`).
|
|
772
|
+
*
|
|
773
|
+
* Contract mapping, verified against the 3.3.0 dist (`index.d.ts`):
|
|
774
|
+
* - `pointClusters` (Float32Array, NaN = unclustered) →
|
|
775
|
+
* `setPointClusters((number | undefined)[])`, where cosmos' documented
|
|
776
|
+
* "does not belong to any cluster" value is `undefined`;
|
|
777
|
+
* - `centers` (Float32Array, `[x0,y0,x1,y1,…]`) →
|
|
778
|
+
* `setClusterPositions((number | undefined)[])`; a non-finite entry means
|
|
779
|
+
* "no position" and cosmos falls back to that cluster's centermass;
|
|
780
|
+
* - `null` clears: an all-`undefined` array of the CURRENT roster length
|
|
781
|
+
* (length must track the roster) plus empty cluster positions.
|
|
782
|
+
* `strength` is the scene-wide `simulationCluster` config coefficient
|
|
783
|
+
* applied in the config block, not the per-point
|
|
784
|
+
* `setPointClusterStrength` buffer (Orbit's strength is scene-wide).
|
|
785
|
+
*/
|
|
786
|
+
applyClusterForce(graph, cluster) {
|
|
787
|
+
if (cluster == null) {
|
|
788
|
+
graph.setPointClusters(new Array(this.lastPointCount).fill(void 0));
|
|
789
|
+
graph.setClusterPositions([]);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
const source = cluster.pointClusters;
|
|
793
|
+
const assignments = new Array(source.length);
|
|
794
|
+
for (let i = 0; i < source.length; i++) {
|
|
795
|
+
const value = source[i];
|
|
796
|
+
assignments[i] = Number.isFinite(value) ? value : void 0;
|
|
797
|
+
}
|
|
798
|
+
graph.setPointClusters(assignments);
|
|
799
|
+
const centers = cluster.centers;
|
|
800
|
+
if (centers !== void 0) {
|
|
801
|
+
const positions = new Array(centers.length);
|
|
802
|
+
for (let i = 0; i < centers.length; i++) {
|
|
803
|
+
const value = centers[i];
|
|
804
|
+
positions[i] = Number.isFinite(value) ? value : void 0;
|
|
805
|
+
}
|
|
806
|
+
graph.setClusterPositions(positions);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Applies the §8 image-atlas channel: upserts convert ImageBitmap →
|
|
811
|
+
* ImageData through an offscreen 2D canvas into the slot mirror, removals
|
|
812
|
+
* blank their slot, and any atlas change re-uploads the FULL ImageData
|
|
813
|
+
* array (cosmos' setImageData is whole-array only, matching
|
|
814
|
+
* `rangeUpdates: []`). Environments without a usable 2D context (jsdom)
|
|
815
|
+
* no-op the channel and report `engine:image-channel-unavailable` once —
|
|
816
|
+
* never throw.
|
|
817
|
+
*/
|
|
818
|
+
applyResources(graph, resources) {
|
|
819
|
+
const atlas = resources.imageAtlas;
|
|
820
|
+
if (atlas) {
|
|
821
|
+
let dirty = false;
|
|
822
|
+
let conversionFailed = false;
|
|
823
|
+
if (atlas.upserts) {
|
|
824
|
+
for (const { slot, bitmap } of atlas.upserts) {
|
|
825
|
+
if (!Number.isInteger(slot) || slot < 0) continue;
|
|
826
|
+
const data = this.canvasImageData(bitmap.width, bitmap.height, bitmap);
|
|
827
|
+
if (data === null) {
|
|
828
|
+
conversionFailed = true;
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
while (this.imageSlots.length <= slot) this.imageSlots.push(null);
|
|
832
|
+
this.imageSlots[slot] = data;
|
|
833
|
+
dirty = true;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
if (atlas.removeSlots) {
|
|
837
|
+
for (const slot of atlas.removeSlots) {
|
|
838
|
+
if (slot >= 0 && slot < this.imageSlots.length && this.imageSlots[slot] != null) {
|
|
839
|
+
this.imageSlots[slot] = null;
|
|
840
|
+
dirty = true;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
if (conversionFailed) this.reportImageChannelUnavailable();
|
|
845
|
+
if (dirty) {
|
|
846
|
+
const blank = this.blankImage ?? (this.blankImage = this.canvasImageData(1, 1));
|
|
847
|
+
if (blank === null) {
|
|
848
|
+
this.reportImageChannelUnavailable();
|
|
849
|
+
} else {
|
|
850
|
+
graph.setImageData(Array.from(this.imageSlots, (d) => d ?? blank));
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
if (resources.pointImageIndex && !this.imageChannelUnavailable) {
|
|
855
|
+
graph.setPointImageIndices(resources.pointImageIndex);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Reads an ImageData of the given size off an offscreen 2D canvas, drawing
|
|
860
|
+
* `bitmap` onto it first when provided (ImageBitmap → ImageData transcode;
|
|
861
|
+
* without a bitmap: a transparent blank). Returns null — never throws —
|
|
862
|
+
* where no 2D context exists (jsdom without the canvas package).
|
|
863
|
+
*/
|
|
864
|
+
canvasImageData(width, height, bitmap) {
|
|
865
|
+
const doc = this.innerDiv?.ownerDocument;
|
|
866
|
+
if (!doc || !(width > 0) || !(height > 0)) return null;
|
|
867
|
+
try {
|
|
868
|
+
const canvas = doc.createElement("canvas");
|
|
869
|
+
canvas.width = width;
|
|
870
|
+
canvas.height = height;
|
|
871
|
+
const ctx = canvas.getContext("2d");
|
|
872
|
+
if (!ctx) return null;
|
|
873
|
+
if (bitmap) ctx.drawImage(bitmap, 0, 0);
|
|
874
|
+
return ctx.getImageData(0, 0, width, height);
|
|
875
|
+
} catch {
|
|
876
|
+
return null;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
/** Documented degradation, reported at most once per engine instance. */
|
|
880
|
+
reportImageChannelUnavailable() {
|
|
881
|
+
if (this.imageChannelUnavailable) return;
|
|
882
|
+
this.imageChannelUnavailable = true;
|
|
883
|
+
this.events?.onDiagnostic?.({
|
|
884
|
+
code: "engine:image-channel-unavailable",
|
|
885
|
+
severity: "warning",
|
|
886
|
+
message: "CosmosEngine: no 2D canvas context is available to convert ImageBitmap atlas entries to ImageData; point images are disabled in this environment."
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Replaces NaN pairs (= "no known position", §7.3) with random points on a
|
|
891
|
+
* ring of radius seedRadius around the space center. cosmos treats NaN
|
|
892
|
+
* positions as *absent* points, so they must never reach setPointPositions.
|
|
893
|
+
* Known positions pass through verbatim (same array when nothing to seed).
|
|
894
|
+
*/
|
|
895
|
+
withSeededPositions(positions) {
|
|
896
|
+
let needsSeeding = false;
|
|
897
|
+
for (let i = 0; i < positions.length; i += 2) {
|
|
898
|
+
if (Number.isNaN(positions[i]) || Number.isNaN(positions[i + 1])) {
|
|
899
|
+
needsSeeding = true;
|
|
900
|
+
break;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
if (!needsSeeding) return positions;
|
|
904
|
+
const out = Float32Array.from(positions);
|
|
905
|
+
const center = this.spaceSize / 2;
|
|
906
|
+
const radius = this.seedRadius;
|
|
907
|
+
for (let i = 0; i < out.length; i += 2) {
|
|
908
|
+
if (Number.isNaN(out[i]) || Number.isNaN(out[i + 1])) {
|
|
909
|
+
const angle = Math.random() * Math.PI * 2;
|
|
910
|
+
out[i] = center + radius * Math.cos(angle);
|
|
911
|
+
out[i + 1] = center + radius * Math.sin(angle);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return out;
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
function mergeResources(previous, update) {
|
|
918
|
+
const upsertBySlot = /* @__PURE__ */ new Map();
|
|
919
|
+
const removes = /* @__PURE__ */ new Set();
|
|
920
|
+
for (const atlas of [previous?.imageAtlas, update?.imageAtlas]) {
|
|
921
|
+
if (!atlas) continue;
|
|
922
|
+
for (const { slot, bitmap } of atlas.upserts ?? []) {
|
|
923
|
+
upsertBySlot.set(slot, bitmap);
|
|
924
|
+
removes.delete(slot);
|
|
925
|
+
}
|
|
926
|
+
for (const slot of atlas.removeSlots ?? []) {
|
|
927
|
+
upsertBySlot.delete(slot);
|
|
928
|
+
removes.add(slot);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
const merged = {};
|
|
932
|
+
if (upsertBySlot.size > 0 || removes.size > 0) {
|
|
933
|
+
const imageAtlas = {};
|
|
934
|
+
if (upsertBySlot.size > 0) {
|
|
935
|
+
imageAtlas.upserts = Array.from(upsertBySlot, ([slot, bitmap]) => ({ slot, bitmap }));
|
|
936
|
+
}
|
|
937
|
+
if (removes.size > 0) imageAtlas.removeSlots = Array.from(removes);
|
|
938
|
+
merged.imageAtlas = imageAtlas;
|
|
939
|
+
}
|
|
940
|
+
const pointImageIndex = update?.pointImageIndex ?? previous?.pointImageIndex;
|
|
941
|
+
if (pointImageIndex !== void 0) merged.pointImageIndex = pointImageIndex;
|
|
942
|
+
return merged;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
export { CosmosEngine };
|
|
946
|
+
//# sourceMappingURL=index.js.map
|
|
947
|
+
//# sourceMappingURL=index.js.map
|