JBrowse 2 · Build Your Own examples

Track settings

Drive a display rather than the view: a Color by menu and a read-height slider, built from the same registry the track menu uses.

A Color by menu, and the legend it raises

The pages before this one drive the view. A display has settings of its own, and they are the same kind of API: a getter to read, an action to write.

display.colorBy.type // 'strand'
display.setColorScheme({ type: 'pairOrientation' })
display.setFeatureHeight(7)

activeDisplay is resolved at runtime, so assert it to the published LinearAlignmentsDisplayModel to type those members.

Take the labels from pickColorOptions. It returns {type, label} for the schemes you name, in your order, from the registry the track menu uses — so your menu cannot drift, and a type that isn’t a scheme fails to compile.

setColorScheme over a bare setConf — it also clears the discovered per-read values the CPU-baked schemes fill in, which a config write would leave stale.

The legend is not yours

Tick Show legend and one appears, drawn by JBrowse — plain, dismissable, and with no provider to swap it, unlike the loading and error states.

A key lists the colours actually painted in the window, not every colour the scheme could paint, so it moves with the data.

showLegend is off by default and is a promotable slot, so read display.showLegend rather than getConf: unset means “follow the session default”, and only the resolved getter knows what that is.

Getting it above your own overlays

A display draws its floating chrome — this legend, the loading scrim, the error bar — inside a contain: strict box, its own stacking context. None of it can out-zIndex the seams this page paints over the stack.

TrackOverlaySlot is the way out, and what JBrowse’s own track container mounts: an overlay node beside the sandbox, so chrome portalling into it paints at the slot’s zIndex. That prop has no default; it answers “above what?”, a fact about your layout. Every TrackRow here mounts one — this is only where you can watch it work.

View source — 527 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 { pickColorOptions } from '@jbrowse/plugin-alignments'
import { createViewState } from '@jbrowse/react-linear-genome-view2'
import { observer } from 'mobx-react'

import type { LinearAlignmentsDisplayModel } from '@jbrowse/plugin-alignments'

// Every page so far drives the *view* -- where it is, how far in, which tracks
// are on. This one goes one level down and drives a **display**: how a track
// paints what it already fetched.
//
// The whole integration is a getter and an action on the display model.
// `display.colorBy` is what it is coloring by now, `setColorScheme({type})`
// changes it, and the labels come from `pickColorOptions`, which is JBrowse's
// own registry of schemes -- so a menu you build cannot drift from the schemes
// that exist, and you do not re-spell "Insert size and pair orientation" in
// your app.
//
// It is also the page where something appears that you did not draw. Tick
// **Show legend** on a scheme that has a key and one shows up in the corner,
// drawn by JBrowse rather than by this file: a display draws its own floating
// chrome, and unlike the loading and error states there is no provider to swap
// it for yours. Two conditions, not one -- the legend is opt-in, and a scheme
// has to have a key to show. `LegendToggle` is the first, and is a setting like
// the others.
//
// That chrome is drawn inside the display's own stacking context, so it cannot
// paint above the region seams this page draws over the stack -- there are two
// regions below, so there is a seam to be buried under. `TrackOverlaySlot` in
// `TrackRow` is what lifts it clear, and it is the same component JBrowse's own
// track container mounts. Every page here mounts it; this is the only one where
// you can watch what it is for.
//
// 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 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 schemes this page offers, and the labels come from JBrowse rather than
// from here. `pickColorOptions` takes the ones you support, in the order you
// want them, and returns `{type, label}` with each label read out of the same
// registry the track menu uses -- so a menu you build says what JBrowse says,
// and a `type` that is not a real scheme does not compile.
//
// Five of about fourteen. A curated subset is the normal case for an embed:
// the full list includes per-base modes that want a different zoom and tag
// modes that want a tag name, and offering everything is how you end up
// shipping a menu item that does nothing on your data.
//
// **A key lists the colours actually painted, not every colour the scheme
// could paint.** It is derived from the reads laid out in the window, so what
// it says depends on the data in front of you: on this CRAM `strand` splits the
// pileup in two and `mappingQuality` comes out a three-step ramp, while a
// scheme whose categories are all absent here collapses to a single row. That
// is the honest rendering rather than a shortcoming, and switching between
// them with the legend on is the second half of what this page shows.
const COLOR_SCHEMES = pickColorOptions(
  'normal',
  'strand',
  'pairOrientation',
  'insertSizeAndOrientation',
  'mappingQuality',
)

