Controlling the view
Navigate, zoom and show tracks from your own UI, and read the click back out of the session.
A location box, zoom buttons and a track list
Navigating, zooming and showing tracks are four calls and one getter on the view model. None is a component, so the toolbar above could be your app’s own header, three floors up the tree.
view.navToLocString(input) takes what a user would type: chr17,
chr17:43,044,295..43,125,364, or several regions separated by spaces. It is
async, and throws on anything it cannot resolve: a box with no .catch
looks like it ignored the typo.
view.showTrack(trackId) instantiates the track and its display from the config
with that id. hideTrack disposes it. Neither touches the config. The
checkboxes read view.tracks rather than a useState beside them, since a
second copy of the answer drifts as soon as anything else can show a track.
Use view.coarseVisibleLocStrings for anything a person looks at. It
recomputes on a 500ms tick. view.visibleLocStrings is live, and re-renders an
input every frame of a drag.
Two regions, and the line between them
Several regions lay out contiguously: no gap, no marker. The boundary comes from
the container JBrowse wraps around a track, not the display, so mounting
RenderingComponent yourself gets both regions and no seam.
RegionBoundaries below draws it from view.paddingSpans — {x, width, kind}
per span, where kind is a region’s right edge, the greyed ends of the genome,
or a region too narrow to draw. Those x values are in the staticBlocks frame,
so one wrapper translated by view.staticBlocksTranslateX places every span at
once. Don’t derive this from isRightEndOfDisplayedRegion: that flag is set
on elided blocks too, so a bar per region is a solid grey wall at whole-genome
zoom, and drawing only the seams loses the elided tail of a genome entirely.
View source — 615 lines
import { Suspense, useState, 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 { createViewState } from '@jbrowse/react-linear-genome-view2'
import { observer } from 'mobx-react'
// The other direction from the mouse: your app tells the browser where to go
// and what to show, and reads back where it ended up.
//
// Everything below the toolbar is the previous pages. The toolbar is four calls
// -- `navToLocString`, `zoom`, `showTrack`, `hideTrack` -- and one getter,
// `coarseVisibleLocStrings`. None of it is a JBrowse component; the point of
// the page is that driving the view is a normal API, so a location box in your
// app's own header works exactly as well as one inside a genome browser.
//
// 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.
const hg38 = {
name: 'hg38',
uri: 'https://jbrowse.org/genomes/GRCh38/fasta/hg38.prefix.fa.gz',
refNameAliases: {
uri: 'https://jbrowse.org/genomes/GRCh38/hg38_aliases.txt',
},
}
const conservationTrack = {
type: 'QuantitativeTrack',
trackId: 'hg38_phylop',
name: 'phyloP 100-way conservation',
assemblyNames: ['hg38'],
adapter: {
type: 'BigWigAdapter',
uri: 'https://hgdownload.soe.ucsc.edu/goldenpath/hg38/phyloP100way/hg38.phyloP100way.bw',
},
displayDefaults: {
defaultRendering: 'xyplot',
height: 100,
color: '#3a7ca5',
},
}
const featureTrack = {
type: 'FeatureTrack',
trackId: 'hg38_genes',
name: 'RefSeq curated genes',
assemblyNames: ['hg38'],
adapter: {
type: 'Gff3TabixAdapter',
uri: 'https://jbrowse.org/ucsc/hg38/ncbiRefSeqCurated.gff.gz',
csi: true,
},
displayDefaults: { height: 120 },
}
const alignmentsTrack = {
type: 'AlignmentsTrack',
trackId: 'na12878_exome',
name: 'NA12878 exome reads',
assemblyNames: ['hg38'],
adapter: {
type: 'CramAdapter',
uri: 'https://s3.amazonaws.com/jbrowse.org/genomes/GRCh38/alignments/NA12878/NA12878.alt_bwamem_GRCh38DH.20150826.CEU.exome.cram',
},
displayDefaults: { height: 150 },
}
// The catalogue the checkboxes render. This is your app's list, not JBrowse's:
// what is *shown* lives on the view (`view.tracks`), and the checkbox state is
// read back off it below rather than kept in React state.
const catalogue = [
{ id: 'hg38_phylop', label: 'Conservation' },
{ id: 'hg38_genes', label: 'Genes' },
{ id: 'na12878_exome', label: 'Reads' },
]
// Somewhere to send the reader that isn't "type a locstring and hope". Real
// apps usually have this list already -- a gene of interest, a saved view, the
// row someone clicked in a table next to the browser.
//
// Both halves of the two-region entry stay inside BRCA1, which is not
// incidental: it keeps the bookmark cheap to fetch, the same reason a real app
// usually links to a gene rather than an arbitrary span.
//
// A locstring takes as many regions as you give it, so `Two BRCA genes` reads
// BRCA1 on chr17 and BRCA2 on chr13 in one call -- both are in the exome
// capture below, so the reads track has data on either side.
// `view.showAllRegionsInAssembly()` would be the wrong move for a bookmark
// like `Every chromosome`: this assembly's FASTA carries 455 sequences,
// including every `_alt` and `_random` scaffold, and all but the 24 named
// chromosomes land sub-pixel -- see the Every chromosome page for the list
// that avoids it.
const bookmarks = [
{ label: 'BRCA1', loc: 'chr17:43,044,295..43,125,364' },
{ label: 'A whole chromosome', loc: 'chr17' },
{
label: 'Two regions at once',
loc: 'chr17:43,044,295..43,060,000 chr17:43,100,000..43,125,364',
},
{
label: 'Two BRCA genes',
loc: 'chr17:43,044,295..43,125,364 chr13:32,315,474..32,400,266',
},
]
function makeView() {
const state = createViewState({
assembly: hg38,
tracks: [conservationTrack, featureTrack, alignmentsTrack],
init: {
loc: 'chr17:43,044,295..43,125,364',
tracks: ['hg38_phylop', 'hg38_genes'],
},
})
const { view } = state.session
// see the Pan and zoom example: scroll-to-zoom is a session preference, shared
// with any display that scrolls vertically inside itself
view.setScrollZoom(true)
return { view, session: state.session }
}
type BrowserView = ReturnType<typeof makeView>['view']
// `view.status` is the view's whole lifecycle as one value, so this switches on
// it rather than re-deriving which non-ready state it is out of `error` and
// `loadingMessage`. Two of the four states are easy to leave out and both fail
// silently: a 404 on a sequence file is `error` -- a state on the model rather
// than a throw, so there is no console error either -- and a view nothing has
// navigated yet is `noRegions`, which the older `view.ready` getter reports as
// ready, so gating on that one draws an empty box that never fills. The Loading
// and error states page draws the long form of this, and has a radio that
// breaks the assembly on purpose.
const ViewStatus = observer(function ViewStatus({
view,
}: {
view: BrowserView
}) {
const { status } = view
if (status.type === 'ready') {
return null
}
return (
<div
role={status.type === 'error' ? 'alert' : 'status'}
style={{ padding: '10px 12px', fontSize: '0.85rem', opacity: 0.75 }}
>
{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>
)
})
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>
)
})
// What each kind of span looks like is yours; that there are three is not. A
// seam must be opaque -- regions are laid out contiguously, so both sides are
// drawn right up to it and a see-through line tints two regions' features
// instead of separating them.
const SPAN_FILL = {
seam: 'color-mix(in srgb, CanvasText 45%, Canvas)',
boundary: 'color-mix(in srgb, CanvasText 12%, Canvas)',
elided: 'color-mix(in srgb, CanvasText 30%, Canvas)',
}
/**
* Everything along the row that is not track data, which you have to draw.
*
* A locstring with two regions in it gives the view two `displayedRegions`, and
* they are laid out **contiguously** -- no gap, no marker. JBrowse's own
* boundary is drawn by its track container, not by the display, so a host that
* mounts `RenderingComponent` directly gets two regions butted edge to edge
* with nothing to say where one ends. A two-region view then looks like a
* one-region view that scrolled somewhere strange, which is worth knowing
* before you conclude the navigation didn't work.
*
* `view.paddingSpans` is the geometry, already worked out: `{x, width, kind}`
* per span, `kind` being `seam` (a region's right edge), `boundary` (past the
* start of the first region or the end of the last) or `elided` (a region too
* narrow to draw at this zoom -- see the Every chromosome page, where that is
* most of a real assembly). Drawing only the seams is the mistake worth naming:
* it leaves the elided tail of a genome rendering as nothing at all.
*
* The x values are in the **staticBlocks frame**, the same one `gridlineTicks`
* and `scalebarLabels` use -- a pixel space spanning every displayed region
* rather than the viewport. So one element translated by
* `view.staticBlocksTranslateX` places every span at once, and a pan moves that
* one transform. That getter is the frame arithmetic, published: it is the only
* coordinate conversion on this site you do not get handed.
*
* Deriving this yourself off `isRightEndOfDisplayedRegion` is a near miss twice
* over: `view.scalebarRegionEndPx` looks like the shortcut and is not one (it
* is the right edge of the blocks *currently loaded*, so inside a region wider
* than the viewport it slides as you scroll), and the flag is set on elided
* blocks too, where a bar per region at whole-genome zoom is a solid grey wall.
*
* **The one thing this layer must not end up over is the display's own
* chrome.** A display floats things of its own -- a colour key, hi-c's overlay
* panel, the loading and error states -- inside a `contain: strict` box, which
* is its own stacking context, so a layer painted over the stack buries them
* and no z-index inside that box can win. `TrackRow` above mounts the node they
* escape into (`TrackOverlaySlot`, at `zIndex: 3`, over these spans at 2), which
* is what JBrowse's own track container does. Leave it out and nothing errors:
* the chrome is simply under a grey bar, and at whole-genome zoom, where most
* spans are elided, under a grey wall. The Track settings page turns a legend on
* and shows it.
*/
const RegionBoundaries = observer(function RegionBoundaries({
view,
}: {
view: BrowserView
}) {
const { paddingSpans, staticBlocksTranslateX } = view
return (
<div
aria-hidden
// this site's smoke test checks that a display's own chrome paints above
// this layer rather than under it, and needs to be able to find the layer.
// Keep or drop it in your own app -- the check needs it, the technique
// does not
data-region-seams
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
zIndex: 2,
pointerEvents: 'none',
// boxes, so unrounded: rounding is for text, where a fractional offset
// blurs a glyph. JBrowse's own PaddingBlocks makes the same call
transform: `translateX(${staticBlocksTranslateX}px)`,
}}
>
{paddingSpans.map(({ key, x, width, kind }) => (
<div
key={key}
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: x,
width,
background: SPAN_FILL[kind],
}}
/>
))}
</div>
)
})
/**
* A location box.
*
* `navToLocString` takes what a user would type -- `chr17`,
* `chr17:43,044,295..43,125,364`, two regions separated by a space -- and does
* the rest: it waits for the assembly, resolves the reference name (including
* aliases, so `chr17` finds a `17`), replaces `displayedRegions` if the new
* location needs different ones,
* and clamps the zoom. It is `async` for the assembly wait, and it **throws**
* on anything it cannot resolve, so a box that does not catch will look like it
* silently ignored a typo.
*
* The interesting part is which way the value flows. The view is the source of
* truth, and it moves constantly -- every pan frame changes the location -- so
* the box shows `coarseVisibleLocStrings`, which recomputes on a 500ms tick
* rather than per frame. (`visibleLocStrings` is the live one. Rendering *that*
* into an input re-renders the box on every frame of a drag, for a number no
* one can read mid-gesture.)
*
* While the user is typing, that has to stop: a value arriving from a pan would
* overwrite what they are halfway through. So a keystroke parks a `draft`, and
* submitting or escaping drops it, which hands the box back to the view. That
* is the whole trick to a control that is both live and editable.
*/
const LocationBox = observer(function LocationBox({
view,
}: {
view: BrowserView
}) {
const [draft, setDraft] = useState<string | undefined>(undefined)
const [error, setError] = useState<unknown>(undefined)
const shown = view.coarseVisibleLocStrings
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<form
style={{ display: 'flex', gap: 4 }}
onSubmit={event => {
event.preventDefault()
setError(undefined)
view
.navToLocString(draft ?? shown)
.then(() => {
setDraft(undefined)
})
.catch((e: unknown) => {
setError(e)
})
}}
>
<input
aria-label="Location"
value={draft ?? shown}
// wide enough for the two-region locstring, which is the longest
// thing the buttons beside it can put in here
size={38}
style={{
fontFamily: 'inherit',
fontSize: '0.85rem',
padding: '2px 4px',
}}
onChange={event => {
setDraft(event.target.value)
}}
onKeyDown={event => {
if (event.key === 'Escape') {
setDraft(undefined)
setError(undefined)
}
}}
/>
<button type="submit">Go</button>
</form>
{error ? (
<span role="alert" style={{ fontSize: '0.75rem', color: '#d97706' }}>
{error instanceof Error ? error.message : String(error)}
</span>
) : null}
</div>
)
})
/**
* Zoom buttons.
*
* `zoom(targetBpPerPx)` is the animated one, and it is what the buttons in
* JBrowse's own header call: it eases to the target over a few frames and
* yields immediately if anything else moves the view, so a click during a
* wheel-zoom doesn't fight it. `zoomTo` -- what the wheel handler above uses --
* is the same move without the animation.
*
* Neither needs a range check. The view clamps to `minBpPerPx`/`maxBpPerPx`,
* which it derives from the assembly, so "zoom out" at the whole-genome end is
* a no-op rather than an error.
*
* Not an `observer`, unlike most components here, and the rule is worth being
* exact about: `observer` re-renders on the observables a component reads *while
* rendering*. This one reads `bpPerPx` inside a click handler, which runs long
* after the render and always sees the current value. Wrapping it would buy a
* subscription that changes nothing. `TrackToggles` below reads `view.tracks` in
* its body, so it does need one.
*/
function ZoomButtons({ view }: { view: BrowserView }) {
return (
<div style={{ display: 'flex', gap: 4 }}>
<button
type="button"
aria-label="Zoom out"
onClick={() => {
view.zoom(view.bpPerPx * 2)
}}
>
−
</button>
<button
type="button"
aria-label="Zoom in"
onClick={() => {
view.zoom(view.bpPerPx / 2)
}}
>
+
</button>
</div>
)
}
/**
* Show and hide tracks.
*
* `showTrack(trackId)` instantiates the track and its display from the config
* of that id and appends it to `view.tracks`; `hideTrack(trackId)` removes it,
* which disposes the display and everything it had on the GPU. Adding a track
* to the *config* is a separate thing -- these two only turn on what is already
* declared.
*
* The checkbox reads `view.tracks` rather than a `useState` next to it. There
* is no way for the two to disagree that way, which matters as soon as anything
* else can show a track: a bookmark that arrives with its own track list, a
* saved session, a second panel in your app.
*/
const TrackToggles = observer(function TrackToggles({
view,
}: {
view: BrowserView
}) {
return (
<div style={{ display: 'flex', gap: 10, fontSize: '0.85rem' }}>
{catalogue.map(({ id, label }) => {
const shown = view.tracks.some(t => t.configuration.trackId === id)
return (
<label
key={id}
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
>
<input
type="checkbox"
checked={shown}
onChange={() => {
if (shown) {
view.hideTrack(id)
} else {
view.showTrack(id)
}
}}
/>
{label}
</label>
)
})}
</div>
)
})
/**
* Jump somewhere, and bring a track list with you.
*
* Two calls in one handler, and the order matters only in that `showTrack` is
* synchronous while `navToLocString` is not -- so the tracks are up before the
* navigation resolves, and they fetch once, for the destination, rather than
* once for here and again for there.
*
* The `.catch` logs rather than showing the reader anything, which is the
* opposite of `LocationBox` above and is deliberate: these locstrings are your
* own, so one that fails to resolve is a bug in your list rather than something
* a user mistyped. Not an `observer` -- see `ZoomButtons`.
*/
function Bookmarks({ view }: { view: BrowserView }) {
return (
<div style={{ display: 'flex', gap: 4, fontSize: '0.85rem' }}>
{bookmarks.map(({ label, loc }) => (
<button
key={label}
type="button"
onClick={() => {
if (
!view.tracks.some(t => t.configuration.trackId === 'hg38_genes')
) {
view.showTrack('hg38_genes')
}
view.navToLocString(loc).catch((e: unknown) => {
console.error(e)
})
}}
>
{label}
</button>
))}
</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,
)
}
// 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.
const viewport: React.CSSProperties = {
position: 'relative',
overflow: 'hidden',
cursor: 'grab',
}
const DriveItFromYourApp = observer(function DriveItFromYourApp() {
const { view, session } = useCreateOnce(makeView)
const ref = useWidthSetter(view)
const { containerProps } = usePanZoom(ref, view)
const mode = useSiteMode()
return (
<SessionPaletteProvider session={session} mode={mode}>
<DisplayUIProvider>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'flex-start',
gap: 12,
paddingBottom: 8,
}}
>
<LocationBox view={view} />
<ZoomButtons view={view} />
<Bookmarks view={view} />
<TrackToggles view={view} />
</div>
<div ref={ref} {...containerProps} style={viewport}>
{/* `RegionBoundaries` is inside the same gate as the tracks, not
* beside it: `staticBlocks` reads `view.width`, which *throws*
* ("make sure to check for model.initialized") until the
* ResizeObserver has reported one. A ready `view.status` is that
* guard, and anything of your own reading block geometry needs it
* too. */}
{view.status.type === 'ready' ? (
<>
{view.tracks.map(track => (
<TrackRow
key={track.configuration.trackId}
view={view}
trackId={track.configuration.trackId}
/>
))}
<RegionBoundaries view={view} />
</>
) : (
<ViewStatus view={view} />
)}
</div>
</DisplayUIProvider>
</SessionPaletteProvider>
)
})
export default DriveItFromYourAppThe whole genome at once
A whole-genome view is the same view every other page builds, just with 24
displayed regions instead of one. init.loc is handed straight to
navToLocString, and a locstring takes as many regions as you give it, so
chr1 chr2 … chrX chrY is the whole mechanism. init.displayedRegionNames
takes the same list as an array.
Avoid view.showAllRegionsInAssembly(). hg38 has 455 sequences counting every
_alt, _random and chrUn_ scaffold, and all but the 24 land sub-pixel and
elide. Which sequences are “the chromosomes” is a choice a human makes. No field
in the file records it.
Regions lay out contiguously, so this is one continuous strip unless you draw
the boundaries. RegionBoundaries and RegionNames are the components the
section above and the
scalebar explain. Both read geometry the
view already computed, rather than deriving it from block flags, which is how
the narrowest bands end up with no name at all instead of an ambiguous 2….
The track still draws because a bigWig carries precomputed summaries. A track with no summary tier (a BAM, a tabix GFF) refuses this width instead, which is correct and not a bug.
View source — 365 lines
import { Suspense, useSyncExternalStore } from 'react'
import {
SessionPaletteProvider,
usePalette,
} 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 { createViewState } from '@jbrowse/react-linear-genome-view2'
import { observer } from 'mobx-react'
// A whole-genome view is not a mode. It is the same view with 24 displayed
// regions instead of one, and everything else on this site applies unchanged.
//
// Two pieces of chrome stop being optional at this width, and both are yours:
// the seam between regions (see the Drive it from your app page) and the name
// on each one, because 24 unlabelled bands are not a genome.
//
// 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.
const hg38 = {
name: 'hg38',
uri: 'https://jbrowse.org/genomes/GRCh38/fasta/hg38.prefix.fa.gz',
// this file names its sequences `1`, `2`, ... and the list below asks for
// `chr1`, `chr2`, ...; the alias table is what makes those the same sequence
refNameAliases: {
uri: 'https://jbrowse.org/genomes/GRCh38/hg38_aliases.txt',
},
}
// Spelled out rather than asked for, and that is the whole trick.
// `view.showAllRegionsInAssembly()` is the call that *sounds* right here, but
// hg38 has 455 sequences in it -- every `_alt`, `_random` and `chrUn_` scaffold
// -- and all but these 24 land sub-pixel and elide into a grey smear. A
// reference genome's "chromosomes" are a subset a human chose; no file records
// which ones they are.
const CHROMOSOMES = [
...Array.from({ length: 22 }, (_, i) => `chr${i + 1}`),
'chrX',
'chrY',
]
const conservationTrack = {
type: 'QuantitativeTrack',
trackId: 'hg38_phylop',
name: 'phyloP 100-way conservation',
assemblyNames: ['hg38'],
adapter: {
type: 'BigWigAdapter',
uri: 'https://hgdownload.soe.ucsc.edu/goldenpath/hg38/phyloP100way/hg38.phyloP100way.bw',
},
// A bigWig carries precomputed summaries, so a track that would be hopeless
// at this width as raw values -- 3.1Gb across ~1000px -- is one cheap read
// per region instead.
displayDefaults: {
defaultRendering: 'xyplot',
height: 120,
color: '#3a7ca5',
},
}
const CHROM_STRIP_HEIGHT = 18
function makeView() {
const state = createViewState({
assembly: hg38,
tracks: [conservationTrack],
init: {
// A locstring takes as many regions as you give it, and `init.loc` hands
// whatever you write here straight to `navToLocString`. `init` also accepts
// `displayedRegionNames: CHROMOSOMES` for the same result without the join.
loc: CHROMOSOMES.join(' '),
tracks: ['hg38_phylop'],
},
})
const { view } = state.session
// see the Pan and zoom example: scroll-to-zoom is a session preference, shared
// with any display that scrolls vertically inside itself
view.setScrollZoom(true)
return { view, session: state.session }
}
type BrowserView = ReturnType<typeof makeView>['view']
// `view.status` is the view's whole lifecycle as one value, so this switches on
// it rather than re-deriving which non-ready state it is out of `error` and
// `loadingMessage`. Two of the four states are easy to leave out and both fail
// silently: a 404 on a sequence file is `error` -- a state on the model rather
// than a throw, so there is no console error either -- and a view nothing has
// navigated yet is `noRegions`, which the older `view.ready` getter reports as
// ready, so gating on that one draws an empty box that never fills. The Loading
// and error states page draws the long form of this, and has a radio that
// breaks the assembly on purpose.
const ViewStatus = observer(function ViewStatus({
view,
}: {
view: BrowserView
}) {
const { status } = view
if (status.type === 'ready') {
return null
}
return (
<div
role={status.type === 'error' ? 'alert' : 'status'}
style={{ padding: '10px 12px', fontSize: '0.85rem', opacity: 0.75 }}
>
{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>
)
})
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 name on each region, from `view.scalebarRefNameLabels`. See the Scalebar
* page for the three rules inside it. Two of them show up here rather than
* there: the label of the chromosome you are inside stays pinned to the left
* edge as you pan past its start, and the narrowest few bands lose their name
* entirely rather than abbreviate it -- `chr16` clipped to its own width reads
* as `chr1`, a different chromosome rather than a shortened one, and `2…` on
* two adjacent bands says nothing at all.
*
* That is JBrowse's own rule, so this page draws what the product draws. It is
* a real trade against the lead above: a band with no name is honest, and it is
* still a band with no name. Zooming in gives it back.
*/
const RegionNames = observer(function RegionNames({
view,
}: {
view: BrowserView
}) {
const palette = usePalette()
return view.scalebarRefNameLabels.labels.map(
({ key, text, transform, maxWidth, paddingLeft }) => (
<span
key={key}
style={{
position: 'absolute',
top: 0,
left: 0,
transform: `translateX(${transform}px)`,
maxWidth,
paddingLeft,
// maxWidth is the label's whole box, paddingLeft included -- the fit
// test above measured it that way. Under content-box the padding
// comes off the text twice and every name wide enough to need the
// space is clipped mid-glyph.
boxSizing: 'border-box',
background: palette.background.paper,
color: palette.text.primary,
fontWeight: 'bold',
// clip, not ellipsis: a name that would not fit whole was already
// dropped, so there is nothing left to abbreviate
overflow: 'clip',
whiteSpace: 'nowrap',
}}
>
{text}
</span>
),
)
})
// What each kind of span looks like is yours; that there are three is not. A
// seam must be opaque -- regions are laid out contiguously, so both sides are
// drawn right up to it and a see-through line tints two regions' features
// instead of separating them.
const SPAN_FILL = {
seam: 'color-mix(in srgb, CanvasText 45%, Canvas)',
boundary: 'color-mix(in srgb, CanvasText 12%, Canvas)',
elided: 'color-mix(in srgb, CanvasText 30%, Canvas)',
}
/**
* The spans along the row that are not track data -- region seams, the greyed
* ends of the genome, and regions too narrow to draw. `view.paddingSpans` is
* the geometry; see the Drive it from your app page for the frame it is in and
* for why deriving it yourself misses two cases.
*/
const RegionBoundaries = observer(function RegionBoundaries({
view,
}: {
view: BrowserView
}) {
const { paddingSpans, staticBlocksTranslateX } = view
return (
<div
aria-hidden
// this site's smoke test checks that a display's own chrome paints above
// this layer rather than under it, and needs to be able to find the layer.
// Keep or drop it in your own app -- the check needs it, the technique
// does not
data-region-seams
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
zIndex: 2,
pointerEvents: 'none',
// boxes, so unrounded: rounding is for text, where a fractional offset
// blurs a glyph. JBrowse's own PaddingBlocks makes the same call
transform: `translateX(${staticBlocksTranslateX}px)`,
}}
>
{paddingSpans.map(({ key, x, width, kind }) => (
<div
key={key}
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: x,
width,
background: SPAN_FILL[kind],
}}
/>
))}
</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,
)
}
// 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.
const viewport: React.CSSProperties = {
position: 'relative',
overflow: 'hidden',
cursor: 'grab',
}
const EveryChromosome = observer(function EveryChromosome() {
const { view, session } = useCreateOnce(makeView)
const ref = useWidthSetter(view)
const { containerProps } = usePanZoom(ref, view)
const mode = useSiteMode()
return (
<SessionPaletteProvider session={session} mode={mode}>
<DisplayUIProvider>
<div ref={ref} {...containerProps} style={viewport}>
{/* both overlays read block geometry, which throws until the
* ResizeObserver has reported a width -- see the Drive it from
* your app page */}
{view.status.type === 'ready' ? (
<>
<div
style={{
position: 'relative',
height: CHROM_STRIP_HEIGHT,
fontSize: '0.7rem',
lineHeight: `${CHROM_STRIP_HEIGHT}px`,
overflow: 'clip',
}}
>
<RegionNames view={view} />
</div>
<TrackRow view={view} trackId="hg38_phylop" />
<RegionBoundaries view={view} />
</>
) : (
<ViewStatus view={view} />
)}
</div>
</DisplayUIProvider>
</SessionPaletteProvider>
)
})
export default EveryChromosomeFeature details on click
Click a gene above. The panel on the right is a plain <dl> in this file, and
the only thing connecting it to JBrowse is one field:
const { selection } = session
if (isFeature(selection)) {
// your panel
}
There is no onClick anywhere in the example. The display already owns that
half (hit-testing the canvas, re-fetching the full feature by id, descending
into whichever subfeature was under the cursor) and finishes by writing to
session.selection.
That is the session-wide selection, not a feature-track one: a circular view
puts a chord there, an arc display a paired feature. So it is typed unknown
and narrowing it is your job. isFeature from
@jbrowse/core/util/simpleFeature is the same guard JBrowse uses internally,
which makes this a check rather than a cast. feature.toJSON() is then a plain
object of that track’s own parsed attributes: a GFF3 gene and a VCF variant do
not have the same keys. session.clearSelection() resets every panel reading
it.
The same click also queues JBrowse’s BaseFeatureWidget into session.widgets.
Nothing here renders the drawer that would show it, and a widget’s React
component is lazy, so it never loads. session.hideWidget removes it.
View source — 344 lines
import { Suspense, useSyncExternalStore } from 'react'
import { SessionPaletteProvider } from '@jbrowse/core/ui/PaletteContext'
import { useCreateOnce, useWidthSetter } from '@jbrowse/core/util/hooks'
import { isFeature } from '@jbrowse/core/util/simpleFeature'
import { usePanZoom } from '@jbrowse/core/util/usePanZoom'
import { DisplayUIProvider, TrackOverlaySlot } from '@jbrowse/display-ui'
import { createViewState } from '@jbrowse/react-linear-genome-view2'
import { observer } from 'mobx-react'
// Every page before this one puts pixels on screen. This one gets data back
// out: click a gene and the panel on the right fills in.
//
// Note what is NOT below: an onClick. The display already handles the click --
// hit-testing the canvas, re-fetching the full feature by id, descending into
// the clicked subfeature -- and finishes by writing the result to
// `session.selection`. So the whole integration is one observer that reads that
// field. You never register a handler, and you never have to know how a click
// lands on a feature drawn into a canvas.
//
// 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.
const hg38 = {
name: 'hg38',
uri: 'https://jbrowse.org/genomes/GRCh38/fasta/hg38.prefix.fa.gz',
refNameAliases: {
uri: 'https://jbrowse.org/genomes/GRCh38/hg38_aliases.txt',
},
}
const featureTrack = {
type: 'FeatureTrack',
trackId: 'hg38_genes',
name: 'RefSeq curated genes',
assemblyNames: ['hg38'],
adapter: {
type: 'Gff3TabixAdapter',
uri: 'https://jbrowse.org/ucsc/hg38/ncbiRefSeqCurated.gff.gz',
csi: true,
},
displayDefaults: { height: 180 },
}
const PANEL_WIDTH = 260
function makeView() {
const state = createViewState({
assembly: hg38,
tracks: [featureTrack],
init: {
loc: 'chr17:43,044,295..43,125,364',
tracks: ['hg38_genes'],
},
})
const { view } = state.session
// see the Pan and zoom example: scroll-to-zoom is a session preference, shared
// with any display that scrolls vertically inside itself
view.setScrollZoom(true)
return { view, session: state.session }
}
type BrowserView = ReturnType<typeof makeView>['view']
type BrowserSession = ReturnType<typeof makeView>['session']
// `view.status` is the view's whole lifecycle as one value, so this switches on
// it rather than re-deriving which non-ready state it is out of `error` and
// `loadingMessage`. Two of the four states are easy to leave out and both fail
// silently: a 404 on a sequence file is `error` -- a state on the model rather
// than a throw, so there is no console error either -- and a view nothing has
// navigated yet is `noRegions`, which the older `view.ready` getter reports as
// ready, so gating on that one draws an empty box that never fills. The Loading
// and error states page draws the long form of this, and has a radio that
// breaks the assembly on purpose.
const ViewStatus = observer(function ViewStatus({
view,
}: {
view: BrowserView
}) {
const { status } = view
if (status.type === 'ready') {
return null
}
return (
<div
role={status.type === 'error' ? 'alert' : 'status'}
style={{ padding: '10px 12px', fontSize: '0.85rem', opacity: 0.75 }}
>
{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>
)
})
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>
)
})
// Fields the panel promotes to a header line, so the rest of the table is the
// track's own attributes rather than coordinates repeated in two places.
const POSITION_FIELDS = new Set([
'refName',
'start',
'end',
'strand',
'type',
'name',
'uniqueId',
])
/**
* `feature.toJSON()` is a plain object -- the parsed GFF3 attributes for this
* track, whatever they happen to be. Nested values (`subfeatures` is the one
* every gene has) are skipped rather than stringified; showing a transcript
* tree is its own UI, and this page is about where the data arrives, not about
* rendering all of it.
*/
function attributeRows(data: Record<string, unknown>) {
return Object.entries(data)
.filter(
([key, value]) =>
!POSITION_FIELDS.has(key) &&
value !== undefined &&
value !== null &&
typeof value !== 'object',
)
.map(([key, value]) => [key, String(value)] as const)
}
/**
* The whole integration with JBrowse: read `session.selection`.
*
* It is a volatile MobX field holding whatever was last selected anywhere in
* the session, so it is typed `unknown` on purpose -- a circular view selects
* chords, an arc display selects paired features. `isFeature` is the narrowing
* JBrowse itself uses, and it is what makes reading this field safe rather than
* a cast.
*
* JBrowse's own click path also queues its `BaseFeatureWidget` into
* `session.widgets`. Nothing here renders the drawer that would show it, so it
* costs nothing: a widget's React component is lazy, so an unrendered widget
* never loads, and Material UI never enters the graph on account of it.
*/
const FeatureDetails = observer(function FeatureDetails({
session,
}: {
session: BrowserSession
}) {
const { selection } = session
if (!isFeature(selection)) {
return (
<div style={{ fontSize: '0.85rem', opacity: 0.7, padding: 12 }}>
Click a gene.
</div>
)
}
const data = selection.toJSON()
const strand = data.strand === -1 ? '−' : data.strand === 1 ? '+' : ''
return (
<div style={{ fontSize: '0.8rem', padding: 12 }}>
<div
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: 8,
}}
>
<strong style={{ fontSize: '0.95rem' }}>
{data.name ?? data.type ?? 'Feature'}
</strong>
<button
type="button"
style={{ font: 'inherit', cursor: 'pointer' }}
onClick={() => {
session.clearSelection()
}}
>
Clear
</button>
</div>
<div style={{ opacity: 0.75, paddingTop: 2 }}>
{data.refName}:{data.start.toLocaleString()}-{data.end.toLocaleString()}{' '}
{strand}
</div>
<dl
style={{
display: 'grid',
gridTemplateColumns: 'auto 1fr',
gap: '2px 10px',
margin: '10px 0 0',
}}
>
{attributeRows(data).map(([key, value]) => (
<div key={key} style={{ display: 'contents' }}>
<dt style={{ opacity: 0.7 }}>{key}</dt>
<dd style={{ margin: 0, wordBreak: 'break-word' }}>{value}</dd>
</div>
))}
</dl>
</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,
)
}
// 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.
const viewport: React.CSSProperties = {
position: 'relative',
overflow: 'hidden',
cursor: 'grab',
}
const YourOwnFeatureDetails = observer(function YourOwnFeatureDetails() {
const { view, session } = useCreateOnce(makeView)
const ref = useWidthSetter(view)
const { containerProps } = usePanZoom(ref, view)
const mode = useSiteMode()
return (
<SessionPaletteProvider session={session} mode={mode}>
<DisplayUIProvider>
<div style={{ display: 'flex' }}>
<div
ref={ref}
{...containerProps}
// `flex: 1, minWidth: 0` because the panel beside it is fixed
// width and this half takes the rest; without the minWidth a flex
// item refuses to shrink below its content
style={{ ...viewport, flex: 1, minWidth: 0 }}
>
{view.status.type === 'ready' ? (
<TrackRow view={view} trackId="hg38_genes" />
) : (
<ViewStatus view={view} />
)}
</div>
<div
style={{
width: PANEL_WIDTH,
flex: 'none',
borderLeft: '1px solid',
borderColor: 'color-mix(in srgb, currentColor 25%, transparent)',
overflow: 'auto',
}}
>
<FeatureDetails session={session} />
</div>
</div>
</DisplayUIProvider>
</SessionPaletteProvider>
)
})
export default YourOwnFeatureDetailsA track selector sidebar
The toolbar above builds its track list from an array written beside it. That stops working the moment a track exists your source file did not know about, so this one derives the list instead.
session.tracks is the catalogue
Every track config the session holds: what you passed to createViewState, plus
anything added since. It is not view.tracks, which holds instantiated
tracks and so only what is on screen. Only one of the two answers “what could I
show”.
Entries are raw configuration models, so slots come off them with
readConfObject(conf, 'name'). getConf is for models that contain a
configuration and will not take these.
Grouping is the category slot
A
stringArray on every track config,
and a path rather than a label: JBrowse’s own selector reads
['RNA-seq', 'Brain'] as a folder inside a folder. This one is a level deep.
Tracks declaring no category still need somewhere to go.
Only the filter is React state
The checkbox reads view.tracks back, so nothing can disagree with it when a
bookmark or a restored session shows a track. The catalogue is read live for the
same reason: Add a variant track calls session.addSessionTrackConf, and no
callback reaches the selector.
Order is yours to pick
showTrack appends, so view.tracks is in tick order and mapping it out gives
a column that reshuffles as the user clicks. This one renders in catalogue order
and skips what is not showing. Order off session.tracks, not off the array
at the top of the file. The two look identical until something adds a track at
runtime, and then it ticks on and draws nothing.
View source — 561 lines
import { Suspense, useState, useSyncExternalStore } from 'react'
import { readConfObject } from '@jbrowse/core/configuration'
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 { createViewState } from '@jbrowse/react-linear-genome-view2'
import { observer } from 'mobx-react'
// A track selector: categories, a filter box, and a checkbox per track.
//
// The previous page drives the view from a toolbar whose track list is an array
// written beside it. That is fine for three tracks and wrong for thirty, and it
// is wrong the moment a track arrives that your source file did not know about.
// So this one builds its list from `session.tracks` -- every track config the
// session holds -- and groups it on the `category` slot, which is the same slot
// JBrowse's own hierarchical selector nests on.
//
// The "Add a variant track" button is the payoff: it puts a config into the
// session and nothing in this selector is told. The new category appears
// because the list is derived rather than declared.
//
// 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.
const hg38 = {
name: 'hg38',
uri: 'https://jbrowse.org/genomes/GRCh38/fasta/hg38.prefix.fa.gz',
refNameAliases: {
uri: 'https://jbrowse.org/genomes/GRCh38/hg38_aliases.txt',
},
}
// `category` is a `stringArray` on every track config, and it is a path rather
// than a label: JBrowse's own selector reads `['RNA-seq', 'Brain']` as a folder
// inside a folder. This selector is one level deep and takes the first entry,
// which is the honest simplification -- say so rather than pretending a
// one-level list is the whole slot.
const catalogueTracks = [
{
type: 'QuantitativeTrack',
trackId: 'hg38_phylop',
name: 'phyloP conservation',
category: ['Signal'],
assemblyNames: ['hg38'],
adapter: {
type: 'BigWigAdapter',
uri: 'https://hgdownload.soe.ucsc.edu/goldenpath/hg38/phyloP100way/hg38.phyloP100way.bw',
},
displayDefaults: {
defaultRendering: 'xyplot',
height: 80,
color: '#3a7ca5',
},
},
{
type: 'QuantitativeTrack',
trackId: 'hg38_gnomad_genome_coverage',
name: 'gnomAD genome coverage',
category: ['Signal'],
assemblyNames: ['hg38'],
adapter: {
type: 'BigWigAdapter',
uri: 'https://hgdownload.soe.ucsc.edu/gbdb/hg38/gnomAD/coverage/v3-genome/gnomad.coverage.mean.bw',
},
displayDefaults: { defaultRendering: 'xyplot', height: 80 },
},
{
type: 'QuantitativeTrack',
trackId: 'hg38_gnomad_exome_coverage',
name: 'gnomAD exome coverage',
category: ['Signal'],
assemblyNames: ['hg38'],
adapter: {
type: 'BigWigAdapter',
uri: 'https://hgdownload.soe.ucsc.edu/gbdb/hg38/gnomAD/coverage/v4-exome/gnomad.coverage.mean.bw',
},
displayDefaults: { defaultRendering: 'xyplot', height: 80 },
},
{
type: 'FeatureTrack',
trackId: 'hg38_genes',
name: 'RefSeq curated genes',
category: ['Annotation'],
assemblyNames: ['hg38'],
adapter: {
type: 'Gff3TabixAdapter',
uri: 'https://jbrowse.org/ucsc/hg38/ncbiRefSeqCurated.gff.gz',
csi: true,
},
displayDefaults: { height: 120 },
},
{
type: 'FeatureTrack',
trackId: 'hg38_segmental_dups',
name: 'Segmental duplications',
category: ['Annotation'],
assemblyNames: ['hg38'],
adapter: {
type: 'BedTabixAdapter',
uri: 'https://jbrowse.org/ucsc/hg38/genomicSuperDups.bed.gz',
csi: true,
},
displayDefaults: { height: 100 },
},
{
type: 'AlignmentsTrack',
trackId: 'na12878_exome',
name: 'NA12878 exome reads',
category: ['Alignments'],
assemblyNames: ['hg38'],
adapter: {
type: 'CramAdapter',
uri: 'https://s3.amazonaws.com/jbrowse.org/genomes/GRCh38/alignments/NA12878/NA12878.alt_bwamem_GRCh38DH.20150826.CEU.exome.cram',
},
displayDefaults: { height: 150 },
},
// No category at all, which is the common case in a real config and the one a
// selector has to have an answer for. It lands under `UNFILED` below.
{
type: 'VariantTrack',
trackId: 'thousand_genomes_snvindels',
name: '1000 Genomes variants',
assemblyNames: ['hg38'],
adapter: {
type: 'VcfTabixAdapter',
uri: 'https://s3.amazonaws.com/jbrowse.org/genomes/GRCh38/variants/ALL.wgs.shapeit2_integrated_snvindels_v2a.GRCh38.27022019.sites.vcf.gz',
},
displayDefaults: { height: 80 },
},
]
// Not in `catalogueTracks`: the button below adds it at runtime, and the
// selector shows it without being told.
const laterTrack = {
type: 'VariantTrack',
trackId: 'thousand_genomes_sv',
name: 'Structural variants',
category: ['Variants'],
assemblyNames: ['hg38'],
adapter: {
type: 'VcfTabixAdapter',
uri: 'https://ftp.1000genomes.ebi.ac.uk/vol1/ftp/data_collections/1000G_2504_high_coverage/working/20210124.SV_Illumina_Integration/1KGP_3202.gatksv_svtools_novelins.freeze_V3.wAF.vcf.gz',
},
displayDefaults: { height: 90 },
}
function makeView() {
const state = createViewState({
assembly: hg38,
tracks: catalogueTracks,
init: {
loc: 'chr17:43,044,295..43,125,364',
tracks: ['hg38_phylop', 'hg38_genes'],
},
})
const { view } = state.session
// see the Pan and zoom example: scroll-to-zoom is a session preference, shared
// with any display that scrolls vertically inside itself
view.setScrollZoom(true)
return { view, session: state.session }
}
type BrowserView = ReturnType<typeof makeView>['view']
type BrowserSession = ReturnType<typeof makeView>['session']
// `view.status` is the view's whole lifecycle as one value, so this switches on
// it rather than re-deriving which non-ready state it is out of `error` and
// `loadingMessage`. Two of the four states are easy to leave out and both fail
// silently: a 404 on a sequence file is `error` -- a state on the model rather
// than a throw, so there is no console error either -- and a view nothing has
// navigated yet is `noRegions`, which the older `view.ready` getter reports as
// ready, so gating on that one draws an empty box that never fills. The Loading
// and error states page draws the long form of this, and has a radio that
// breaks the assembly on purpose.
const ViewStatus = observer(function ViewStatus({
view,
}: {
view: BrowserView
}) {
const { status } = view
if (status.type === 'ready') {
return null
}
return (
<div
role={status.type === 'error' ? 'alert' : 'status'}
style={{ padding: '10px 12px', fontSize: '0.85rem', opacity: 0.75 }}
>
{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>
)
})
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>
)
})
// Where a track with no `category` goes. Every real config has some.
const UNFILED = 'Uncategorized'
interface CatalogueEntry {
trackId: string
name: string
category: string
}
/**
* The catalogue, read out of the session instead of written beside the UI.
*
* `session.tracks` is every track config the session knows: the ones you passed
* to `createViewState`, plus anything added since. It is a list of *config
* models*, not of instantiated tracks -- `view.tracks` is that, and it holds
* only what is currently on screen. Confusing the two is the one mistake worth
* naming here, because both are arrays of things called tracks and only one of
* them answers "what could I show".
*
* `readConfObject(conf, slot)`, not `getConf`: these are raw configuration
* models with no `.configuration` of their own, and `getConf` exists for models
* that *contain* one. `name` falls back to the id, which is what the config
* schema itself does when a track declares no name.
*/
function catalogueEntries(session: BrowserSession): CatalogueEntry[] {
return session.tracks.map(conf => {
const path: string[] = readConfObject(conf, 'category')
const trackId: string = readConfObject(conf, 'trackId')
const name: string = readConfObject(conf, 'name')
return {
trackId,
name: name || trackId,
category: path[0] ?? UNFILED,
}
})
}
/**
* Group in first-appearance order, which means the config file's order. Sorting
* the categories alphabetically is the obvious next line and is worth not
* writing: whoever wrote the config put the important tracks at the top, and an
* alphabetical selector throws that away for a property nobody asked for.
*/
function byCategory(entries: CatalogueEntry[]) {
const groups = new Map<string, CatalogueEntry[]>()
for (const entry of entries) {
const existing = groups.get(entry.category)
if (existing) {
existing.push(entry)
} else {
groups.set(entry.category, [entry])
}
}
return [...groups]
}
// 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.
const viewport: React.CSSProperties = {
position: 'relative',
overflow: 'hidden',
cursor: 'grab',
}
const checkboxRow: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '1px 0',
cursor: 'pointer',
lineHeight: 1.3,
}
/**
* The selector.
*
* Three things it does NOT keep in React state, and each would be a bug:
*
* - **which tracks are on.** `view.tracks` is that, so the checkbox reads it
* back. A `useState` beside it goes stale the first time anything else shows
* a track: a bookmark that arrives with its own list, a restored session, a
* second panel in your app.
* - **the catalogue.** `session.tracks` is observable, so the button at the
* bottom needs no callback into this component. Snapshotting it into
* `useState` on mount is the version that looks like it works.
* - **the groups.** Derived from the two above on every render, which is what
* `observer` makes cheap enough to stop thinking about.
*
* The filter *is* state, because it is this component's own.
*/
const TrackSelector = observer(function TrackSelector({
view,
session,
}: {
view: BrowserView
session: BrowserSession
}) {
const [filter, setFilter] = useState('')
const needle = filter.trim().toLowerCase()
const onScreen = new Set(view.tracks.map(t => t.configuration.trackId))
const matching = catalogueEntries(session).filter(
e =>
e.name.toLowerCase().includes(needle) ||
e.category.toLowerCase().includes(needle),
)
const groups = byCategory(matching)
const alreadyAdded = session.tracks.some(
conf => readConfObject(conf, 'trackId') === laterTrack.trackId,
)
return (
<div
style={{
width: 220,
flex: 'none',
display: 'flex',
flexDirection: 'column',
gap: 8,
padding: 10,
borderRight: '1px solid',
borderColor: 'color-mix(in srgb, currentColor 25%, transparent)',
fontSize: '0.8rem',
overflow: 'auto',
}}
>
<input
aria-label="Filter tracks"
placeholder="Filter tracks"
value={filter}
style={{
font: 'inherit',
padding: '3px 5px',
width: '100%',
}}
onChange={event => {
setFilter(event.target.value)
}}
/>
{groups.length === 0 ? (
<div style={{ opacity: 0.7 }}>No track matches “{filter}”.</div>
) : null}
{groups.map(([category, entries]) => (
<div key={category}>
<div
style={{
fontSize: '0.7rem',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.05em',
opacity: 0.65,
paddingBottom: 2,
}}
>
{category}
</div>
{entries.map(({ trackId, name }) => (
<label key={trackId} style={checkboxRow}>
<input
type="checkbox"
checked={onScreen.has(trackId)}
onChange={() => {
if (onScreen.has(trackId)) {
view.hideTrack(trackId)
} else {
view.showTrack(trackId)
}
}}
/>
{name}
</label>
))}
</div>
))}
{/* `addSessionTrackConf`, not `addTrackConf`: the latter routes an
* admin's track into the shared config and everyone else's into the
* session, which is two destinations behind one name and the wrong one
* to reach for when you mean "add it for this visitor". The selector
* above re-renders because `session.tracks` moved, not because anything
* here called it. */}
<button
type="button"
disabled={alreadyAdded}
style={{ font: 'inherit', marginTop: 'auto', cursor: 'pointer' }}
onClick={() => {
session.addSessionTrackConf(laterTrack)
}}
>
{alreadyAdded ? 'Variant track added' : 'Add a variant track'}
</button>
</div>
)
})
/**
* The column, drawn in catalogue order rather than in `view.tracks` order.
*
* `showTrack` **appends**, so `view.tracks` is in the order the boxes were
* ticked. Mapping it straight out gives a column that reshuffles as the user
* clicks, and the reads landing above the genes because that is the order
* somebody happened to tick them in is not a bug anyone reports -- it just
* reads as a browser that cannot keep still.
*
* So the order comes from the catalogue and `TrackRow` skips what is not
* showing. Whichever order you want, decide it here: this is the only place
* that knows.
*
* **The catalogue is `session.tracks`, not the array at the top of this file.**
* Ordering off that array instead reads identically until something adds a
* track at runtime, at which point the new track ticks on in the selector and
* draws nothing, because the loop placing rows has never heard of it. Nothing
* errors and the checkbox looks right. It cost this page a debugging session,
* and it is the same mistake the selector avoids one component up.
*
* A session track sorts ahead of the config ones, which is `session.tracks`'
* own order rather than a choice made here -- so the added track arrives at the
* top of both the sidebar and the column, and the two agree because they read
* the same list.
*/
const TrackColumn = observer(function TrackColumn({
view,
session,
}: {
view: BrowserView
session: BrowserSession
}) {
const ref = useWidthSetter(view)
const { containerProps } = usePanZoom(ref, view)
return (
<div
ref={ref}
{...containerProps}
// `flex: 1, minWidth: 0` because the sidebar beside it is fixed width and
// this half takes the rest; without the minWidth a flex item refuses to
// shrink below its content
style={{ ...viewport, flex: 1, minWidth: 0 }}
>
{view.status.type === 'ready' ? (
catalogueEntries(session).map(({ trackId }) => (
<TrackRow key={trackId} view={view} trackId={trackId} />
))
) : (
<ViewStatus view={view} />
)}
</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,
)
}
const TrackSelectorSidebar = observer(function TrackSelectorSidebar() {
const { view, session } = useCreateOnce(makeView)
const mode = useSiteMode()
return (
<SessionPaletteProvider session={session} mode={mode}>
<DisplayUIProvider>
{/* minHeight, not height: the sidebar is taller than two tracks and
* shorter than seven, so the row has to be able to grow past it. A
* fixed height would either clip the column or leave a gap under it
* depending on how many boxes are ticked. */}
<div style={{ display: 'flex', minHeight: 330 }}>
<TrackSelector view={view} session={session} />
<TrackColumn view={view} session={session} />
</div>
</DisplayUIProvider>
</SessionPaletteProvider>
)
})
export default TrackSelectorSidebar