Skip to content

View Hierarchy

The views API exposes the live layout hierarchy of an embedded GenomeSpy instance. It can inspect views, read rendered bounds for external UI, and mutate children of supported container views.

The hierarchy matches the layout tree derived from the visualization spec. GenomeSpy may add an implicit root layout container, for example when a root unit view needs space for axes, titles, or other guides. Use api.views.root() to inspect the actual runtime root.

Addressing views

Most views methods accept a view address. A view can be addressed with:

  • "root" for the runtime root layout view
  • a ViewHandle returned by the API
  • a selector such as { scope: [], view: "tracks" }

Use a handle after resolving, inserting, or traversing views through the API. Use a selector for durable references to named views, especially when the same spec or template may appear more than once.

api.views.get(address) resolves an address and throws if it cannot be resolved. api.views.resolve(address) returns undefined instead.

const root = api.views.root();
const tracks = api.views.get({ scope: [], view: "tracks" });

console.log(root.children());
console.log(tracks.parent());

Selector scopes

A selector scope is a namespace for addressing named views. The root namespace is [], so { scope: [], view: "tracks" } resolves a view named "tracks" in the top-level spec.

Scopes are created by named import instances and by insert(..., { scope }). In an import spec, name overrides the imported view's own name and creates the scope for addressing descendant views. See Importing Views for import-specific details.

A scope does not replace a descendant view's own name; it is the path used to reach the namespace that contains the view.

For example, if a track spec named "signalTrack" is inserted with scope: "sample-1-signal", the inserted view can be addressed as:

api.views.get({
  scope: ["sample-1-signal"],
  view: "signalTrack",
});

Nested scopes are written from outermost to innermost:

api.views.get({
  scope: ["panelA", "innerA"],
  view: "coverage",
});

Use scopes when the same spec, template, or imported view can appear more than once. Each scope name must be unique within its parent scope. View names only need to be unambiguous within the selected scope.

Inspecting the hierarchy

A ViewHandle is a live reference to a view. It exposes the view's id, name, selector, public type, parent, and current children.

const tracks = api.views.get({ scope: [], view: "tracks" });

for (const child of tracks.children()) {
  console.log(child.name, child.type, child.selector);
}

Handles remain stable while their views are live. After removing a subtree, handle.isAlive() returns false.

if (tracks.isAlive()) {
  console.log(tracks.children().length);
}

Reading transformed records

Each handle exposes data for reading and analyzing its current transformed records. Declared input datasets are updated through datasets.

view.data.read({scope, limit}) returns {rows, truncated} from a unit view's loaded transformed records. Scope is required:

const track = api.views.get({ scope: [], view: "observations" });
const all = track.data.read({ scope: "all", limit: 1000 });
const viewport = track.data.read({ scope: "viewport", limit: 1000 });
const brushed = track.data.read({ scope: { selection: "brush" }, limit: 1000 });

The "all" scope reads the current collector without positional constraints, including records outside the viewport or brush. It supports nominal, conditional, or absent positional encodings.

Viewport reads test encoded data positions against continuous x/y scale domains, using the same overlap rules as viewport-derived domains: point boundaries are inclusive, and ranged marks must overlap. Brush reads use the named interval selection's existing membership predicates, independently of the viewport; a cleared brush returns no rows. Viewport and brush scopes require unconditional positional encodings. They describe data-space membership before pixel offsets, clipping, opacity, or occlusion, rather than proving visible pixels.

Membership is tested before the nonnegative safe integer limit. Only returned records are structured-cloned, without picking identifiers. truncated means additional matching records exist; reject it before treating results as complete. Zero checks whether any matches exist. Uncloneable values throw and shared memory retains the platform's sharing semantics. The limit bounds rows, not nested values.

Reads scan synchronously; sparse brushes over large collections may still inspect every loaded record. Spatial indexing is deferred to a separate change. Readback does not fetch missing data or establish source coverage. Await current visible lazy requests with api.awaitVisibleLazyData(signal) when needed, and verify completeness for analysis. Unready data, containers, unsupported projections, and stale handles throw.

