| name | svg-export |
|---|---|
| description | SVG export pipeline covering the renderSvg shape, the svgReady/settled readiness gates, paintLayer, and clip ids. Read when touching a display's renderSvg or export readiness. |
- The GPU shader path is an accelerator; the Canvas2D draw function is the source of truth, and SVG export runs it. So a shader-only tweak can't silently diverge the export.
- Every
renderSvg.tsxis one shape:await awaitSvgReady(model), mountSvgChromewitherrorandregionTooLarge, then a sync body callingpaintLayer(width, height, opts, ctx => drawXxxBlocks(ctx, …)). - Never inline
when(() => …), hand-rollif (model.error) return, or gate a body on data size. Those belong toawaitSvgReady,SvgChrome, and "render empty naturally". svgReadydeliberately excludescanvasDrawn(a headless export's canvas may never paint) and always carries a freshness axis, so an export fired right after a pan captures fresh data.settledis the separate on-screen capture gate:canvasDrawnplus that same freshness axis.- Anything draw-shaped goes through
paintLayer. Hand-rolled<rect>/<path>/<line>is a red flag, with three permitted exceptions (trivial chrome, bezier-arc overlays, shared React-SVG overlays). - Clip-path ids must be scoped by the owning model's
.id. SVG ids are document-global; a duplicate renders the second group unclipped.
SVG export and on-screen rendering share the same pure Canvas2D draw functions,
so a shader-only tweak can't silently diverge the export. Read this when
touching a display's renderSvg.tsx, the svgReady gate, or the on-screen
capture (settled) gate.
The rule that makes it all work: the GPU shader path is an accelerator; the
Canvas2D draw function is the source of truth, and SVG export runs it. See
ARCHITECTURE.md §"Keeping the two backends in parity."
Picked by whether there's a non-trivial builder step between fetched data and paint:
- Direct —
drawXxxBlocks(ctx, regions, blocks, state)is the only entry point;regionsIS the fetched data (or a 1:1 derived map likelaidOutDataMap). Both the on-screenCanvas2DXxxRenderer.renderBlocksandrenderSvg.tsxcall it directly. Plugins: canvas, MAF, HiC, LD, multi-variant-matrix, sequence, manhattan, dotplot. - With builder wrapper — fetched data needs transformation
(encode/filter/merge) before painting:
drawXxxBlocks(ctx, regions, blocks, state)paints a pre-built map (the on-screen renderer accumulates regions and calls this).drawXxxToCtx(ctx, sources, blocks, state)is a one-shot wrapper used byrenderSvg.tsx: it builds the regions map from observable sources, then callsdrawXxxBlocks. Plugins: alignments (merge pileup + arcs), wiggle / multi-wiggle (per-region encode), multi-variant (Record→Map + filter), multi-LGV-synteny (merge into layout map). Alignments exports a namedbuildAlignmentsRegionMapbecause the on-screensync(sources)reuses it.
Per-block-vs-monolithic is an upload/data-shape question (see ARCHITECTURE.md
§"Three upload patterns"), not a draw-API question. Whether a plugin needs a
drawXxxToCtx wrapper depends only on whether there's transformation between raw
data and paint.
All entry points take any 2D-context-shaped surface: a real
CanvasRenderingContext2D on-screen, an SvgCanvas for vector export.
Canvas2DXxxRenderer is bound (canvas required at construction) — SVG export
does not instantiate the renderer; it calls the pure functions directly.
Canonical references: builder-wrapper shape →
plugins/alignments/src/LinearAlignmentsDisplay/renderers/Canvas2DAlignmentsRenderer.ts
(buildAlignmentsRegionMap + drawAlignmentsToCtx + drawAlignmentBlocks);
direct shape → plugins/maf/src/LinearMafRenderer/drawMafBlocks.ts.
An async wrapper that awaits readiness and mounts the error-gated chrome, plus a sync body that paints. One file learned, all twelve known.
export async function renderSvg(model, opts?) {
await awaitSvgReady(model)
const view = getContainingView(model) as LGV
const height = model.height
return (
<SvgChrome
error={model.error}
regionTooLarge={model.regionTooLarge}
width={view.width}
height={height}
>
<XxxSvgBody model={model} view={view} height={height} opts={opts} />
</SvgChrome>
)
}
function XxxSvgBody({ model, view, height, opts }) {
const renderBlocks = buildRenderBlocks(view.visibleRegions)
return paintLayer(width, height, opts, ctx => {
drawXxxBlocks(ctx, model.rpcDataMap, renderBlocks, state)
// OR, for multi-source: drawXxxToCtx(ctx, sources, renderBlocks, state)
})
}Three invariants hold for every GPU display:
- Gate the read with
awaitSvgReady(model)— the one shared helper, re-exported from@jbrowse/plugin-linear-genome-view. Never re-inlinewhen(() => …). The duck-typed model interfaces eachextends SvgExportable({ svgReady; error; regionTooLarge }), so a missing field is a compile error, not a runtime hang. SvgChromeis the single terminal-state gate. Pass iterrorandregionTooLarge; never hand-rollif (model.error) return …or infer too-large from empty data. It renders the terminal itself (SVGErrorBoxon error, anSVGMessageBox"region too large" next) and paints children only when there's renderable data, so a body never runs in a terminal state. An over-budget or errored track exports a labeled box, not a silent blank.- Render empty naturally — never gate on data size.
awaitSvgReady+SvgChromealready own "still loading" and the terminal states, so asize === 0/numContacts === 0check in the body only ever fired for a loaded-but-empty region, and returningnullthere wrongly dropped a legitimate empty render (e.g. alignments' coverage axis). Every draw function is empty-safe (self-guards or map-lookup), so the body just draws.
The only guard a body keeps is a single TS narrow, and it means the same thing
everywhere: awaitSvgReady + SvgChrome guarantee the data is present and
non-terminal when the body runs, but TS can't see that invariant through the
field's type. Every such narrow is runtime-unreachable in the export path —
a type formality, not a loading branch. A body needs one only when it
destructures fields off a single nullable object; bodies that iterate the
rpcDataMap (an ObservableMap) or read individually-guarded getters need none,
because iterating an empty map is already valid.
So "nullable fetch" is not a category a display is — it's the shape (single blob vs per-region map) its fetch happens to take:
- Single nullable fetch object — HiC / LD (
if (!rpcData)), multi-variant / multi-variant-matrix (if (!cellData)). The monolithic-blob fetch storesnulluntil the dataset lands, and the body destructures fields off it.svgReady'sdataCurrentdisjunct is exactly what makes theSvgChromepass (!error && !regionTooLarge) imply the object is set. Drop any&& numContacts === 0size clause — the narrow alone is enough, and even it never fires. - MAF's
renderStateis the same narrow, not a distinct "still loading" category:renderStateisundefinedonly while!view.initialized || (!sources && loadedRegions.size === 0), andsvgReadyrequiresloadedRegions.size > 0, soif (!state) return nullis unreachable here too. (On-screen, the render autorun legitimately seesundefinedpre-load — a real branch there, just not in export.) - Sequence is the genuinely-different case: a terminal gate (
if (zoomedOut)) wired throughsvgReadyExtraTerminal, not a data narrow.
These narrows stay (rather than being deleted) only because the field is T | null at the type level and can't be made non-nullable without a fake
empty-blob sentinel that would just duplicate dataCurrent. Where a getter's
undefined came from view-shape alone, it was made non-nullable and the guard
deleted:
- alignments / multi-row-feature —
renderStatewasundefinedonly pre-view.initialized, unreachable at either real reader. Rule of thumb: if arenderStategetter's soleundefinedtrigger is!view.initialized, drop it and return a value. - wiggle / multi-wiggle / manhattan —
renderStatenow always builds: a real domain, else an inertEMPTY_PLOT_DOMAIN([0,1]) stub so a loaded-but-scoreless region still runsrenderBlocksto clear the canvas + flipcanvasDrawn. Nothing is plotted against the stub and the axis/legend is gated on the realdomain, so it never shows a fake scale. This is the one place a placeholder domain is unavoidable: the GPU render-state can't be constructed without a domain, yet an empty region must still paint (clear).
Every GPU display exposes a svgReady getter, and the off-screen renderer
awaits only that — never an inlined data != null || error || regionTooLarge.
The inline form resolved on the first datum (so multi-region/whole-genome
exports drew a partial viewport) and stayed true through an in-place refetch (so
a pan/zoom export captured stale data). svgReady fixes both.
It deliberately excludes canvasDrawn/isReady — an off-screen export runs
on a display whose on-screen canvas may never have painted (e.g. headless
jbrowse-img), so gating on the paint flag would hang forever.
One policy, five predicates. The formula is single-sourced in
computeSvgReady(terminals, dataCurrent) (@jbrowse/core/svg/svgReady, beside
awaitSvgReady) — the computeDisplayPhase treatment applied to export
readiness: error || regionTooLarge || extraTerminal || dataCurrent(), with
dataCurrent a thunk so a display under a banner doesn't subscribe to the
view's visibleRegions/loadedRegions churn. Every svgReady getter in the
tree calls it; what varies is only how that display answers dataCurrent, the
one freshness name every foundation exposes. Each of the five hand-written copies
this replaced was a place to forget a terminal (hang the export) or forget
freshness (capture a stale viewport) — both have shipped.
MultiRegionDisplayMixin(per-region streamed — canvas, alignments, MAF, manhattan, wiggle / multi-wiggle, multi-variant, multi-variant-matrix):dataCurrent=viewportWithinLoadedData && loadedRegions.size > 0. The spatial-coverage check waits for every visible region (not the first to stream in) and goes false the instant a pan/zoom moves the viewport past loaded data;loadedRegions.sizerules out the vacuously-true empty viewport.viewportWithinLoadedDatastays a separate getter — it is the raw coverage predicate the fetch autorun and loading overlay use.svgReadyExtraTerminalis the overridable hook the sequence display uses.GlobalFetchMixin(whole-view single-blob — HiC, LD, and arc): a global display has no per-region spatial axis, so it requires the single dataset to actually be current — deliberately notdisplayPhase !== 'loading', because the fetch trigger is a debouncedafterAttachautorun, so at export timeisLoadingcan be false with no data yet, and adisplayPhase !== 'loading'test would capture an empty render.dataCurrentis an overridable getter (defaultfalse) each display must implement. HiC and LD returnrpcData !== null && viewportMatchesLastDrawn(…)(comparing thesetLastDrawnViewportsnapshot committed alongsidesetRpcDatato the liveoffsetPx/bpPerPx); arc compares a region signature. Presence alone (rpcData !== null) would leave an in-place-refetch gap: a pan/zoom export resolving on the pre-pan matrix during the debounce+RPC window, since neither fetch clearsrpcDataat refetch start. A display that forgets to overridedataCurrentmakessvgReadyunable to resolve on a successful load, soawaitSvgReadynever returns and the export hangs. There is no time bound on that wait (svgReadyis a terminal state, so it resolves once the fetch it observes settles); a missingdataCurrentoverride shows up as an export that never finishes, not as a diagnostic.
Every view's renderToSvg opens with the same wait, and it has the same failure
mode one level up: view.initialized folds in the assemblies, so an assembly
that fails to load leaves it false forever and a bare
when(() => model.initialized) hangs the export with the dialog spinner up and
nothing said. Use awaitViewInitialized(model)
(@jbrowse/core/svg/svgReady) — it waits on initialized || error and throws
the error when the view never initialized, which surfaces in the dialog's error
banner. An error on an initialized view is not fatal: the export proceeds and
the errored track renders its own box.
This is why every view's error must be resolved (fold in assembly errors
and, for the composed views, the sub-views'), not just its raw volatileError —
the same rule that makes showLoading fall back to the import form instead of
spinning. LGV, dotplot and linear-comparative are on the helper; circular and
breakpoint-split still inline the bare when().
The sequence display adds one extra terminal disjunct — it overrides
svgReadyExtraTerminal to return zoomedOut, because zoomed past its
base-render threshold it shows a static "zoom in" message and issues no fetch,
so svgReady alone would never resolve.
They don't track loadedRegions/displayPhase the same way, but they run the
same computeSvgReady policy:
- Arc / paired-arc are still LGV track displays and compose
GlobalFetchMixin, so they getsvgReadyfrom it and override onlydataCurrent:isDataCurrent(loadedRegionSignature, currentRegionSignature(self)). Drawing all features into a single array (gated byRegionTooLargeMixin), the signature freshness compare makes an export fired right after a pan/zoom wait for fresh arcs instead of capturing stale ones. - Multi-LGV synteny is non-LGV (a
LinearSyntenyViewlevel composing onlyBaseDisplaywith its own fetch) yet rectangular, so it keeps the sharedSvgChrome+awaitSvgReadycontract, callingcomputeSvgReadydirectly withdataCurrent=ready && !refetching && dataCurrent(ready=featureData !== undefined). It needs BOTH freshness terms —!refetchingcovers the in-flight RPC, but a debounced fetch (500ms) leaves a pre-refetch window where a region/zoom change has invalidated the held data yetfetchinghasn't flipped true, so!refetchingalone still resolves on stale ribbons.dataCurrent(loadedFetchKey === currentFetchKey) closes that window exactly as arc's signature does. It has noregionTooLargestate, so itsSvgChromeis passederroronly.SvgChromeis not LGV-specific — it is the terminal chrome for any rectangular display, and synteny and dotplot are the proof. Synteny is also the one case where the chrome sits a level above the display: every synteny display in a level paints the same full-height band, soSVGSyntenyLevelowns oneSvgChromeover the errors combined across the level (mirroringLevelSyntenyCanvas's single banner). A per-display error box would cover its siblings' ribbons.
Every display foundation answers one question under one name — dataCurrent:
does the held data correspond to what is on screen right now? What differs is
only how it is computed, and there are three ways:
| Mechanism | Foundation | Implementation |
|---|---|---|
| Spatial coverage | MultiRegionDisplayMixin |
viewportWithinLoadedData && loadedRegions.size > 0 |
| Viewport-snapshot compare | GlobalFetchMixin (HiC, LD) |
viewportMatchesLastDrawn(…) |
| Signature compare | GlobalFetchMixin (arc), synteny, dotplot |
isDataCurrent(loaded, current) |
Consumers — computeSvgReady, the settled capture gates, BreakpointSplitView's
overlays — read dataCurrent and never the mechanism, so a display composes a
freshness answer rather than choosing which of three names to expose.
isDataCurrent(loadedSignature, currentSignature) (@jbrowse/core/util,
loaded !== undefined && loaded === current) is the shared rule for the third
row: arc / paired-arc (region-key signature), dotplot + linear-comparative
synteny (fetch-input signature). The view-specific part (how each builds its
signature) stays per-display; only the final compare is shared.
svgReady gates the off-screen SVG export; a separate gate, settled, gates the
on-screen GPU canvas for screenshot capture and browser tests. Dotplot
(DotplotView.settled → dotplot_webgl_canvas_done) and multi-LGV synteny
(LinearSyntenyViewHelper.settled → synteny_canvas_done) each expose it: a
testid the capturer waits on so it never snapshots a mid-render frame.
It is canvasDrawn && every display (!loading && !refetching && dataCurrent) —
the same dataCurrent freshness axis as svgReady, for the same reason.
Without it, the debounce gap bites capture harder than export: dotplot's
init-time autoDiagonalize reorders the query axis, and for ~1s afterward no
fetch is in flight, so the stale rpcData (absolute-cumBp positions computed for
the OLD order) gets redrawn against the NEW axes — a diagonal-looking hairball —
and settled fired on it. This only reproduces on a cold cache (the refetch
loses the race with capture); warm reruns hide it, which is why it read as
"flaky per-environment." dataCurrent makes the gate honest.
Signatures: dotplotFetchKey (lodMode + per-axis bpPerPx + displayed-region
refName/start/end/reversed + the snapped h-axis fetch window); synteny composes
currentFetchKey from its
existing tracked-dep getters (fetchRegionsKey, bpPerPxBucketKey, region
order, CIGAR/marker opts, LOD). Dotplot additionally keeps an
autoDiagonalizeRequested/Complete pair: dataCurrent catches
reordered-but-stale data, but a skipped/errored diagonalize never reorders at
all, so its (correctly-fetched, un-diagonalized) data is dataCurrent — that
pair makes settled wait for the reorder to actually run, else the capture
times out loudly rather than commit an un-diagonalized plot.
Displays outside the LGV mixins still expose a svgReady getter and await it via
the shared awaitSvgReady — never an inlined when(). Both below run
computeSvgReady with regionTooLarge: false (neither gates on region size) and
supply their own dataCurrent thunk:
- dotplot:
!!geometry && dataCurrent(which makes it stale-safe, matching the capture gate above). Its plot area is rectangular, so it mounts the sharedSvgChromelike every other rectangular display — passingerroronly, since it has noregionTooLargestate. - circular chord:
ready— a chord fetch covers the whole view at once, so "features arrived" is the whole freshness axis. This is the one genuinely bespoke error UI (<DisplayError>): a radial display has no width/height box to host a message rect.
So the readiness gate is uniform across every display (LGV, arc, synteny,
dotplot, circular), and so is the error chrome except for the radial one.
SvgChrome is not LGV-specific — synteny and dotplot are the proof.
paintLayer (@jbrowse/core/util/paintLayer) decides between a 2× DPR raster
canvas (when opts.rasterizeLayers) and an SvgCanvas, returning one
ReactNode (<image xlinkHref=…> or <g dangerouslySetInnerHTML=…>). Raster
mode bakes the 2× DPR scaling into the embedded PNG; vector mode serializes the
SvgCanvas call log to SVG markup. Either way the caller draws to Ctx2D in CSS
pixels — no manual DPR.
Avoid hand-rolled JSX-SVG inside renderSvg.tsx. Anything draw-shaped
(rects, paths, fills, strokes) should go through paintLayer so both raster and
vector modes work and the on-screen draw code can be shared. Hand-rolled
<rect>/<path>/<line> inside renderSvg.tsx is a red flag — it can't
rasterize, drifts from on-screen output, and locks in vector output.
Permitted exception classes (only these — anything else is a regression):
- Trivial chrome: scalebars, single separator lines, clipPath wrappers,
transform
<g>for offsetting an already-paintLayer'd block. Use<SvgClipRect>from@jbrowse/plugin-linear-genome-viewfor the clipPath+rect pair. - Bezier-arc overlays (sashimi in
plugins/alignments, paired arcs inplugins/alignmentsandplugins/arc): low element count, native SVG<path>gives hover/tooltip behavior raster can't match. Math comes from a sharedcomputeXxxArcs(opts) → Arc[]so overlay and export consume identical geometry. Don't add a new "vector by design" exception just because something is "interactive" — these already render as JSX on-screen, so the JSX path is the on-screen path. - Shared React-SVG overlays the on-screen view also uses (
VariantLabels,LinesConnectingMatrixToGenomicPosition,RecombinationTrack,MultiSampleVariantRowColors,SvgRowLabels/SvgTreePathfrom@jbrowse/tree-sidebar). Same component renders on-screen + in export via anexportSVGprop. The heavy raster-friendly fill path (the matrix itself) must still go throughpaintLayer; only the overlays stay JSX.
Everything else — fills, glyphs, mismatches, coverage bins, score bars, ribbons,
dot lines, sequence text — goes through paintLayer(width, height, opts, ctx => drawXxx{Blocks,ToCtx}(ctx, …)). This kills the older "SVG-only renderToCtx"
pattern that drifted out of sync with the on-screen renderer (different bicolor
handling, Y-axis offsets, bezier curves, palettes — each plugin had its own
flavor of drift).
Shared utilities (@jbrowse/core/util/):
createSvgRasterCanvas(width, height, opts)— the 2× DPR canvas +opts.createCanvasfallback ritual.PaintLayer({ width, height, opts, paint }) → ReactNode— raster-vs-vector dispatch (@jbrowse/core/util/paintLayer).SvgExport—SVGErrorBox(red error banner) +SvgClipRect(clipPath wrapper), in@jbrowse/core/svg/SvgExport.Ctx2D = CanvasRenderingContext2D | SvgCanvas— the shared type alias everydrawXxxBlockssignature uses.
Every id on a <clipPath>/<use> must be scoped by the owning view or
display model's unique .id — never a bare literal like "clip-ruler", and
never derived only from trackId/block key/array index. SVG ids are
document-global: a second <clipPath id="x"> wins nothing; browsers resolve
every url(#x) to the first match, so the second clipped group renders
unclipped. This is invisible in isolation and only surfaces once two view panels
land in the same document — synteny rows, breakpoint-split panels.
exportAndVerifySvg in products/jbrowse-web/src/tests/util.tsx asserts no
duplicate ids as a regression guard; prefer SvgClipRect over hand-rolled
<defs><clipPath> for new clip ids.
The one sanctioned exception is SvgCanvas.clip()
(packages/core/src/util/SvgCanvas.ts), which mints ids from a module-level
counter (svgcanvas-clip-${clipIdCounter++}). It's safe — a process-global
monotonic counter is unique across every canvas and export in the document — and
it has no model to scope to (it's the Canvas2D-shim path, driven by imperative
ctx.clip() calls with no MST node in scope). Don't "fix" it to use .id; do
keep new component-level clip ids on .id.