Highlighting a region
Arrive from a search hit or a variant table with the region marked, and keep it marked while the reader pans.
A band your app places, and the four cases it survives
The page before this one ends by navigating to a search hit, which is half the job: the reader lands on a window of sequence with nothing marking what they clicked.
view.highlight is the mark, and it is data: an array of
{refName, start, end, color?, label?}. setHighlight(list) replaces the set,
addToHighlights(one) appends, and createViewState takes location and
highlight as top-level options, so an embed can arrive already marked.
What getHighlightCoords knows
It returns {left, width}, or undefined when the region is nowhere on screen.
Four things live inside it, each a bug that only appears on an awkward input —
the three buttons above:
- it clips to the displayed regions, so a highlight running past the end of a chromosome is trimmed instead of drawn over the greyed-out end of the genome;
- width has a 3px floor, so a one-base hit at 40kb of zoom is still findable rather than a hundredth of a pixel;
- it is independent of direction, so a reversed region never renders a band inside-out;
- it resolves the refName the way the view does, so a highlight naming
chr1lands on an assembly whose sequence file calls it1.
left is in viewport pixels, already net of offsetPx — so bands go in a
plain container, unlike paddingSpans and gridlineTicks, which are laid out
across every displayed region and need view.staticBlocksTranslateX on a
wrapper. scalebarRefNameLabels is in the viewport frame too.
session.highlightsVisible is session-wide, so read it even if you draw no
control for it. JBrowse re-reveals on growth — addToHighlights while the
toggle is off turns it back on rather than dropping the band silently, while
setHighlight replacing a one-entry set does not, which is what the buttons
above do. Untick the box, click one, and nothing appears.
View source — 416 lines
import { Suspense, 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'
import type { HighlightType } from '@jbrowse/core/util/highlights'
// The archetypal embed: a table, a search result or a variant report links into
// a browser, and the region it linked to has to be visible once you get there.
// Navigating is not enough -- the reader lands on a window of sequence with no
// mark on the thing they clicked.
//
// `view.highlight` is that mark, and like everything else on this site it is
// data rather than a component: an array of `{refName, start, end, color?,
// label?}`, and `view.getHighlightCoords(h)` turns one into the `{left, width}`
// to draw a band at. What the band looks like is yours.
//
// The four things that method knows, each of which is a bug you would otherwise
// ship once, are in `Highlights` below.
//
// 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: 140 },
}
// Whatever your app already has a list of: search hits, a variant report, the
// row someone clicked in a table beside the browser. `color` is optional and
// overrides the theme's default band; a user-supplied one is used as-is, so an
// explicit alpha survives.
const HITS: { label: string; loc: string; highlight: HighlightType }[] = [
{
label: 'BRCA1',
loc: 'chr17:43,020,000..43,150,000',
highlight: {
assemblyName: 'hg38',
refName: 'chr17',
start: 43044295,
end: 43125364,
label: 'BRCA1',
},
},
{
label: 'A single base',
loc: 'chr17:43,020,000..43,150,000',
highlight: {
assemblyName: 'hg38',
refName: 'chr17',
start: 43090000,
end: 43090001,
color: 'rgba(217, 119, 6, 0.45)',
label: 'one base, mid-intron',
},
},
{
label: 'Off the end of the chromosome',
loc: 'chr17:83,200,000..83,257,441',
highlight: {
assemblyName: 'hg38',
refName: 'chr17',
start: 83250000,
end: 83300000,
label: 'clipped to the chromosome',
},
},
]
function makeView() {
const state = createViewState({
assembly: hg38,
tracks: [featureTrack],
// `location` and `highlight` are top-level options, so a browser can arrive
// already marked rather than being navigated after it mounts. Both are
// sugar for fields of the same declarative `init` the buttons below drive
// imperatively, which is what makes the two paths agree -- and mixing them
// with an `init` of your own is fine, since the sugar only fills in the two
// fields it names.
location: HITS[0]!.loc,
highlight: [HITS[0]!.highlight],
init: { tracks: [featureTrack.trackId] },
})
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>
)
})
/**
* The bands.
*
* `view.getHighlightCoords(region)` is the whole geometry: `{left, width}`, or
* `undefined` when the region is nowhere on screen. Four things live inside it,
* and each is a bug that only shows up on the awkward input:
*
* - **it clips to the displayed regions.** A highlight that runs off the end of
* a contig -- because your source rounded, or because the region came from a
* different assembly build -- is trimmed rather than drawn into the greyed
* area past the genome's end. The third button does this.
* - **width has a 3px floor.** A one-base highlight at 40kb of zoom is a
* hundredth of a pixel, so the honest rendering of it is nothing at all. The
* second button is that case, and the band stays findable.
* - **it is independent of direction.** A reversed displayed region makes the
* end map left of the start; the returned `width` is an absolute value and
* `left` the smaller of the two, so a band never renders inside-out.
* - **it resolves the refName the way the view does.** A highlight naming
* `chr1` still lands on an assembly whose sequence file calls it `1`, which
* matters because these regions usually come from somewhere else.
*
* **Viewport pixels, not the staticBlocks frame.** `left` is already net of
* `offsetPx`, so these go in a plain container -- unlike `paddingSpans` and
* `gridlineTicks`, which are laid out across every displayed region and need
* `view.staticBlocksTranslateX` on a wrapper. Two frames on one row is the
* thing to keep straight; `scalebarRefNameLabels` is in this one too.
*/
const Highlights = observer(function Highlights({
view,
session,
}: {
view: BrowserView
session: BrowserSession
}) {
// Session-wide, not per view, and on by default. Worth reading even if you
// draw no control for it, because something else in your app might: a band
// that renders unconditionally would ignore the toggle.
//
// JBrowse re-reveals on *growth* -- an autorun on `highlight.length` -- so
// `addToHighlights` while the toggle is off turns it back on rather than
// dropping the band silently. `setHighlight` replacing a one-entry set with
// another one-entry set is not growth and does not, which is the buttons
// above: turn the box off, click one, and nothing appears. That is the
// trade-off for a toggle that can be turned off at all.
if (!session.highlightsVisible) {
return null
}
return view.highlight.map((highlight, i) => {
const coords = view.getHighlightCoords(highlight)
return coords ? (
<div
// highlights have no id and can legitimately duplicate, so the index
// only breaks ties between otherwise-identical regions
// eslint-disable-next-line @eslint-react/no-array-index-key -- ^ that tie-break is the point; without it two identical regions share a key
key={`${highlight.refName}-${highlight.start}-${i}`}
// this site's smoke test clicks the buttons above and measures the
// band, because the three cases the method survives are invisible until
// something drives them. Keep or drop it in your own app -- the demo
// needs it, the technique does not
data-testid="highlight-band"
title={highlight.label}
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: coords.left,
width: coords.width,
zIndex: 3,
// a band is a mark on the data, not a lid over it: it must not take
// the click a display underneath is waiting for
pointerEvents: 'none',
background:
highlight.color ??
'color-mix(in srgb, CanvasText 18%, transparent)',
}}
/>
) : null
})
})
// 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,
)
}
/**
* Arriving from somewhere else, which is what this whole page is about.
*
* `setHighlight(list)` replaces the set, `addToHighlights(one)` appends. This
* uses the first, because each button *is* the one thing the reader followed;
* an app showing several hits at once appends instead.
*
* The highlight is set before the navigation rather than after, and not for a
* correctness reason -- `navToLocString` is async, so setting it after would
* leave the band absent for the whole of the fetch. Neither call waits on the
* other: a highlight for a region that is not on screen simply has no
* coordinates yet, and `getHighlightCoords` returns `undefined` until it does.
*
* Not an `observer`, unlike most components here: `observer` subscribes to the
* observables a component reads *while rendering*, and this one touches the
* view only from a click handler, which always sees the current value anyway.
*/
function HitList({ view }: { view: BrowserView }) {
return (
<div style={{ display: 'flex', gap: 6, fontSize: '0.85rem' }}>
{HITS.map(({ label, loc, highlight }) => (
<button
key={label}
type="button"
style={{ font: 'inherit' }}
onClick={() => {
view.setHighlight([highlight])
view.navToLocString(loc).catch((e: unknown) => {
// these locstrings are your own, so one that fails to resolve is
// a bug in your list rather than something a user mistyped
console.error(e)
})
}}
>
{label}
</button>
))}
</div>
)
}
// 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 HighlightARegion = observer(function HighlightARegion() {
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: 'center',
gap: 14,
paddingBottom: 8,
}}
>
<HitList view={view} />
<label style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<input
type="checkbox"
checked={session.highlightsVisible}
onChange={event => {
session.setHighlightsVisible(event.target.checked)
}}
/>
<span style={{ fontSize: '0.85rem' }}>
Show highlights — session-wide, across every view
</span>
</label>
</div>
<div ref={ref} {...containerProps} style={viewport}>
{/* `getHighlightCoords` reads block geometry, which throws until the
* ResizeObserver has reported a width -- so the bands sit inside the
* same gate as the tracks. See the Drive it from your app page. */}
{view.status.type === 'ready' ? (
<>
<TrackRow view={view} trackId="hg38_genes" />
<Highlights view={view} session={session} />
</>
) : (
<ViewStatus view={view} />
)}
</div>
</DisplayUIProvider>
</SessionPaletteProvider>
)
})
export default HighlightARegion