// jshint esversion: 6 import React from "react"; import _ from "lodash"; import * as d3 from "d3"; import { connect } from "react-redux"; import mat4 from "gl-mat4"; import _regl from "regl"; import { Button, AnchorButton, Tooltip, Popover, Menu, MenuItem, Position } from "@blueprintjs/core"; import * as globals from "../../globals"; import setupSVGandBrushElements from "./setupSVGandBrush"; import actions from "../../actions"; import _camera from "../../util/camera"; import _drawPoints from "./drawPointsRegl"; import scaleLinear from "../../util/scaleLinear"; import { World } from "../../util/stateManager"; /* https://bl.ocks.org/mbostock/9078690 - quadtree for onClick / hover selections */ @connect(state => ({ world: state.world, universe: state.universe, crossfilter: state.crossfilter, responsive: state.responsive, colorRGB: state.colors.rgb, opacityForDeselectedCells: state.controls.opacityForDeselectedCells, resettingInterface: state.controls.resettingInterface, userDefinedGenes: state.controls.userDefinedGenes, diffexpGenes: state.controls.diffexpGenes, colorAccessor: state.colors.colorAccessor, scatterplotXXaccessor: state.controls.scatterplotXXaccessor, scatterplotYYaccessor: state.controls.scatterplotYYaccessor, celllist1: state.differential.celllist1, celllist2: state.differential.celllist2, libraryVersions: state.config?.library_versions, // eslint-disable-line camelcase undoDisabled: state["@@undoable/past"].length === 0, redoDisabled: state["@@undoable/future"].length === 0, selectionTool: state.graphSelection.tool, currentSelection: state.graphSelection.selection })) class Graph extends React.Component { constructor(props) { super(props); this.count = 0; this.graphPaddingTop = 0; this.graphPaddingBottom = 45; this.graphPaddingRight = globals.leftSidebarWidth; this.renderCache = { positions: null, colors: null, sizes: null }; this.state = { svg: null, tool: null, container: null, mode: "select" }; } componentDidMount() { // setup canvas and camera const camera = _camera(this.reglCanvas, { scale: true, rotate: false }); const regl = _regl(this.reglCanvas); const drawPoints = _drawPoints(regl); // preallocate buffers const pointBuffer = regl.buffer(); const colorBuffer = regl.buffer(); const sizeBuffer = regl.buffer(); /* first time, but this duplicates above function, should be possile to avoid this */ const reglRender = regl.frame(() => { this.reglDraw( regl, drawPoints, sizeBuffer, colorBuffer, pointBuffer, camera ); camera.tick(); }); this.reglRenderState = "rendering"; this.setState({ regl, drawPoints, pointBuffer, colorBuffer, sizeBuffer, camera, reglRender }); } componentDidUpdate(prevProps, prevState) { const { renderCache } = this; const { world, crossfilter, colorRGB, responsive, selectionTool, currentSelection } = this.props; const { reglRender, mode, regl, drawPoints, camera, pointBuffer, colorBuffer, sizeBuffer, svg } = this.state; let stateChanges = {}; if (reglRender && this.reglRenderState === "rendering" && mode !== "zoom") { reglRender.cancel(); this.reglRenderState = "paused"; } if (regl && world) { /* update the regl state */ const { obsLayout, nObs } = world; const X = obsLayout.col("X").asArray(); const Y = obsLayout.col("Y").asArray(); // X/Y positions for each point - a cached value that only // changes if we have loaded entirely new cell data // if (!renderCache.positions || world !== prevProps.world) { renderCache.positions = new Float32Array(2 * nObs); const glScaleX = scaleLinear([0, 1], [-1, 1]); const glScaleY = scaleLinear([0, 1], [1, -1]); const offset = [d3.mean(X) - 0.5, d3.mean(Y) - 0.5]; for (let i = 0, { positions } = renderCache; i < nObs; i += 1) { positions[2 * i] = glScaleX(X[i] - offset[0]); positions[2 * i + 1] = glScaleY(Y[i] - offset[1]); } pointBuffer({ data: renderCache.positions, dimension: 2 }); stateChanges.offset = offset; } // Colors for each point - a cached value that only changes when // the cell metadata changes. if (!renderCache.colors || colorRGB !== prevProps.colorRGB) { const rgb = colorRGB; if (!renderCache.colors) { renderCache.colors = new Float32Array(3 * rgb.length); } for (let i = 0, { colors } = renderCache; i < rgb.length; i += 1) { colors.set(rgb[i], 3 * i); } colorBuffer({ data: renderCache.colors, dimension: 3 }); } // Sizes for each point - updates are triggered only when selected // obs change if (!renderCache.sizes || crossfilter !== prevProps.crossfilter) { if (!renderCache.sizes) { renderCache.sizes = new Float32Array(nObs); } crossfilter.fillByIsSelected(renderCache.sizes, 4, 0.2); sizeBuffer({ data: renderCache.sizes, dimension: 1 }); } this.count = nObs; regl._refresh(); this.reglDraw( regl, drawPoints, sizeBuffer, colorBuffer, pointBuffer, camera ); } if ( prevProps.responsive.height !== responsive.height || prevProps.responsive.width !== responsive.width || /* first time */ (responsive.height && responsive.width && !svg) || selectionTool !== prevProps.selectionTool ) { /* clear out whatever was on the div, even if nothing, but usually the brushes etc */ d3.select("#graphAttachPoint") .selectAll("svg") .remove(); let handleStart; let handleDrag; let handleEnd; let handleCancel; if (selectionTool === "brush") { handleStart = this.handleBrushStartAction.bind(this); handleDrag = this.handleBrushDragAction.bind(this); handleEnd = this.handleBrushEndAction.bind(this); } else { handleStart = this.handleLassoStart.bind(this); handleEnd = this.handleLassoEnd.bind(this); handleCancel = this.handleLassoCancel.bind(this); } const { svg: newSvg, tool, container } = setupSVGandBrushElements( selectionTool, handleStart, handleDrag, handleEnd, handleCancel, responsive, this.graphPaddingRight ); stateChanges = { ...stateChanges, svg: newSvg, tool, container }; } /* if the selection tool or state has changed, ensure that the selection tool correctly reflects the underlying selection. */ if ( currentSelection !== prevProps.currentSelection || mode !== prevState.mode || stateChanges.svg ) { const { tool, container, offset } = this.state; this.selectionToolUpdate( stateChanges.tool ? stateChanges.tool : tool, stateChanges.container ? stateChanges.container : container, stateChanges.offset ? stateChanges.offset : offset ); } if (Object.keys(stateChanges).length > 0) { this.setState(stateChanges); } } isResetDisabled = () => { /* Reset should be disabled when all of the following are true: * nothing is selected in the crossfilter * world EQ universe * nothing is colored by * there are no userDefinedGenes or diffexpGenes displayed * scatterplot is not displayed * nothing in cellset1 or cellset2 */ const { crossfilter, world, universe, userDefinedGenes, diffexpGenes, colorAccessor, scatterplotXXaccessor, scatterplotYYaccessor, celllist1, celllist2 } = this.props; if (!crossfilter || !world || !universe) { return false; } const nothingSelected = crossfilter.countSelected() === crossfilter.size(); const nothingColoredBy = !colorAccessor; const noGenes = userDefinedGenes.length === 0 && diffexpGenes.length === 0; const scatterNotDpl = !scatterplotXXaccessor || !scatterplotYYaccessor; const nothingInCellsets = !celllist1 && !celllist2; return ( nothingSelected && World.worldEqUniverse(world, universe) && nothingColoredBy && noGenes && scatterNotDpl && nothingInCellsets ); }; resetInterface = () => { const { dispatch } = this.props; dispatch({ type: "interface reset started" }); dispatch(actions.resetInterface()); }; brushToolUpdate(tool, container, offset) { /* this is called from componentDidUpdate(), so be very careful using anything from this.state, which may be updated asynchronously. */ const { currentSelection } = this.props; if (container) { const toolCurrentSelection = d3.brushSelection(container.node()); if (currentSelection.mode === "within-rect") { /* if there is a selection, make sure the brush tool matches */ const screenCoords = [ this.mapPointToScreen(currentSelection.brushCoords.northwest, offset), this.mapPointToScreen(currentSelection.brushCoords.southeast, offset) ]; if (!toolCurrentSelection) { /* tool is not selected, so just move the brush */ container.call(tool.move, screenCoords); } else { /* there is an active selection and a brush - make sure they match */ /* this just sums the difference of each dimension, of each point */ let delta = 0; for (let x = 0; x < 2; x += 1) { for (let y = 0; y < 2; y += 1) { delta += Math.abs( screenCoords[x][y] - toolCurrentSelection[x][y] ); } } if (delta > 0) { container.call(tool.move, screenCoords); } } } else if (toolCurrentSelection) { /* no selection, so clear the brush tool if it is set */ container.call(tool.move, null); } } } lassoToolUpdate(tool, container, offset) { /* this is called from componentDidUpdate(), so be very careful using anything from this.state, which may be updated asynchronously. */ const { currentSelection } = this.props; if (currentSelection.mode === "within-polygon") { /* if there is a current selection, make sure the lasso tool matches */ const polygon = currentSelection.polygon.map(p => this.mapPointToScreen(p, offset) ); tool.move(polygon); } else { tool.reset(); } } selectionToolUpdate(tool, container, offset) { /* this is called from componentDidUpdate(), so be very careful using anything from this.state, which may be updated asynchronously. */ const { selectionTool } = this.props; switch (selectionTool) { case "brush": this.brushToolUpdate(tool, container, offset); break; case "lasso": this.lassoToolUpdate(tool, container, offset); break; default: /* punt? */ break; } } reglDraw(regl, drawPoints, sizeBuffer, colorBuffer, pointBuffer, camera) { regl.clear({ depth: 1, color: [1, 1, 1, 1] }); drawPoints({ size: sizeBuffer, distance: camera.distance, color: colorBuffer, position: pointBuffer, count: this.count, view: camera.view() }); } restartReglLoop() { const { regl, drawPoints, sizeBuffer, colorBuffer, pointBuffer, camera } = this.state; const reglRender = regl.frame(() => { this.reglDraw( regl, drawPoints, sizeBuffer, colorBuffer, pointBuffer, camera ); camera.tick(); }); this.reglRenderState = "rendering"; this.setState({ reglRender }); } mapScreenToPoint(pin) { /* Map an XY coordinates from screen domain to cell/point range, accounting for current pan/zoom camera. */ const { responsive } = this.props; const { regl, camera, offset } = this.state; const gl = regl._gl; // get aspect ratio const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight; // compute inverse view matrix const inverse = mat4.invert([], camera.view()); // transform screen coordinates -> cell coordinates const x = (2 * pin[0]) / (responsive.width - this.graphPaddingRight) - 1; const y = 2 * (1 - pin[1] / (responsive.height - this.graphPaddingTop)) - 1; const pout = [ x * inverse[14] * aspect + inverse[12], y * inverse[14] + inverse[13] ]; return [(pout[0] + 1) / 2 + offset[0], (pout[1] + 1) / 2 + offset[1]]; } mapPointToScreen(xyCell, offset) { /* Map an XY coordinate from cell/point domain to screen range. Inverse of mapScreenToPoint() */ const { responsive } = this.props; const { regl, camera } = this.state; const gl = regl._gl; // get aspect ratio const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight; // compute inverse view matrix const inverse = mat4.invert([], camera.view()); // variable names are choosen to reflect inverse of those used // in mapScreenToPoint(). const pout = [ (xyCell[0] - offset[0]) * 2 - 1, (xyCell[1] - offset[1]) * 2 - 1 ]; const x = (pout[0] - inverse[12]) / aspect / inverse[14]; const y = (pout[1] - inverse[13]) / inverse[14]; const pin = [ Math.round(((x + 1) * (responsive.width - this.graphPaddingRight)) / 2), Math.round( -((y + 1) / 2 - 1) * (responsive.height - this.graphPaddingTop) ) ]; return pin; } handleBrushDragAction() { /* event describing brush position: @-------| | | | | |-------@ */ // ignore programatically generated events if (d3.event.sourceEvent === null || !d3.event.selection) return; const { dispatch } = this.props; const s = d3.event.selection; const brushCoords = { northwest: this.mapScreenToPoint([s[0][0], s[0][1]]), southeast: this.mapScreenToPoint([s[1][0], s[1][1]]) }; dispatch({ type: "graph brush change", brushCoords }); } handleBrushStartAction() { // Ignore programatically generated events. if (!d3.event.sourceEvent) return; const { dispatch } = this.props; dispatch({ type: "graph brush start" }); } handleBrushEndAction() { // Ignore programatically generated events. if (!d3.event.sourceEvent) return; /* coordinates will be included if selection made, null if selection cleared. */ const { dispatch } = this.props; const s = d3.event.selection; if (s) { const brushCoords = { northwest: this.mapScreenToPoint(s[0]), southeast: this.mapScreenToPoint(s[1]) }; dispatch({ type: "graph brush end", brushCoords }); } else { dispatch({ type: "graph brush deselect" }); } } handleBrushDeselectAction() { const { dispatch } = this.props; dispatch({ type: "graph brush deselect" }); } handleLassoStart() { const { dispatch } = this.props; dispatch({ type: "graph lasso start" }); } // when a lasso is completed, filter to the points within the lasso polygon handleLassoEnd(polygon) { const minimumPolygonArea = 10; const { dispatch } = this.props; if ( polygon.length < 3 || Math.abs(d3.polygonArea(polygon)) < minimumPolygonArea ) { // if less than three points, or super small area, treat as a clear selection. dispatch({ type: "graph lasso deselect" }); } else { dispatch({ type: "graph lasso end", polygon: polygon.map(xy => this.mapScreenToPoint(xy)) // transform the polygon }); } } handleLassoCancel() { const { dispatch } = this.props; dispatch({ type: "graph lasso cancel" }); } handleLassoDeselectAction() { const { dispatch } = this.props; dispatch({ type: "graph lasso deselect" }); } handleDeselectAction() { const { selectionTool } = this.props; if (selectionTool === "brush") this.handleBrushDeselectAction(); if (selectionTool === "lasso") this.handleLassoDeselectAction(); } handleOpacityRangeChange(e) { const { dispatch } = this.props; dispatch({ type: "change opacity deselected cells in 2d graph background", data: e.target.value }); } render() { const { dispatch, responsive, crossfilter, resettingInterface, libraryVersions, undoDisabled, redoDisabled, selectionTool } = this.props; const { mode } = this.state; // constants used to create selection tool button let selectionTooltip; let selectionButtonClass; if (selectionTool === "brush") { selectionTooltip = "Brush selection"; selectionButtonClass = "bp3-icon-select"; } else { selectionTooltip = "Lasso selection"; selectionButtonClass = "bp3-icon-polygon-filter"; } return (
{ dispatch(actions.regraph()); dispatch({ type: "increment graph render counter" }); }} > subset to current selection reset
} position={Position.BOTTOM_RIGHT} >
{ this.reglCanvas = canvas; }} />
); } } export default Graph;