SVG export
TL;DR: implement renderSvg() on your display by returning
renderDisplaySvg(model, opts, YourSvgBody) and painting through PaintLayer.
It is optional — a display without one is left out of the export, and the user
is told which tracks were left out.
The Linear Genome View's exportSvg() action calls each visible display's
renderSvg(), collecting the returned React nodes and rendering them into a
server-side SVG via renderToSvg.
A display that implements no renderSvg is dropped from the export the same way
a minimized track is, and dropped at the same point — before the legend is
measured and before the height is reserved, so it leaves no labelled gap where
the track would have been. The export notifies the session once, naming the
tracks it left out and why. The rest of the export proceeds normally, so a
display type that never implements one is absent from the figure.
PaintLayer
PaintLayer from @jbrowse/core/util/paintLayer drives both on-screen and
export drawing from one callback. It is a component rendering either an
<image> or a <g>, and callers don't branch on which:
import { SvgCanvas } from './SvgCanvas.ts'
import { createSvgRasterCanvas } from './createSvgRasterCanvas.ts'
import type { SvgRasterCanvasOpts } from './createSvgRasterCanvas.ts'
import type React from 'react'
// Shared 2D-context type for the SVG-export draw pipeline. Real
// CanvasRenderingContext2D when rasterizing to PNG; SvgCanvas when emitting
// vector. Most plugin draw functions duck-type against this union.
export type Ctx2D = CanvasRenderingContext2D | SvgCanvas
export type PaintLayerOpts = SvgRasterCanvasOpts & {
rasterizeLayers?: boolean
}
/**
* Paint into either a 2× rasterize canvas (PNG-embedded as <image>) or an
* SvgCanvas (serialized into a <g>). Renders one element — callers don't
* branch on which mode was picked.
*
* Used by every renderSvg.tsx that has a heavy draw path: the same `paint`
* callback runs on both surfaces, with `paint(ctx)` doing whatever drawing
* the plugin needs in logical coordinates (the raster canvas is pre-scaled, so
* callbacks never deal with devicePixelRatio). Width 0 or height 0 falls
* through to the vector branch (canvas creation rejects 0×0).
*
* A vector layer whose `paint` drew nothing renders nothing — not an empty
* `<g>`. Layers are routinely conditional on data (a highlight pass with no
* highlighted feature, a legend-less track, a band that is switched off), and
* every such layer was leaving a stray group in the file for a reader to open
* and find empty. The raster branch has no cheap equivalent — asking whether a
* canvas is blank means reading its pixels back — and it does not need one: a
* fully transparent PNG is a couple of hundred bytes.
*/
export function PaintLayer({
width,
height,
opts,
paint,
}: {
width: number
height: number
opts?: PaintLayerOpts
paint: (ctx: Ctx2D) => void
}): React.ReactNode {
if (opts?.rasterizeLayers && width > 0 && height > 0) {
const { canvas, ctx } = createSvgRasterCanvas(width, height, opts)
paint(ctx)
return (
<image
width={width}
height={height}
xlinkHref={canvas.toDataURL('image/png')}
/>
)
}
const svg = new SvgCanvas()
paint(svg)
const markup = svg.getSerializedSvg()
return markup ? (
// eslint-disable-next-line @eslint-react/dom-no-dangerously-set-innerhtml
<g dangerouslySetInnerHTML={{ __html: markup }} />
) : null
}
The surface comes from opts:
- With
opts.rasterizeLayersset it draws to an offscreen 2x canvas and embeds a PNG. - Otherwise it draws to
SvgCanvas, aCanvasRenderingContext2Dduck-type emitting<rect>,<text>,<path>. Passundefinedforoptsto force this vector output; do that for text and labels so they stay crisp.
Anything draw-shaped should go through it. Hand-rolled
<rect>/<path>/<line> is a red flag, the exceptions being trivial chrome
and React-SVG overlays shared with the on-screen path.
Implementing renderSvg
Create renderSvg.tsx
Every LGV renderSvg is the same shape, and the shape is a function call:
renderDisplaySvg(model, opts, YourSvgBody). The shell awaits readiness,
resolves the view geometry once, and mounts the terminal-state gate around your
body — so do not write when(() => ...), an if (model.error) return, or
an SvgChrome of your own. Body is a component so that it never runs in a
terminal state.
A display whose data failed to load fails the whole export: the export dialog shows the error and saves nothing. The one terminal an export does draw is "region too large", which is a state the user navigated to on purpose.
LinearReferenceSequenceDisplay is the whole pattern in one file:
import { svgNodeId } from '@jbrowse/core/svg/svgId'
/* eslint-disable react-refresh/only-export-components */
import { resolvePalette } from '@jbrowse/core/ui/palette'
import { PaintLayer } from '@jbrowse/core/util/paintLayer'
import { renderDisplaySvg } from '@jbrowse/display-kit/renderDisplaySvg'
import { SvgClipRect } from '@jbrowse/plugin-linear-genome-view'
import { drawSequenceBlocks } from './components/drawSequence.ts'
import { buildColorPalette } from './components/sequenceGeometry.ts'
import type { DrawSequenceState } from './components/drawSequence.ts'
import type { SequenceRegionData } from './model.ts'
import type { LgvSvgBodyProps } from '@jbrowse/display-kit/renderDisplaySvg'
import type { ExportSvgDisplayOptions } from '@jbrowse/display-kit/types'
import type {
LinearGenomeViewModel,
SvgExportable,
} from '@jbrowse/plugin-linear-genome-view'
interface SequenceDisplayModel extends SvgExportable {
id: string
view: LinearGenomeViewModel
height: number
sequenceData: ReadonlyMap<number, SequenceRegionData>
renderState: DrawSequenceState
// terminal static-message state (zoomed past base resolution, or every row
// toggled off), folded into svgReady via fetchInert; still read
// here to skip painting bases
placeholderMessage: string | undefined
}
export async function renderSvg(
model: SequenceDisplayModel,
opts?: ExportSvgDisplayOptions,
) {
return renderDisplaySvg(model, opts, SequenceSvgBody)
}
function SequenceSvgBody({
model,
height,
canvasWidth,
renderBlocks,
opts,
}: LgvSvgBodyProps<SequenceDisplayModel>) {
const { sequenceData } = model
// the terminal static-message state (no fetch); an empty but loaded
// sequenceData still paints naturally below.
if (model.placeholderMessage) {
return null
}
// The export theme can differ from the session theme, so rebuild the palette
// here and reuse the rest of the live renderState.
const state: DrawSequenceState = {
...model.renderState,
// canvasWidth is the block scissor bound, so it has to be the width this
// layer is actually painted at — see LgvSvgBodyProps.canvasWidth.
canvasWidth,
palette: buildColorPalette(
resolvePalette({ configTheme: opts?.theme }),
model.view.colorByCDS,
),
}
// Sequence is text-heavy; routed through PaintLayer so rasterizeLayers can
// PNG-embed when set, but the default (vector) path keeps letters crisp.
return (
<SvgClipRect
id={`sequence-clip-${svgNodeId(model)}`}
width={canvasWidth}
height={height}
>
<PaintLayer
width={canvasWidth}
height={height}
opts={opts}
paint={ctx => {
drawSequenceBlocks(ctx, sequenceData, renderBlocks, state)
}}
/>
</SvgClipRect>
)
}
Add the action to your display model
/**
* #action
* Dynamic import so the export path — and everything it pulls in — stays
* out of the bundle until someone actually exports.
*/
async renderSvg(opts: ExportSvgDisplayOptions) {
const { renderSvg } = await import('./renderSvg.tsx')
return renderSvg(self as LinearMafDisplayModel, opts)
},
Coordinate system
Paint at the canvasWidth the shell hands your body, never at
model.renderState.canvasWidth. The on-screen render state carries
view.trackWidthPx — view.width minus the 2px track outline the export does
not draw — and that same number is the block scissor bound, so painting an
export at it clips the rightmost 2px column of content inside a view.width
frame. LinearMultiRowFeatureDisplay shipped exactly that bug. A body reusing
model.renderState has to override canvasWidth with the prop, as the sequence
body above does.
The Y axis runs 0 (top) to model.height (bottom), same as on-screen.
Horizontal placement comes from renderBlocks, which gives { startPx, endPx }
per region: take it off the body's props, where renderDisplaySvg has resolved
buildRenderBlocks(view.visibleRegions) once, for the same reason it resolves
canvasWidth. (MultiRegionDisplayMixin exposes a renderBlocks getter of the
same expression, for the on-screen path.)
Clip-path ids must be scoped by the owning model's .id — SVG ids are
document-global, and a duplicate renders the second group unclipped.
Reusing on-screen drawing code
The GPU shader path is an accelerator, the Canvas2D draw function is the source of truth, and SVG export runs it. A shader-only tweak therefore leaves the export unchanged.
Write drawing functions against Ctx2D and call them from both the on-screen
renderer and renderSvg — the sequence body above calls the same
drawSequenceBlocks its Canvas2D renderer does:
// Shared 2D-context type for the SVG-export draw pipeline. Real
// CanvasRenderingContext2D when rasterizing to PNG; SvgCanvas when emitting
// vector. Most plugin draw functions duck-type against this union.
export type Ctx2D = CanvasRenderingContext2D | SvgCanvas
Reference examples
Simplest to most complex:
plugins/sequence/src/LinearReferenceSequenceDisplay/renderSvg.tsx- text onlyplugins/wiggle/src/LinearWiggleDisplay/renderSvg.tsx- score plot with scale barplugins/canvas/src/LinearBasicDisplay/renderSvg.tsx- features + labels layersplugins/alignments/src/LinearAlignmentsDisplay/renderSvg.tsx- coverage, pileup, arcs
See also
- Custom track and display types
- GPU displays
- SVG_EXPORT.md
— the pipeline behind this page: the
svgReady/settledreadiness gates an export waits on, and how clip ids are kept unique across displays