Skip to content

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 blockRegister withBase classPurpose
LayerregisterLayerBaseLayerPure data — geometry, shader, params.
ControlregisterControlBaseControlPure reducer — (action, state) → state.
OverlayregisterOverlayBaseOverlayDOM-only presentation on top of a view.
ViewregisterViewBaseViewGPU-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-pointcloud
  • control-* — e.g. @galavi/control-trackball
  • overlay-* — e.g. @galavi/overlay-crosshair, @galavi/overlay-axes
  • view-* — 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.

ts
// @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:

ts
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

ts
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

ts
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. Use getCanvas(), isViewActive(), and getViewLayerIds() from BaseOverlay to read view-local context.
  • setOptions(opts) — runtime update channel from Galavi.view(id).setOverlayOptions(type, opts).

View

Views are the heaviest extension point. Implement only when none of volume / slice / navigator fit.

ts
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:

ts
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/*:

  1. Set "peerDependencies": { "galavi": "^1.0.0" } (and do not depend on a specific version in dependencies).
  2. Set "sideEffects": false if your index.ts only re-exports types and classes; set "sideEffects": ["./dist/index.js"] if it calls registerXxx at import time (the common case).
  3. Ship ESM only ("type": "module", single "exports": { "import": "./dist/index.js", "types": "./dist/index.d.ts" }).
  4. Document the registered id(s) and any options in your README.
  5. 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:

  1. Open an issue on github.com/i-z-j/galavi describing the plugin and a runnable example.
  2. We'll discuss API surface and naming.
  3. Maintainers transfer the package to the @galavi scope 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.

Released under the GPL-3.0 License.