Comparing two genomes
Human and mouse at BRCA1: a synteny view is two ordinary linear views plus a ribbon band, so every page above applies to each row.
Two linear views and a ribbon band
Human and mouse at BRCA1, with UCSC’s hg38→mm39 liftOver between them. Drag either row. They move independently, and the ribbons follow.
Almost nothing here is new. A LinearSyntenyView holds ordinary linear genome
views in views, so every page above applies to each row unchanged:
useWidthSetter, usePanZoom, getTrack(id).activeDisplay.RenderingComponent.
The outer view answers view.status with the same four values, so the gate is
the same one too — and here it earns its keep, since initialized waits on
every row and a failure in either assembly leaves it false for good. What it
adds is levels: one band between each pair of rows, drawn by
LevelSyntenyCanvas on the same GPU backend the rest of JBrowse renders with.
The alignment is a PIF: a PAF sorted and indexed on both sides, so the view
fetches only what covers the window. jbrowse make-pif builds one from any PAF.
The index here is a .csi, hence csi: true. drawCurves: true bends the
ribbons, which matters once the rows are offset. cigarMode: 'matches' leaves
the indel wedges see-through.
A different package for the engine
import { createViewState } from '@jbrowse/react-app2'
Every other page imports @jbrowse/react-linear-genome-view2, whose session has
exactly one view slot, welded to LinearGenomeView. Two views need a session
whose views are an array, which is @jbrowse/react-app2.
Width is set once, on the synteny view: setWidth fans out, so rows cannot
disagree about how wide they are. Panning is bound per row, which is what lets
them move independently.
View source — 465 lines
import { Suspense, useRef, useSyncExternalStore } from 'react'
import { SessionPaletteProvider } from '@jbrowse/core/ui/PaletteContext'
import { useCreateOnce, useWidthSetter } from '@jbrowse/core/util/hooks'
import { usePanZoom } from '@jbrowse/core/util/usePanZoom'
import { DisplayUIProvider, TrackOverlaySlot } from '@jbrowse/display-ui'
import {
LevelSyntenyCanvas,
type LinearSyntenyViewHelperModel,
type LinearSyntenyViewModel,
} from '@jbrowse/plugin-linear-comparative-view'
import { createViewState } from '@jbrowse/react-app2'
import { observer } from 'mobx-react'
// Human and mouse at BRCA1, one above the other, and the ribbons that say which
// piece of one is which piece of the other. Drag either row -- they move
// independently, and the ribbons follow.
//
// The thing worth noticing is how little of this is new. A synteny view is a
// view whose `views` are **ordinary linear genome views**, so every page before
// this one applies to each row unchanged: `useWidthSetter`, `usePanZoom`,
// `getTrack(id).activeDisplay.RenderingComponent`. What the synteny view adds is
// `levels` -- one band between each pair of rows -- and the band is a component
// over a model like everything else.
//
// The engine comes from a DIFFERENT published package here, and that is the
// whole reason this page exists at the end. `@jbrowse/react-linear-genome-view2`,
// which every other page uses, has a session with exactly one view slot, welded
// to `LinearGenomeView` -- deliberately, since it is the single-view product.
// Two views need a session with a views *array*, and that is
// `@jbrowse/react-app2`. Nothing else about the technique changes; if you have
// read the pages above, you can already read the rest of this file.
//
// Self-contained, like every page here: nothing below is imported from the rest
// of this site, so you can copy the file and run it.
// Human and mouse, ~90 My apart. Only the chromosome names are needed to lay
// out a coordinate space, so the assemblies are `.chrom.sizes` rather than a
// FASTA -- there is no base-level zoom on this page, and skipping the sequence
// keeps two whole genomes cheap.
const assemblies = [
{
name: 'hg38',
sequence: {
type: 'ReferenceSequenceTrack',
trackId: 'hg38-ref',
adapter: {
type: 'ChromSizesAdapter',
uri: 'https://jbrowse.org/ucsc/hg38/hg38.chrom.sizes',
},
},
},
{
name: 'mm39',
sequence: {
type: 'ReferenceSequenceTrack',
trackId: 'mm39-ref',
adapter: {
type: 'ChromSizesAdapter',
uri: 'https://jbrowse.org/ucsc/mm39/mm39.chrom.sizes',
},
},
},
]
// UCSC's hg38->mm39 liftOver chain, converted to PIF: a PAF sorted and indexed
// on both sides, so the view fetches only the alignments over the window
// instead of reading the whole file into memory. That is the difference between
// this and `PAFAdapter`, and it is what makes a whole-genome alignment usable
// as a track -- `jbrowse make-pif` produces one from any PAF.
//
// Note the two `assemblyNames`. The track's says which pair of rows it can sit
// between; the adapter's is [query, target] and says which way round the file
// is written.
const syntenyTrack = {
type: 'SyntenyTrack',
trackId: 'hg38_mm39',
name: 'Human vs mouse (UCSC liftOver)',
assemblyNames: ['hg38', 'mm39'],
adapter: {
type: 'PairwiseIndexedPAFAdapter',
uri: 'https://jbrowse.org/ucsc/hg38/liftOver/hg38ToMm39.over.pif.gz',
// the index is a .csi beside the file, not the .tbi the shorthand assumes
csi: true,
assemblyNames: ['mm39', 'hg38'],
},
}
// One gene track per row -- ordinary feature tracks, nothing comparative about
// them. A row is just a linear genome view, so this is the same track config it
// was five pages ago; the only reason there are two is that there are two
// genomes.
const geneTracks = [
{
type: 'FeatureTrack',
trackId: 'hg38_genes',
name: 'RefSeq curated (human)',
assemblyNames: ['hg38'],
adapter: {
type: 'Gff3TabixAdapter',
uri: 'https://jbrowse.org/ucsc/hg38/ncbiRefSeqCurated.gff.gz',
csi: true,
},
// One transcript per gene, on both rows. `auto` would pick this at
// whole-genome zoom and `all` here, and `all` is the wrong answer for a
// comparison: human BRCA1 has an order of magnitude more annotated
// isoforms than mouse Brca1, so the rows would differ in height for a
// reason that is about annotation depth rather than about the genes.
displayDefaults: { height: 110, geneGlyphMode: 'longestCoding' },
},
{
type: 'FeatureTrack',
trackId: 'mm39_genes',
name: 'RefSeq curated (mouse)',
assemblyNames: ['mm39'],
adapter: {
type: 'Gff3TabixAdapter',
uri: 'https://jbrowse.org/ucsc/mm39/ncbiRefSeqCurated.gff.gz',
csi: true,
},
// One transcript per gene, on both rows. `auto` would pick this at
// whole-genome zoom and `all` here, and `all` is the wrong answer for a
// comparison: human BRCA1 has an order of magnitude more annotated
// isoforms than mouse Brca1, so the rows would differ in height for a
// reason that is about annotation depth rather than about the genes.
displayDefaults: { height: 110, geneGlyphMode: 'longestCoding' },
},
]
// BRCA1, and its mouse ortholog Brca1. Coordinates are the RefSeq curated spans
// plus a little air: hg38 chr17:43,044,295-43,125,364 and mm39
// chr11:101,379,590-101,442,781.
//
// The gene is the point of opening here rather than at whole-genome zoom. At
// ~90 My the liftOver chain has nothing to say about most of an intron, so the
// ribbons collapse onto the coding exons -- the alignment picks out exactly the
// part of the gene that had to stay put, against a human copy ~25% longer than
// the mouse one.
const HUMAN_LOC = 'chr17:43,040,000..43,130,000'
const MOUSE_LOC = 'chr11:101,375,000..101,447,000'
/**
* `createViewState` from the app product takes its config in one blob and
* builds the session from `defaultSession`, rather than the single-view
* product's `setInit` on a view that already exists. Same idea, one level up:
* declare what you want and let the engine resolve assemblies in the right
* order.
*
* `init` on the view snapshot is the synteny view's own version of `setInit`:
* per row an assembly, where to open it and which tracks to show, and per band
* which synteny track goes in it. The view resolves both assemblies first, then
* builds a linear genome view for each row -- so a row arrives already at its
* gene, rather than at the whole genome and then navigating.
*/
function makeView() {
const state = createViewState({
config: {
assemblies,
tracks: [syntenyTrack, ...geneTracks],
defaultSession: {
name: 'synteny',
views: [
{
type: 'LinearSyntenyView',
init: {
views: [
{
assembly: 'hg38',
loc: HUMAN_LOC,
tracks: ['hg38_genes'],
},
{
assembly: 'mm39',
loc: MOUSE_LOC,
tracks: ['mm39_genes'],
},
],
tracks: ['hg38_mm39'],
// Bezier ribbons rather than straight chords. Two rows opened at
// orthologous genes are offset from each other, so every chord
// runs at a slant; curves leave the two ends vertical and only
// bend in the middle, which is what makes a stack of them
// readable instead of a hatch pattern.
drawCurves: true,
// 'matches' leaves the indel wedges see-through and paints only
// the aligned runs. Human BRCA1 is ~25% longer than the mouse
// copy, so at 'full' the wedges for that extra sequence are the
// largest coloured areas on screen and the conserved exons --
// the thing worth seeing -- are the thin bits between them.
cigarMode: 'matches',
},
},
],
},
},
})
// see the Pan and zoom example: scroll-to-zoom is a session preference, so
// this one call covers both rows and the band between them
state.session.setScrollZoom(true)
// `views` is a pluggable MST array, so its element type is resolved at
// runtime and there is nothing narrower than this to assert. The published
// `LinearSyntenyViewModel` is the type to assert to.
return {
session: state.session,
view: state.session.views[0] as LinearSyntenyViewModel,
}
}
type SyntenyView = ReturnType<typeof makeView>['view']
type BrowserView = SyntenyView['views'][number]
const TrackRow = observer(function TrackRow({
view,
trackId,
}: {
view: BrowserView
trackId: string
}) {
// `view.getTrack(id)`, not a scan of `view.tracks` comparing
// `configuration.trackId` by hand: the view keeps a map for exactly this. The
// guard stays -- a ready `view.status` says the view can draw, not that your
// track is instantiated yet.
const track = view.getTrack(trackId)
if (!track) {
return null
}
const display = track.activeDisplay
const { RenderingComponent } = display
// `TrackOverlaySlot`, not a plain sized div. A display draws floating chrome
// of its own -- a colour key, a corner control, the loading and error states
// -- and `contain: strict` seals that into its own stacking context, where
// nothing you paint over the stack can be out-z-indexed. The slot is the node
// it portals into, mounted beside the sandbox, and it is what JBrowse's own
// track container mounts. See the Track settings page.
return (
<TrackOverlaySlot zIndex={3} style={{ height: display.height }}>
<div style={{ position: 'absolute', inset: 0, contain: 'strict' }}>
<Suspense fallback={null}>
<RenderingComponent
model={display}
onHorizontalScroll={view.horizontalScroll}
/>
</Suspense>
</div>
</TrackOverlaySlot>
)
})
// The box `usePanZoom`'s handlers go on -- see the Pan and zoom page for what
// each property is doing, and for the one the hook writes itself. One per row
// here, which is what lets the rows move independently.
const viewport: React.CSSProperties = {
position: 'relative',
overflow: 'hidden',
cursor: 'grab',
}
/**
* One genome row.
*
* `usePanZoom` is bound per row, against that row's own view, which is what
* makes the rows move independently -- the gesture layer never knew about
* synteny and does not need to. The width is NOT set here: the synteny view
* takes one width and fans it out to every row, so there is a single
* `useWidthSetter` at the bottom of this file rather than one per row.
*/
const SyntenyRow = observer(function SyntenyRow({
view,
label,
}: {
view: BrowserView
label: string
}) {
const ref = useRef<HTMLDivElement>(null)
const { containerProps } = usePanZoom(ref, view)
return (
<div ref={ref} {...containerProps} style={viewport}>
<div
style={{
fontSize: '0.7rem',
opacity: 0.7,
padding: '2px 4px',
userSelect: 'none',
}}
>
{label}
</div>
{view.status.type === 'ready'
? view.tracks.map(track => (
<TrackRow
key={track.configuration.trackId}
view={view}
trackId={track.configuration.trackId}
/>
))
: null}
</div>
)
})
/**
* The band between two rows.
*
* `LevelSyntenyCanvas` is the ribbon layer, and it is the one piece of this
* page you could not write yourself: it drives the same GPU rendering backend
* the rest of JBrowse draws with, so a hand-rolled substitute would be a worse
* copy of the engine rather than chrome of your own. It is absolutely
* positioned, so the band owns the height -- `level.height`, not whatever
* happens to be drawn in it.
*
* The per-display `RenderingComponent` on top is the interactive half: the
* tooltip, the right-click menu and the fetch status. It comes off the display
* model rather than an import, exactly like a track's does. Its layer takes no
* pointer events except where those states put them back.
*
* `LinearSyntenyViewHelperModel` on the prop rather than `view.levels[number]`.
* That array is declared loosely inside the plugin to break a type cycle
* between the view, the level and the display, so an element off it is `any` --
* and so is everything you then read from one, including the displays below.
* Naming the published type here is what gets them back; JBrowse's own
* comparative render area does the same on the same value.
*/
const Ribbons = observer(function Ribbons({
level,
}: {
level: LinearSyntenyViewHelperModel
}) {
return (
<div style={{ position: 'relative', height: level.height }}>
<LevelSyntenyCanvas model={level} />
{level.linearSyntenyDisplays.map(display => (
<div
key={display.id}
style={{
position: 'absolute',
inset: 0,
zIndex: 1,
pointerEvents: 'none',
overflow: 'hidden',
}}
>
<Suspense fallback={null}>
<display.RenderingComponent model={display} />
</Suspense>
</div>
))}
</div>
)
})
// A display paints no background of its own -- its labels are drawn straight
// onto whatever is behind them, so light-theme text on a dark page is near-black
// on near-black. This is the page's own answer to "which mode am I in".
function readSiteMode(): 'light' | 'dark' {
const chosen = document.documentElement.dataset.theme
if (chosen === 'light' || chosen === 'dark') {
return chosen
}
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
}
// The two places that answer can change from. The site's toggle writes an
// attribute on <html> and the OS preference arrives as a media query, and
// either can move without the other, so both are watched.
function watchSiteMode(onChange: () => void) {
const observer = new MutationObserver(onChange)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
})
const media = window.matchMedia('(prefers-color-scheme: dark)')
media.addEventListener('change', onChange)
return () => {
observer.disconnect()
media.removeEventListener('change', onChange)
}
}
/**
* Follow whatever the page around this demo is themed as. All of this is the
* *host's* half, and yours will look nothing like it -- swap it for however
* your app already knows it is in dark mode.
*
* `useSyncExternalStore`, not `useState` + `useEffect`: the mode lives outside
* React, so this reads it *during* render rather than publishing one value and
* correcting it a paint later. The third argument is the server snapshot, for
* a reader pasting this into a framework that prerenders.
*
* JBrowse's half is one mount, `SessionPaletteProvider` below. It writes the
* config slot that *both* halves of the rendering derive from -- the palette
* React draws with, and the theme shipped to the worker that bakes feature
* labels into the image. `PaletteProvider` on its own is the near miss: it
* colours React and leaves those baked labels in the old mode.
*/
function useSiteMode() {
return useSyncExternalStore(
watchSiteMode,
readSiteMode,
() => 'light' as const,
)
}
// JBrowse's stock displays read a palette to colour their own *content*: the
// feature display wants a highlight colour, the synteny display wants its
// ribbon fills. That is a palette of colour strings, not a UI toolkit, so it
// arrives through `SessionPaletteProvider` and Material UI is not involved.
//
// One mount covers both rows and the band between them. The palette is
// session-wide, and a synteny view's rows are ordinary linear genome views
// sharing this session -- not separately themed browsers.
const SyntenyRibbons = observer(function SyntenyRibbons() {
const { view, session } = useCreateOnce(makeView)
const mode = useSiteMode()
// One measurement for the whole stack: `setWidth` on the synteny view assigns
// it to every row, so the rows cannot disagree about how wide they are.
const ref = useWidthSetter(view)
const { status } = view
return (
<SessionPaletteProvider session={session} mode={mode}>
<DisplayUIProvider>
<div ref={ref}>
{status.type === 'ready' ? (
view.views.map((row, i) => (
<div key={row.id}>
{i > 0 ? <Ribbons level={view.levels[i - 1]} /> : null}
<SyntenyRow
view={row}
label={row.assemblyNames[0] ?? `row ${i + 1}`}
/>
</div>
))
) : (
// A synteny view answers `view.status` with the same four values a
// linear one does, so this is the gate every other page here
// writes, on a view whose rows are themselves linear views.
//
// **Not `view.initialized`**, which is what this page used to gate
// on: that one waits on every row, so a failure in either assembly
// leaves it false for good and this box would say "loading" for as
// long as the tab stayed open. The status branch carries the error
// instead, and this view's `error` folds in its rows' -- so a 404
// on the mouse genome names itself here rather than stalling the
// pair.
<div
role={status.type === 'error' ? 'alert' : 'status'}
style={{ fontSize: '0.85rem', opacity: 0.7, padding: 8 }}
>
{status.type === 'error'
? `Could not load: ${status.error instanceof Error ? status.error.message : String(status.error)}`
: status.type === 'loading'
? status.message
: 'Nothing to show yet'}
</div>
)}
</div>
</DisplayUIProvider>
</SessionPaletteProvider>
)
})
export default SyntenyRibbons