State (DataState) and events#
This page is the source-of-truth developer guide for how Cellucid models app state.
If you are changing anything that affects:
fields (color-by, legends, deleted/renamed/user-defined),
filters/visibility/outliers,
highlights or highlight pages,
multiview snapshots,
the embedding each view shows (and so its dimension, 1D/2D/3D),
vector-field overlays,
…you should read this page before implementing changes.
At a glance#
Audience
Computational users: skim “Events” and “How to debug state”.
Developers: read fully (this is where most subtle bugs come from).
Time
30–60 minutes (longer if you follow code pointers)
Prerequisites
Familiarity with the architecture overview: App architecture overview
DataState in one sentence#
DataState is the state coordinator that owns core typed arrays and exposes the public state API by mixing in method surfaces from focused manager modules.
Code:
cellucid/assets/js/app/state/core/data-state.js
What DataState owns (core data + invariants)#
Core typed arrays (hot-path state)#
DataState owns the arrays that back rendering and visibility:
pointCount: number of points/cellspositionsArray: the live view’s normalized positions, with exactlypositionsDimensioncomponents per cell (x, xy or xyz — never padded to XYZ); both are updated together whenever the live view shows another embedding, and read by smoke density and the session fingerprintcolorsArray: RGBA color buffer (uint8 packed); smoke density reads it too, so the cloud carries the active field’s colourscategoryTransparency: per-point visibility, aUint8Arrayholding exactly 0 or 1 (every filter keeps or drops a cell; highlight dimming and fog are done in the shaders), validated byrequirePointTransparency(rendering/alpha-visibility.js)outlier quantiles are not copied into the state: the outlier filter reads the active field’s own
outlierQuantilesthroughgetActiveOutlierQuantiles()
Key invariant:
indices are stable: point
irefers to the same cell across all arrays (positions/colors/alpha/highlights).
Field data + caches#
Loaded field values live in _fieldDataCache (obs fields) and
_varFieldDataCache (gene-expression fields), shared across views. They are
bounded in bytes, not entries (field/residency.js): once inactive fields
hold more than their budget, the least recently used are released. The budget
is residencyBudgetBytes('inactiveFields') (data/memory-budget.js): an
eighth of what the working set leaves of the allowance (see
Memory budgets). The active field, any field with filter state (a
range, hidden categories or an outlier threshold: every filter applies
whatever is coloured), and fields still loading are always kept. Release runs
when the active field changes, when a .h5ad export has written a field’s
column, and when the memory model trims the share (the allowance or the
working set changed, the page is hidden) – except while the state is in a
batch (a session restore staging the fields it shows next), whose fields are
trimmed at the next field change.
(An entry-count bound did not bound memory: browsing genes kept every one at
4 bytes per cell.)
Memory budgets#
data/memory-budget.js is the one memory model: what the device allows,
what is drawn, what every cache keeps, and what happens when memory is
short. A budget is a residency policy: it decides what is released, spilled
to disk or read again, never what can be opened or shown.
The device is estimated by the page from what the browser reports:
navigator.deviceMemory(Chromium; earlier releases report at most 8 GiB, Chromium 151 reports 16 on a 16 GiB Mac) and the JavaScript heap limit (performance.memory.jsHeapSizeLimit, Chromium pages only), which V8 sets at about a quarter of physical memory (3.76 GB on that Mac). The heap limit raises an 8 GiB report that may be a cap and stands in when deviceMemory is absent. A browser that reports neither (Firefox, Safari) is taken to be 8 GiB. The page hands its estimate to every reader worker it starts, so every thread counts against the same device.The allowance is half the device: what the app may use, drawn and kept together. The other half is the system, the browser and other tabs. Cellucid pages of one origin (tabs, the widgets of one notebook) divide it (
data/memory-census.js): each may use what the others do not hold, and at least an equal share. A page knows which others are open from Web Locks (the browser frees a page’s lock when the page is gone) and what each holds from aBroadcastChannel. Pages in different storage partitions (a Cellucid tab and a notebook widget, two hosts) do not see each other, and a page that is not a secure context counts only itself.The working set is what is drawn: 77 bytes a cell of the shown dataset’s open cells (
CELL_WORKING_SET_BYTES, measured: positions, colours, visibility, the spatial tree and draw order on the page and the GPU, the coloured field), a neighbour graph while a view shows its edges or the k-NN tool uses it, which cells of the file the open cells are (the page’s cell identity,'cell identity': the kept cells, their open order and its lookups, 4 to 16 bytes an open cell), the reader worker’s rows of an open subsample ('cell rows': the kept rows, their runs and their inverse, the inverse in the smaller of a table of the kept rows and an array of every row of the file, and the ranks the subsample was drawn from, kept so a growth or another size draws without ranking again), the values the action history keeps for every step of the dataset shown ('history': the typed arrays of its value pool, each buffer once; the person’s work, never evicted, held while the dataset is shown), and, where the browser measures the page (measureUserAgentSpecificMemory, cross-origin isolated pages only), what it uses beyond everything counted. The auto-fit sizes the open cells so the working set and the gene index fit the allowance together (Subsampled loading: open a share of the cells).The shares divide what the working set leaves of the allowance and sum to exactly one, so the working set and every cache together never exceed the allowance and no two caches count the same bytes: the gene-column index with the CSR row pointers it is built from 3/8 (the index spills to disk beyond it), positions of layouts no view shows 1/8, inactive fields 1/8, analysis runs 1/16 (the genes a run buffers, the per-cell state it keeps in every analysis worker, kept marker runs), the values analysis windows keep for their pages with the page cell lists made for them (
analysisPages,app/analysis/data/data-layer.js) 1/16, the AnnData adapter’s cached copies (obs payloads not handed to a view, coordinates, the neighbour graph while nothing draws it) 1/16, a latent space held for outlier quantiles 1/16, decoded chunks of every open store 1/32, bytes of remote files the page keeps for its reader workers 1/32 (data/byte-range-cache.js), decode workers 1/32 (about 40 MB each; with the cores they size the pool) and selection undo history 1/32.The ledger. Every cache registers as a holder of its share (
registerResidentHolder): what it keeps now and how to keep less, least recently used first, never what is in use. Whenever the allowance or the working set changes, every share over its budget is trimmed, cheapest to rebuild first (RELEASE_ORDER), in every thread: the reader worker reports what its holders keep to the page, and the page tells the other pages what it holds in all. The JavaScript heap is never measured: it counts noArrayBuffer, where every per-cell array lives, and exists only on Chromium pages.A refused allocation (in any thread) of fewer bytes than the allowance halves the allowance everywhere, and every cache over its new budget gives memory back at once; one notification names what went. It comes back a step at a time, on evidence: an allocation at least as large granted while the app already holds the lowered allowance, or the refused size granted again when another dataset opens.
A tab ended while it opened a dataset. A phone ends a tab that holds more memory than it allows, with no refused allocation the page could hear first, and a browser that reports neither its device memory nor a heap limit (Safari, Firefox) is taken to be an 8 GiB device. An opening under way is marked in the tab’s session storage (
app/page-memory.js), and the mark goes when it ends or the page is left (a reload or a navigation firespagehide; an ended tab fires nothing). The next load that finds the mark halves the allowance (recordInterruptedOpening), so the opening fits half the cells, and says why; it comes back as a refused allocation’s does.Hidden and frozen pages. A page the person does not see keeps only what it draws: every cache but the gene-column index and the selection undo steps is let go at once, and every idle worker ends. A page the browser freezes, or puts in the back/forward cache, lets the gene-column index go too. Everything comes back on demand when the page is shown again.
A dataset no longer shown (another source is) keeps only what opens it again: its reader worker ends with everything it held, and its next read reopens the same cells, from what this browser keeps of it (
AnnDataFileSource.suspend). A dataset opening makes the open ones give back their rebuildable caches first, keeping what is shown.Workers that have had nothing to do for 10 s end (
workers/idle-retirement.js): the decode pool, the analysis pool, the plot raster workers and the figure-export worker with its WebGL contexts. The analysis pool runs no more workers than the analysis share holds a run’s per-cell state for (5 bytes a cell a worker).Nothing is invalidated by a trim: reads in flight finish and are delivered, no analysis window is closed. Invalidating a dataset’s caches (another source shown, the dataset closed) stops its reads in flight as cancellations (
AbortError), never failures.
Before any cell is drawn (the working set takes its part first: 77 MB for 1M cells, 770 MB for 10M):
Device |
Allowance |
Gene index |
Positions, inactive fields (each) |
Analysis, analysis pages (each) |
Decode workers |
|---|---|---|---|---|---|
4 GiB |
2 GiB |
768 MiB |
256 MiB |
128 MiB |
1 |
8 GiB (or unreported) |
4 GiB |
1.5 GiB |
512 MiB |
256 MiB |
3 |
16 GiB |
8 GiB |
3 GiB |
1 GiB |
512 MiB |
6 |
Not counted: GPU objects beyond the working set’s measured share (kept views’ colour stores, highlights, edge geometry), the shard indexes of sharded Zarr arrays (a store’s structure, 16 MiB a store), and the operations of the read activity log under way (ended ones are counted into its totals and dropped).
Multi-view contexts (live + snapshots)#
Cellucid supports:
one live view (
viewId = "live")any number of snapshot views (“Keep view”)
There is no count cap: GPU memory decides what stays resident. When an
allocation is refused, the colour stores and alpha textures of kept views that
are not presented are released first and rebuilt when they are shown again
(tests/browser/kept-views-memory.spec.mjs). The grid layout fits any number
of panes. createSnapshotView refuses only in smoke render mode.
DataState maintains a viewContexts map (viewId → context) so each view can have:
independent active field selections (obs/var)
independent filters/outlier settings
the embedding it shows, and whether it follows its dimension’s default (
followsDefault); its dimension is that embedding’sits own camera and navigation mode; Link cameras shares one camera between views that show the same embedding (viewer-side:
viewer.setCamerasLocked(),viewer.getCameraLinkGroups())
Per-cell arrays (colors, category transparency, the visibility mask, outlier
quantiles) are owned, not copied, as views change. The active view’s context
shares the arrays DataState itself holds, so syncing it after every coloring,
filtering or centroid change costs nothing per cell. setActiveView moves the
arrays: the outgoing context keeps the ones the state was using, and the state
adopts the incoming context’s. A new snapshot (“Keep view”) shares the
transparency array and visibility mask with the view it was kept from, copy on
write (state/managers/per-cell-array-ownership.js): every writer goes through
writableTransparency, writableCellVisibilityMask or writableColors, which
copy first if another view still holds the array. A kept view shares the
colours array too: the viewer and renderer keep it by reference, and once
published it is never written again, so whichever view recolours first writes
a copy. A filter writes the transparency alone: colours carry no visibility
(their alpha byte layers a field’s no-value grey beneath the coloured cells,
BENEATH_ALPHA_BYTE, and a gene sample’s cells not read beneath both,
UNREAD_ALPHA_BYTE). A context handed
to restoreContext is copied, because its caller may still hold it. So never
write a per-cell array directly — always take it through a writable* helper.
A kept view added about 6–7.4 bytes per cell instead of 26, measured while it
still copied the colours (4 bytes a cell on the CPU, now shared until a view
recolours).
Field switch cost#
Switching the colored field recolors every cell on the main thread, so its
per-cell loops are kept to table reads. Category colors come from one RGBA word
per category (_writeCategoryColors, shared by coloring and the color refresh
after a filter edit); continuous colors sample the colormap without allocating.
The value scans that only depend on a field’s data are proved once per array:
a continuous field’s range and non-finite check (scanContinuousValues), and a
categorical field’s code-range check (requireCategoryCodesInInventory).
Category counts resolve the active filters once per count instead of once per
cell. Field arrays are never written in place once published — a changed
field gets a new array — which is what makes those per-array proofs valid.
At 5,000,000 cells (Chromium, Apple M1 Pro) a cached switch to a continuous
field blocked the main thread for about 480 ms and now for about 140 ms; to a
categorical field, about 400 ms and now about 180 ms.
Highlights (multi-page highlight system)#
Highlights are modeled as:
highlight pages: independent collections of highlight groups
highlight groups: named groups with colors, enabled flags, and cell index membership
one derived per-point overlay buffer:
highlightArray(Uint8Array)
The key point:
highlights are layered on top of visibility (filtered-out points can still “be in a highlight group”).
Selections in progress (the annotation, lasso, KNN and proximity candidates)
are CellSets (app/utils/cell-set.js): one bit a cell, with word-wise union,
intersection and difference. Never hold a set of cells in a JavaScript Set
or Map: V8 refuses more than 16,777,216 entries, so a selection of a large
cluster in a 30M-cell dataset would fail. Cell lists are checked with
requireCellList (the same module), which refuses duplicates without a Set,
for the same reason. tests/cell-set-contract.test.mjs
enforces this in the selection modules.
Order cells the same way: by a typed key, never by a comparator sort or an
object per cell. orderByKey (utils/radix-order.js) is the stable order of
32-bit keys in two linear passes (10M cells by original position: 0.24 s,
against 1.4 s for a comparator sort and 7.8 s and 1.2 GB for an array of
[cell, value] pairs); exports, scores moved with their cells and values
named by original position use it. A per-cell column the reader decodes is a
typed array too: a boolean column is one byte a cell (0, 1, or missing,
decodeAnnDataBooleanFlags), coded without a lookup per cell.
tests/scale-boundaries-contract.test.mjs holds each of these past 2^24
cells.
Representations + vector fields#
A view shows one representation: one embedding, at its columns, of any dimension. Cellucid tracks them via:
dimensionManager(from the data layer): the layout registry. Every position layout has alayoutKey; positions, normalization and cache eviction are all keyed by layout (loadLayout,getPositions,getNormTransform). The data mapping publishes which layouts are enabled — any number, of any mix of dimensions — and at most one default per dimension (setRepresentations,getEnabledLayoutsForDimension,getDefaultLayout). The layout a view shows isdimensionManager.getViewLayout(viewId); nothing stores a view’s dimension beside it (state.getViewDimensionLevel(viewId)reads the layout’s).state.getEnabledRepresentations()lists what the picker offers, andstate.getDataMapping()/state.applyDataMapping()(state/managers/mapping-manager.js) read and change the mapping.
Coordinates of an enabled layout are read the first time a view shows it and
kept while a view shows it; afterwards they stay in a cache whose budget is an
eighth of what the working set leaves of the allowance
(residencyBudgetBytes('positions'), see Memory budgets), released least
recently shown first, and entirely when the page is hidden. The budget never
refuses what the views show: the layouts shown plus the one being shown are
always admitted, even above it.
Vector fields (velocity/drift overlays) are optional and use:
vectorFieldManager(from the data layer): lists available fields and loads vectors per layout (loadField(fieldId, layoutKey)); each field is bound to the embeddings it was computed on (hasFieldLayout(fieldId, layoutKey)), and is drawn in every view that shows one of them
How the public API is assembled (mixins + managers)#
DataState is a single class, but its API surface is assembled from multiple modules:
cellucid/assets/js/app/state/core/data-state.jsconstructs
DataStatemixes in method prototypes:
DataStateViewMethodsDataStateFieldMethodsDataStateColorMethodsDataStateFilterMethodshighlightStateMethods
defines “manager getters”:
state.views→ViewManagerstate.fields→FieldManagerstate.filters→FilterManagerstate.colors→ColorManagerstate.highlights→HighlightManager
Why this structure exists#
Keeps concerns separated (filters vs coloring vs view orchestration).
Makes it easier to reason about “what should be serialized”.
Avoids accidental cyclic imports between UI and state.
Who changes DataState: actions and state domains#
DataState is changed in two ways, and only these two
(Action layer, state domains and history):
By an action. A control dispatches the action it stands for (
dispatchFromControl,app/actions/control-dispatch.js); an agent, a Python or R host and the history’s replay dispatch the same actions. The action’s implementation, provided by the module that owns the state (app/state/actions/field-actions.jsfor the state’s own, the control’s module for its orchestration), resolves the names its parameters give and calls theDataStatemethod the control used to call. A UI module calls no mutatingDataStatemethod itself. Settings controls and camera gestures change their owner first and are recorded after (recordObserved).By a state domain’s owner (
app/state/domains/), when the history puts a value back (undo, redo, a jump, a session or saved state loaded, a starting view):restore(value)changes only what differs, through the sameDataStatemethods the controls use, never by dispatching an action. Per-view state of a view that is not focused is changed while that view is focused, and the focus is put back (view-list.jsinEachView).capture()reads the value back; a value is plain data and is never written after capture.
Cells to open (Session › Subsampling) is the action cells.open
(ui/modules/subsample-control.js): one step of the history, on the same
tracker, that reads the dataset again with other cells and keeps what was made
by original cell position. Undoing it puts the cells domain’s value back,
which reads the cells shown before again (state/domains/cells.js), and the
history captures every other domain again after it (REREADING_DOMAINS);
redo reads the share again.
Events (what the UI and analysis listen to)#
DataState extends a small EventEmitter (cellucid/assets/js/app/utils/event-emitter.js).
Events are the main way UI modules stay decoupled from state internals.
Core events (stable names)#
Event |
Payload |
Meaning |
|---|---|---|
|
none |
Visibility/transparency changed (filters/outliers updated) |
|
|
Field metadata/state changed (rename/delete/load/filter/color changes) |
|
none |
Highlight groups changed (membership/colors/enabled) |
|
none |
Highlight page set changed (add/remove/rename/switch) |
|
|
The representation a view shows changed (any view, active or not), or focus moved to a view showing another one |
|
|
The data mapping was applied; |
|
|
The enabled representations changed (fires with |
|
list of available fields |
Vector-field availability changed (dataset load / manager attached) |
Code pointers:
Visibility + field events are emitted from view-context sync code:
cellucid/assets/js/app/state/managers/view-context-viewer-sync.js
Highlight/page events:
cellucid/assets/js/app/state/managers/highlight-manager.js
Representation + vectorFields events:
cellucid/assets/js/app/state/managers/view-manager.js
Mapping events:
cellucid/assets/js/app/state/managers/mapping-manager.js
Event ordering and “why did this fire twice?”#
Common patterns:
One user action (e.g. “switch active field”) can cause both:
field:changed(active field selection)visibility:changed(if outlier defaults/filters change)
A history restore (an undo, a jump, a session load) puts back only the domains that differ, each through its owner; an owner may fire fewer events by using batch mode.
If you are debugging a double-render / double-recompute:
Confirm whether the UI is subscribed in multiple modules.
Confirm whether the state is emitting from both a “setter” and a derived recompute.
Batch mode (bulk changes without repeated recompute)#
When applying many state changes (especially while the history puts a value back), you want to avoid:
re-computing visibility N times
re-uploading buffers N times
DataState provides:
beginBatch()endBatch()
Semantics:
In batch mode, managers mark “dirty” flags instead of immediately recomputing.
On
endBatch(), a single consolidated recomputation occurs.
Code:
cellucid/assets/js/app/state/managers/view-manager.js
State ↔ viewer synchronization (how changes become pixels)#
The renderer is updated explicitly by state methods (not by UI code directly). Common flows:
Active field changes:
loads field data (if needed)
updates
colorsArraycalls
viewer.updateColors(colorsArray)triggers
field:changed
Filter/outlier changes:
updates
categoryTransparencycalls
viewer.updateTransparency(categoryTransparency)triggers
visibility:changed
Important
The two halves of a visibility change have very different costs, and code written as if they were the same has been a recurring source of regressions.
Deriving
categoryTransparencyisO(pointCount)every time, no matter how few cells moved. Do not add another full-dataset pass beside it; fold new conditions into the existing loop.Publishing it to the GPU is proportional to what changed. The viewer compares the new alpha bytes against the uploaded texture and re-uploads only the dirty rows, coalesced into a bounded number of contiguous regions. A republication in which nothing moved uploads nothing and reports that fact back, so dependent work — notably repacking the highlight geometry — is skipped entirely.
Two consequences for contributors:
Never call
updateTransparencywith a freshly allocated array that happens to hold identical values expecting it to be free; it is free because the bytes match, and it is your job not to perturb them.If you add a consumer that must react to visibility, hang it off the “generation moved” signal rather than off the call itself, or you reintroduce the cost the comparison exists to avoid.
Representation changes all go through
setViewRepresentation(viewId, layoutKey, { followsDefault }). The callers aresetViewEmbedding(viewId, ref)(an explicit choice: the picker, the actionview.embedding.set), theviewsdomain’s restore (state/domains/views.js: the embedding andfollowsDefaulta step or a session recorded), a data-mapping change that moves a view off an embedding it turns off (mapping-manager.js),showDefaultRepresentation(viewId, level)andcycleRepresentation(viewId, level)(keys 1, 2 and 3). It:refuses a layout that is not enabled; a request for the layout the view already shows only records
followsDefaultloads that layout’s positions, pinned while the change is staged, and stages its centroids, with rollback for every owner on failure
updates
positionsArrayandpositionsDimension(live view)calls
viewer.updatePositions(newPositions, dimensionLevel)(orviewer.setViewPositionsfor a kept view), thendimensionManager.setViewLayout(viewId, layoutKey)andviewer.setViewEmbedding(viewId, { dimension, layoutKey }), which drops that view’s LOD, frustum, picking and vector state for the old layouttriggers
view:representation-changed
The bulk of this wiring lives under:
cellucid/assets/js/app/state/managers/view-context-*cellucid/assets/js/app/state/managers/*-manager.js
How to debug DataState quickly#
1) Use the dev globals#
In the browser console:
window._cellucidStategives you the active state.window._cellucidViewergives you the viewer.
2) Subscribe to events interactively#
Example pattern:
const state = window._cellucidState;
state.on('field:changed', (e) => console.log('field:changed', e));
state.on('visibility:changed', () => console.log('visibility:changed'));
state.on('highlight:changed', () => console.log('highlight:changed'));
3) Confirm invariants#
Useful checks:
state.pointCountmatchesstate.colorsArray.length / 4andstate.positionsArray.length / state.positionsDimension.state.getActiveViewId()is what you think it is (live vs snapshot).
If these invariants break, most UI behavior becomes misleading.
Edge cases (common sources of subtle bugs)#
Dataset reload / switching#
Risks:
listeners are still subscribed to old state/viewer objects
caches carry over incorrectly
highlights/snapshot views persist unexpectedly
Mitigation patterns:
centralize reload in
main.jsensure state reset paths clear view contexts, highlights, and caches as appropriate
a dataset replaced retires its history first (
app/history/dataset-history.jsretire): recording stops at once and nothing more is put back; the same dataset read again on other cells keeps its history (keepHistory)
Empty datasets#
Cellucid supports “no dataset selected” and “empty data” states. Make sure new code handles:
pointCount === 0a dataset without genes (an empty
var)
Async representation changes#
Representation changes are async (may involve fetching embeddings). If you add code that awaits during one, watch for:
“cross-await state corruption” (the active view changed while awaiting)
The code uses a _dimensionChangeLock to serialize changes; preserve that property when refactoring.
Highlights vs filters#
Users often expect highlights to disappear when filtered; Cellucid treats them as independent. If you change highlight behavior, document:
whether highlight membership is preserved when hidden
whether analysis uses “visible highlights” or “all highlights”
Troubleshooting#
Symptom: “Filters don’t update the viewer” / “count changed but points didn’t”#
Likely causes:
categoryTransparencyupdated butviewer.updateTransparencynot called.A view context is active but you updated live-view state (or vice versa).
How to confirm:
Log
state.getActiveViewId()and inspect which view you are editing.Put a breakpoint in
viewer.updateTransparency.
Fix:
Ensure state methods (not UI code) own the viewer sync calls.
Use the view-context-aware APIs when operating on snapshot views.
Symptom: “The picker changes, but the embedding does not”#
Likely causes:
the representation is not enabled (
dimensionManager.isEnabled(layoutKey))async load failed (network / field list mismatch / values refused)
the change targeted another view than the one on screen
Fix:
Confirm
state.getEnabledRepresentations()andstate.getViewLayoutKey(viewId)for the view you expect.Check Network tab for embedding fetch failures.