vitessce
Version:
Vitessce app and React component library
363 lines (337 loc) • 12.1 kB
JavaScript
import React, { forwardRef } from 'react';
import { PolygonLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers'; // eslint-disable-line import/no-extraneous-dependencies
import { forceSimulation } from 'd3-force';
import { getSelectionLayers } from '../../layers';
import { cellLayerDefaultProps, getDefaultColor } from '../utils';
import {
createCellsQuadTree,
} from '../shared-spatial-scatterplot/quadtree';
import AbstractSpatialOrScatterplot from '../shared-spatial-scatterplot/AbstractSpatialOrScatterplot';
import { forceCollideRects } from '../shared-spatial-scatterplot/force-collide-rects';
import { ScaledExpressionExtension, SelectionExtension } from '../../layer-extensions';
const CELLS_LAYER_ID = 'scatterplot';
const LABEL_FONT_FAMILY = "-apple-system, 'Helvetica Neue', Arial, sans-serif";
const NUM_FORCE_SIMULATION_TICKS = 100;
const LABEL_UPDATE_ZOOM_DELTA = 0.25;
// Default getter function props.
const makeDefaultGetCellPosition = mapping => (cellEntry) => {
const { mappings } = cellEntry[1];
if (!(mapping in mappings)) {
const available = Object.keys(mappings).map(s => `"${s}"`).join(', ');
throw new Error(`Expected to find "${mapping}", but available mappings are: ${available}`);
}
const mappedCell = mappings[mapping];
// The negative applied to the y-axis is because
// graphics rendering has the y-axis positive going south.
return [mappedCell[0], -mappedCell[1], 0];
};
const makeDefaultGetCellCoords = mapping => cell => cell.mappings[mapping];
const makeDefaultGetCellColors = (cellColors, theme) => (cellEntry) => {
const [r, g, b, a] = (cellColors && cellColors.get(cellEntry[0])) || getDefaultColor(theme);
return [r, g, b, 255 * (a || 1)];
};
/**
* React component which renders a scatterplot from cell data.
* @param {object} props
* @param {string} props.uuid A unique identifier for this component.
* @param {string} props.theme The current vitessce theme.
* @param {object} props.viewState The deck.gl view state.
* @param {function} props.setViewState Function to call to update the deck.gl view state.
* @param {object} props.cells
* @param {string} props.mapping The name of the coordinate mapping field,
* for each cell, for example "PCA" or "t-SNE".
* @param {Map} props.cellColors Mapping of cell IDs to colors.
* @param {array} props.cellSelection Array of selected cell IDs.
* @param {array} props.cellFilter Array of filtered cell IDs. By default, null.
* @param {number} props.cellRadius The value for `radiusScale` to pass
* to the deck.gl cells ScatterplotLayer.
* @param {number} props.cellOpacity The value for `opacity` to pass
* to the deck.gl cells ScatterplotLayer.
* @param {function} props.getCellCoords Getter function for cell coordinates
* (used by the selection layer).
* @param {function} props.getCellPosition Getter function for cell [x, y, z] position.
* @param {function} props.getCellColor Getter function for cell color as [r, g, b] array.
* @param {function} props.getExpressionValue Getter function for cell expression value.
* @param {function} props.getCellIsSelected Getter function for cell layer isSelected.
* @param {function} props.setCellSelection
* @param {function} props.setCellHighlight
* @param {function} props.updateViewInfo
* @param {function} props.onToolChange Callback for tool changes
* (lasso/pan/rectangle selection tools).
* @param {function} props.onCellClick Getter function for cell layer onClick.
*/
class Scatterplot extends AbstractSpatialOrScatterplot {
constructor(props) {
super(props);
// To avoid storing large arrays/objects
// in React state, this component
// uses instance variables.
// All instance variables used in this class:
this.cellsEntries = [];
this.cellsQuadTree = null;
this.cellsLayer = null;
this.cellSetsForceSimulation = forceCollideRects();
this.cellSetsLabelPrevZoom = null;
this.cellSetsLayers = [];
// Initialize data and layers.
this.onUpdateCellsData();
this.onUpdateCellsLayer();
this.onUpdateCellSetsLayers();
}
createCellsLayer() {
const { cellsEntries } = this;
const {
theme,
mapping,
getCellPosition = makeDefaultGetCellPosition(mapping),
cellRadius = 1.0,
cellOpacity = 1.0,
cellFilter,
cellSelection,
setCellHighlight,
setComponentHover,
getCellIsSelected,
cellColors,
getCellColor = makeDefaultGetCellColors(cellColors, theme),
getExpressionValue,
onCellClick,
geneExpressionColormap,
geneExpressionColormapRange = [0.0, 1.0],
cellColorEncoding,
} = this.props;
const filteredCellsEntries = (cellFilter
? cellsEntries.filter(cellEntry => cellFilter.includes(cellEntry[0]))
: cellsEntries);
return new ScatterplotLayer({
id: CELLS_LAYER_ID,
backgroundColor: (theme === 'dark' ? [0, 0, 0] : [241, 241, 241]),
getCellIsSelected,
opacity: cellOpacity,
radiusScale: cellRadius,
radiusMinPixels: 1,
radiusMaxPixels: 30,
// Our radius pixel setters measure in pixels.
radiusUnits: 'pixels',
lineWidthUnits: 'pixels',
getPosition: getCellPosition,
getFillColor: getCellColor,
getLineColor: getCellColor,
getRadius: 1,
getExpressionValue,
getLineWidth: 0,
extensions: [
new ScaledExpressionExtension(),
new SelectionExtension({ instanced: true }),
],
colorScaleLo: geneExpressionColormapRange[0],
colorScaleHi: geneExpressionColormapRange[1],
isExpressionMode: (cellColorEncoding === 'geneSelection'),
colormap: geneExpressionColormap,
onClick: (info) => {
if (onCellClick) {
onCellClick(info);
}
},
updateTriggers: {
getExpressionValue,
getFillColor: [cellColorEncoding, cellSelection, cellColors],
getLineColor: [cellColorEncoding, cellSelection, cellColors],
getPosition: [mapping],
getCellIsSelected,
},
...cellLayerDefaultProps(
filteredCellsEntries, undefined, setCellHighlight, setComponentHover,
),
stroked: 0,
});
}
createCellSetsLayers() {
const {
theme,
cellSetPolygons,
viewState,
cellSetPolygonsVisible,
cellSetLabelsVisible,
cellSetLabelSize,
} = this.props;
const result = [];
if (cellSetPolygonsVisible) {
result.push(new PolygonLayer({
id: 'cell-sets-polygon-layer',
data: cellSetPolygons,
stroked: true,
filled: false,
wireframe: true,
lineWidthMaxPixels: 1,
getPolygon: d => d.hull,
getLineColor: d => d.color,
getLineWidth: 1,
}));
}
if (cellSetLabelsVisible) {
const { zoom } = viewState;
const nodes = cellSetPolygons.map(p => ({
x: p.centroid[0],
y: p.centroid[1],
label: p.name,
}));
const collisionForce = this.cellSetsForceSimulation
.size(d => ([
cellSetLabelSize * 1 / (2 ** zoom) * 4 * d.label.length,
cellSetLabelSize * 1 / (2 ** zoom) * 1.5,
]));
forceSimulation()
.nodes(nodes)
.force('collision', collisionForce)
.tick(NUM_FORCE_SIMULATION_TICKS);
result.push(new TextLayer({
id: 'cell-sets-text-layer',
data: nodes,
getPosition: d => ([d.x, d.y]),
getText: d => d.label,
getColor: (theme === 'dark' ? [255, 255, 255] : [0, 0, 0]),
getSize: cellSetLabelSize,
getAngle: 0,
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
fontFamily: LABEL_FONT_FAMILY,
fontWeight: 'normal',
}));
}
return result;
}
createSelectionLayers() {
const {
viewState,
mapping,
getCellCoords = makeDefaultGetCellCoords(mapping),
setCellSelection,
} = this.props;
const { tool } = this.state;
const { cellsQuadTree } = this;
const flipYTooltip = true;
return getSelectionLayers(
tool,
viewState.zoom,
CELLS_LAYER_ID,
getCellCoords,
setCellSelection,
cellsQuadTree,
flipYTooltip,
);
}
getLayers() {
const {
cellsLayer,
cellSetsLayers,
} = this;
return [
cellsLayer,
...cellSetsLayers,
...this.createSelectionLayers(),
];
}
onUpdateCellsData() {
const {
cells = {},
mapping,
getCellCoords = makeDefaultGetCellCoords(mapping),
} = this.props;
const cellsEntries = Object.entries(cells);
this.cellsEntries = cellsEntries;
this.cellsQuadTree = createCellsQuadTree(cellsEntries, getCellCoords);
}
onUpdateCellsLayer() {
this.cellsLayer = this.createCellsLayer();
}
onUpdateCellSetsLayers(onlyViewStateChange) {
// Because the label sizes for the force simulation depend on the zoom level,
// we _could_ run the simulation every time the zoom level changes.
// However, this has a performance impact in firefox.
if (onlyViewStateChange) {
const { viewState, cellSetLabelsVisible } = this.props;
const { zoom } = viewState;
const { cellSetsLabelPrevZoom } = this;
// Instead, we can just check if the zoom level has changed
// by some relatively large delta, to be more conservative
// about re-running the force simulation.
if (cellSetLabelsVisible
&& (
cellSetsLabelPrevZoom === null
|| Math.abs(cellSetsLabelPrevZoom - zoom) > LABEL_UPDATE_ZOOM_DELTA
)
) {
this.cellSetsLayers = this.createCellSetsLayers();
this.cellSetsLabelPrevZoom = zoom;
}
} else {
// Otherwise, something more substantial than just
// the viewState has changed, such as the label array
// itself, so we always want to update the layer
// in this case.
this.cellSetsLayers = this.createCellSetsLayers();
}
}
viewInfoDidUpdate() {
const {
mapping,
getCellPosition = makeDefaultGetCellPosition(mapping),
} = this.props;
super.viewInfoDidUpdate(cell => getCellPosition([null, cell]));
}
/**
* Here, asynchronously check whether props have
* updated which require re-computing memoized variables,
* followed by a re-render.
* This function does not follow React conventions or paradigms,
* it is only implemented this way to try to squeeze out
* performance.
* @param {object} prevProps The previous props to diff against.
*/
componentDidUpdate(prevProps) {
this.viewInfoDidUpdate();
const shallowDiff = propName => (prevProps[propName] !== this.props[propName]);
if (['cells'].some(shallowDiff)) {
// Cells data changed.
this.onUpdateCellsData();
this.forceUpdate();
}
if ([
'cells', 'mapping', 'cellFilter', 'cellSelection', 'cellColors',
'cellRadius', 'cellOpacity', 'cellRadiusMode', 'geneExpressionColormap',
'geneExpressionColormapRange', 'geneSelection', 'cellColorEncoding',
].some(shallowDiff)) {
// Cells layer props changed.
this.onUpdateCellsLayer();
this.forceUpdate();
}
if ([
'cellSetPolygons', 'cellSetPolygonsVisible',
'cellSetLabelsVisible', 'cellSetLabelSize',
].some(shallowDiff)) {
// Cell sets layer props changed.
this.onUpdateCellSetsLayers(false);
this.forceUpdate();
}
if (shallowDiff('viewState')) {
// The viewState prop has changed (due to zoom or pan).
this.onUpdateCellSetsLayers(true);
this.forceUpdate();
}
}
// render() is implemented in the abstract parent class.
}
/**
* Need this wrapper function here,
* since we want to pass a forwardRef
* so that outer components can
* access the grandchild DeckGL ref,
* but we are using a class component.
*/
const ScatterplotWrapper = forwardRef((props, deckRef) => (
<Scatterplot
{...props}
deckRef={deckRef}
/>
));
export default ScatterplotWrapper;