JBrowse 2 · Build Your Own examples

Scalebar and track labels

Draw the parts around the tracks yourself: a scalebar with gridlines, region names and drag-to-zoom, plus labels and resize bars down the side.

A scalebar: gridlines, region names, drag to zoom

A coordinate row starts as a for loop over one tick pitch and ends somewhere else: labels that avoid each other, a second region with its own name on it, a chromosome that stays legible while you pan into it. The view has worked all of that out already, so none of it is yours to get right.

view.gridlineTicks gives {x, major} per tick and view.scalebarLabels gives {x, label, key} per label, off the same formula, so a number always sits on a line. The view drops labels that would collide and formats them for the zoom.

Both x values are in the staticBlocks frame: a pixel space spanning every displayed region, not the viewport. One element translated by view.staticBlocksTranslateX places every tick at once, and a pan moves that transform instead of each tick. Use the getter rather than translating by -view.offsetPx over an absolutely-placed overlay: offsetPx is a whole-genome coordinate, past 1e10 on hg38 chr1 at base resolution, and a CSS length that size is float32 by the time it reaches the compositor.

view.scalebarRefNameLabels hands back each region’s name already placed. Three rules live inside it: which block carries the sliding label (not the region’s first, which vanishes once you zoom past it), one label per run of a refName, and whole name or none, since chr16 clipped reads as chr1.

Drag across the row to zoom: view.pxToBp(px) turns a pixel offset into an anchor and view.moveTo(start, end) frames the span between two. Colours come from usePalette(), the toolkit-free counterpart to MUI’s useTheme.

View source — 642 lines
import { Suspense, useRef, useState, 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 { usePointerDrag } from '@jbrowse/core/util/usePointerDrag'
import { DisplayUIProvider, TrackOverlaySlot } from '@jbrowse/display-ui'
import { createViewState } from '@jbrowse/react-linear-genome-view2'
import { observer } from 'mobx-react'

// A coordinate row is easy to start and hard to finish: a loop over one tick
// pitch gets you ticks, and then labels collide, a second region arrives with
// no name on it, and the chromosome scrolls off the moment you pan into it.
// This is the finished one -- gridlines behind the data, coordinate labels that
// don't collide, the region name kept on screen while you pan past its start,
// and drag-to-zoom. Four getters on the view do the work --
// `gridlineTicks`, `scalebarLabels`, `scalebarRefNameLabels` and `paddingSpans`
// -- so none of it is tick maths you have to get right.
//
// 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 trackIds = ['hg38_phylop', 'hg38_genes']

const SCALEBAR_HEIGHT = 20

function makeView() {
  const state = createViewState({
    assembly: hg38,
    tracks: [conservationTrack, featureTrack],
    init: {
      // Two regions, so there is a name to keep on screen at each one and a seam
      // between them. Both windows sit inside BRCA1's own span, which keeps the
      // fetch cheap -- see the Drive it from your app page for when you'd reach
      // for two different chromosomes instead.
      loc: 'chr17:43,044,295..43,060,000 chr17:43,100,000..43,125,364',
      tracks: trackIds,
    },
  })
  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>
  )
})

/**
 * Background gridlines, drawn once for the whole stack -- scalebar included, so
 * a label sits on its own line.
 *
 * `view.gridlineTicks` is the tick list the view computed for the zoom it is
 * at, `{x, major}` each. Two `<path>`s rather than a div per tick: a zoom frame
 * then patches two `d` strings instead of reconciling a hundred nodes, and the
 * lines stay crisp at any device pixel ratio. They run to y=100000 and are
 * clipped by the svg's own box, so nothing has to measure the height.
 *
 * The x values are in the **staticBlocks frame**: a pixel space that spans
 * every displayed region rather than the viewport. So one element translated by
 * `view.staticBlocksTranslateX` puts every tick in the right place at once, and
 * a pan moves that one transform rather than each tick. The coordinate labels
 * below use the same frame for the same reason.
 *
 * Laying the overlay out in absolute genome pixels and translating by
 * `-view.offsetPx` is the shape that looks equivalent and is not. `offsetPx` is
 * a whole-genome coordinate -- hg38 chr1 at base resolution is past 1e10 -- and
 * a CSS length that size is float32 by the time it reaches the compositor,
 * where neighbouring representable values are ~1000px apart. The getter does
 * the subtraction in JS, so only its small difference reaches CSS.
 *
 * `usePalette()` is how a component asks JBrowse for a color without Material
 * UI -- the same hook its own displays use, reading the same theme, so chrome
 * you write follows the app's light/dark switch along with the data. Note what
 * it is *not*: the CSS system colors (`Canvas`, `CanvasText`) follow the
 * browser's colour scheme rather than the app's, so a dark app that hasn't set
 * `color-scheme` gets a white label box for its trouble.
 */