function makeView() {
  const state = createViewState({
    assembly: hg38,
    tracks: [alignmentsTrack],
    init: {
      // Close enough in that reads are individually visible -- a colour scheme
      // you cannot see one read of is not a demo of a colour scheme -- and two
      // regions rather than one, so there is a seam for the legend to have to
      // paint above. Both inside BRCA1, which is where this exome CRAM's reads
      // are dense.
      loc: 'chr17:43,044,295..43,052,295 chr17:43,090,000..43,098,000',
      tracks: [alignmentsTrack.trackId],
    },
  })
  const { view } = state.session
  // see the Pan and zoom example: scroll-to-zoom is a session preference, and the
  // pileup below reads the same one to know the plain wheel is spoken for
  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>
  )
})

/**
 * The same `TrackRow` every page here writes, and this is the page where the
 * one part of it that looks like ceremony stops being invisible.
 *
 * The problem is a stacking context. `contain: strict` is what stops a display
 * painting over its neighbours, and it seals the display's React tree into its
 * own stacking context as a side effect -- so the legend below, which the
 * display draws, cannot out-z-index the seams this page draws over the stack at
 * `zIndex: 2`. Nothing errors. The legend is simply under a grey bar, and at
 * whole-genome zoom, where most spans are elided, under a grey wall.
 *
 * `TrackOverlaySlot` is the way out and it is the same component JBrowse's own
 * track container mounts: the display's box, plus an overlay node *beside* the
 * sandbox rather than inside it, published to the display through
 * `TrackOverlayContext`. Chrome that calls `TrackOverlayPortal` -- the colour
 * key, hi-c's overlay panel, maf's row labels, and the loading and error states
 * every display has -- lands in that node and paints at the `zIndex` you give
 * it. With no slot the context is null, the portal falls back to rendering in
 * place, and the chrome is back inside the sandbox with nothing to say so.
 *
 * So it goes on every page rather than on this one, which is the only page that
 * can *show* it: nothing else here turns on a colouring that has a key. A host
 * whose displays are quiet today and whose layout grows a mask tomorrow gets no
 * warning either way.
 *
 * **`zIndex` has no default, and that is deliberate.** It is the answer to
 * "above what?", which is a fact about your layout: 3 here, because the seams
 * are at 2. JBrowse's own container passes 100, positioned against its own
 * stacking, and inheriting that number would be right once and quietly wrong
 * everywhere else.
 *
 * The `contain: strict` box is `position: absolute; inset: 0` inside the slot's
 * box rather than being the sized box itself, because the slot owns the height
 * -- exactly what `TrackRenderingContainer` does inside JBrowse.
 */
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
  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)',
}

/**
 * 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>
  )
})

/**
 * The whole of it: read `display.colorBy`, call `display.setColorScheme`.
 *
 * `activeDisplay` is a pluggable MST type resolved at runtime, so there is no
 * static element type on `getTrack` to narrow it -- the assertion to the
 * published `LinearAlignmentsDisplayModel` is what gives the two members below
 * their types. It is an assertion rather than a check because this page put the
 * track in the config itself and knows what it is; a host whose tracks come from
 * a config file it did not write should test `type` on the track config first.
 *
 * **A `<select>`, not React state.** The value is read back off the model on
 * every render, so there is nothing here that can disagree with the display --
 * which matters as soon as anything else can set a scheme, and something else
 * always can: the track's own right-click menu sets exactly this field, and a
 * saved session arrives with it already set.
 *
 * `setColorScheme` is one action rather than a `setConf`, and the difference is
 * not cosmetic: it also clears the map of discovered per-read values that the
 * CPU-baked schemes fill in, which a bare config write would leave stale
 * against the new scheme.
 */
const ColorBySelect = observer(function ColorBySelect({
  view,
  trackId,
}: {
  view: BrowserView
  trackId: string
}) {
  const display = view.getTrack(trackId)?.activeDisplay as
    | LinearAlignmentsDisplayModel
    | undefined
  if (!display) {
    return null
  }
  return (
    <label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
      Color by
      <select
        value={display.colorBy.type}
        style={{ font: 'inherit', padding: '2px 4px' }}
        onChange={event => {
          display.setColorScheme({
            // the option values ARE the scheme types, so this is the registry's
            // own vocabulary round-tripping through the DOM rather than a
            // string this file invented
            type: event.target.value as (typeof COLOR_SCHEMES)[number]['type'],
          })
        }}
      >
        {COLOR_SCHEMES.map(({ type, label }) => (
          <option key={type} value={type}>
            {label}
          </option>
        ))}
      </select>
    </label>
  )
})

