Plugin Development
Galavi is built around four extensible building-block kinds. Anything that ships in the core uses the same registration entry points you do.
| Building block | Register with | Base class | Purpose |
|---|---|---|---|
| Layer | registerLayer | BaseLayer | Pure data — geometry, shader, params. |
| Control | registerControl | BaseControl | Pure reducer — (action, state) → state. |
| Overlay | registerOverlay | BaseOverlay | DOM-only presentation on top of a view. |
| View | registerView | BaseView | GPU-owning render target. |
Everything described here is part of the public API. Implementation-only machinery (the per-view ImagePipeline, the scene uniform layout, internal parsers) is intentionally not exposed; you should not need it.
TIP
Read Architecture Notes before writing a layer or view plugin. The data/render split and per-frame protocol are load-bearing for plugin authors.
Naming convention
Official and community plugins should be published under the @galavi/ scope. Two patterns are used, depending on whether the package implements a registered building block or a data adapter.
Building blocks — @galavi/<building-block-type>-<name>:
layer-*— e.g.@galavi/layer-heatmap,@galavi/layer-pointcloudcontrol-*— e.g.@galavi/control-trackballoverlay-*— e.g.@galavi/overlay-crosshair,@galavi/overlay-axesview-*— e.g.@galavi/view-split,@galavi/view-mip
Data adapters — @galavi/<format>-adapter:
- e.g.
@galavi/ome-zarr-adapter,@galavi/precomputed-adapter
Adapters are not a registered building block (there is no registerSource); the -adapter suffix is the conventional marker so npm searches surface them alongside the building blocks. The format-first ordering reads naturally for data formats (ome-zarr-adapter, not adapter-ome-zarr).
Unofficial / community plugins can use the same conventions under their own scope, e.g. @yourorg/layer-awesome or @yourorg/myformat-adapter.
These conventions are conventional, not enforced. Galavi's registries take any string id; the prefix exists so npm searches for @galavi/layer- or @galavi/*-adapter surface what's available.
Anatomy of a plugin
A Galavi plugin is a regular npm package whose default export (or named exports) call registerXxx at import time. Consumers import "@galavi/layer-heatmap" and the side effect installs the building block.
// @galavi/overlay-crosshair/src/index.ts
import {
BaseOverlay,
registerOverlay,
type State,
} from "galavi";
class CrosshairOverlay extends BaseOverlay {
private el?: HTMLDivElement;
protected override onMount(root: HTMLDivElement): void {
const el = document.createElement("div");
el.style.position = "absolute";
el.style.left = "50%";
el.style.top = "50%";
el.style.width = "12px";
el.style.height = "12px";
el.style.marginLeft = "-6px";
el.style.marginTop = "-6px";
el.style.border = "1px solid #fff";
root.appendChild(el);
this.el = el;
}
protected override onUnmount(): void {
this.el = undefined;
}
protected override onRender(_state: State): void {
if (!this.root || !this.el) return;
const canvas = this.getCanvas();
this.root.style.display = canvas && this.isViewActive() ? "block" : "none";
}
}
registerOverlay("crosshair", () => new CrosshairOverlay());Application side:
import "@galavi/overlay-crosshair"; // side-effect: registers "crosshair"
import { createGalavi } from "galavi";
const galavi = await createGalavi({
state: { /* ... */ },
views: {
main: {
type : "volume",
canvas,
layers : ["volume"],
overlays: { crosshair: {} }, // ← refers to the registered id
},
},
});Galavi will not complain if a config references an unregistered id — it just won't render. Importing the plugin package before createGalavi is the contract.
Registering each kind
Layer
import { BaseLayer, registerLayer, type LayerConfig } from "galavi";
class HeatmapLayer extends BaseLayer {
static readonly layerType = "heatmap";
static fromConfig(id: string, desc: LayerConfig) {
return new HeatmapLayer(id, desc);
}
// implement getGeometry / getShader / getParams / getStorageData …
}
registerLayer("heatmap", (id, desc) => HeatmapLayer.fromConfig(id, desc));Layers are pure data — they describe what the view should bind. They never call into WebGPU. See Architecture Notes → Layer side for the full contract.
Control
import { BaseControl, registerControl, type Action, type State } from "galavi";
class TrackballControl extends BaseControl {
static readonly controlType = "trackball";
override handle(action: Action, state: State): State {
// pure reducer: return the same `state` reference if nothing changed,
// a new state object if it did. Never mutate `state`.
return state;
}
}
registerControl("trackball", (id, opts) => new TrackballControl(id, opts));Controls have no DOM, no canvas, no GPU. The active view routes input into its control chain; each control runs as (action, state) → state.
Overlay
See the crosshair example above. Overlays have a small lifecycle:
onMount(root)— append DOM into the supplied overlay root.onUnmount()— release DOM references.onRender(state)— react to a state or render event. UsegetCanvas(),isViewActive(), andgetViewLayerIds()fromBaseOverlayto read view-local context.setOptions(opts)— runtime update channel fromGalavi.view(id).setOverlayOptions(type, opts).
View
Views are the heaviest extension point. Implement only when none of volume / slice / navigator fit.
import { BaseView, registerView, type State } from "galavi";
class SplitView extends BaseView {
static readonly viewType = "split";
override render(state: State): void {
// dispatch to internal sub-pipelines …
}
protected override onDestroy(): void {
// free GPU resources you own.
}
}
registerView("split", (id, type) => new SplitView(id, type));A new view type owns its render pipeline, bind groups, and (for image layers) tile pools. Reuse the per-view ImagePipeline is not possible from a plugin — the public surface is the BaseView lifecycle hooks and the public utilities (planTiles, TilePool, …) for assembling your own.
Tile sources (data adapters)
Tile sources are not a registered building block — there is no registerSource. A source is anything that satisfies the public TileSource / TileLoader contract (see utils/tile.ts) and is referenced from a layer's data config.
The convention @galavi/ome-zarr-adapter, @galavi/precomputed-adapter, etc. is for npm discoverability only. A source plugin exports a function or class that returns a TileLoader; the application wires it up:
import { createOMEZarrLoader } from "@galavi/ome-zarr-adapter";
const loader = await createOMEZarrLoader({ url: "https://server/data.zarr" });
const galavi = await createGalavi({
state: {
layers: [{
id : "vol",
type : "volume",
data : { url: loader.url, urlTemplate: loader.urlTemplate },
options: loader.options,
// ...
}],
// ...
},
views: { /* ... */ },
});Source plugins should consume only the public tile/pyramid utilities (planTiles, pickPyramidLevel, TilePool, TileLoadQueue, TileManager, floatToFloat16, buildTileFetcher, …) — that's the same surface the built-in volume/slice loaders are written against.
Publishing checklist
Before publishing a plugin under @galavi/*:
- Set
"peerDependencies": { "galavi": "^1.0.0" }(and do not depend on a specific version independencies). - Set
"sideEffects": falseif yourindex.tsonly re-exports types and classes; set"sideEffects": ["./dist/index.js"]if it callsregisterXxxat import time (the common case). - Ship ESM only (
"type": "module", single"exports": { "import": "./dist/index.js", "types": "./dist/index.d.ts" }). - Document the registered id(s) and any options in your README.
- Write a runnable example consumed by an actual Galavi instance — without one, no one can verify your plugin works.
Submitting an official plugin
If you want a plugin promoted to @galavi/* with a maintained published package:
- Open an issue on github.com/i-z-j/galavi describing the plugin and a runnable example.
- We'll discuss API surface and naming.
- Maintainers transfer the package to the
@galaviscope on npm.
The bar for @galavi/* is "covers a use case the core consciously left out." Domain-specific plugins (a particular file format, a specific UI affordance) are good candidates. Re-implementations of built-ins are not.