vitessce
Version:
Vitessce app and React component library
565 lines (510 loc) • 18.7 kB
JavaScript
import React, {
useRef, useState, useCallback, useMemo, useEffect, useReducer, forwardRef,
} from 'react';
import uuidv4 from 'uuid/v4';
import DeckGL from 'deck.gl';
import { OrthographicView } from '@deck.gl/core'; // eslint-disable-line import/no-extraneous-dependencies
import range from 'lodash/range';
import clamp from 'lodash/clamp';
import isEqual from 'lodash/isEqual';
import { max } from 'd3-array';
import HeatmapCompositeTextLayer from '../../layers/HeatmapCompositeTextLayer';
import PixelatedBitmapLayer from '../../layers/PixelatedBitmapLayer';
import HeatmapBitmapLayer from '../../layers/HeatmapBitmapLayer';
import {
DEFAULT_GL_OPTIONS,
createDefaultUpdateCellsHover,
createDefaultUpdateGenesHover,
createDefaultUpdateViewInfo,
copyUint8Array,
} from '../utils';
import {
layerFilter,
getAxisSizes,
mouseToHeatmapPosition,
heatmapToMousePosition,
} from './utils';
import {
TILE_SIZE, MAX_ROW_AGG, MIN_ROW_AGG,
COLOR_BAR_SIZE,
AXIS_MARGIN,
} from '../../layers/heatmap-constants';
import HeatmapWorkerPool from './HeatmapWorkerPool';
/**
* A heatmap component for cell x gene matrices.
* @param {object} props
* @param {string} props.uuid The uuid of this component,
* used by tooltips to determine whether to render a tooltip or
* a crosshair.
* @param {string} props.theme The current theme name.
* @param {object} props.initialViewState The initial viewState for
* DeckGL.
* @param {number} props.width The width of the canvas.
* @param {number} props.height The height of the canvas.
* @param {object} props.expressionMatrix An object { rows, cols, matrix },
* where matrix is a flat Uint8Array, rows is a list of cell ID strings,
* and cols is a list of gene ID strings.
* @param {Map} props.cellColors Map of cell ID to color. Optional.
* If defined, the key ordering is used to order the cell axis of the heatmap.
* @param {function} props.clearPleaseWait The clear please wait callback,
* called when the expression matrix has loaded (is not null).
* @param {function} props.setCellHighlight Callback function called on
* hover with the cell ID. Optional.
* @param {function} props.setGeneHighlight Callback function called on
* hover with the gene ID. Optional.
* @param {function} props.updateViewInfo Callback function that gets called with an
* object { uuid, project() } where project is a function that maps (cellId, geneId)
* to canvas (x,y) coordinates. Used to show tooltips. Optional.
* @param {boolean} props.transpose By default, false.
* @param {string} props.variablesTitle By default, 'Genes'.
* @param {string} props.observationsTitle By default, 'Cells'.
* @param {string} props.colormap The name of the colormap function to use.
* @param {array} props.colormapRange A tuple [lower, upper] to adjust the color scale.
* @param {function} props.setColormapRange The setter function for colormapRange.
*/
const Heatmap = forwardRef((props, deckRef) => {
const {
uuid,
theme,
viewState: rawViewState,
setViewState,
width: viewWidth,
height: viewHeight,
expressionMatrix: expression,
cellColors,
colormap,
colormapRange,
clearPleaseWait,
setComponentHover,
setCellHighlight = createDefaultUpdateCellsHover('Heatmap'),
setGeneHighlight = createDefaultUpdateGenesHover('Heatmap'),
updateViewInfo = createDefaultUpdateViewInfo('Heatmap'),
setIsRendering = () => {},
transpose = false,
variablesTitle = 'Genes',
observationsTitle = 'Cells',
} = props;
const viewState = {
...rawViewState,
target: (transpose ? [rawViewState.target[1], rawViewState.target[0]] : rawViewState.target),
minZoom: 0,
};
const axisLeftTitle = (transpose ? variablesTitle : observationsTitle);
const axisTopTitle = (transpose ? observationsTitle : variablesTitle);
const workerPool = useMemo(() => new HeatmapWorkerPool(), []);
useEffect(() => {
if (clearPleaseWait && expression) {
clearPleaseWait('expression-matrix');
}
}, [clearPleaseWait, expression]);
const tilesRef = useRef();
const dataRef = useRef();
const [axisLeftLabels, setAxisLeftLabels] = useState([]);
const [axisTopLabels, setAxisTopLabels] = useState([]);
// Since we are storing the tile data in a ref,
// and updating it asynchronously when the worker finishes,
// we need to tie it to a piece of state through this iteration value.
const [tileIteration, incTileIteration] = useReducer(i => i + 1, 0);
// We need to keep a backlog of the tasks for the worker thread,
// since the array buffer can only be held by one thread at a time.
const [backlog, setBacklog] = useState([]);
// Store a reference to the matrix Uint8Array in the dataRef,
// since we need to access its array buffer to transfer
// it back and forth from the worker thread.
useEffect(() => {
// Store the expression matrix Uint8Array in the dataRef.
if (expression && expression.matrix) {
dataRef.current = copyUint8Array(expression.matrix);
}
}, [dataRef, expression]);
// Check if the ordering of axis labels needs to be changed,
// for example if the cells "selected" (technically just colored)
// have changed.
useEffect(() => {
if (!expression) {
return;
}
const newCellOrdering = (!cellColors || cellColors.size === 0
? expression.rows
: Array.from(cellColors.keys())
);
const oldCellOrdering = (transpose ? axisTopLabels : axisLeftLabels);
if (!isEqual(oldCellOrdering, newCellOrdering)) {
if (transpose) {
setAxisTopLabels(newCellOrdering);
} else {
setAxisLeftLabels(newCellOrdering);
}
}
}, [expression, cellColors, axisTopLabels, axisLeftLabels, transpose]);
// Set the genes ordering.
useEffect(() => {
if (!expression) {
return;
}
if (transpose) {
setAxisLeftLabels(expression.cols);
} else {
setAxisTopLabels(expression.cols);
}
}, [expression, transpose]);
const [cellLabelMaxLength, geneLabelMaxLength] = useMemo(() => {
if (!expression) {
return [0, 0];
}
return [
max(expression.rows.map(cellId => cellId.length)),
max(expression.cols.map(geneId => geneId.length)),
];
}, [expression]);
const width = axisTopLabels.length;
const height = axisLeftLabels.length;
const [axisOffsetLeft, axisOffsetTop] = getAxisSizes(
transpose, geneLabelMaxLength, cellLabelMaxLength,
);
const offsetTop = axisOffsetTop + COLOR_BAR_SIZE;
const offsetLeft = axisOffsetLeft + COLOR_BAR_SIZE;
const matrixWidth = viewWidth - offsetLeft;
const matrixHeight = viewHeight - offsetTop;
const matrixLeft = -matrixWidth / 2;
const matrixRight = matrixWidth / 2;
const matrixTop = -matrixHeight / 2;
const matrixBottom = matrixHeight / 2;
const xTiles = Math.ceil(width / TILE_SIZE);
const yTiles = Math.ceil(height / TILE_SIZE);
const widthRatio = 1 - (TILE_SIZE - (width % TILE_SIZE)) / (xTiles * TILE_SIZE);
const heightRatio = 1 - (TILE_SIZE - (height % TILE_SIZE)) / (yTiles * TILE_SIZE);
const tileWidth = (matrixWidth / widthRatio) / (xTiles);
const tileHeight = (matrixHeight / heightRatio) / (yTiles);
const scaleFactor = 2 ** viewState.zoom;
const cellHeight = (matrixHeight * scaleFactor) / height;
const cellWidth = (matrixWidth * scaleFactor) / width;
// Get power of 2 between 1 and 16,
// for number of cells to aggregate together in each direction.
const aggSizeX = clamp(2 ** Math.ceil(Math.log2(1 / cellWidth)), MIN_ROW_AGG, MAX_ROW_AGG);
const aggSizeY = clamp(2 ** Math.ceil(Math.log2(1 / cellHeight)), MIN_ROW_AGG, MAX_ROW_AGG);
const [targetX, targetY] = viewState.target;
// Emit the viewInfo object on viewState updates
// (used by tooltips / crosshair elements).
useEffect(() => {
updateViewInfo({
uuid,
project: (cellId, geneId) => {
const colI = transpose ? axisTopLabels.indexOf(cellId) : axisTopLabels.indexOf(geneId);
const rowI = transpose ? axisLeftLabels.indexOf(geneId) : axisLeftLabels.indexOf(cellId);
return heatmapToMousePosition(
colI, rowI, {
offsetLeft,
offsetTop,
targetX: viewState.target[0],
targetY: viewState.target[1],
scaleFactor,
matrixWidth,
matrixHeight,
numRows: height,
numCols: width,
},
);
},
});
}, [uuid, updateViewInfo, transpose, axisTopLabels, axisLeftLabels, offsetLeft,
offsetTop, viewState, scaleFactor, matrixWidth, matrixHeight, height, width]);
// Listen for viewState changes.
// Do not allow the user to zoom and pan outside of the initial window.
const onViewStateChange = useCallback(({ viewState: nextViewState }) => {
const { zoom: nextZoom } = nextViewState;
const nextScaleFactor = 2 ** nextZoom;
const minTargetX = nextZoom === 0 ? 0 : -(matrixRight - (matrixRight / nextScaleFactor));
const maxTargetX = -1 * minTargetX;
const minTargetY = nextZoom === 0 ? 0 : -(matrixBottom - (matrixBottom / nextScaleFactor));
const maxTargetY = -1 * minTargetY;
// Manipulate view state if necessary to keep the user in the window.
const nextTarget = [
clamp(nextViewState.target[0], minTargetX, maxTargetX),
clamp(nextViewState.target[1], minTargetY, maxTargetY),
];
setViewState({
zoom: nextZoom,
target: (transpose ? [nextTarget[1], nextTarget[0]] : nextTarget),
});
}, [matrixRight, matrixBottom, transpose, setViewState]);
// If `expression` or `cellOrdering` have changed,
// then new tiles need to be generated,
// so add a new task to the backlog.
useEffect(() => {
if (!expression) {
return;
}
// Use a uuid to give the task a unique ID,
// to help identify where in the list it is located
// after the worker thread asynchronously sends the data back
// to this thread.
if (
axisTopLabels && axisLeftLabels && xTiles && yTiles
) {
setBacklog(prev => [...prev, uuidv4()]);
}
}, [dataRef, expression, axisTopLabels, axisLeftLabels, xTiles, yTiles]);
// When the backlog has updated, a new worker job can be submitted if:
// - the backlog has length >= 1 (at least one job is waiting), and
// - buffer.byteLength is not zero, so the worker does not currently "own" the buffer.
useEffect(() => {
if (backlog.length < 1) {
return;
}
const curr = backlog[backlog.length - 1];
if (dataRef.current && dataRef.current.buffer.byteLength) {
const { rows, cols, matrix } = expression;
const promises = range(yTiles).map(i => range(xTiles).map(async j => workerPool.process({
curr,
tileI: i,
tileJ: j,
tileSize: TILE_SIZE,
cellOrdering: transpose ? axisTopLabels : axisLeftLabels,
rows,
cols,
transpose,
data: matrix.buffer.slice(),
})));
const process = async () => {
const tiles = await Promise.all(promises.flat());
tilesRef.current = tiles.map(i => i.tile);
incTileIteration();
dataRef.current = new Uint8Array(tiles[0].buffer);
const { curr: currWork } = tiles[0];
setBacklog((prev) => {
const currIndex = prev.indexOf(currWork);
return prev.slice(currIndex + 1, prev.length);
});
};
process();
}
}, [axisLeftLabels, axisTopLabels, backlog, expression, transpose, xTiles, yTiles, workerPool]);
useEffect(() => {
setIsRendering(backlog.length > 0);
}, [backlog, setIsRendering]);
// Update the heatmap tiles if:
// - new tiles are available (`tileIteration` has changed), or
// - the matrix bounds have changed, or
// - the `aggSizeX` or `aggSizeY` have changed, or
// - the cell ordering has changed.
const heatmapLayers = useMemo(() => {
if (!tilesRef.current || backlog.length) {
return [];
}
function getLayer(i, j, tile) {
return new HeatmapBitmapLayer({
id: `heatmapLayer-${tileIteration}-${i}-${j}`,
image: tile,
bounds: [
matrixLeft + j * tileWidth,
matrixTop + i * tileHeight,
matrixLeft + (j + 1) * tileWidth,
matrixTop + (i + 1) * tileHeight,
],
aggSizeX,
aggSizeY,
colormap,
colorScaleLo: colormapRange[0],
colorScaleHi: colormapRange[1],
updateTriggers: {
image: [axisLeftLabels, axisTopLabels],
bounds: [tileHeight, tileWidth],
},
});
}
const layers = tilesRef
.current.map((tile, index) => getLayer(Math.floor(index / xTiles), index % xTiles, tile));
return layers;
}, [backlog, tileIteration, matrixLeft, tileWidth, matrixTop, tileHeight,
aggSizeX, aggSizeY, colormap, colormapRange,
axisLeftLabels, axisTopLabels, xTiles]);
// Map cell and gene names to arrays with indices,
// to prepare to render the names in TextLayers.
const axisTopLabelData = useMemo(() => axisTopLabels.map((d, i) => [i, d]), [axisTopLabels]);
const axisLeftLabelData = useMemo(() => axisLeftLabels.map((d, i) => [i, d]), [axisLeftLabels]);
// Generate the axis label, axis title, and loading indicator text layers.
const textLayers = [
new HeatmapCompositeTextLayer({
targetX,
targetY,
scaleFactor,
axisLeftLabelData,
matrixTop,
height,
matrixHeight,
cellHeight,
cellWidth,
axisTopLabelData,
matrixLeft,
width,
matrixWidth,
viewHeight,
viewWidth,
theme,
axisLeftTitle,
axisTopTitle,
axisOffsetLeft,
axisOffsetTop,
}),
];
// Create the left color bar with a BitmapLayer.
// TODO: find a way to do aggregation for this as well.
const cellColorsTiles = useMemo(() => {
if (!cellColors) {
return null;
}
let cellId;
let offset;
let color;
let rowI;
const cellOrdering = (transpose ? axisTopLabels : axisLeftLabels);
const colorBarTileWidthPx = (transpose ? TILE_SIZE : 1);
const colorBarTileHeightPx = (transpose ? 1 : TILE_SIZE);
const result = range((transpose ? xTiles : yTiles)).map((i) => {
const tileData = new Uint8ClampedArray(TILE_SIZE * 1 * 4);
range(TILE_SIZE).forEach((tileY) => {
rowI = (i * TILE_SIZE) + tileY; // the row / cell index
if (rowI < cellOrdering.length) {
cellId = cellOrdering[rowI];
color = cellColors.get(cellId);
offset = (transpose ? tileY : (TILE_SIZE - tileY - 1)) * 4;
if (color) {
const [rValue, gValue, bValue] = color;
tileData[offset + 0] = rValue;
tileData[offset + 1] = gValue;
tileData[offset + 2] = bValue;
tileData[offset + 3] = 255;
}
}
});
return new ImageData(tileData, colorBarTileWidthPx, colorBarTileHeightPx);
});
return result;
}, [cellColors, transpose, axisTopLabels, axisLeftLabels, xTiles, yTiles]);
const cellColorsLayers = useMemo(() => (cellColorsTiles
? cellColorsTiles
.map((tile, i) => new PixelatedBitmapLayer({
id: `${(transpose ? 'colorsTopLayer' : 'colorsLeftLayer')}-${i}-${uuidv4()}`,
image: tile,
bounds: (transpose ? [
matrixLeft + i * tileWidth,
-matrixHeight / 2,
matrixLeft + (i + 1) * tileWidth,
matrixHeight / 2,
] : [
-matrixWidth / 2,
matrixTop + i * tileHeight,
matrixWidth / 2,
matrixTop + (i + 1) * tileHeight,
]),
}))
: []), [cellColorsTiles, matrixTop, matrixLeft, matrixHeight,
matrixWidth, tileWidth, tileHeight, transpose]);
const layers = heatmapLayers
.concat(textLayers)
.concat(cellColorsLayers);
// Set up the onHover function.
function onHover(info, event) {
if (!expression) {
return;
}
const { x: mouseX, y: mouseY } = event.offsetCenter;
const [colI, rowI] = mouseToHeatmapPosition(mouseX, mouseY, {
offsetLeft,
offsetTop,
targetX,
targetY,
scaleFactor,
matrixWidth,
matrixHeight,
numRows: height,
numCols: width,
});
if (colI === null) {
if (transpose) {
setCellHighlight(null);
} else {
setGeneHighlight(null);
}
}
if (rowI === null) {
if (transpose) {
setGeneHighlight(null);
} else {
setCellHighlight(null);
}
}
const obsI = expression.rows.indexOf(transpose
? axisTopLabels[colI]
: axisLeftLabels[rowI]);
const varI = expression.cols.indexOf(transpose
? axisLeftLabels[rowI]
: axisTopLabels[colI]);
const obsId = expression.rows[obsI];
const varId = expression.cols[varI];
if (setComponentHover) {
setComponentHover();
}
setCellHighlight(obsId || null);
setGeneHighlight(varId || null);
}
return (
<DeckGL
id={`deckgl-overlay-${uuid}`}
ref={deckRef}
views={[
// Note that there are multiple views here,
// but only one viewState.
new OrthographicView({
id: 'heatmap',
controller: true,
x: offsetLeft,
y: offsetTop,
width: matrixWidth,
height: matrixHeight,
}),
new OrthographicView({
id: 'axisLeft',
controller: false,
x: (transpose ? COLOR_BAR_SIZE : 0),
y: offsetTop,
width: axisOffsetLeft,
height: matrixHeight,
}),
new OrthographicView({
id: 'axisTop',
controller: false,
x: offsetLeft,
y: (transpose ? 0 : COLOR_BAR_SIZE),
width: matrixWidth,
height: axisOffsetTop,
}),
new OrthographicView({
id: 'colorsLeft',
controller: false,
x: axisOffsetLeft,
y: offsetTop,
width: COLOR_BAR_SIZE - AXIS_MARGIN,
height: matrixHeight,
}),
new OrthographicView({
id: 'colorsTop',
controller: false,
x: offsetLeft,
y: axisOffsetTop,
width: matrixWidth,
height: COLOR_BAR_SIZE - AXIS_MARGIN,
}),
]}
layers={layers}
layerFilter={layerFilter}
getCursor={interactionState => (interactionState.isDragging ? 'grabbing' : 'default')}
glOptions={DEFAULT_GL_OPTIONS}
onViewStateChange={onViewStateChange}
viewState={viewState}
onHover={onHover}
/>
);
});
export default Heatmap;