Single-cell UMAP
A UMAP of 5k PBMCs beside per-cell-type coverage: select clusters to filter rows, click a gene to color cells.
Both panels are driven by the same cell-type list: the UMAP’s palette and the
track’s subadapters are built from cells.json, so nothing is kept in sync by
hand.
Selecting cell types calls the display’s own row filter — the one the track’s
sidebar tree drives. Passing undefined clears it. Hidden rows drop out of the
shared score axis too, so filtering to two cell types rescales the plot to those
two:
const display = view.getTrack(TRACK_ID)
?.activeDisplay as MultiWiggleDisplayModel
display.setSubtreeFilter(['CD8 T', 'NK'])
The other direction needs no wiring: clicking a feature sets
session.selection, and an observer reading it re-renders the UMAP with that
gene’s expression.
Coverage is 10x 3’ data, so it piles at the 3’ end of each gene and the height
of that pile is the cell type’s expression — a marker gene reads as one tall row
against eight flat ones. The third track is the same reads unpooled, one row per
cell, from a Zarr matrix read by MultiWiggleZarrAdapter (an external plugin,
loaded by URL alongside the data). Its axis is pinned rather than
autoscaled: against the home cell type’s maximum every single-UMI cell renders
white, and pinning a low maxScore is what makes the ambient-RNA speckle
visible.
Built by
build_scrna_pseudobulk.sh
— see the
scRNA pseudobulk tutorial.
View source — 414 lines
import { Suspense, use, useEffect, useRef, useState } from 'react'
import { isFeature } from '@jbrowse/core/util'
import {
JBrowseLinearGenomeView,
loadPlugins,
useCreateViewState,
} from '@jbrowse/react-linear-genome-view2'
import { observer } from 'mobx-react'
import type { MultiWiggleDisplayModel } from '@jbrowse/plugin-wiggle'
// ---------------------------------------------------------------------------
// The UMAP panel. It is plain canvas with no JBrowse in it at all, and it lives
// here rather than in a shared module so this page's source stays one complete
// file you can copy. `Cells` is also the shape of the cells.json the demo
// fetches below.
// ---------------------------------------------------------------------------
interface CellType {
name: string
group: string
color: string
}
interface Cells {
dataset: string
cellTypes: CellType[]
// coordinates are pre-normalized to 0..1, so the page never needs to know
// the embedding's units
x: number[]
y: number[]
type: number[]
genes: string[]
geneLoc: string[]
// the subset of `genes` whose windows the per-cell Zarr matrix covers
perCellGenes: string[]
// [record offset, record count, the expression value a byte of 255 means]
exprIndex: [number, number, number][]
exprUrl: string
perCellUrl: string
}
const WIDTH = 560
const HEIGHT = 400
const PAD = 14
const DOT = 2.6
const GREY = '#d8dbe0'
// Values for one gene, unpacked from the sparse blob: three bytes per
// expressing cell, a uint16 cell index and a byte of expression.
function geneValues(cells: Cells, expr: Uint8Array, gene: string) {
const g = cells.genes.indexOf(gene)
const out = new Uint8Array(cells.x.length)
if (g !== -1) {
const [offset, count] = cells.exprIndex[g]!
for (let i = 0; i < count; i++) {
const at = (offset + i) * 3
out[expr[at]! | (expr[at + 1]! << 8)] = expr[at + 2]!
}
}
return out
}
// Light to dark single hue, so a bright point reads as more expression without
// competing with the categorical cell-type colors.
function ramp(v: number) {
const t = v / 255
const r = Math.round(232 + (8 - 232) * t)
const g = Math.round(238 + (48 - 238) * t)
const b = Math.round(247 + (107 - 247) * t)
return `rgb(${r},${g},${b})`
}
function UmapScatter({
cells,
expr,
gene,
selected,
onPick,
}: {
cells: Cells
expr: Uint8Array
gene?: string
selected: string[]
onPick: (cellType: string) => void
}) {
const ref = useRef<HTMLCanvasElement>(null)
// an imperative canvas repaint whenever the inputs change
useEffect(() => {
const ctx = ref.current?.getContext('2d')
if (ctx) {
const dpr = 2
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, WIDTH, HEIGHT)
const px = (i: number) => PAD + cells.x[i]! * (WIDTH - 2 * PAD)
const py = (i: number) => HEIGHT - PAD - cells.y[i]! * (HEIGHT - 2 * PAD)
const values = gene ? geneValues(cells, expr, gene) : undefined
const active = new Set(selected)
// draw the de-emphasized cells first so the ones being asked about are
// never hidden underneath them
const order = [...cells.x.keys()].sort((a, b) =>
values
? values[a]! - values[b]!
: Number(
active.size > 0 &&
active.has(cells.cellTypes[cells.type[a]!]!.name),
) -
Number(
active.size > 0 &&
active.has(cells.cellTypes[cells.type[b]!]!.name),
),
)
for (const i of order) {
const type = cells.cellTypes[cells.type[i]!]!
ctx.fillStyle = values
? ramp(values[i]!)
: active.size === 0 || active.has(type.name)
? type.color
: GREY
ctx.beginPath()
ctx.arc(px(i), py(i), DOT, 0, 2 * Math.PI)
ctx.fill()
}
}
}, [cells, expr, gene, selected])
return (
<div>
<canvas
ref={ref}
width={WIDTH * 2}
height={HEIGHT * 2}
style={{
width: WIDTH,
height: HEIGHT,
maxWidth: '100%',
cursor: 'pointer',
}}
onClick={event => {
const box = event.currentTarget.getBoundingClientRect()
const scale = WIDTH / box.width
const mx = (event.clientX - box.left) * scale
const my = (event.clientY - box.top) * scale
let best = -1
let bestDist = 64
for (let i = 0; i < cells.x.length; i++) {
const dx = PAD + cells.x[i]! * (WIDTH - 2 * PAD) - mx
const dy = HEIGHT - PAD - cells.y[i]! * (HEIGHT - 2 * PAD) - my
const d = dx * dx + dy * dy
if (d < bestDist) {
bestDist = d
best = i
}
}
if (best !== -1) {
onPick(cells.cellTypes[cells.type[best]!]!.name)
}
}}
/>
<div
style={{ display: 'flex', flexWrap: 'wrap', gap: 8, maxWidth: WIDTH }}
>
{cells.cellTypes.map(type => (
<button
key={type.name}
type="button"
onClick={() => {
onPick(type.name)
}}
style={{
display: 'flex',
alignItems: 'center',
gap: 5,
padding: '2px 7px',
cursor: 'pointer',
// CanvasText, not a hex grey: the selected outline has to stay
// visible whichever scheme the host page is in
border: selected.includes(type.name)
? '2px solid CanvasText'
: '2px solid transparent',
background: 'none',
font: 'inherit',
fontSize: '0.85em',
}}
>
<span
style={{
width: 10,
height: 10,
borderRadius: 5,
background: type.color,
}}
/>
{type.name}
</button>
))}
</div>
</div>
)
}
// --- end of the UMAP panel; the genome view starts here ---
// 10x Genomics 5k PBMC v3, clustered and labeled with scanpy, then pseudobulked
// into one coverage BigWig per cell type. The UMAP coordinates, the cell-type
// palette, and the BigWigs all come out of the same build script, so a cluster
// and its coverage row are the same color by construction.
const BASE = 'https://jbrowse.org/demos/scrna_pbmc5k'
const TRACK_ID = 'pbmc5k_scrna_pseudobulk'
const PER_CELL_TRACK_ID = 'pbmc5k_scrna_percell'
// The per-cell rows come from a Zarr signal matrix, which is an external plugin
const ZARR_PLUGIN =
'https://jbrowse.org/demos/zarr/jbrowse-plugin-zarr.umd.production.min.js'
const dataPromise = Promise.all([
fetch(`${BASE}/cells.json`).then(res => res.json() as Promise<Cells>),
fetch(`${BASE}/expr.bin`)
.then(res => res.arrayBuffer())
.then(buf => new Uint8Array(buf)),
loadPlugins([{ name: 'Zarr', url: ZARR_PLUGIN }]).then(loaded =>
loaded.map(p => p.plugin),
),
])
const assembly = {
name: 'GRCh38',
aliases: ['hg38'],
uri: 'https://s3.amazonaws.com/jbrowse.org/genomes/GRCh38/fasta/GRCh38.fa.gz',
refNameAliases: {
uri: 'https://s3.amazonaws.com/jbrowse.org/genomes/GRCh38/hg38_aliases.txt',
},
}
// One subadapter per cell type, built from the same list that colors the UMAP.
function tracks(cells: Cells) {
return [
{
type: 'FeatureTrack',
trackId: 'hg38_refseq_curated',
name: 'NCBI RefSeq genes',
assemblyNames: ['GRCh38'],
adapter: {
type: 'Gff3TabixAdapter',
uri: 'https://jbrowse.org/ucsc/hg38/ncbiRefSeqCurated.gff.gz',
// this one is indexed with a .csi, not a .tbi
csi: true,
},
displayDefaults: { height: 90 },
},
{
type: 'MultiQuantitativeTrack',
trackId: TRACK_ID,
name: 'scRNA pseudobulk by cell type',
assemblyNames: ['GRCh38'],
adapter: {
type: 'MultiWiggleAdapter',
subadapters: cells.cellTypes.map(type => ({
type: 'BigWigAdapter',
name: type.name,
group: type.group,
color: type.color,
uri: `${BASE}/${type.name.replaceAll(' ', '_')}.bw`,
})),
},
displayDefaults: { defaultRendering: 'multirowxy', height: 330 },
},
{
// One row per cell instead of one per cell type, read out of a
// cells-by-bins Zarr matrix. It covers the marker windows in
// cells.perCellGenes and is empty everywhere else.
type: 'MultiQuantitativeTrack',
trackId: PER_CELL_TRACK_ID,
name: 'Per-cell coverage (marker loci)',
assemblyNames: ['GRCh38'],
adapter: {
type: 'MultiWiggleZarrAdapter',
uri: `${BASE}/percell.zarr`,
},
// The scale is pinned deliberately. Autoscaling puts the maximum at
// whatever the home cell type reached (hundreds of UMIs in a monocyte at
// LYZ), which renders every single-UMI cell as white and hides the thing
// per-cell rows are here to show: the "flat" cell types are not empty,
// they carry one ambient UMI each.
displayDefaults: {
defaultRendering: 'multirowdensity',
height: 420,
minScore: 0,
maxScore: 4,
},
},
]
}
const Demo = observer(function Demo() {
const [cells, expr, plugins] = use(dataPromise)
const [picked, setPicked] = useState<string>()
const [selected, setSelected] = useState<string[]>([])
const state = useCreateViewState({
assembly,
plugins,
tracks: tracks(cells),
defaultSession: {
name: 'scRNA pseudobulk',
view: {
type: 'LinearGenomeView',
init: {
assembly: 'GRCh38',
// MS4A1, the B-cell marker: one row carries the coverage
loc: '11:60,453,846-60,472,752',
tracks: ['hg38_refseq_curated', TRACK_ID, PER_CELL_TRACK_ID],
},
},
},
})
const { session } = state
const { selection, view } = session
// A feature clicked in the gene track wins over the dropdown, which clears
// the selection when it is used, so the two never disagree.
const clicked = isFeature(selection)
? String(selection.get('name'))
: undefined
const gene = clicked && cells.genes.includes(clicked) ? clicked : picked
function pickCellType(name: string) {
const next = selected.includes(name)
? selected.filter(t => t !== name)
: [...selected, name]
setSelected(next)
// the display's own row filter, the same one the sidebar tree drives
const display = view.getTrack(TRACK_ID)?.activeDisplay as
| MultiWiggleDisplayModel
| undefined
display?.setSubtreeFilter(next.length ? next : undefined)
}
return (
<div>
<div style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}>
<div>
<UmapScatter
cells={cells}
expr={expr}
gene={gene}
selected={selected}
onPick={name => {
pickCellType(name)
}}
/>
</div>
<div style={{ minWidth: 220 }}>
<p style={{ marginTop: 0 }}>
<b>{cells.dataset}</b>
</p>
<label>
Color by gene{' '}
<select
value={gene ?? ''}
onChange={event => {
const name = event.target.value
session.clearSelection()
setPicked(name || undefined)
const at = cells.genes.indexOf(name)
if (at !== -1) {
void view.navToLocString(cells.geneLoc[at]!)
}
}}
>
<option value="">cell type</option>
<optgroup label="With per-cell rows">
{cells.perCellGenes.map(name => (
<option key={name} value={name}>
{name}
</option>
))}
</optgroup>
<optgroup label="Expression panel only">
{cells.genes
.filter(name => !cells.perCellGenes.includes(name))
.map(name => (
<option key={name} value={name}>
{name}
</option>
))}
</optgroup>
</select>
</label>
<p>
{gene
? `Cells colored by ${gene} expression.`
: 'Click a cluster or a legend swatch to filter the coverage rows to those cell types.'}
</p>
{clicked && !gene ? (
<p>
No expression in this demo's panel for <b>{clicked}</b>.
</p>
) : null}
</div>
</div>
<JBrowseLinearGenomeView viewState={state} />
</div>
)
})
export default function SingleCellUmap() {
return (
<Suspense fallback={<p>Loading cells...</p>}>
<Demo />
</Suspense>
)
}