const Gridlines = observer(function Gridlines({ view }: { view: BrowserView }) {
  const { gridlineTicks, staticBlocks, staticBlocksTranslateX } = view
  const palette = usePalette()
  let minorD = ''
  let majorD = ''
  for (const tick of gridlineTicks) {
    // +0.5 centers a 1px stroke on a pixel column
    const segment = `M${tick.x + 0.5} 0V100000`
    if (tick.major) {
      majorD += segment
    } else {
      minorD += segment
    }
  }
  return (
    <svg
      aria-hidden
      style={{
        position: 'absolute',
        top: 0,
        left: 0,
        height: '100%',
        width: staticBlocks.totalWidthPx,
        // unrounded, unlike the labels below: these are paths, and there is no
        // glyph to blur. JBrowse's own Gridlines makes the same call, through
        // `ZoomTransform`
        transform: `translateX(${staticBlocksTranslateX}px)`,
        pointerEvents: 'none',
      }}
    >
      <path
        d={minorD}
        strokeWidth={1}
        style={{ stroke: palette.gridlineMinor }}
      />
      <path
        d={majorD}
        strokeWidth={1}
        style={{ stroke: palette.gridlineMajor }}
      />
    </svg>
  )
})

/**
 * The coordinate labels.
 *
 * `view.scalebarLabels` is already the answer: `{x, label, key}` per label, in
 * the same staticBlocks frame and off the same tick formula as the gridlines,
 * so a number always sits on a line. The view drops the ones that would not fit
 * -- against a region edge, against the region's own name, against each other
 * -- rather than leaving you to notice them overlapping at some zoom you did
 * not test, and it formats them for the zoom (`1,000` up close, `10.5kb` out).
 *
 * The opaque background is not decoration: the label straddles the gridline it
 * belongs to, and needs to mask it to stay readable.
 */
const ScalebarLabels = observer(function ScalebarLabels({
  view,
}: {
  view: BrowserView
}) {
  const { scalebarLabels, staticBlocks, staticBlocksTranslateX } = view
  const palette = usePalette()
  return (
    <div
      style={{
        position: 'absolute',
        top: 0,
        left: 0,
        height: '100%',
        width: staticBlocks.totalWidthPx,
        // rounded, unlike the gridlines above: these are text, and a fractional
        // offset blurs every number in the row. Same split JBrowse's own
        // ScalebarCoordinateLabels makes
        transform: `translateX(${Math.round(staticBlocksTranslateX)}px)`,
      }}
    >
      {/* keyed by position rather than by each label's `key`, which makes this
      a pool: a zoom moves the whole tick set, so identity keys would unmount
      and remount every node instead of relabelling it */}
      {scalebarLabels.map(({ x, label }, i) => (
        <span
          // eslint-disable-next-line @eslint-react/no-array-index-key -- position IS the identity here; keying by label is what the pooling above removes
          key={i}
          style={{
            position: 'absolute',
            top: 0,
            left: x,
            transform: 'translateX(-50%)',
            padding: '0 3px',
            background: palette.background.paper,
            color: palette.text.primary,
            whiteSpace: 'nowrap',
          }}
        >
          {label}
        </span>
      ))}
    </div>
  )
})