/**
 * The legend is a setting too, and it is off by default.
 *
 * Worth a control of its own because the default surprises people: pick a
 * scheme with a key and nothing appears, because a legend eagerly covering the
 * top of every alignments track is worse than one you ask for.
 * `setShowLegend(true)` is the ask.
 *
 * **Read it back through `display.showLegend`, never `getConf`.** It is a
 * *promotable* slot: unset means "follow the session-wide default for this
 * display type", and only the resolved getter knows what that came out as. The
 * raw config value for an untouched track is `undefined`, which as a checkbox
 * state reads as "off" whether or not the session turned it on. `setShowLegend`
 * also takes `undefined`, which puts a track back to inheriting -- that is what
 * a tri-state control would write, and this checkbox does not offer it.
 *
 * Once it is on, the legend's own `×` writes the same slot. Nothing here is
 * told; the checkbox goes back to unticked because it reads the model.
 */
const LegendToggle = observer(function LegendToggle({
  view,
  trackId,
}: {
  view: BrowserView
  trackId: string
}) {
  const display = view.getTrack(trackId)?.activeDisplay as
    | LinearAlignmentsDisplayModel
    | undefined
  if (!display) {
    return null
  }
  return (
    <label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
      <input
        type="checkbox"
        checked={display.showLegend}
        onChange={event => {
          display.setShowLegend(event.target.checked)
        }}
      />
      Show legend
    </label>
  )
})

/**
 * A second setting, to show that the first was not special.
 *
 * `display.setFeatureHeight(px)` is how tall one read is drawn. Nothing about
 * it is alignments-specific in shape -- it is the same pattern as the colour
 * scheme, a getter to read and an action to write -- which is the point of
 * having two on one page: a display's settings are an ordinary model API, not a
 * menu you have to reach into.
 *
 * The display re-lays out and re-renders on its own; there is no invalidate
 * call, and no reason to touch `view`.
 */
const ReadHeightControl = observer(function ReadHeightControl({
  view,
  trackId,
}: {
  view: BrowserView
  trackId: string
}) {
  const display = view.getTrack(trackId)?.activeDisplay as
    | LinearAlignmentsDisplayModel
    | undefined
  if (!display) {
    return null
  }
  return (
    <label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
      Read height
      <input
        type="range"
        min={2}
        max={12}
        step={1}
        value={display.featureHeight}
        style={{ width: 90 }}
        onChange={event => {
          display.setFeatureHeight(Number(event.target.value))
        }}
      />
      <span style={{ opacity: 0.7, minWidth: '3ch' }}>
        {display.featureHeight}px
      </span>
    </label>
  )
})

// 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 TrackSettings = observer(function TrackSettings() {
  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: 16,
            fontSize: '0.85rem',
            paddingBottom: 8,
          }}
        >
          {/* Both controls are outside the `view.status` gate on purpose: they
           * read the *display*, which exists as soon as the track does, and each
           * returns null until then. Putting them inside would make the toolbar
           * appear a beat after the box, which is the kind of jump the reserved
           * demo height exists to avoid. */}
          <ColorBySelect view={view} trackId={alignmentsTrack.trackId} />
          <LegendToggle view={view} trackId={alignmentsTrack.trackId} />
          <ReadHeightControl view={view} trackId={alignmentsTrack.trackId} />
        </div>
        <div ref={ref} {...containerProps} style={viewport}>
          {/* `RegionBoundaries` reads block geometry, which throws until the
           * ResizeObserver has reported a width, so it sits inside the same
           * gate as the track. See the Drive it from your app page. */}
          {view.status.type === 'ready' ? (
            <>
              <TrackRow view={view} trackId={alignmentsTrack.trackId} />
              <RegionBoundaries view={view} />
            </>
          ) : (
            <ViewStatus view={view} />
          )}
        </div>
      </DisplayUIProvider>
    </SessionPaletteProvider>
  )
})

export default TrackSettings