Plotting features in a custom display
TL;DR: A custom display that fetches features in a worker and draws them with Canvas2D, no shaders required. Right for gene-scale tracks (hundreds to thousands of features per frame); move to GPU displays only when a profile shows Canvas2D can't hold 60fps (roughly ≳100K features per frame). Both paths share the same model, fetch chain, and lifecycle.
A build-step plugin, not a
no-build one: it bundles
@jbrowse/render-core and composes mixins from
@jbrowse/plugin-linear-genome-view, whose surface is larger and faster-moving
than @jbrowse/core, so pin the
versions you develop against. @jbrowse/render-core first publishes in the next
release; until then, build against a jbrowse-components checkout.
Rendering across two threads
Rendering splits across two threads:
The worker returns compact data, never pixels, with all genomic positions
absolute (not region-relative). The model owns the fetched data (rpcDataMap),
a cheap per-frame renderState, and the fetch/draw wiring; mixins supply the
fetch and draw lifecycles. The renderer paints the visible blocks with an
ordinary CanvasRenderingContext2D, and the same pure draw function backs SVG
export.
Three terms recur below (the architecture spec's vocabulary is fuller):
- region: one entry of
view.displayedRegions. Your worker fetches and stores data one region at a time. - block: a visible slice of a region with its on-screen pixel span. You draw per block.
displayedRegionIndex: a region's index inview.displayedRegions, the join key betweenrpcDataMapand the blocks:rpcDataMap.get(block.displayedRegionIndex).
The simplest complete in-tree reference is
plugins/sequence/src/LinearReferenceSequenceDisplay/, a Canvas2D-only display
whose renderer is ~30 lines; this guide mirrors its shape.
Files to create
example-plugins/score-example/ is the finished plugin — a standalone package
CI installs from a packed tarball and asserts renders, so it stays buildable
against the published packages. It ships both renderers, so the [GPU only]
rows are the ones a Canvas2D display skips:
src/
index.ts the plugin class; installs the display, the RPC method and the feature panel
LinearScoreDisplay/
configSchema.ts config slots (color, scoreColumn)
index.ts registers the display type
model.ts MST model: rpcDataMap, renderState, fetchNeeded, startRenderingBackend
components/
Canvas2DScoreRenderer.ts extends Canvas2DPerRegionRenderingBackend; the SVG-export path too
GpuScoreRenderer.ts [GPU only] extends GpuPerRegionRenderingBackend; packs instances, writes uniforms
ScoreDisplayComponent.tsx React: DisplayChrome wrapping the canvas
ScoreRendererFactory.ts the factory DisplayChrome calls; picks GPU or Canvas2D
drawScore.ts pure draw function over a Ctx2D
scoreTypes.ts ScoreRenderState and the backend type
shaders/
score.slang [GPU only] vertex + fragment for one pass; compiled by gen:shaders
ScoreFeaturePanel/
index.tsx adds a panel to the feature details widget
ScoreRPC/
GetScoreData.ts worker: fetch features from the adapter, then pack
buildScoreResult.ts pure packer, unit-tested without a worker
index.ts registers the RPC method
rpcTypes.ts ScoreRegionData and the RPC arg types
Step 1: Define the data the worker returns
Keep it compact and structured-clone-friendly. Use absolute genomic positions.
// One region's worth of features packed into parallel typed arrays. Positions
// are absolute genomic uint32 (never region-relative) so they cross the worker
// boundary without precision loss and the renderer can map them directly.
export interface ScoreRegionData {
starts: Uint32Array
ends: Uint32Array
// score normalized to 0..1 (fraction of the region's max), driving box height
scores: Float32Array
numFeatures: number
}
Step 2: Write the RPC method
The worker fetches from the adapter and packs the result. See
RPC and worker system for the full RpcMethodType contract;
the shape is:
ScoreRPC/GetScoreData.ts:
import { getFeatureAdapterOrThrow } from '@jbrowse/core/data_adapters/getFeatureAdapter'
import RpcMethodType from '@jbrowse/core/pluggableElementTypes/RpcMethodType'
import { buildScoreResult } from './buildScoreResult.ts'
import type { GetScoreDataArgs, ScoreRegionData } from './rpcTypes.ts'
import type { RpcExecuteArgs } from '@jbrowse/core/rpc/RpcRegistry'
// Registering the name here is what types `rpcManager.call(…, 'GetScoreData', …)`
// at every call site: the args are checked and the return type is inferred,
// instead of both being `any`.
declare module '@jbrowse/core/rpc/RpcRegistry' {
interface RpcRegistry {
GetScoreData: {
args: GetScoreDataArgs
return: ScoreRegionData
}
}
}
export default class GetScoreData extends RpcMethodType<'GetScoreData'> {
name = 'GetScoreData' as const
async execute(args: RpcExecuteArgs<'GetScoreData'>) {
const {
sessionId,
adapterConfig,
region,
scoreColumn,
stopToken,
statusCallback,
} = args
const dataAdapter = await getFeatureAdapterOrThrow({
pluginManager: this.pluginManager,
sessionId,
adapterConfig,
})
// statusCallback arrives as an ordinary function: the caller's never
// crossed the boundary, the RPC layer replaced it with a side channel and
// rebuilt one here. Hand it to whatever does the slow work rather than only
// bracketing that work, so the message tracks the download.
statusCallback?.('Fetching features')
const features = await dataAdapter.getFeaturesArray(region, {
stopToken,
statusCallback,
})
return buildScoreResult(features, scoreColumn)
}
}
Step 3: The MST model
Compose MultiRegionDisplayMixin (which brings the fetch autoruns and the
render lifecycle) and TrackHeightMixin. You supply four things: a place to
store fetched data (rpcDataMap), a per-frame renderState, a fetchNeeded
action, and a startRenderingBackend action.
import { ConfigurationReference, getConf } from '@jbrowse/core/configuration'
import { BaseDisplay } from '@jbrowse/core/pluggableElementTypes/models'
import { getContainingView } from '@jbrowse/core/util'
import MultiRegionDisplayMixin, {
fetchEachRegion,
} from '@jbrowse/display-kit/MultiRegionDisplayMixin'
import TrackHeightMixin from '@jbrowse/display-kit/TrackHeightMixin'
import { types } from '@jbrowse/mobx-state-tree'
import { installUpload } from '@jbrowse/render-core/installUpload'
import { observable } from 'mobx'
import type { ScoreRegionData } from '../ScoreRPC/rpcTypes.ts'
import type {
ScoreRenderState,
ScoreRenderingBackend,
} from './components/scoreTypes.ts'
import type { LinearScoreDisplayConfigModel } from './configSchema.ts'
import type { Region } from '@jbrowse/core/util'
import type { Instance } from '@jbrowse/mobx-state-tree'
import type { LinearGenomeViewModel } from '@jbrowse/plugin-linear-genome-view'
/**
* #stateModel LinearScoreDisplay
* #displayFoundation MultiRegionDisplayMixin
* The worked-example score display: one value per feature, drawn as a scatter
* along the genome. The developer guides walk through this model.
*
* #example
* The display attaches to any `FeatureTrack`, so a track naming it in
* `displays` gets it in place of the stock linear one:
* ```js
* {
* type: 'FeatureTrack',
* trackId: 'genes_with_scores',
* name: 'Genes (scored)',
* assemblyNames: ['hg38'],
* adapter: {
* type: 'BedTabixAdapter',
* bedGzLocation: { uri: 'https://example.com/genes.bed.gz' },
* index: { location: { uri: 'https://example.com/genes.bed.gz.tbi' } },
* },
* displays: [
* {
* type: 'LinearScoreDisplay',
* displayId: 'genes_with_scores-LinearScoreDisplay',
* scoreColumn: 'score',
* },
* ],
* }
* ```
*/
export function modelFactory(configSchema: LinearScoreDisplayConfigModel) {
return types
.compose(
'LinearScoreDisplay',
BaseDisplay,
TrackHeightMixin(),
MultiRegionDisplayMixin(),
types.model({
type: types.literal('LinearScoreDisplay'),
configuration: ConfigurationReference(configSchema),
}),
)
.volatile(() => ({
// fetched data keyed by displayedRegionIndex; the render lifecycle
// uploads/draws one region at a time from this map
rpcDataMap: observable.map<number, ScoreRegionData>(),
}))
.views(self => ({
get view() {
return getContainingView(self) as LinearGenomeViewModel
},
// fetch inputs watched by SettingsInvalidate; any change refetches. Put
// settings that change what the worker computes here; never scroll/zoom
// (those change every frame) or the fetch results themselves.
rpcProps() {
return { scoreColumn: getConf(self, 'scoreColumn') }
},
// recomputed cheaply every frame without fetching; carries the canvas
// dimensions (required) plus whatever the draw path reads
get renderState(): ScoreRenderState {
return {
canvasWidth: self.canvasWidthPx,
canvasHeight: self.height,
color: getConf(self, 'color'),
}
},
}))
.actions(self => ({
setRpcData(idx: number, data: ScoreRegionData) {
self.rpcDataMap.set(idx, data)
},
clearDisplaySpecificData() {
self.rpcDataMap.clear()
},
}))
.actions(self => ({
// called by the fetch autorun for the regions that need loading;
// fetchEachRegion handles cancellation, stop tokens and staleness
fetchNeeded(needed: { region: Region; displayedRegionIndex: number }[]) {
// no `if (!adapterConfig)` guard: the `adapter` slot is a union of the
// registered adapter schemas, all of which are creatable from an empty
// snapshot, so MST always materializes an object there and the guard
// could never fire
const { adapterConfig } = self
return fetchEachRegion(self, needed, {
// `ctx.callRpc`, never `rpcManager.call`: the context injects this
// fetch's stop token and its status callback, and forgetting either
// is silent — no cancellation for this display, or no progress. The
// callback here is this region's own slot in the fan-out, so the N
// parallel calls aggregate into one bar instead of overwriting each
// other
call: (region, ctx) =>
ctx.callRpc('GetScoreData', {
adapterConfig,
region,
...self.rpcProps(),
}),
onResult: (idx, result) => {
self.setRpcData(idx, result)
},
})
},
// called once by DisplayChrome when the backend is created. Streams each
// region into the backend and draws every frame from renderState. This is
// the only part of the model that knows a backend exists, and it is
// identical whether that backend is the GPU or the Canvas2D one.
startRenderingBackend(backend: ScoreRenderingBackend) {
installUpload(self, backend, {
cells: () => self.rpcDataMap,
render: (b, regions) => {
if (regions.size === 0) {
return false // keep the loading overlay up until data lands
}
b.renderBlocks(self.renderBlocks, regions, self.renderState)
return true
},
})
},
}))
}
export type LinearScoreDisplayStateModel = ReturnType<typeof modelFactory>
export type LinearScoreDisplayModel = Instance<LinearScoreDisplayStateModel>
renderBlocks (the list of visible blocks with their pixel spans) comes from
MultiRegionDisplayMixin, so you don't compute it. The fetch chain
(fetchNeeded, rpcProps, cancellation, regionTooLarge) is documented in
full in the data fetching pipeline.
Step 4: The renderer
The renderer has two parts: a pure draw function that paints blocks into any 2D context, and a thin backend class that the lifecycle drives. Keeping the draw logic pure means SVG export reuses it unchanged (see SVG export).
import {
bpToScreenPx,
forEachClippedBlock,
} from '@jbrowse/render-core/canvas2dUtils'
import type { ScoreRegionData } from '../../ScoreRPC/rpcTypes.ts'
import type { ScoreRenderState } from './scoreTypes.ts'
import type { Ctx2D } from '@jbrowse/core/util/paintLayer'
import type { RenderBlock } from '@jbrowse/render-core/renderBlock'
// Pure draw function: paints the visible blocks into any 2D context. Ctx2D =
// CanvasRenderingContext2D | SvgCanvas, so the same implementation backs both
// on-screen Canvas2D rendering and SVG export.
export function drawScoreBlocks(
ctx: Ctx2D,
regions: ReadonlyMap<number, ScoreRegionData>,
blocks: RenderBlock[],
state: ScoreRenderState,
) {
const { canvasWidth, canvasHeight, color } = state
ctx.fillStyle = color
forEachClippedBlock(
ctx,
blocks,
canvasWidth,
canvasHeight,
block => regions.get(block.displayedRegionIndex),
(data, block) => {
const { start, end, screenStartPx, screenEndPx, reversed } = block
for (let i = 0; i < data.numFeatures; i++) {
const left = bpToScreenPx(
data.starts[i]!,
start,
end,
screenStartPx,
screenEndPx,
reversed,
)
const right = bpToScreenPx(
data.ends[i]!,
start,
end,
screenStartPx,
screenEndPx,
reversed,
)
const h = data.scores[i]! * canvasHeight
ctx.fillRect(
Math.min(left, right),
canvasHeight - h,
Math.abs(right - left) || 1,
h,
)
}
},
)
}
import { Canvas2DPerRegionRenderingBackend } from '@jbrowse/render-core/perRegionRenderingBackend'
import { drawScoreBlocks } from './drawScore.ts'
import type { ScoreRegionData } from '../../ScoreRPC/rpcTypes.ts'
import type { ScoreRenderState } from './scoreTypes.ts'
import type { RenderBlock } from '@jbrowse/render-core/renderBlock'
// The base class owns renderBlocks (DPR-aware canvas sizing, then calls draw);
// this subclass implements only the pure paint step. Runs both as the WebGPU/
// WebGL2 fallback and as the SVG-export path.
export class Canvas2DScoreRenderer extends Canvas2DPerRegionRenderingBackend<
ScoreRegionData,
ScoreRenderState
> {
protected draw(
blocks: RenderBlock[],
regions: ReadonlyMap<number, ScoreRegionData>,
state: ScoreRenderState,
) {
drawScoreBlocks(this.ctx, regions, blocks, state)
}
}
Export the factory DisplayChrome will call from the same file. A Canvas2D-only
display returns its backend through createCanvas2DBackend, skipping
createRenderingBackend's WebGPU→WebGL2→Canvas2D ladder, which is the whole
difference from the GPU path's factory. plugins/sequence does exactly this:
// A Canvas2D-only display needs no separate factory file and no HAL ladder:
// createCanvas2DBackend just wraps the backend in the Promise DisplayChrome
// awaits. Swap in createRenderingBackend (and its createGpuBackend option) only
// once a profile shows Canvas2D can't hold 60fps.
export function SequenceRenderer(canvas: HTMLCanvasElement) {
return createCanvas2DBackend(canvas, c => new Canvas2DSequenceRenderer(c))
}
ScoreRenderState must include canvasWidth and canvasHeight (the
FrameDimensions the base class needs to size the backing store); add whatever
else your draw function reads:
// Recomputed cheaply every frame without fetching. Carries the canvas
// dimensions (required by the base class to size the backing store) plus the
// one setting the draw path reads.
export interface ScoreRenderState {
canvasWidth: number
canvasHeight: number
color: string
}
Step 5: The React component
DisplayChrome wraps your canvas with shared status chrome (loading scrim,
error bar, "region too large" banner) and wires the rendering-backend factory
and WebGL/WebGPU context-loss recovery. You give it your factory and render the
<canvas> from the canvasRef it hands back.
import DisplayChrome from '@jbrowse/display-kit/DisplayChrome'
import { observer } from 'mobx-react'
import { ScoreRenderer } from './ScoreRendererFactory.ts'
import type { LinearScoreDisplayModel } from '../model.ts'
// DisplayChrome supplies the display's chrome (loading scrim, error bar,
// region-too-large banner) and WebGL/WebGPU context-loss recovery, and is the
// only place useRenderingBackend is called. Its render-prop hands back the
// canvasRef to attach to the <canvas>.
const ScoreDisplayComponent = observer(function ScoreDisplayComponent({
model,
}: {
model: LinearScoreDisplayModel
}) {
return (
<DisplayChrome
model={model}
factory={ScoreRenderer}
testid="score-display"
style={{ width: '100%', height: model.height }}
>
{({ canvasRef }) => (
<canvas
ref={canvasRef}
style={{ width: '100%', height: '100%', display: 'block' }}
/>
)}
</DisplayChrome>
)
})
export default ScoreDisplayComponent
Step 6: Register the display
import { lazy } from 'react'
import { DisplayType } from '@jbrowse/core/pluggableElementTypes'
import { configSchema } from './configSchema.ts'
import { modelFactory } from './model.ts'
import type PluginManager from '@jbrowse/core/PluginManager'
const ScoreDisplayComponent = lazy(
() => import('./components/ScoreDisplayComponent.tsx'),
)
export default function LinearScoreDisplayF(pluginManager: PluginManager) {
pluginManager.addDisplayType(() => {
return new DisplayType({
name: 'LinearScoreDisplay',
configSchema,
stateModel: modelFactory(configSchema),
displayName: 'Score display (example)',
trackType: 'FeatureTrack',
viewType: 'LinearGenomeView',
ReactComponent: ScoreDisplayComponent,
})
})
}
Register the RPC method in the same plugin's install() with
pluginManager.addRpcMethod(() => new GetScoreData(pluginManager)), and see
custom track and display types for
how displays attach to a track type.
Hit-testing (clicks and hovers)
Hit-testing is plugin-owned and runs on the main thread, separately from
rendering. Build a spatial index (e.g.
Flatbush)
from rpcDataMap in a cached view, and query it from your React mouse handlers
against the cursor's (x, y). plugins/gwas's findManhattanHit.ts is a
worked example.
SVG export
drawScoreBlocks takes a Ctx2D, so SVG export calls it with an SvgCanvas
and emits vector output from the same code. Add a renderSvg action per
SVG export.
Moving to the GPU path
Everything above carries over unchanged — model, fetch chain, renderState,
hit-testing, and the Canvas2D renderer, which SVG export keeps using either way.
You add a .slang shader and a GPU renderer, and move the factory from
createCanvas2DBackend to createRenderingBackend. See
GPU displays.
In-tree references
plugins/sequence/src/LinearReferenceSequenceDisplay/- the simplest Canvas2D-only display (this guide mirrors it)plugins/gwas/src/LinearManhattanDisplay/- a real feature-plotting display (scored scatter) that ships both renderers behind one model, plus hit-testing and LD coloringplugins/canvas/src/LinearBasicDisplay/- the fullest reference: the generic feature display with the dual GPU + Canvas2D path