/**
 * The name of each region, kept on screen.
 *
 * A name drawn at the region's start scrolls away the moment you pan into the
 * region, which is when you most want it. So one label slides, pinned to the
 * viewport's left edge until its region's right edge takes it away again.
 *
 * `view.scalebarRefNameLabels` is that already decided -- `{text, transform,
 * maxWidth, paddingLeft}` per label, the same set JBrowse's own scalebar draws.
 * Three rules are inside it, and each is a bug you would otherwise ship once:
 *
 * - **which block carries the sliding label.** Not the region's first:
 *   `staticBlocks` only covers what is on screen, so once you zoom past a
 *   region's start that block is gone from the set entirely, and a label hung
 *   on it takes the chromosome name off screen at exactly the zoom where
 *   nothing else on the page names it.
 * - **one label per run of a refName**, so a view of collapsed introns doesn't
 *   repeat the same chromosome down the row.
 * - **whole name or none.** `chr16` clipped to its own width reads as `chr1`,
 *   which is a different chromosome rather than a shortened name -- so a name
 *   that doesn't fit its region is dropped rather than abbreviated. The Every
 *   chromosome page is where that is visible, on its narrowest bands.
 *
 * `transform` is a screen x, already net of `offsetPx`, unlike `gridlineTicks`
 * and `scalebarLabels` above -- the sliding label's position is a function of
 * the scroll rather than of block geometry, so it has no fixed place in the
 * staticBlocks frame. These labels go in a plain container, not the translated
 * one.
 */
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>
    ),
  )
})

const RUBBERBAND_MIN_PX = 4

/**
 * Drag across the scalebar to zoom to what you dragged over.
 *
 * Two model calls. `view.pxToBp(px)` turns a pixel offset in the view into an
 * anchor -- which displayed region, and how far into it -- and
 * `view.moveTo(start, end)` frames the span between two of them, working out
 * the zoom itself. Both are the same calls JBrowse's own rubberband makes.
 *
 * `usePointerDrag` is the lifecycle around them, and it is core's -- the same
 * one every JBrowse resize handle runs. Written by hand this is the block that
 * looks finished and is not: it owns the pointer capture (so the drag survives
 * the cursor leaving the row and ends even if the button comes up outside the
 * window), it starts only on a primary press, and it keeps the whole gesture to
 * the pointer that began it, so a second finger neither hijacks a drag nor
 * re-anchors one.
 *
 * The row's left edge is measured once at the press rather than per move: it
 * cannot move during the drag, and `getBoundingClientRect` in a pointermove
 * handler forces layout on every frame of one.
 *
 * A drag shorter than a few pixels is a click, and zooming to it would land the
 * user somewhere absurd, so it is dropped.
 */
function useRubberband(view: BrowserView) {
  const [range, setRange] = useState<
    { left: number; right: number } | undefined
  >(undefined)
  // written by `onDragStart`, which the hook guarantees runs first
  const originRef = useRef({ anchor: 0, originX: 0 })

  function clampToView(clientX: number, originX: number) {
    return Math.min(Math.max(clientX - originX, 0), view.width)
  }

  function spanTo(clientX: number) {
    const { anchor, originX } = originRef.current
    const x = clampToView(clientX, originX)
    return { left: Math.min(anchor, x), right: Math.max(anchor, x) }
  }

  return {
    range,
    props: usePointerDrag({
      onDragStart(event) {
        const { left } = event.currentTarget.getBoundingClientRect()
        originRef.current = {
          anchor: clampToView(event.clientX, left),
          originX: left,
        }
      },
      onDrag(event) {
        setRange(spanTo(event.clientX))
      },
      onDragEnd(event) {
        const { left, right } = spanTo(event.clientX)
        setRange(undefined)
        if (right - left >= RUBBERBAND_MIN_PX) {
          view.moveTo(view.pxToBp(left), view.pxToBp(right))
        }
      },
    }),
  }
}

function RangeSelection({ range }: { range: { left: number; right: number } }) {
  const palette = usePalette()
  return (
    <div
      aria-hidden
      // this site's smoke test drags across the scalebar and measures this
      // band, because a gesture is invisible to a census of a page at rest.
      // Keep or drop it in your own app -- the demo needs it, the technique
      // does not
      data-testid="rubberband"
      style={{
        position: 'absolute',
        top: 0,
        bottom: 0,
        left: range.left,
        width: range.right - range.left,
        zIndex: 4,
        pointerEvents: 'none',
        background: `color-mix(in srgb, ${palette.primary.main} 20%, transparent)`,
        borderLeft: `1px solid ${palette.primary.main}`,
        borderRight: `1px solid ${palette.primary.main}`,
      }}
    />
  )
}

