From b309367bb0548f813b44d6615bf06c6758d29263 Mon Sep 17 00:00:00 2001 From: Bruce Martin Date: Tue, 27 Aug 2019 14:01:30 -0700 Subject: [PATCH] Add hover-over highlighting to the scatterplot (#900) * small perf improvement - do not recreate functions each time component is updated * add hover-over highlighting to scatterplot --- client/src/components/graph/graph.js | 135 ++++++++++-------- .../components/scatterplot/drawPointsRegl.js | 3 +- .../src/components/scatterplot/scatterplot.js | 72 ++++++---- 3 files changed, 121 insertions(+), 89 deletions(-) diff --git a/client/src/components/graph/graph.js b/client/src/components/graph/graph.js index 08818c33..49158367 100644 --- a/client/src/components/graph/graph.js +++ b/client/src/components/graph/graph.js @@ -53,6 +53,12 @@ function createModelTF() { } function renderThrottle(callback) { + /* + This wraps a call to requestAnimationFrame(), enforcing a single + render callback at any given time (ie, you can call this any number + of times, and it will coallesce multiple inter-frame calls into a + single render). + */ let rafCurrentlyInProgress = null; return function f() { if (rafCurrentlyInProgress) return; @@ -326,62 +332,6 @@ class Graph extends React.Component { } } - const createToolSVG = () => { - /* clear out whatever was on the div, even if nothing, but usually the brushes etc */ - d3.select("#graphAttachPoint") - .select("#tool") - .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: newToolSVG, tool, container } = setupSVGandBrushElements( - selectionTool, - handleStart, - handleDrag, - handleEnd, - handleCancel, - responsive, - this.graphPaddingRight, - graphInteractionMode - ); - - stateChanges = { ...stateChanges, toolSVG: newToolSVG, tool, container }; - }; - - const createCentroidSVG = () => { - d3.select("#graphAttachPoint") - .select("#centroid-container") - .remove(); - - if (centroidLabel.metadataField === "" || !centroidLabel.centroidXY) { - return; - } - - const centroidScreen = this.mapPointToScreen(centroidLabel.centroidXY); - - const newCentroidSVG = setupCentroidSVG( - responsive, - this.graphPaddingRight, - centroidScreen, - centroidLabel.categoryField, - colorAccessor - ); - - stateChanges = { ...stateChanges, centroidSVG: newCentroidSVG }; - }; - // Centroid SVG creation is disabled for now but should go into the // first and third cases if enabled if ( @@ -389,14 +339,14 @@ class Graph extends React.Component { prevProps.responsive.width !== responsive.width ) { // If the window size has changed we want to recreate all SVGs - createToolSVG(); + stateChanges = { ...stateChanges, ...this.createToolSVG() }; } else if ( (responsive.height && responsive.width && !toolSVG) || selectionTool !== prevProps.selectionTool || prevProps.graphInteractionMode !== graphInteractionMode ) { // first time or change of selection tool6 - createToolSVG(); + stateChanges = { ...stateChanges, ...this.createToolSVG() }; } else if ( centroidLabel !== prevProps.centroidLabel || (responsive.height && responsive.width && !centroidSVG) @@ -433,6 +383,75 @@ class Graph extends React.Component { } }; + createToolSVG() { + /* + Called from componentDidUpdate. Create the tool SVG, and return any + state changes that should be passed to setState(). + */ + const { responsive, selectionTool, graphInteractionMode } = this.props; + + /* clear out whatever was on the div, even if nothing, but usually the brushes etc */ + d3.select("#graphAttachPoint") + .select("#tool") + .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: newToolSVG, tool, container } = setupSVGandBrushElements( + selectionTool, + handleStart, + handleDrag, + handleEnd, + handleCancel, + responsive, + this.graphPaddingRight, + graphInteractionMode + ); + + return { toolSVG: newToolSVG, tool, container }; + } + + createCentroidSVG() { + /* + Called from componentDidUpdate. Create the centroid SVG, and return any + state changes that should be passed to setState(). + + CURRENTLY UNUSED + */ + const { responsive, centroidLabel, colorAccessor } = this.props; + d3.select("#graphAttachPoint") + .select("#centroid-container") + .remove(); + + if (centroidLabel.metadataField === "" || !centroidLabel.centroidXY) { + return {}; + } + + const centroidScreen = this.mapPointToScreen(centroidLabel.centroidXY); + + const newCentroidSVG = setupCentroidSVG( + responsive, + this.graphPaddingRight, + centroidScreen, + centroidLabel.categoryField, + colorAccessor + ); + + return { centroidSVG: newCentroidSVG }; + } + brushToolUpdate(tool, container) { /* this is called from componentDidUpdate(), so be very careful using diff --git a/client/src/components/scatterplot/drawPointsRegl.js b/client/src/components/scatterplot/drawPointsRegl.js index 7b67d736..4f1d01df 100644 --- a/client/src/components/scatterplot/drawPointsRegl.js +++ b/client/src/components/scatterplot/drawPointsRegl.js @@ -26,8 +26,7 @@ export default function(regl) { bool isNaN, isSelected, isHighlight; getFlags(flag, isNaN, isSelected, isHighlight); - float size = isHighlight ? 8. : isSelected ? 4. : 1.; - gl_PointSize = size; + gl_PointSize = isHighlight ? 8. : isSelected ? 4. : 1.; float z = isNaN ? zBottom : (isHighlight ? zTop : zMiddle); vec3 xy = projection * vec3(position, 1.); diff --git a/client/src/components/scatterplot/scatterplot.js b/client/src/components/scatterplot/scatterplot.js index 14f677f6..734103ed 100644 --- a/client/src/components/scatterplot/scatterplot.js +++ b/client/src/components/scatterplot/scatterplot.js @@ -25,12 +25,12 @@ function createProjectionTF(viewportWidth, viewportHeight) { @connect(state => { const { world, crossfilter, universe } = state; const { scatterplotXXaccessor, scatterplotYYaccessor } = state.controls; - const expressionX = - scatterplotXXaccessor && - world.varData.col(scatterplotXXaccessor)?.asArray(); - const expressionY = - scatterplotYYaccessor && - world.varData.col(scatterplotYYaccessor)?.asArray(); + const expressionX = scatterplotXXaccessor + ? world.varData.col(scatterplotXXaccessor)?.asArray() + : null; + const expressionY = scatterplotYYaccessor + ? world.varData.col(scatterplotYYaccessor)?.asArray() + : null; return { world, @@ -40,6 +40,8 @@ function createProjectionTF(viewportWidth, viewportHeight) { colorScale: state.colors.scale, colorAccessor: state.colors.colorAccessor, + centroidLabel: state.centroidLabel, + // Accessors are var/gene names (strings) scatterplotXXaccessor, scatterplotYYaccessor, @@ -85,32 +87,42 @@ class Scatterplot extends React.Component { } ); - computePointFlags = memoize((world, crossfilter, colorAccessor) => { - const flagSelected = 1; - const flagNaN = 2; - // XXX - coming soon. - // const flagHighlight = 4; + computePointFlags = memoize( + (world, crossfilter, colorAccessor, centroidLabel) => { + const flagSelected = 1; + const flagNaN = 2; + const flagHighlight = 4; - const flags = this.computeSelectedFlags( - crossfilter, - flagSelected, - 0 - ).slice(); + const flags = this.computeSelectedFlags( + crossfilter, + flagSelected, + 0 + ).slice(); - const colorByColumn = colorAccessor - ? world.obsAnnotations.col(colorAccessor)?.asArray() || - world.varData.col(colorAccessor)?.asArray() - : null; - const colorByData = - colorByColumn && isTypedArray(colorByColumn) ? colorByColumn : null; + const { metadataField, categoryField } = centroidLabel; + const highlightData = metadataField + ? world.obsAnnotations.col(metadataField)?.asArray() + : null; + const colorByColumn = colorAccessor + ? world.obsAnnotations.col(colorAccessor)?.asArray() || + world.varData.col(colorAccessor)?.asArray() + : null; + const colorByData = + colorByColumn && isTypedArray(colorByColumn) ? colorByColumn : null; - if (colorByData) { - for (let i = 0, len = flags.length; i < len; i += 1) { - flags[i] += Number.isFinite(colorByData[i]) ? 0 : flagNaN; + if (colorByData || highlightData) { + for (let i = 0, len = flags.length; i < len; i += 1) { + if (highlightData) { + flags[i] += highlightData[i] === categoryField ? flagHighlight : 0; + } + if (colorByData) { + flags[i] += Number.isFinite(colorByData[i]) ? 0 : flagNaN; + } + } } + return flags; } - return flags; - }); + ); constructor(props) { super(props); @@ -183,7 +195,8 @@ class Scatterplot extends React.Component { expressionX, expressionY, colorRGB, - colorAccessor + colorAccessor, + centroidLabel } = this.props; const { regl, @@ -233,7 +246,8 @@ class Scatterplot extends React.Component { const newFlags = this.computePointFlags( world, crossfilter, - colorAccessor + colorAccessor, + centroidLabel ); if (renderCache.flags !== newFlags) { renderCache.flags = newFlags;