@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ModernRelay
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,122 @@
1
+ # @modernrelay/orbit-engine-cosmos
2
+
3
+ Cosmos engine adapter for Orbit.
4
+
5
+ ## Tested engine range
6
+
7
+ This adapter is developed and probed against exactly **`@cosmos.gl/graph@3.3.0`**
8
+ (exact pin, no range). Any pin bump requires re-running the M0 probe suite and
9
+ regenerating the conformance matrix before the new version is considered
10
+ supported.
11
+
12
+ ## Capability profile
13
+
14
+ Node-verifiable facts (measured by `pnpm probe:node`, re-checked in CI):
15
+
16
+ | Capability | Value | Basis |
17
+ | --- | --- | --- |
18
+ | `rangeUpdates` | `[]` | Every buffer setter in `dist/index.d.ts` (`setPointPositions`, `setPointColors`, `setLinks`, …) takes a full array; no offset/count variants exist. Whole-buffer re-upload is the only update path. |
19
+ | `postDrawFrames` | `false` | The `config.d.ts` callback inventory has no draw/render-phase hook — only simulation (`onSimulationTick/Start/End/Pause/Unpause`), transition, zoom, drag and pointer callbacks. Post-draw work must be scheduled externally. |
20
+ | `node-import-safe` | `true` | `await import('@cosmos.gl/graph')` succeeds in plain Node (module scope guards `typeof window`), so SSR and test imports never crash. |
21
+
22
+ GPU-probed capabilities (measured 2026-07-21 on Apple M5 Pro, ANGLE Metal, headful
23
+ Chromium; evidence in `docs/evidence/m0/`):
24
+
25
+ | Capability | Result | Finding |
26
+ | --- | --- | --- |
27
+ | `atomic-commit` | ✅ pass | Multi-channel commit (positions+colors+links) applied mid-simulation shows zero mixed frames — a monotonic A→B switch one frame after commit. Visibly atomic commits are real. |
28
+ | `point-picking` | ✅ pass | `onPointClick` delivers the exact index at `spaceToScreenPosition` coords; misses report `undefined`. |
29
+ | `link-picking` | ✅ pass | Native `onLinkClick`/`onLinkMouseOver` deliver correct link indices; ~4 px perpendicular tolerance on a 2 px link. **Flipped in S6:** the adapter now reports `capabilities.linkPicking: true` and wires `onLinkClick`/`onLinkMouseOver`/`onLinkMouseOut` to the host events, backed by this record ([full matrix](../../docs/m0-conformance.md)). |
30
+ | `nan-tombstones` | ✅ pass | NaN'd points escape hit-testing, incident links vanish, `fitView` ignores tombstoned slots. |
31
+ | `native-pinning` | ✅ pass | `setPinnedPoints` excludes points from force integration; `[]`/repeat calls are idempotent. |
32
+ | `tracked-positions` | ⚠️ fail (budget) | Semantics correct (packing order, last-call-replaces), but each `getTrackedPointPositionsArray` read stalls ~18 ms p50 **regardless of k** (256→16384) — a sync pipeline flush, not payload cost. Per-frame tracked readback is not viable on 3.3.0; DOM-label positioning must read at settle/idle cadence. |
33
+ | `quiescence` | ⚠️ fail | The render loop free-runs at display refresh whenever data is present (`stopFrames` fires only on empty scene/destroy) — 91 frames in a 750 ms idle window. Orbit must impose stop-at-rest externally (plan S13-T06). |
34
+ | `context-loss` | ⚠️ fail (by design) | `lost`/`restored` events surface, but no combination of buffer/config replay revives rendering after `restoreContext()` — only a full `Graph` destroy + re-create recovers (match 1.0). This validates the adapter's recreate-on-restore protocol: `CosmosEngine` rebuilds the Graph in the same container and the core replays the scene. |
35
+
36
+ The full, generated matrix (including deferred areas: `mobile`, `resize`,
37
+ `resource-estimates`) lives at [docs/m0-conformance.md](../../docs/m0-conformance.md).
38
+
39
+ ## Interaction surface (S6)
40
+
41
+ Coordinate-space facts verified against the 3.3.0 dist (see the
42
+ `CosmosEngine.ts` module header for the exact evidence):
43
+
44
+ - `pointsInPolygon(screenPolygon)` — cosmos' `findPointsInPolygon` takes
45
+ **screen** coordinates (0 to canvas width/height per its d.ts), so the
46
+ host's screen polygon passes through without conversion.
47
+ - `onDragEnd(index, x, y)` reports **space** coordinates: cosmos' drag shader
48
+ pins the dragged point to the mouse's space position every frame, so
49
+ `screenToSpacePosition([event.x, event.y])` at drag end is the point's exact
50
+ final position (O(1), no GPU readback).
51
+ - Native dragging is on by default (`CosmosEngineOptions.enableDrag`
52
+ overrides); setting any `onLink*` callback is what enables cosmos' native
53
+ link hit-testing.
54
+ - `setPinnedIndices` maps to `setPinnedPoints` (full-set replace; `null`/`[]`
55
+ unpin all). Re-applying pins after context-loss recovery is core-owned.
56
+
57
+ ## Overlay & toolbar surface (S7)
58
+
59
+ | Capability | Behavior | Basis / degradation |
60
+ | --- | --- | --- |
61
+ | `onFrame` activity clock | The adapter owns a single `requestAnimationFrame` loop: started at mount, stopped on context loss and `destroy()`, resumed after recovery. Each tick is one `onFrame(timeMs)` call (the core skips all work when nothing subscribes); the callback is skipped while `document.hidden` (the clock keeps ticking). | **Documented degradation:** cosmos 3.3.0 exposes no draw/render-phase hook (`postDrawFrames: false`), so this is an *activity* clock, not a post-draw hook — DOM overlays (labels, tooltips) may lag the canvas by one frame. Reported once at mount via the `engine:overlay-activity-clock` info diagnostic. Stop-at-rest quiescence is S13 scope. |
62
+ | `onContextMenu` | cosmos' unified `onContextMenu(index \| undefined, pos, event)` config callback → host `onContextMenu(index \| null, [containerX, containerY])` (container-relative CSS px from the event's `clientX/Y`). Covers desktop right-click and touch long-press (cosmos synthesizes the latter). The native event is `preventDefault`-ed only when the host registered `onContextMenu` (Orbit's core always does — it owns the typed `contextMenu` event). | Only the unified callback is wired: the per-target `onPointContextMenu`/`onLinkContextMenu`/`onBackgroundContextMenu` callbacks fire *additionally* for the same gesture and would double-report. A context menu over a link arrives with `index === undefined` and is reported as background. |
63
+ | `pointsInRect(screenRect)` | `[x0, y0, x1, y1]` → cosmos `findPointsInRect([[left, top], [right, bottom]])` (corners normalized to min/max so any opposite-corner pair works). Lifecycle-guarded: `[]` pre-mount / while lost / after destroy. | Same **screen** coordinate space as `findPointsInPolygon` (0 to canvas width/height per the 3.3.0 d.ts; the dist flips Y assuming ordered corners) — no conversion. |
64
+ | `captureScreenshot()` | Resolves a PNG `Blob` of the current frame, or `null` when unsupported, not ready, lost, or on any capture failure. | The M0 same-tick capture method (`apps/spike/src/instrument.ts`): cosmos renders with a non-preserved drawing buffer, so the WebGL canvas is only readable via a synchronous `drawImage` onto an offscreen 2D canvas inside the same rAF tick — the adapter schedules one rAF and captures inside it. |
65
+
66
+ ## Styling channels (S10)
67
+
68
+ Facts verified against the 3.3.0 dist typings (`config.d.ts` / `index.d.ts`):
69
+
70
+ | Capability | Value | Mechanism / basis |
71
+ | --- | --- | --- |
72
+ | `edgeArrows` | `true` | Commit `config.linkArrows` maps to cosmos' `linkDefaultArrows` config key (config.d.ts, default `false`) via `setConfigPartial` — instanced arrowheads toggle atomically within the commit's single `render()`. No per-link `setLinkArrows(boolean[])` buffer is used (Orbit's toggle is scene-wide). |
73
+ | `pointImages` | `true` | cosmos 3.3.0 exposes `setImageData(ImageData[])` + `setPointImageIndices(Float32Array)` (index.d.ts). The adapter maintains a slot→`ImageData` mirror of the atlas: commit upserts transcode each `ImageBitmap` through an offscreen 2D canvas (`drawImage` + `getImageData`), `removeSlots` blanks entries with a 1×1 transparent `ImageData` (slot indices stay stable), and any atlas change re-uploads the FULL array (cosmos has no partial image update — consistent with `rangeUpdates: []`). The mirror is CPU-side, so it survives context loss; post-restore atlas commits re-upload everything to the fresh graph. **Degradation:** where no 2D context exists (jsdom without the canvas package), the image channel no-ops — never throws — and reports the `engine:image-channel-unavailable` warning diagnostic exactly once. |
74
+ | `renderLinks` toggle | config-only | cosmos 3.3.0 has a first-class `renderLinks: boolean` config key (config.d.ts, default `true`), so commit `config.renderLinks` is a pure `setConfigPartial` toggle — **zero buffer setters**, no `linkOpacity` workaround, no link-buffer rebuild. |
75
+ | Theme default colors | config-only | Commit `config.defaultPointColor`/`config.defaultLinkColor` map to cosmos' `pointDefaultColor`/`linkDefaultColor` config keys (CSS color strings accepted natively). |
76
+
77
+ While pre-mount, context-lost, or terminally failed, resources fold into the
78
+ pending-commit per-channel merge like every other channel: image upserts union
79
+ per slot (latest bitmap wins; a later remove drops an earlier pending upsert of
80
+ the same slot), and the latest `pointImageIndex` replaces wholesale.
81
+
82
+ ## Cluster force (S12, §16.3 stage 4)
83
+
84
+ | Capability | Value | Mechanism / basis |
85
+ | --- | --- | --- |
86
+ | `clusterForce` | `true` | **Dist investigation (3.3.0, the exact pin):** `dist/index.d.ts` exposes `setPointClusters((number \| undefined)[])`, `setClusterPositions((number \| undefined)[])`, `setPointClusterStrength(Float32Array)` and `getClusterPositions()`; `dist/config.d.ts` exposes the `simulationCluster` coefficient (default `0.1`); `dist/modules/Clusters/index.d.ts` is the GPU module implementing it (centermass FBO + force application pass). The capability is therefore declared honestly, not faked. **Evidence class: Node-verifiable (dist typing scan), like `rangeUpdates`/`postDrawFrames` — the API surface is present and wired, but the VISIBLE force behavior is not yet GPU-probed; a headful probe record is the S13 conformance follow-up (see the GPU evidence policy below).** |
87
+
88
+ Contract mapping applied inside the single atomic commit (staged after the
89
+ roster, before the one `render()`):
90
+
91
+ - `config.cluster.pointClusters` (`Float32Array`, **NaN = unclustered**) →
92
+ `setPointClusters`, converting NaN to cosmos' documented `undefined`
93
+ ("does not belong to any cluster and will not be affected by cluster
94
+ forces").
95
+ - `config.cluster.centers` (`[x0,y0,x1,y1,…]`) → `setClusterPositions`; a
96
+ non-finite entry becomes `undefined`, which cosmos documents as "position
97
+ not defined → use centermass positioning instead".
98
+ - `config.cluster.strength` → the **scene-wide** `simulationCluster` config
99
+ key via `setConfigPartial`, not the per-point `setPointClusterStrength`
100
+ buffer (Orbit's strength is scene-wide by contract; the per-point buffer is
101
+ available for a future per-node strength channel).
102
+ - `config.cluster: null` (the D2 explicit clear) → an all-`undefined`
103
+ membership array of the **current roster length** plus empty cluster
104
+ positions, so the mapping length never lags the roster (I2).
105
+
106
+ ## Camera surface (S11)
107
+
108
+ | Capability | Behavior | Basis / formula |
109
+ | --- | --- | --- |
110
+ | `setViewport({x, y, zoom})` — **real pan** | Centers space point `(x, y)` at exactly the requested (or current) zoom in one call; a follow-up `getViewport()` returns the same `{x, y, zoom}` (modulo cosmos' d3 `scaleExtent` clamp). A missing `x` or `y` is filled from the current viewport; zoom-only calls keep the `setZoomLevel` path (d3 `scaleTo` preserves the center). Instant unless `durationMs` is given. | **The former zoom-only limitation is LIFTED.** cosmos 3.3 has no pan-to API, but `setZoomTransformByPointPositions(positions, duration, scale, padding)` is an exact center+zoom when `scale` is explicit — verified in the 3.3.0 dist (`zoomInstance.getTransform`): `store.scaleX/scaleY` are linear slope-±1 space→screen maps, a single-point bbox is widened ±0.5 *symmetrically* (center preserved), and an explicit `scale` bypasses the fit math and `padding` entirely, yielding `translate(w/2 − scaleX(x)·k, h/2 − scaleY(y)·k).scale(k)` with `k = scale`. The adapter calls `setZoomTransformByPointPositions(Float32Array.of(x, y), durationMs ?? 0, zoom ?? getZoomLevel())`. |
111
+ | §13.1 viewport restore | Context-loss recovery now restores the full camera: the core replays the last stored `{x, y, zoom}` through `setViewport` after the scene commit, and the pan lands (previously only `zoom` was honored). | Supersedes the "viewport restore is zoom-only" caveat recorded in ADR-004 / spec §13.1 for cosmos 3.3. |
112
+
113
+ ## GPU evidence policy
114
+
115
+ GPU probe records are produced locally by running the probe suite headful on a
116
+ real GPU (`pnpm probe`, which sets `PROBE_HEADFUL=1`; set `PROBE_GPU` to
117
+ describe the hardware). CI never produces GPU evidence: it re-runs only the
118
+ Node-safe probes (`pnpm probe:node`) and validates the committed matrix
119
+ (`pnpm conformance:check`). Results rendered through SwiftShader or any other
120
+ software rasterizer are not accepted as conformance evidence — headless runs
121
+ are permitted solely as a harness smoke test and their records must not be
122
+ committed.
@@ -0,0 +1,322 @@
1
+ import { GraphEngine, EngineCapabilities, EngineHostEvents, EngineCommit, FitViewOptions } from '@modernrelay/orbit-core/engine';
2
+
3
+ /**
4
+ * CosmosEngine — GraphEngine adapter over @cosmos.gl/graph (spec §13).
5
+ *
6
+ * Node-safe module: cosmos is loaded lazily via `await import()` inside
7
+ * `mount()` (§18); module scope carries only type-only imports (erased at
8
+ * runtime) and never touches the DOM.
9
+ *
10
+ * Color scale note: the EngineCommit contract carries RGBA floats in [0,1],
11
+ * which is exactly cosmos' native scale (verified against the 3.3.0 dist:
12
+ * hex config colors are parsed via /255 into 0–1 floats and setPointColors
13
+ * input is fed to GPU buffers untouched) — so color buffers pass through.
14
+ *
15
+ * Interaction notes (verified against the 3.3.0 dist):
16
+ * - Link picking is native: setting any onLink* config callback flips
17
+ * cosmos' internal `isLinkHoveringEnabled` on, so wiring the callbacks in
18
+ * `buildInitialConfig` is sufficient (no extra config flag exists).
19
+ * - `findPointsInPolygon` takes SCREEN coordinates ("from 0 to the
20
+ * width/height of the canvas" per dist/index.d.ts), so `pointsInPolygon`
21
+ * passes the host's screen polygon through without conversion.
22
+ * - `findPointsInRect` uses the SAME screen space and expects ordered
23
+ * corners `[[left, top], [right, bottom]]` (the dist flips Y assuming that
24
+ * ordering), so `pointsInRect` normalizes the host's [x0,y0,x1,y1] rect to
25
+ * min/max corners and passes it through without conversion.
26
+ * - Camera pan (S11 — the zoom-only limitation is LIFTED): cosmos 3.3 has no
27
+ * pan-to API, but `setZoomTransformByPointPositions(positions, duration,
28
+ * scale, padding)` (dist/index.d.ts) is an exact center+zoom when `scale`
29
+ * is explicit. Derivation from the dist (`zoomInstance.getTransform`):
30
+ * space→screen-at-k=1 goes through `store.scaleX/scaleY`, which are LINEAR
31
+ * with slope ±1 (scaleX(x) = x + (w−S)/2; scaleY(y) = (S−y) + (h−S)/2,
32
+ * S = adjustedSpaceSize, w/h = canvas size); a single-point bbox is widened
33
+ * ±0.5 SYMMETRICALLY (center preserved); an explicit `scale` bypasses the
34
+ * fit math and `padding` entirely — k = clamp(scale, d3 scaleExtent) and
35
+ * the result is translate(w/2 − scaleX(x)·k, h/2 − scaleY(y)·k).scale(k),
36
+ * i.e. space point (x, y) lands exactly at the screen center with
37
+ * eventTransform.k = scale. Since `getZoomLevel()` returns eventTransform.k
38
+ * and `getViewport()` reads screenToSpacePosition(screen center),
39
+ * `setViewport({x, y, zoom})` → setZoomTransformByPointPositions(
40
+ * Float32Array.of(x, y), durationMs ?? 0, zoom ?? getZoomLevel()) makes a
41
+ * follow-up `getViewport()` return exactly {x, y, zoom} (modulo the
42
+ * scaleExtent clamp). This also completes §13.1 recovery: the core replays
43
+ * the full stored {x, y, zoom} through setViewport, so viewport restore is
44
+ * no longer zoom-only.
45
+ * - Context menu: cosmos fires the unified config `onContextMenu(index |
46
+ * undefined, pos, event)` exactly ONCE per gesture — desktop right-click
47
+ * (dist `onContextMenu(e)`) and touch/pen long-press (dist long-press timer
48
+ * → `fireContextMenu`) both route through it — while the per-target
49
+ * `onPointContextMenu`/`onLinkContextMenu`/`onBackgroundContextMenu`
50
+ * callbacks fire ADDITIONALLY for the same gesture. Wiring only the unified
51
+ * callback therefore avoids a double-report (same shape as the
52
+ * onClick/onBackgroundClick dedupe). A context menu over a LINK arrives
53
+ * with index undefined and is reported as background (the host event
54
+ * carries node-or-background only).
55
+ *
56
+ * Overlay/activity clock (M0 evidence, docs/m0-conformance.md): cosmos exposes
57
+ * no draw/render-phase hook (`postDrawFrames: false`), so the adapter owns a
58
+ * single requestAnimationFrame loop that reports `onFrame(timeMs)` to the host
59
+ * — an ACTIVITY clock, not a post-draw hook: overlays may lag the canvas by
60
+ * one sample. That degradation is reported once at mount via the
61
+ * `engine:overlay-activity-clock` info diagnostic. The loop is started at
62
+ * mount, stopped on context loss/destroy, resumed on recovery, and skips the
63
+ * callback while `document.hidden` (stop-at-rest quiescence is S13 scope).
64
+ *
65
+ * Screenshots use the M0 same-tick capture method (apps/spike/src/instrument.ts):
66
+ * cosmos renders with preserveDrawingBuffer:false, so the WebGL buffer is only
67
+ * readable via a synchronous drawImage inside the same rAF tick it was drawn —
68
+ * `captureScreenshot` schedules one rAF, draws the cosmos canvas onto an
69
+ * offscreen 2D canvas inside that tick, and resolves the Blob (null on any
70
+ * failure or unusable lifecycle state).
71
+ * - The D3 drag events carry NO point index (the drag subject is a bare
72
+ * `{x, y}`); cosmos assigns `store.draggingPointIndex` immediately before
73
+ * invoking `onDragStart`, so the adapter reads it there (see
74
+ * `readDraggingIndex` for the public-callback fallback).
75
+ * - During a drag, cosmos' drag shader hard-pins the dragged point's
76
+ * position texture entry to the mouse's SPACE position every frame
77
+ * (dist `drag()`: `pointPosition.rg = mousePos` where `mousePos` =
78
+ * screenToSpace(pointer)). So the point's final space position equals
79
+ * `screenToSpacePosition([event.x, event.y])` at drag end — an O(1) CPU
80
+ * transform, chosen over `getPointPositions()` (full GPU readback).
81
+ */
82
+
83
+ /** Derived from the contract to avoid importing the core root barrel. */
84
+ type ViewportState = NonNullable<ReturnType<GraphEngine['getViewport']>>;
85
+ interface CosmosEngineOptions {
86
+ /** Simulation space size passed to cosmos (cosmos default: 4096). */
87
+ spaceSize?: number;
88
+ /** Ring radius for seeding unknown (NaN) positions. Default: spaceSize/4. */
89
+ seedRadius?: number;
90
+ /** Whether cosmos auto-fits the view on init. Default: false (the core drives the camera). */
91
+ fitViewOnInit?: boolean;
92
+ /** Native point dragging (cosmos `enableDrag`). Default: true. */
93
+ enableDrag?: boolean;
94
+ /** Escape hatch: shallow-merged last into the cosmos constructor config. */
95
+ initialConfig?: Record<string, unknown>;
96
+ /**
97
+ * Visible-time budget (ms) for the browser to restore a lost WebGL context
98
+ * before an `engine:context-restore-deadline` diagnostic is emitted. The
99
+ * deadline is observability only — a later restore still recovers.
100
+ * Default: 10_000.
101
+ */
102
+ restoreDeadlineMs?: number;
103
+ }
104
+ declare class CosmosEngine implements GraphEngine {
105
+ readonly capabilities: EngineCapabilities;
106
+ private readonly options;
107
+ private graph;
108
+ private innerDiv;
109
+ private events;
110
+ /**
111
+ * Pre-mount/lost-context commits collapse per channel and are applied as one
112
+ * atomic commit once a usable graph exists.
113
+ */
114
+ private pendingCommit;
115
+ private applied;
116
+ private destroyed;
117
+ private mounting;
118
+ /** Constructed graph whose `ready` promise has not settled yet. */
119
+ private graphAwaitingReady;
120
+ /** Guards every graph teardown, including async init/recovery races. */
121
+ private readonly destroyedGraphs;
122
+ /** Sticky override from EngineConfigUpdate.seedRadius. */
123
+ private seedRadiusOverride;
124
+ /** Point count of the last structure-bearing commit — the roster length a
125
+ * `cluster: null` clear must write an all-unclustered array for (§16.3). */
126
+ private lastPointCount;
127
+ /**
128
+ * cosmos fires `onClick` (index undefined) AND `onBackgroundClick` for the
129
+ * same background click; remembering the MouseEvent dedupes the null emit.
130
+ */
131
+ private lastNullClickEvent;
132
+ /** Point latched at cosmos onDragStart; cleared when the gesture ends. */
133
+ private dragIndex;
134
+ /** Last hover reported by cosmos — fallback dragged-point source. */
135
+ private lastHoverIndex;
136
+ /** rAF id of the pending activity-clock tick; null = clock not running. */
137
+ private frameHandle;
138
+ /** Window driving the clock, cached so stop works after the div detaches. */
139
+ private frameWindow;
140
+ /** cosmos' canvas (queried post-mount) carrying the webglcontext* listeners. */
141
+ private canvas;
142
+ private contextLost;
143
+ /** Terminal: GL reinitialization failed; commits stash inertly forever. */
144
+ private failed;
145
+ private deadline;
146
+ /** Graph constructor cached at mount so recovery never re-imports cosmos. */
147
+ private cosmosCtor;
148
+ /**
149
+ * slot → ImageData mirror of the cosmos image atlas. ImageData is CPU-side,
150
+ * so the mirror survives context loss — any post-restore atlas commit
151
+ * re-uploads the FULL array to the fresh graph. `null` = removed (blank).
152
+ */
153
+ private imageSlots;
154
+ /** Lazily created 1×1 transparent entry filling removed/hole slots. */
155
+ private blankImage;
156
+ /** One-shot guard for the `engine:image-channel-unavailable` diagnostic. */
157
+ private imageChannelUnavailable;
158
+ constructor(options?: CosmosEngineOptions);
159
+ mount(container: HTMLElement, events: EngineHostEvents): Promise<void>;
160
+ commit(update: EngineCommit): void;
161
+ appliedRevision(): number | null;
162
+ fitView(opts?: FitViewOptions): void;
163
+ zoom(factor: number, durationMs?: number): void;
164
+ /**
165
+ * REAL pan (the former zoom-only limitation is lifted — see module header):
166
+ * when a target center is provided, one `setZoomTransformByPointPositions`
167
+ * call centers space point (x, y) at EXACTLY the requested (or current)
168
+ * zoom — `setViewport(p)` then `getViewport()` returns p, modulo cosmos'
169
+ * d3 scaleExtent clamp. A missing x or y is filled from the current
170
+ * viewport; zoom-only calls keep the `setZoomLevel` path (d3's scaleTo
171
+ * preserves the current center). Instant unless `durationMs` is given —
172
+ * cosmos' own default duration is 250 ms, which would animate context-
173
+ * recovery replays.
174
+ */
175
+ setViewport(v: Partial<ViewportState>, opts?: {
176
+ durationMs?: number;
177
+ }): void;
178
+ getViewport(): ViewportState | null;
179
+ zoomToIndex(index: number, durationMs?: number): void;
180
+ start(alpha?: number): void;
181
+ pause(): void;
182
+ setSelectedIndices(indices: readonly number[] | null): void;
183
+ setFocusedIndex(index: number | null): void;
184
+ pointsInPolygon(screenPolygon: readonly [number, number][]): number[];
185
+ pointsInRect(screenRect: readonly [number, number, number, number]): number[];
186
+ neighborIndices(index: number): number[];
187
+ screenToSpace(p: readonly [number, number]): [number, number] | null;
188
+ spaceToScreen(p: readonly [number, number]): [number, number] | null;
189
+ setPinnedIndices(indices: readonly number[] | null): void;
190
+ getPositions(): Float32Array | null;
191
+ /**
192
+ * Captures the cosmos canvas via the M0 same-tick method (see module
193
+ * header): one rAF is scheduled and, inside that tick, the WebGL canvas is
194
+ * drawn synchronously onto an offscreen 2D canvas (cosmos renders with
195
+ * preserveDrawingBuffer:false, so the buffer is only readable same-tick).
196
+ * Resolves null on any failure or unusable lifecycle state (pre-mount,
197
+ * context lost/failed, destroyed, no 2D context, toBlob failure).
198
+ */
199
+ captureScreenshot(): Promise<Blob | null>;
200
+ destroy(): void;
201
+ /**
202
+ * One rAF tick of the activity clock. Reschedules FIRST so a throwing host
203
+ * callback can never kill the clock; skips the callback (but keeps ticking)
204
+ * while the document is hidden. Deliberately cheap: a single callback
205
+ * invocation — the core skips all work when nothing subscribes.
206
+ */
207
+ private readonly frameTick;
208
+ private startFrameLoop;
209
+ private stopFrameLoop;
210
+ /** The graph, unless it is unusable (context lost / terminally failed). */
211
+ private get activeGraph();
212
+ /**
213
+ * cosmos owns its canvas; we can only wire webglcontext* listeners after
214
+ * init by querying it. A missing canvas downgrades to an info diagnostic —
215
+ * the engine keeps working, just without context-loss recovery.
216
+ */
217
+ private attachContextListeners;
218
+ private detachContextListeners;
219
+ /** DOM listener: nothing may throw out of it. */
220
+ private readonly handleContextLost;
221
+ /** DOM listener: nothing may throw out of it (async work is caught below). */
222
+ private readonly handleContextRestored;
223
+ /**
224
+ * cosmos cannot reuse a restored context (its GPU resources are gone), so
225
+ * recovery = tear down the old Graph and build a fresh one in the same div,
226
+ * flush the stashed commit, and only then report `restored` — the core
227
+ * re-commits the full scene in response.
228
+ */
229
+ private reinitializeAfterRestore;
230
+ private startRestoreDeadline;
231
+ /**
232
+ * Coalesces partial commits without discarding independent channels. The
233
+ * newest call supplies the visible revision; structure is one atomic
234
+ * channel, buffers/config merge per field, and restart persists until a
235
+ * later explicit directive (including `false`) replaces it.
236
+ */
237
+ private queueCommit;
238
+ /** Invokes a graph's destructor at most once, even across async races. */
239
+ private destroyGraphOnce;
240
+ private get spaceSize();
241
+ private get seedRadius();
242
+ private buildInitialConfig;
243
+ /**
244
+ * Maps cosmos' unified context-menu callback to the host event: index
245
+ * undefined → null (background), MouseEvent client coords → container-
246
+ * relative CSS px (the inner div fills the host container exactly). The
247
+ * native event is preventDefault-ed so the browser menu never opens — but
248
+ * only when the host actually registered onContextMenu (it always does in
249
+ * Orbit: the core owns the typed 'contextMenu' event channel). cosmos'
250
+ * desktop `contextmenu` handler already prevents the event itself; repeating
251
+ * it is an idempotent no-op that also covers the touch long-press path
252
+ * (where cosmos forwards the originating pointerdown event instead).
253
+ */
254
+ private handleContextMenu;
255
+ /** Emits onPointClick(null) once per originating click event. */
256
+ private emitNullPointClick;
257
+ /**
258
+ * The D3 drag event carries no point index; cosmos assigns
259
+ * `store.draggingPointIndex` right before invoking onDragStart (see module
260
+ * header). The public onPointMouseOver stream is the fallback — cosmos only
261
+ * starts a drag while a point is hovered.
262
+ */
263
+ private readDraggingIndex;
264
+ private handleDragStart;
265
+ /**
266
+ * Reports the dragged point's final SPACE position. cosmos' drag shader
267
+ * pins the point to the mouse's space position every frame, so converting
268
+ * the event's screen coords is exact and O(1) (see module header).
269
+ */
270
+ private handleDragEnd;
271
+ private emitViewportChange;
272
+ /**
273
+ * One visibly atomic update (§13): all channels and config are staged, then
274
+ * exactly one render() draws them; restart reheats after the render.
275
+ */
276
+ private applyCommit;
277
+ /**
278
+ * Applies the §16.3 stage-4 cluster force (capability `clusterForce`).
279
+ *
280
+ * Contract mapping, verified against the 3.3.0 dist (`index.d.ts`):
281
+ * - `pointClusters` (Float32Array, NaN = unclustered) →
282
+ * `setPointClusters((number | undefined)[])`, where cosmos' documented
283
+ * "does not belong to any cluster" value is `undefined`;
284
+ * - `centers` (Float32Array, `[x0,y0,x1,y1,…]`) →
285
+ * `setClusterPositions((number | undefined)[])`; a non-finite entry means
286
+ * "no position" and cosmos falls back to that cluster's centermass;
287
+ * - `null` clears: an all-`undefined` array of the CURRENT roster length
288
+ * (length must track the roster) plus empty cluster positions.
289
+ * `strength` is the scene-wide `simulationCluster` config coefficient
290
+ * applied in the config block, not the per-point
291
+ * `setPointClusterStrength` buffer (Orbit's strength is scene-wide).
292
+ */
293
+ private applyClusterForce;
294
+ /**
295
+ * Applies the §8 image-atlas channel: upserts convert ImageBitmap →
296
+ * ImageData through an offscreen 2D canvas into the slot mirror, removals
297
+ * blank their slot, and any atlas change re-uploads the FULL ImageData
298
+ * array (cosmos' setImageData is whole-array only, matching
299
+ * `rangeUpdates: []`). Environments without a usable 2D context (jsdom)
300
+ * no-op the channel and report `engine:image-channel-unavailable` once —
301
+ * never throw.
302
+ */
303
+ private applyResources;
304
+ /**
305
+ * Reads an ImageData of the given size off an offscreen 2D canvas, drawing
306
+ * `bitmap` onto it first when provided (ImageBitmap → ImageData transcode;
307
+ * without a bitmap: a transparent blank). Returns null — never throws —
308
+ * where no 2D context exists (jsdom without the canvas package).
309
+ */
310
+ private canvasImageData;
311
+ /** Documented degradation, reported at most once per engine instance. */
312
+ private reportImageChannelUnavailable;
313
+ /**
314
+ * Replaces NaN pairs (= "no known position", §7.3) with random points on a
315
+ * ring of radius seedRadius around the space center. cosmos treats NaN
316
+ * positions as *absent* points, so they must never reach setPointPositions.
317
+ * Known positions pass through verbatim (same array when nothing to seed).
318
+ */
319
+ private withSeededPositions;
320
+ }
321
+
322
+ export { CosmosEngine, type CosmosEngineOptions };