/**
 * The row itself. `data-gesture-owner` is the marker `usePanZoom` tests before
 * starting a drag of its own -- without it, dragging out a range here would pan
 * the view sideways at the same time. JBrowse's own scalebar carries the same
 * attribute for the same reason.
 */
const ScalebarRow = observer(function ScalebarRow({
  view,
  ...handlers
}: { view: BrowserView } & React.ComponentProps<'div'>) {
  const palette = usePalette()
  return (
    <div
      data-gesture-owner="true"
      // this site's smoke test drags across this row to prove the rubberband
      // still reaches the model. Keep or drop it in your own app -- the check
      // needs it, the technique does not
      data-testid="scalebar"
      style={{
        position: 'relative',
        height: SCALEBAR_HEIGHT,
        overflow: 'clip',
        fontSize: '0.7rem',
        lineHeight: `${SCALEBAR_HEIGHT}px`,
        cursor: 'crosshair',
        userSelect: 'none',
        touchAction: 'none',
        borderBottom: `1px solid ${palette.divider}`,
      }}
      {...handlers}
    >
      <ScalebarLabels view={view} />
      <RegionNames view={view} />
    </div>
  )
})

// 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 -- see Gridlines above for the split
        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 Scalebar = observer(function Scalebar() {
  const { view, session } = useCreateOnce(makeView)
  const ref = useWidthSetter(view)
  const { containerProps } = usePanZoom(ref, view)
  const rubberband = useRubberband(view)
  const mode = useSiteMode()

  return (
    <SessionPaletteProvider session={session} mode={mode}>
      <DisplayUIProvider>
        <div ref={ref} {...containerProps} style={viewport}>
          {/* every piece below reads block geometry, and `staticBlocks`
           * *throws* until the ResizeObserver has reported a width -- so all
           * of it sits inside one gate rather than each guarding itself. See
           * the Drive it from your app page. */}
          {view.status.type === 'ready' ? (
            <>
              <Gridlines view={view} />
              <ScalebarRow view={view} {...rubberband.props} />
              {trackIds.map(id => (
                <TrackRow key={id} view={view} trackId={id} />
              ))}
              <RegionBoundaries view={view} />
              {rubberband.range ? (
                <RangeSelection range={rubberband.range} />
              ) : null}
            </>
          ) : (
            <ViewStatus view={view} />
          )}
        </div>
      </DisplayUIProvider>
    </SessionPaletteProvider>
  )
})

export default Scalebar

Track labels and resize bars

The other half of what an app draws around its data, and much less code than the row above: a column of labels beside the tracks, and a bar to drag each one taller. Both read the same view model the tracks read, so neither needs telling when the user pans.

The labels read track.activeDisplay.height, so they stay aligned when a track is resized or a display grows to fit.

The resize bars are the only piece that writes. display.resizeHeight(deltaPx) is the whole resize: it clamps to the minimum, and knows a manual drag on a grow-to-fit track means “stop growing”. Bracket the gesture with display.setResizing(true/false) so displays that restretch rows per frame can sit that layer out.

The bar is yours. The drag behind it isn’t. useResizeDrag from @jbrowse/core/util/useResizeDrag spreads props onto whatever you draw and reports one distance per animation frame. data-gesture-owner rides in those props. Without it, dragging to resize also pans the view sideways.

View source — 369 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 { useResizeDrag } from '@jbrowse/core/util/useResizeDrag'
import { DisplayUIProvider, TrackOverlaySlot } from '@jbrowse/display-ui'
import { createViewState } from '@jbrowse/react-linear-genome-view2'
import { observer } from 'mobx-react'

// Pan, zoom, three kinds of track, your own status overlays, your own track
// labels, your own resize bars. Everything the browser draws is now either data
// or yours. The page after this one goes the other way, and reads a click back
// out.
//
// Nothing here draws coordinates: that is the scalebar above, and the view
// computes all of it. A column of labels beside the tracks is the other half of
// what an app usually wants around the data, and it is much less code.
//
// The labels are a plain flex row next to each track, which is the cheapest
// thing that works. JBrowse's own label layer does more (drag to reorder, a
// per-track menu, an overlap mode that floats the label over the data) and if
// you want those you should use the full component rather than rebuild them.
// Knowing where that line is for your app is the whole point of starting here.
//
// 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 },
}