Point-selection records remain available through the existing view.params.getSelection(name).getValue().data; their getter and subscription behavior are unchanged. Named selection scopes accept interval selections only. See Read and select records for a small readback-to-selection example.

Calculating results from scoped records

view.data.transform() is designed primarily for agentic queries: calculate summaries, rankings, or samples inside GenomeSpy without loading the full raw dataset into the agent's context. Its limited declarative grammar has a JSON Schema for validating tool inputs. Explicit scopes, isolated calculations, and clear overflow and truncation signals help agents refine queries without changing the visualization.

view.data.transform({scope, transform, inputLimit, limit}) runs an explicit pipeline over a complete snapshot captured with the same scope rules as data.read. Region membership is applied before this additional pipeline. It uses the unit's current transformed records; it cannot recover observations already filtered out or aggregated away by the visualization.

const result = track.data.transform({
  scope: { selection: "brush" },
  transform: [
    { type: "filter", expr: "datum.value >= 0" },
    { type: "formula", expr: "datum.value * 2", as: "score" },
    {
      type: "aggregate",
      groupby: ["group"],
      fields: ["score", "score"],
      ops: ["count", "mean"],
      as: ["count", "meanScore"],
    },
  ],
  inputLimit: 50000,
  limit: 100,
});

Supported transforms are formula, expression-based filter, sample, aggregate, and window, using their existing visualization grammar. Expressions have datum and built-in functions; they do not inherit live view parameters. Selection-parameter filters, reactive debounce settings, and other transform types are outside this API's supported grammar. Sampling uses the existing default size of 500 and is not seeded; an explicit size must be a positive safe integer. The generated request schema ships as @genome-spy/core/transform-data-schema.json. The method does not run schema validation internally.

For example, these pipelines calculate scores or retain the five highest-ranked records while preserving their keys:

const scores = [
  { type: "formula", expr: "datum.value * 2", as: "score" },
  { type: "filter", expr: "datum.score >= 10" },
];
const topFive = [
  {
    type: "window",
    sort: { field: "value", order: "descending" },
    ops: ["row_number"],
    as: ["rank"],
  },
  { type: "filter", expr: "datum.rank <= 5" },
];

Both limits are required nonnegative safe integers. Exceeding inputLimit rejects before analysis starts, avoiding summaries calculated from an incomplete prefix. limit applies after the full pipeline finishes; truncated reports additional output rows. Zero input accepts only an empty scope; zero output still runs the calculation. Existing empty-input semantics apply: an aggregate emits no rows, including a whole-table count. Facets are flattened into one table; use explicit groupby fields for analysis partitions.

The private flow owns the captured copies. It leaves live records, parameters, selections, view layout, and renderer resources unchanged. App code can display the results, update datasets, or send preserved keys to the selection setter. Aggregates that drop source keys do not automatically identify contributing records. Results retain readback's structured-clone/shared-memory semantics. Execution is synchronous; row limits do not bound time, object size, or all memory use. The restricted expression grammar is not a resource sandbox. Source readiness and coverage have the same requirements as record readback.

See Analyze brushed records for one small example calculating group summaries from brushed raw measurements.

Marks and scoped interaction

Each handle exposes marks for interaction with marks in that view's subtree. Mark events pick the event coordinates before invoking the listener. GPU-backed readback can make delivery asynchronous, and the callback is skipped if the scene or owning view/embed is invalidated before the pick completes. See Interaction events for native canvas input and browser default cancellation.

const track = api.views.get({ scope: [], view: "track" });

const stopHover = track.marks.observeHover((hit) => {
  inspector.textContent = hit ? JSON.stringify(hit.datum) : "No mark";
});

const stopClick = track.marks.subscribe("click", async ({ hit }) => {
  inspect(hit.view, hit.datum);
});

api.events.subscribe("contextmenu", (event) => {
  event.sourceEvent.preventDefault();
  event.preventViewDefault();
  void openContextMenu(event.sourceEvent, event.point);
});

