Architecture Notes
Working notes on the layer/view split, overlay state model, and per-frame protocol. Where Design Philosophy explains what and why, this page explains how.
Overlays and view-local presentation
Galavi has three distinct kinds of state, only one of which is portable:
Shared scene
State— physical space, layers, camera, exploration. The document. Round-trips throughgetState()/setState(). Two peers rendering the same dataset agree on this.View-scoped runtime state — the
BaseViewinstance fields: GPU pipelines, bound canvas, controls, dirty flags. Local to the process; never serialized.View-local presentation state — overlay visibility, marker shape, scalebar styling, label position. Owned by the overlay instance. Not in
State. Mutated at runtime viaGalavi.view(id).setOverlayOptions(type, opts).
The third tier is the easy one to get wrong. Overlays sit on top of a view and they read shared State in onRender(state) (e.g. MarkerOverlay projects state.exploration.camera.target to screen space). That makes them sound like pure projections — and on the read side they are. But on the write side they own UI state that has no business being in the scene document:
- Two clients sharing the same
Statemay legitimately disagree on whether the scale bar is showing. - A "marker visible" toggle on view A does not, and should not, propagate to view B over the network.
- Restoring a saved scene should not reset the user's overlay preferences.
So: overlays read State, but their own presentation options live next to the overlay, not in State. The runtime channel is BaseOverlay.setOptions(opts), exposed on the public API as:
galavi.view("main").setOverlayOptions("marker", { visible: true });ViewConfig.overlays provides the initial values; setOverlayOptions mutates them at runtime. Neither path touches State, so no commit / notify cycle runs — setOverlayOptions calls requestRender() directly.
What goes where
| Concern | Lives in |
|---|---|
| Camera position / target | State.exploration.camera |
| LOD level / mode | State.exploration.lod |
| Layer order, opacity, contrast | State.layers[i] |
| Which overlays exist on a view | ViewConfig.overlays (construction) |
| Whether the marker is visible now | overlay options (via setOptions) |
| Marker shape / precision | overlay options (via setOptions) |
| GPU pipelines, bind groups | BaseView instance |
If a future feature wants to share overlay state across peers (e.g. an "annotation" overlay where the dots themselves are the data), that data should become a real layer or a real field of State — it should not be smuggled in as overlay options.
Layers and views: data/render split
Design Philosophy says layers are pure data and views own GPU residency. This is how that split is actually wired.
Layer side (data only)
A BaseLayer instance never holds a GPUDevice, a GPUBuffer, or a TilePool. It exposes:
getGeometry(),getShader(),getParams(),getStorageData()— what to bind for this layer's pipeline.getTileSpec(): TileSpec | null— for tiled image layers, the residency descriptor (tileSize,gridCells,format,bytesPerTexel,maxPoolSize). Pure constructor-time + class constants; never depends on the current data identity.planTiles(target, effectiveScale, options): TileFramePlan | null— per frame, returns the desiredTilePlan(one tile per grid cell), theTileLoaderto resolve each tile, and an optional in-bounds filter.geometryVersion,colormapVersion,dataVersion— monotonic counters read by the view's per-layer renderer to decide what to invalidate.attach(requestRender)/detach()— owner-injected render-request channel, used by async loaders (data fetch, surface load) to ask for a repaint without re-running commit.
View side (all the GPU)
Each view has a private ImagePipeline that maintains one LayerRenderer per layer in its layer list. The renderer owns:
- the
GPURenderPipelineand bind groups, - the params/model/storage
GPUBuffers, - the colormap
GPUTexture, - for tiled layers, the entire
TileManager+TilePool(texture, slot ring, pending-tiles queue, per-framecommit/pump).
Per-frame protocol:
- host calls
pipeline.markDirty()whenever the view's layer set changes, pipeline.sync(layers)reconciles renderers (rebuild on geometry/blending drift; soft-reset tile residency ondataVersiondrift; in-place LUT rewrite on colormap drift),pipeline.updateTiles(layers, effectiveScale, localTarget, opts)asks each layer for itsTileFramePlanand commits to that renderer'sTileManager,pipeline.writeFrameUniforms(layers, beforeWrite?)writes per-layer params + model uniforms,pipeline.draw(pass, layers)emits sorted, visible draw calls.
Reuse over rebuild
The pool's structure is fixed by getTileSpec(), which depends only on constructor-time fields and class constants. Source / selection / sliceIndex changes never need a different pool shape. So:
dataVersiondrift →tileManager.reset()only. Pool texture, pipeline, bind groups, colormap texture, params/model buffers all survive.geometryVersionorblendingdrift → full renderer rebuild. (Tiled layers in current code don't bumpgeometryVersion; this path is for surfaces, points, vectors, …)colormapVersiondrift →writeTextureinto the existing colormap LUT.
Why integer version counters, not booleans or callbacks
geometryVersion, colormapVersion, dataVersion are monotonic ints, not isDirty booleans or onChange callbacks. The reason is per-consumer cache invalidation: the same layer can be cached by multiple LayerRenderers (one per view), and each renderer needs to decide independently whether its cache is stale.
- A boolean flag breaks for >1 consumer: whichever renderer reads-and-clears it first hides the change from the others.
- A push-style callback would force the layer to hold references to its consumers, re-introducing the
layer → viewcoupling the pure-data refactor removed. Push is right for one-shot events ("a tile finished uploading, repaint" — that'srequestRender); pull-with-version is right for cache invalidation. - An int counter + a per-consumer
lastSeen*field has no clear/reset step at all; the comparison itself is the read.
Rule of thumb:
- Per-consumer cache invalidation → integer version.
- One-shot "something happened, please react" → injected callback.
- Boolean flags → only when the flag is the data (e.g.
visible,isReady).
One layer in many views
A single BaseLayer instance can appear in multiple views' layer lists. The explorer renders the same volume layer simultaneously in three slice views, a volume view, and a navigator. This is supported, not banned.
Why it's safe:
- JS is single-threaded; views render sequentially within a frame. There is no concurrent mutation.
- After the pure-data refactor, every piece of GPU state lives on the view-side
LayerRenderer. Two views over the same layer get independentTilePools, bind groups, params/model buffers, and active pyramid levels. They cannot interfere at the GPU level. - The per-frame sequence inside one view —
sync→updateTiles→writeFrameUniforms→draw— runs to completion before the next view's sequence starts. Layer-level scratch fields used during planning (currentLevel,params.viewport) are read back into that view's params buffer before the next view touches them.
What you must not do:
- Don't read layer-level scratch fields (
getCurrentLevel(),params.viewport) from outside the per-view plan→draw window and treat them as "where is layer X drawing?" They are last-writer-wins across views and reflect whichever view ran most recently. If you need that information for a UX surface, query the view, not the layer.