const tracks = [
  { id: 'hg38_phylop', label: 'Conservation' },
  { id: 'hg38_genes', label: 'Genes' },
  { id: 'na12878_exome', label: 'Reads' },
]
const trackIds = tracks.map(t => t.id)

const LABEL_WIDTH = 90

function makeView() {
  const state = createViewState({
    assembly: hg38,
    tracks: [conservationTrack, featureTrack, alignmentsTrack],
    init: {
      loc: 'chr17:43,044,295..43,125,364',
      tracks: trackIds,
    },
  })
  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>
  )
})

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

// Reads the display's own height so the label stays aligned when a track is
// resized or a display grows to fit its content.
const TrackLabel = observer(function TrackLabel({
  view,
  trackId,
  label,
}: {
  view: BrowserView
  trackId: string
  label: string
}) {
  const track = view.getTrack(trackId)
  if (!track) {
    return null
  }
  return (
    <div
      style={{
        height: track.activeDisplay.height,
        fontSize: '0.75rem',
        paddingRight: 8,
        overflow: 'hidden',
        textOverflow: 'ellipsis',
        whiteSpace: 'nowrap',
      }}
    >
      {label}
    </div>
  )
})

const RESIZE_HANDLE_HEIGHT = 4

/**
 * Drag the bar under a track to resize it. The bar is yours (it is a divider in
 * your own row), the gesture is not: `useResizeDrag` hands back the props for a
 * pointer-capture drag reported as one distance per animation frame, which is
 * the same gesture JBrowse's own track dividers run. Spread them and style the
 * div however your app wants.
 *
 * Two model calls do the rest:
 *
 * - `display.resizeHeight(deltaPx)` is the whole resize. It clamps to the
 *   display's minimum, and it also knows what a manual drag *means*: a display
 *   in grow-to-fit mode is pinned to fixed height first, so the drag isn't
 *   immediately undone by the next relayout.
 * - `display.setResizing(true/false)` brackets the gesture. Displays whose row
 *   geometry is a function of track height restretch every row per frame, and
 *   use this to sit an expensive layer out of the drag. Skipping it costs you
 *   correctness nowhere and frames somewhere.
 *
 * `touchAction: 'none'` is yours to write here, unlike on the viewport below:
 * `usePanZoom` is handed the element as a ref and sets it itself, while this
 * hook returns props and never sees a node. Without it the browser claims a
 * touch drag as a page scroll and the pointer stream never arrives.
 */
const TrackResizeHandle = observer(function TrackResizeHandle({
  view,
  trackId,
}: {
  view: BrowserView
  trackId: string
}) {
  const display = view.getTrack(trackId)?.activeDisplay
  const handleProps = useResizeDrag({
    onDrag: distance => {
      display?.resizeHeight(distance)
    },
    onDragStart: () => {
      display?.setResizing(true)
    },
    onDragEnd: () => {
      display?.setResizing(false)
    },
  })
  return display ? (
    <div
      {...handleProps}
      aria-label={`Resize ${trackId}`}
      style={{
        height: RESIZE_HANDLE_HEIGHT,
        cursor: 'row-resize',
        touchAction: 'none',
        background: 'color-mix(in srgb, currentColor 20%, 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,
  )
}

// Still needed even with the overlays swapped: the feature and alignments
// displays read the palette for their own content colours. See the previous
// two pages.

// 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 TrackLabels = observer(function TrackLabels() {
  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 style={{ width: LABEL_WIDTH, flex: 'none' }}>
            {tracks.map(t => (
              // one spacer per resize bar, so a label stays level with its
              // track as the stack grows
              <div key={t.id}>
                <TrackLabel view={view} trackId={t.id} label={t.label} />
                <div style={{ height: RESIZE_HANDLE_HEIGHT }} />
              </div>
            ))}
          </div>
          <div style={{ flex: 1, minWidth: 0 }}>
            <div ref={ref} {...containerProps} style={viewport}>
              {view.status.type === 'ready' ? (
                trackIds.map(id => (
                  <div key={id}>
                    <TrackRow view={view} trackId={id} />
                    <TrackResizeHandle view={view} trackId={id} />
                  </div>
                ))
              ) : (
                <ViewStatus view={view} />
              )}
            </div>
          </div>
        </div>
      </DisplayUIProvider>
    </SessionPaletteProvider>
  )
})

export default TrackLabels