async function openContextMenu(sourceEvent, point) {
  const result = await track.marks.pick(point);
  if (result.status !== "hit") {
    return;
  }
  inspect(result.hit.view, result.hit.datum);
  showMenuAt(sourceEvent.clientX, sourceEvent.clientY);
}

pick() returns hit, empty, or invalidated. A query is invalidated when the scene changes or the embed is finalized while it is pending. MarkHit.view identifies the unit view that owns the mark, uniqueId identifies the mark in the rendered scene, and datum is a detached shallow copy without the internal ID. Nested datum values are not deep-cloned.

Parameters and selections

api.params starts at the authored top-level specification, while view.params starts at that view's lexical scope. Both expose the nearest declaration and distinguish a child selection that writes to an outer value from the outer declaration itself.

const brush = track.params.getSelection("brush");
const stopBrush = brush.subscribe((snapshot) => updateForm(snapshot), {
  delivery: "commit",
});

if (brush.type === "interval" && brush.contains({ x: 120, y: 80 })) {
  brush.clear();
}

See Parameters for selection snapshot shape, subscription delivery, and clearing behavior.

For a runnable browser form and notebook bridge, see the annotationEditor and selectionForm pages in the embed examples.

Updating named data

Use api.datasets for declarations in the top-level specification and ViewHandle.datasets for declarations owned by nested views. The exact-owner rule means updates do not search ancestors or descendants. See Runtime State for declarations, loading, reset, initial data, and migration from the deprecated global APIs.

Reading layout bounds

getLayoutBounds() returns the rendered bounds of a view for positioning external UI. Bounds are reported in CSS pixels in the embedded GenomeSpy canvas coordinate space. Convert them to DOM coordinates when the external UI does not share the same positioning origin.

The method returns undefined until the view has been rendered or when the address cannot be resolved:

const bounds = api.views.getLayoutBounds({ scope: [], view: "tracks" });

subscribeToLayout() runs a callback after GenomeSpy has recomputed view bounds, which is useful for updating external UI. The method returns an unsubscribe function:

const unsubscribe = api.views.subscribeToLayout(() => {
  const bounds = api.views.getLayoutBounds(tracks);
});

Mutating views

Mutation methods are asynchronous because view creation, imports, dataflow initialization, data loading, guide rebuilding, and layout updates may all be involved. Await the returned promise before using the new hierarchy.

Inserting views

insert(parent, spec, options) adds a child view under a mutable container. The parent must be a concat or layer view. The inserted value is a view spec or an import spec, the same kind of object that appears inside vconcat, hconcat, or layer in a visualization specification.

When inserting the same spec multiple times, pass scope to create a selector namespace for each inserted subtree:

const signalTrackSpec = {
  name: "signalTrack",
  data: {
    // ...
  },
  mark: "point",
  encoding: {
    // ...
  },
};

const tracks = api.views.get({ scope: [], view: "tracks" });

const signalTrack = await api.views.insert(tracks, signalTrackSpec, {
  scope: "sample-1-signal",
});

const sameSignalTrack = api.views.get({
  scope: ["sample-1-signal"],
  view: "signalTrack",
});

Pass index to insert at a specific child position. If omitted, the new child is appended.

await api.views.insert(tracks, signalTrackSpec, {
  index: 0,
  scope: "sample-2-signal",
});

Removing views

remove(target) removes a view and disposes its subtree. Removing the root view is not supported.

await api.views.remove(signalTrack);

console.log(signalTrack.isAlive()); // false

Moving views

move() reorders a view within its current parent. The destination index is evaluated after temporarily removing the target from its parent:

await api.views.move(signalTrack, { index: 0 });

The mutation API does not move views between different parent containers.

Transactions

transaction() applies ordered mutations while deferring layout work until the outer transaction finishes:

await api.views.transaction(async (views) => {
  const inserted = await views.insert(tracks, signalTrackSpec, {
    scope: "sample-3-signal",
  });

  await views.move(inserted, { index: 0 });
});

The view mutation example demonstrates adding, removing, reordering, and positioning external controls next to live views.