centroid labels (#872)

* refactor reducer to no longer support hover state and hold many labels

* refactor to generate centroidCoordinates for all values of a category

* create hash for function and memoize export

* create button to display all labels for a category

* clear state

* create label for each thing

* calculate on each value

* change to in place modification of map

* switch to for loop with iterator instead of forEach

* use map from centroidLabel instead of creating copy

* adapt for map

* utilize tarrays

* begin documentation

* disable centroids if in zoom mode

* clean up

* persist uncalc coordinates

* document

* clean up and document

* cleanup and document

* fix

* fix undefined labels and document changes

* fix first element skip

* fix conditional recalc

* rename centroidLabel -> centroidLabels

* break out dilation on hover to new reducer

* numerous styling changes for readability

* change centroid icon

* remove colorAccessor from parameters

* recalc centroids on world change

* make label toggle undoable

* remove unused import

* highlight labels on hover

* remove special characters from svg id

* lighten backdrop

* only generate new centroids if they pre-exist

* fix issue with spaces in catagorical value name

* add label buttons to menubar

* change reducer to use colorAccessor and have single toggle

* fix check to see if svg should be rendered

* move svg overlays onto a single svg layer

* dilate on label hover

* remove logs

* allow centroid to update along side regl renders

* allow actions to pass through svg if in zoom mode

* remove artifact from circle

* remove comment

* remove disabling of centroid button

* fix conditional map to screen

* make styling label conditions stricter

* prettier

* refactor onto master

* refactor computePointFlags() to use pointDilation store

* notify when viewport changes

* move svg attributes out of lasso setup and prevent rerenders/writes

* begin playing with transform matrix

* first solution for camera interaction

* create transform using nested groups

* semi-working method using nested groups with transforms

* inversely scale text

* properly do final transform

* cleanup dead / test code

* reinstate original functionality

* breakout centroid labels labels into separate component

* default toggle on for testing

* separate lasso and centroid layers

* remove unnecessary attributes, working hover

* dilation on label hover

* fix dilation on scatterplot

* add dilation on label hover

* break overlay into separate component

* make overlay agnostic to children

* move label mouse actions to centroidlabels component, add overlay state

* remove lasso on switch to camera

* disallow user selection

* fix reducer

* fix subset with continuous color error

* reset labels on color by continuous

* revert centroids on by default

* refactor for nested restructuring

* remove update checking

* remove unused method

* readd deleted hover delay

* remove old centroid setup

* remove centroid from undoable

* cleanup dead code

* remove dead code

* rollback unnecessary changes

* begin adding annotation functionality

* add annotation functionality

* add reset and undo functionality

* change centroids on layout change

* don't create label for unassigned

* add comment pointing out POI for performance

* touch up matrix transform comment

* add comment explaining coordinate space and children's assumed space

* remove dead code

* switch to pure component

* connect centroidLabels to redux

* clean up camera check and null result

* tool tip change

* rename centroid toggle and the like

* fix the misalignment of buttons, also make blueprint use consistent

* fix comment spelling mistakes

* introduce variable for cleaner logic expressions and state assignment

* alter tooltip text to back color by interaction

* remove manual iterator manipulation for forEach()

* remove debounce

* nit fix

* tooltip wording fix

* lint
This commit is contained in:
Severiano Badajoz
2020-01-22 13:40:22 -08:00
committed by GitHub
parent 69c639b14d
commit 8d725b1ad9
18 changed files with 541 additions and 233 deletions
+3
View File
@@ -323,6 +323,9 @@ const resetInterface = () => (dispatch, getState) => {
dispatch({ dispatch({
type: "reset colorscale" type: "reset colorscale"
}); });
dispatch({
type: "reset centroid labels"
});
dispatch({ dispatch({
type: "clear scatterplot" type: "clear scatterplot"
}); });
@@ -1,3 +1,4 @@
:local(.hover),
:local(.value):hover { :local(.value):hover {
background: rgba(167, 182, 194, 0.3); background: rgba(167, 182, 194, 0.3);
} }
+15 -3
View File
@@ -24,6 +24,7 @@ import { AnnotationsHelpers } from "../../util/stateManager";
annotations: state.annotations, annotations: state.annotations,
colorScale: state.colors.scale, colorScale: state.colors.scale,
colorAccessor: state.colors.colorAccessor, colorAccessor: state.colors.colorAccessor,
pointDilation: state.pointDilation,
schema: state.world?.schema, schema: state.world?.schema,
world: state.world, world: state.world,
crossfilter: state.crossfilter crossfilter: state.crossfilter
@@ -194,6 +195,7 @@ class CategoryValue extends React.Component {
const crossfilterChange = const crossfilterChange =
props.isUserAnno && props.crossfilter !== nextProps.crossfilter; props.isUserAnno && props.crossfilter !== nextProps.crossfilter;
const editingLabel = state.editedLabelText !== nextState.editedLabelText; const editingLabel = state.editedLabelText !== nextState.editedLabelText;
const dilationChange = props.pointDilation !== nextProps.pointDilation;
return ( return (
valueSelectionChange || valueSelectionChange ||
@@ -201,7 +203,8 @@ class CategoryValue extends React.Component {
colorAccessorChange || colorAccessorChange ||
annotationsChange || annotationsChange ||
crossfilterChange || crossfilterChange ||
editingLabel editingLabel ||
dilationChange
); );
}; };
@@ -288,7 +291,8 @@ class CategoryValue extends React.Component {
annotations, annotations,
// flippedProps is potentially brittle, their docs want {...flippedProps} on our div, // flippedProps is potentially brittle, their docs want {...flippedProps} on our div,
// our lint doesn't like jsx spread, we are version pinned to prevent api change on their part // our lint doesn't like jsx spread, we are version pinned to prevent api change on their part
flippedProps flippedProps,
pointDilation
} = this.props; } = this.props;
const { editedLabelText } = this.state; const { editedLabelText } = this.state;
@@ -341,7 +345,15 @@ class CategoryValue extends React.Component {
data-flip-config={flippedProps["data-flip-config"]} data-flip-config={flippedProps["data-flip-config"]}
data-flip-id={flippedProps["data-flip-id"]} data-flip-id={flippedProps["data-flip-id"]}
data-portal-key={flippedProps["data-portal-key"]} data-portal-key={flippedProps["data-portal-key"]}
className={styles.value} className={
/* This code is to change the styles on centroid label hover is causing over-rendering */
`${styles.value}${
pointDilation.metadataField === metadataField &&
pointDilation.categoryField === displayString
? ` ${styles.hover}`
: ""
}`
}
data-testclass="categorical-row" data-testclass="categorical-row"
style={{ style={{
padding: "4px 7px", padding: "4px 7px",
+70 -68
View File
@@ -8,10 +8,13 @@ import memoize from "memoize-one";
import * as globals from "../../globals"; import * as globals from "../../globals";
import setupSVGandBrushElements from "./setupSVGandBrush"; import setupSVGandBrushElements from "./setupSVGandBrush";
import setupCentroidSVG from "./setupCentroidSVG";
import _camera from "../../util/camera"; import _camera from "../../util/camera";
import _drawPoints from "./drawPointsRegl"; import _drawPoints from "./drawPointsRegl";
import { isTypedArray } from "../../util/typeHelpers"; import { isTypedArray } from "../../util/typeHelpers";
import styles from "./graph.css";
import GraphOverlayLayer from "./overlays/graphOverlayLayer";
import CentroidLabels from "./overlays/centroidLabels";
/* /*
Simple 2D transforms control all point painting. There are three: Simple 2D transforms control all point painting. There are three:
@@ -79,9 +82,10 @@ function renderThrottle(callback) {
selectionTool: state.graphSelection.tool, selectionTool: state.graphSelection.tool,
currentSelection: state.graphSelection.selection, currentSelection: state.graphSelection.selection,
layoutChoice: state.layoutChoice, layoutChoice: state.layoutChoice,
centroidLabel: state.centroidLabel, centroidLabels: state.centroidLabels,
graphInteractionMode: state.controls.graphInteractionMode, graphInteractionMode: state.controls.graphInteractionMode,
colorAccessor: state.colors.colorAccessor colorAccessor: state.colors.colorAccessor,
pointDilation: state.pointDilation
})) }))
class Graph extends React.PureComponent { class Graph extends React.PureComponent {
computePointPositions = memoize((X, Y, modelTF) => { computePointPositions = memoize((X, Y, modelTF) => {
@@ -121,7 +125,7 @@ class Graph extends React.PureComponent {
); );
computePointFlags = memoize( computePointFlags = memoize(
(world, crossfilter, colorAccessor, centroidLabel) => { (world, crossfilter, colorAccessor, pointDilation) => {
/* /*
We communicate with the shader using three flags: We communicate with the shader using three flags:
- isNaN -- the value is a NaN. Only makes sense when we have a colorAccessor - isNaN -- the value is a NaN. Only makes sense when we have a colorAccessor
@@ -146,7 +150,7 @@ class Graph extends React.PureComponent {
0 0
).slice(); ).slice();
const { metadataField, categoryField } = centroidLabel; const { metadataField, categoryField } = pointDilation;
const highlightData = metadataField const highlightData = metadataField
? world.obsAnnotations.col(metadataField)?.asArray() ? world.obsAnnotations.col(metadataField)?.asArray()
: null; : null;
@@ -186,9 +190,9 @@ class Graph extends React.PureComponent {
}; };
this.state = { this.state = {
toolSVG: null, toolSVG: null,
centroidSVG: null,
tool: null, tool: null,
container: null container: null,
cameraRender: 0
}; };
} }
@@ -245,23 +249,17 @@ class Graph extends React.PureComponent {
currentSelection, currentSelection,
layoutChoice, layoutChoice,
graphInteractionMode, graphInteractionMode,
colorAccessor, pointDilation,
centroidLabel colorAccessor
} = this.props; } = this.props;
const { regl, toolSVG, centroidSVG } = this.state; const { regl, toolSVG, camera, modelTF } = this.state;
let stateChanges = {}; let stateChanges = {};
if (regl && world) { if (regl && world) {
/* update the regl and point rendering state */ /* update the regl and point rendering state */
const { obsLayout, nObs } = world; const { obsLayout, nObs } = world;
const { const { drawPoints, pointBuffer, colorBuffer, flagBuffer } = this.state;
drawPoints,
camera,
pointBuffer,
colorBuffer,
flagBuffer,
modelTF
} = this.state;
let { projectionTF } = this.state; let { projectionTF } = this.state;
let needsRepaint = false; let needsRepaint = false;
@@ -305,12 +303,12 @@ class Graph extends React.PureComponent {
world, world,
crossfilter, crossfilter,
colorAccessor, colorAccessor,
centroidLabel pointDilation
); );
if (renderCache.flags !== newFlags) { if (renderCache.flags !== newFlags) {
renderCache.flags = newFlags; renderCache.flags = newFlags;
flagBuffer({ data: newFlags, dimension: 1 });
needsRepaint = true; needsRepaint = true;
flagBuffer({ data: newFlags, dimension: 1 });
} }
this.count = nObs; this.count = nObs;
@@ -328,26 +326,27 @@ class Graph extends React.PureComponent {
} }
} }
// Centroid SVG creation is disabled for now but should go into the
// first and third cases if enabled
if ( if (
prevProps.responsive.height !== responsive.height || prevProps.responsive.height !== responsive.height ||
prevProps.responsive.width !== responsive.width prevProps.responsive.width !== responsive.width
) { ) {
// If the window size has changed we want to recreate all SVGs // If the window size has changed we want to recreate all SVGs
stateChanges = { ...stateChanges, ...this.createToolSVG() }; stateChanges = {
...stateChanges,
...this.createToolSVG()
};
} else if ( } else if (
(responsive.height && responsive.width && !toolSVG) || (responsive.height && responsive.width && !toolSVG) ||
selectionTool !== prevProps.selectionTool || selectionTool !== prevProps.selectionTool
prevProps.graphInteractionMode !== graphInteractionMode
) { ) {
// first time or change of selection tool6 // first time or change of selection tool
stateChanges = { ...stateChanges, ...this.createToolSVG() }; stateChanges = { ...stateChanges, ...this.createToolSVG(true) };
} else if ( } else if (prevProps.graphInteractionMode !== graphInteractionMode) {
centroidLabel !== prevProps.centroidLabel || // If lasso/zoom is switched
(responsive.height && responsive.width && !centroidSVG) stateChanges = {
) { ...stateChanges,
// First time for centroid or label change ...this.createToolSVG()
};
} }
/* /*
@@ -365,7 +364,6 @@ class Graph extends React.PureComponent {
stateChanges.container ? stateChanges.container : container stateChanges.container ? stateChanges.container : container
); );
} }
if (Object.keys(stateChanges).length > 0) { if (Object.keys(stateChanges).length > 0) {
this.setState(stateChanges); this.setState(stateChanges);
} }
@@ -376,10 +374,13 @@ class Graph extends React.PureComponent {
if (e.type !== "wheel") e.preventDefault(); if (e.type !== "wheel") e.preventDefault();
if (camera.handleEvent(e, projectionTF)) { if (camera.handleEvent(e, projectionTF)) {
this.renderCanvas(); this.renderCanvas();
this.setState(state => {
return { ...state, updateOverlay: !state.updateOverlay };
});
} }
}; };
createToolSVG() { createToolSVG = () => {
/* /*
Called from componentDidUpdate. Create the tool SVG, and return any Called from componentDidUpdate. Create the tool SVG, and return any
state changes that should be passed to setState(). state changes that should be passed to setState().
@@ -387,10 +388,14 @@ class Graph extends React.PureComponent {
const { responsive, selectionTool, graphInteractionMode } = this.props; const { responsive, selectionTool, graphInteractionMode } = this.props;
/* clear out whatever was on the div, even if nothing, but usually the brushes etc */ /* clear out whatever was on the div, even if nothing, but usually the brushes etc */
d3.select("#graphAttachPoint")
.select("#tool") d3.select("#lasso-layer")
.selectAll(".lasso-group")
.remove(); .remove();
// Don't render or recreate toolSVG if currently in zoom mode
if (graphInteractionMode !== "select") return { toolSVG: undefined };
let handleStart; let handleStart;
let handleDrag; let handleDrag;
let handleEnd; let handleEnd;
@@ -417,36 +422,7 @@ class Graph extends React.PureComponent {
); );
return { toolSVG: newToolSVG, tool, container }; 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.graphPaddingRightLeft,
centroidScreen,
centroidLabel.categoryField,
colorAccessor
);
return { centroidSVG: newCentroidSVG };
}
brushToolUpdate(tool, container) { brushToolUpdate(tool, container) {
/* /*
@@ -745,7 +721,10 @@ class Graph extends React.PureComponent {
}); });
render() { render() {
const { responsive } = this.props; const { responsive, graphInteractionMode } = this.props;
const { modelTF, projectionTF, camera } = this.state;
const cameraTF = camera?.view();
return ( return (
<div id="graphWrapper"> <div id="graphWrapper">
@@ -757,7 +736,30 @@ class Graph extends React.PureComponent {
right: globals.leftSidebarWidth right: globals.leftSidebarWidth
}} }}
> >
<div id="graphAttachPoint" /> <div id="graphAttachPoint">
<GraphOverlayLayer
cameraTF={cameraTF}
modelTF={modelTF}
projectionTF={projectionTF}
graphPaddingRightLeft={this.graphPaddingRightLeft}
graphPaddingTop={this.graphPaddingTop}
responsive={responsive}
>
<CentroidLabels />
</GraphOverlayLayer>
<svg
id="lasso-layer"
data-testid="layout-overlay"
className={styles.graphSVG}
width={responsive.width - this.graphPaddingRightLeft}
height={responsive.height}
pointerEvents={
graphInteractionMode === "select" ? "auto" : "none"
}
style={{ zIndex: 89 }}
/>
</div>
<div style={{ padding: 0, margin: 0 }}> <div style={{ padding: 0, margin: 0 }}>
<canvas <canvas
width={responsive.width - this.graphPaddingRightLeft} width={responsive.width - this.graphPaddingRightLeft}
@@ -0,0 +1,73 @@
/* eslint-disable jsx-a11y/mouse-events-have-key-events */
import React, { PureComponent } from "react";
import { connect } from "react-redux";
export default
@connect(state => ({
colorAccessor: state.colors.colorAccessor,
dilatedValue: state.pointDilation.categoryField,
labels: state.centroidLabels.labels
}))
class CentroidLabels extends PureComponent {
render() {
const {
labels,
inverseTransform,
dilatedValue,
dispatch,
colorAccessor
} = this.props;
const labelSVGS = [];
let fontSize = "15px";
let fontWeight = null;
labels.forEach((value, key) => {
fontSize = "15px";
fontWeight = null;
if (key === dilatedValue) {
fontSize = "18px";
fontWeight = "800";
}
labelSVGS.push(
<g
// eslint-disable-next-line react/no-array-index-key
key={key}
className="centroid-label"
transform={`translate(${value[0]}, ${value[1]})`}
>
<text
transform={inverseTransform}
textAnchor="middle"
data-label={key}
style={{
fontFamily: "Roboto Condensed",
fontSize,
fontWeight,
fill: "black",
userSelect: "none"
}}
onMouseEnter={e =>
dispatch({
type: "category value mouse hover start",
metadataField: colorAccessor,
categoryField: e.target.getAttribute("data-label")
})
}
onMouseOut={e =>
dispatch({
type: "category value mouse hover end",
metadataField: colorAccessor,
categoryField: e.target.getAttribute("data-label")
})
}
pointerEvents="visiblePainted"
>
{key.length > 20 ? `${key.substr(0, 20)}...` : key}
</text>
</g>
);
});
return <>{labelSVGS}</>;
}
}
@@ -0,0 +1,100 @@
import React, { PureComponent, cloneElement } from "react";
import { connect } from "react-redux";
import styles from "../graph.css";
export default
@connect(state => ({
responsive: state.responsive
}))
class GraphOverlayLayer extends PureComponent {
/*
This component takes its children (assumed in the data coordinate space ([0, 1] range, origin in bottom left corner))
and transforms itself multiple times resulting in screen space ([0, screenWidth/Height] range, origin in top left corner)
Children are assigned in the graph component
*/
matrixToTransformString = m => {
/*
Translates the gl-matrix mat3 to SVG matrix transform style
mat3 SVG Transform Function
a c e
b d f / [a, b, 0, c, d, 0, e, f, 1] => matrix(a, b, c, d, e, f) / matrix(sx, 0, 0, sy, tx, ty) / matrix(m[0] m[3] m[1] m[4] m[6] m[7])
0 0 1
*/
return `matrix(${m[0]} ${m[1]} ${m[3]} ${m[4]} ${m[6]} ${m[7]})`;
};
reverseMatrixScaleTransformString = m => {
return `matrix(${1 / m[0]} 0 0 ${1 / m[4]} 0 0)`;
};
render() {
const {
cameraTF,
modelTF,
projectionTF,
responsive,
graphPaddingRightLeft,
graphPaddingTop,
children
} = this.props;
if (!cameraTF) return null;
const inverseTransform = `${this.reverseMatrixScaleTransformString(
modelTF
)} ${this.reverseMatrixScaleTransformString(
cameraTF
)} ${this.reverseMatrixScaleTransformString(
projectionTF
)} scale(1 2) scale(1 ${1 /
-(responsive.height - graphPaddingTop)}) scale(2 1) scale(${1 /
(responsive.width - graphPaddingRightLeft)} 1)`;
const newChildren = React.Children.toArray(children);
return (
<svg
className={styles.graphSVG}
width={responsive.width - graphPaddingRightLeft}
height={responsive.height}
pointerEvents="none"
style={{ zIndex: 99 }}
>
<g
id="canvas-transformation-group-x"
transform={`scale(${responsive.width -
graphPaddingRightLeft} 1) scale(.5 1) translate(1 0)`}
>
<g
id="canvas-transformation-group-y"
transform={`scale(1 ${-(
responsive.height - graphPaddingTop
)}) translate(0 -1) scale(1 .5) translate(0 1)`}
>
<g
id="projection-transformation-group"
transform={this.matrixToTransformString(projectionTF)}
>
<g
id="camera-transformation-group"
transform={this.matrixToTransformString(cameraTF)}
>
<g
id="model-transformation-group"
transform={this.matrixToTransformString(modelTF)}
>
{newChildren.map(child =>
cloneElement(child, { inverseTransform })
)}
</g>
</g>
</g>
</g>
</g>
</svg>
);
}
}
@@ -1,34 +0,0 @@
import * as d3 from "d3";
import styles from "./graph.css";
export default (responsive, graphPaddingRight, xy, text, colorBy) => {
const containerWidth = responsive.width - graphPaddingRight;
const svg = d3
.select("#graphAttachPoint")
.append("svg")
.attr("id", "centroid-container")
.attr("data-testid", "centroid-overlay")
.attr("width", containerWidth)
.attr("height", responsive.height)
.attr("class", `${styles.graphSVG}`)
.style("z-index", 998)
.style("pointer-events", "none");
// TODO: Create own styles, ask Colin for an explanation on the css
// For now I'm going to put centroid z-index at 998 and lasso on 999
const label = svg
.append("g")
.attr("transform", `translate(${xy[0]}, ${xy[1]})`);
label
.append("text")
.attr("text-anchor", "middle")
.text(text)
.style("font-family", "Roboto Condensed")
.style("font-size", "18px")
.style("font-weight", "700")
.style("fill", colorBy ? "black" : "rgb(32, 178, 212)");
return svg;
};
@@ -1,6 +1,5 @@
// jshint esversion: 6 // jshint esversion: 6
import * as d3 from "d3"; import * as d3 from "d3";
import styles from "./graph.css";
import Lasso from "./setupLasso"; import Lasso from "./setupLasso";
/****************************************** /******************************************
@@ -16,19 +15,9 @@ export default (
handleEndAction, handleEndAction,
handleCancelAction, handleCancelAction,
responsive, responsive,
graphPaddingRight, graphPaddingRight
graphInteractionMode
) => { ) => {
const svg = d3 const svg = d3.select("#graphAttachPoint").select("#lasso-layer");
.select("#graphAttachPoint")
.append("svg")
.attr("id", "tool")
.attr("data-testid", "layout-overlay")
.attr("width", responsive.width - graphPaddingRight)
.attr("height", responsive.height)
.attr("class", `${styles.graphSVG}`)
.style("z-index", 999)
.style("display", graphInteractionMode === "select" ? "inherit" : "none");
if (selectionToolType === "brush") { if (selectionToolType === "brush") {
const brush = d3 const brush = d3
+1 -1
View File
@@ -37,7 +37,7 @@ function Clip(props) {
<div <div
className="bp3-button-group" className="bp3-button-group"
style={{ style={{
marginLeft: 10 marginRight: 10
}} }}
> >
<Popover <Popover
+46 -21
View File
@@ -3,6 +3,7 @@ import React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { import {
Button, Button,
ButtonGroup,
AnchorButton, AnchorButton,
Tooltip, Tooltip,
Popover, Popover,
@@ -42,7 +43,8 @@ import * as globals from "../../globals";
redoDisabled: state["@@undoable/future"].length === 0, redoDisabled: state["@@undoable/future"].length === 0,
aboutLink: state.config?.links?.["about-dataset"], aboutLink: state.config?.links?.["about-dataset"],
disableDiffexp: state.config?.parameters?.["disable-diffexp"] ?? false, disableDiffexp: state.config?.parameters?.["disable-diffexp"] ?? false,
diffexpMayBeSlow: state.config?.parameters?.["diffexp-may-be-slow"] ?? false diffexpMayBeSlow: state.config?.parameters?.["diffexp-may-be-slow"] ?? false,
showCentroidLabels: state.centroidLabels.showLabels
})) }))
class MenuBar extends React.Component { class MenuBar extends React.Component {
static isValidDigitKeyEvent(e) { static isValidDigitKeyEvent(e) {
@@ -258,6 +260,15 @@ class MenuBar extends React.Component {
}); });
}; };
handleCentroidChange = () => {
const { dispatch, showCentroidLabels } = this.props;
dispatch({
type: "show centroid labels for category",
showLabels: !showCentroidLabels
});
};
renderDiffExp() { renderDiffExp() {
/* diffexp-related buttons may be disabled */ /* diffexp-related buttons may be disabled */
const { disableDiffexp, differential, diffexpMayBeSlow } = this.props; const { disableDiffexp, differential, diffexpMayBeSlow } = this.props;
@@ -272,7 +283,7 @@ class MenuBar extends React.Component {
const tipMessage = `See top 10 differentially expressed genes${slowMsg}`; const tipMessage = `See top 10 differentially expressed genes${slowMsg}`;
return ( return (
<div className="bp3-button-group" style={{ marginRight: 10 }}> <ButtonGroup style={{ marginRight: 10 }}>
<CellSetButton {...this.props} eitherCellSetOneOrTwo={1} /> <CellSetButton {...this.props} eitherCellSetOneOrTwo={1} />
<CellSetButton {...this.props} eitherCellSetOneOrTwo={2} /> <CellSetButton {...this.props} eitherCellSetOneOrTwo={2} />
{!differential.diffExp ? ( {!differential.diffExp ? (
@@ -288,7 +299,6 @@ class MenuBar extends React.Component {
loading={differential.loading} loading={differential.loading}
icon="left-join" icon="left-join"
fill fill
type="button"
onClick={this.computeDiffExp} onClick={this.computeDiffExp}
/> />
</Tooltip> </Tooltip>
@@ -310,7 +320,7 @@ class MenuBar extends React.Component {
</Button> </Button>
</Tooltip> </Tooltip>
) : null} ) : null}
</div> </ButtonGroup>
); );
} }
@@ -327,19 +337,20 @@ class MenuBar extends React.Component {
clipPercentileMax, clipPercentileMax,
layoutChoice, layoutChoice,
graphInteractionMode, graphInteractionMode,
aboutLink aboutLink,
showCentroidLabels
} = this.props; } = this.props;
const { pendingClipPercentiles } = this.state; const { pendingClipPercentiles } = this.state;
// constants used to create selection tool button // constants used to create selection tool button
let selectionTooltip; let selectionTooltip;
let selectionButtonClass; let selectionButtonIcon;
if (selectionTool === "brush") { if (selectionTool === "brush") {
selectionTooltip = "Brush selection"; selectionTooltip = "Brush selection";
selectionButtonClass = "bp3-icon-select"; selectionButtonIcon = "select";
} else { } else {
selectionTooltip = "Lasso selection"; selectionTooltip = "Lasso selection";
selectionButtonClass = "bp3-icon-polygon-filter"; selectionButtonIcon = "polygon-filter";
} }
return ( return (
@@ -347,7 +358,8 @@ class MenuBar extends React.Component {
style={{ style={{
position: "fixed", position: "fixed",
right: globals.leftSidebarWidth + 8, right: globals.leftSidebarWidth + 8,
top: 8 top: 8,
display: "flex"
}} }}
> >
{this.renderDiffExp()} {this.renderDiffExp()}
@@ -357,7 +369,6 @@ class MenuBar extends React.Component {
hoverOpenDelay={globals.tooltipHoverOpenDelay} hoverOpenDelay={globals.tooltipHoverOpenDelay}
> >
<AnchorButton <AnchorButton
type="button"
data-testid="subset-button" data-testid="subset-button"
disabled={ disabled={
crossfilter && crossfilter &&
@@ -375,16 +386,16 @@ class MenuBar extends React.Component {
<Icon icon="double-chevron-down" /> <Icon icon="double-chevron-down" />
</AnchorButton> </AnchorButton>
</Tooltip> </Tooltip>
<div className="bp3-button-group"> <ButtonGroup style={{ marginRight: "10px" }}>
<Tooltip <Tooltip
content={selectionTooltip} content={selectionTooltip}
position="bottom" position="bottom"
hoverOpenDelay={globals.tooltipHoverOpenDelay} hoverOpenDelay={globals.tooltipHoverOpenDelay}
> >
<Button <AnchorButton
type="button" type="button"
data-testid="mode-lasso" data-testid="mode-lasso"
className={`bp3-button ${selectionButtonClass}`} icon={selectionButtonIcon}
active={graphInteractionMode === "select"} active={graphInteractionMode === "select"}
onClick={() => { onClick={() => {
dispatch({ dispatch({
@@ -402,10 +413,10 @@ class MenuBar extends React.Component {
position="bottom" position="bottom"
hoverOpenDelay={globals.tooltipHoverOpenDelay} hoverOpenDelay={globals.tooltipHoverOpenDelay}
> >
<Button <AnchorButton
type="button" type="button"
data-testid="mode-pan-zoom" data-testid="mode-pan-zoom"
className="bp3-button bp3-icon-zoom-in" icon="zoom-in"
active={graphInteractionMode === "zoom"} active={graphInteractionMode === "zoom"}
onClick={() => { onClick={() => {
dispatch({ dispatch({
@@ -418,11 +429,25 @@ class MenuBar extends React.Component {
}} }}
/> />
</Tooltip> </Tooltip>
</div> </ButtonGroup>
<div <Tooltip
className="bp3-button-group" content="When a category is colored by, show labels on the graph"
position="bottom"
disabled={graphInteractionMode === "zoom"}
>
<Button
icon="property"
onClick={this.handleCentroidChange}
active={showCentroidLabels}
intent={showCentroidLabels ? "primary" : "none"}
style={{
marginRight: 10
}}
/>
</Tooltip>
<ButtonGroup
style={{ style={{
marginLeft: 10 marginRight: 10
}} }}
> >
<Popover <Popover
@@ -435,7 +460,7 @@ class MenuBar extends React.Component {
<Button <Button
type="button" type="button"
data-testid="layout-choice" data-testid="layout-choice"
className="bp3-button bp3-icon-heatmap" icon="heatmap"
style={{ style={{
cursor: "pointer" cursor: "pointer"
}} }}
@@ -465,7 +490,7 @@ class MenuBar extends React.Component {
</div> </div>
} }
/> />
</div> </ButtonGroup>
<Clip <Clip
pendingClipPercentiles={pendingClipPercentiles} pendingClipPercentiles={pendingClipPercentiles}
clipPercentileMin={clipPercentileMin} clipPercentileMin={clipPercentileMin}
+1 -1
View File
@@ -5,7 +5,7 @@ import { Button, Popover, Menu, MenuItem, Position } from "@blueprintjs/core";
function InformationMenu(props) { function InformationMenu(props) {
const { libraryVersions, aboutLink } = props; const { libraryVersions, aboutLink } = props;
return ( return (
<div style={{ marginLeft: 10 }} className="bp3-button-group"> <div style={{}} className="bp3-button-group">
<Popover <Popover
content={ content={
<Menu> <Menu>
@@ -13,7 +13,7 @@ function InformationMenu(props) {
dispatch dispatch
} = props; } = props;
return ( return (
<div style={{ marginLeft: 10 }} className="bp3-button-group"> <div style={{ marginRight: 10 }} className="bp3-button-group">
<Tooltip <Tooltip
content="Undo" content="Undo"
position="bottom" position="bottom"
@@ -40,7 +40,7 @@ function createProjectionTF(viewportWidth, viewportHeight) {
colorScale: state.colors.scale, colorScale: state.colors.scale,
colorAccessor: state.colors.colorAccessor, colorAccessor: state.colors.colorAccessor,
centroidLabel: state.centroidLabel, pointDilation: state.pointDilation,
// Accessors are var/gene names (strings) // Accessors are var/gene names (strings)
scatterplotXXaccessor, scatterplotXXaccessor,
@@ -90,7 +90,7 @@ class Scatterplot extends React.PureComponent {
); );
computePointFlags = memoize( computePointFlags = memoize(
(world, crossfilter, colorAccessor, centroidLabel) => { (world, crossfilter, colorAccessor, pointDilation) => {
const flagSelected = 1; const flagSelected = 1;
const flagNaN = 2; const flagNaN = 2;
const flagHighlight = 4; const flagHighlight = 4;
@@ -101,7 +101,7 @@ class Scatterplot extends React.PureComponent {
0 0
).slice(); ).slice();
const { metadataField, categoryField } = centroidLabel; const { metadataField, categoryField } = pointDilation;
const highlightData = metadataField const highlightData = metadataField
? world.obsAnnotations.col(metadataField)?.asArray() ? world.obsAnnotations.col(metadataField)?.asArray()
: null; : null;
@@ -198,7 +198,7 @@ class Scatterplot extends React.PureComponent {
expressionY, expressionY,
colorRGB, colorRGB,
colorAccessor, colorAccessor,
centroidLabel pointDilation
} = this.props; } = this.props;
const { const {
regl, regl,
@@ -249,7 +249,7 @@ class Scatterplot extends React.PureComponent {
world, world,
crossfilter, crossfilter,
colorAccessor, colorAccessor,
centroidLabel pointDilation
); );
if (renderCache.flags !== newFlags) { if (renderCache.flags !== newFlags) {
renderCache.flags = newFlags; renderCache.flags = newFlags;
+76
View File
@@ -0,0 +1,76 @@
import calcCentroid from "../util/centroid";
const initialState = {
labels: [],
showLabels: false
};
const centroidLabels = (state = initialState, action, sharedNextState) => {
const {
world,
layoutChoice,
categoricalSelection,
colors: { colorAccessor }
} = sharedNextState;
const showLabels = action.showLabels ?? state.showLabels;
switch (action.type) {
case "annotation: label current cell selection":
case "annotation: label edited":
case "annotation: delete label":
case "set layout choice":
case "set World to current selection":
case "reset World to eq Universe":
return {
...state,
labels:
!!colorAccessor && showLabels && !!categoricalSelection[colorAccessor]
? calcCentroid(
world.obsAnnotations,
world.obsLayout,
colorAccessor,
layoutChoice.currentDimNames,
categoricalSelection,
world.schema.annotations.obsByName
)
: []
};
case "color by categorical metadata":
case "show centroid labels for category":
// If colorby is not enabled or labels are not toggled to show
// then clear the labels and make sure the toggle is off
if (!colorAccessor || !showLabels) {
return {
...state,
labels: [],
showLabels
};
}
return {
...state,
labels: calcCentroid(
world.obsAnnotations,
world.obsLayout,
colorAccessor,
layoutChoice.currentDimNames,
categoricalSelection,
world.schema.annotations.obsByName
),
showLabels
};
case "color by continuous metadata":
return { ...state, labels: [] };
case "reset centroid labels":
return initialState;
default:
return state;
}
};
export default centroidLabels;
+36 -33
View File
@@ -16,45 +16,48 @@ import layoutChoice from "./layoutChoice";
import responsive from "./responsive"; import responsive from "./responsive";
import controls from "./controls"; import controls from "./controls";
import resetCache from "./resetCache"; import resetCache from "./resetCache";
import centroidLabel from "./centroidLabel";
import annotations from "./annotations"; import annotations from "./annotations";
import autosave from "./autosave"; import autosave from "./autosave";
import centroidLabels from "./centroidLabels";
import pointDialation from "./pointDilation";
import undoableConfig from "./undoableConfig"; import undoableConfig from "./undoableConfig";
const Reducer = undoable( const Reducer = undoable(
cascadeReducers([ cascadeReducers([
["config", config], ["config", config],
["universe", universe], ["universe", universe],
["world", world], ["world", world],
["annotations", annotations], ["annotations", annotations],
["layoutChoice", layoutChoice], ["layoutChoice", layoutChoice],
["categoricalSelection", categoricalSelection], ["categoricalSelection", categoricalSelection],
["continuousSelection", continuousSelection], ["continuousSelection", continuousSelection],
["graphSelection", graphSelection], ["graphSelection", graphSelection],
["crossfilter", crossfilter], ["crossfilter", crossfilter],
["colors", colors], ["colors", colors],
["controls", controls], ["controls", controls],
["differential", differential], ["differential", differential],
["responsive", responsive], ["responsive", responsive],
["centroidLabel", centroidLabel], ["centroidLabels", centroidLabels],
["autosave", autosave], ["pointDilation", pointDialation],
["resetCache", resetCache] ["autosave", autosave],
]), ["resetCache", resetCache]
[ ]),
"universe", [
"world", "universe",
"categoricalSelection", "categoricalSelection",
"continuousSelection", "world",
"graphSelection", "continuousSelection",
"crossfilter", "graphSelection",
"colors", "crossfilter",
"controls", "layoutChoice",
"differential", "controls",
"layoutChoice", "differential",
"annotations" "colors",
], "centroidLabels",
undoableConfig "annotations"
],
undoableConfig
); );
const store = createStore(Reducer, applyMiddleware(thunk)); const store = createStore(Reducer, applyMiddleware(thunk));
@@ -1,29 +1,27 @@
const initialState = { const initialState = {
metadataField: "", metadataField: "",
categoryIndex: -1, categoryField: ""
categoryField: "",
centroidXY: [-1, -1]
}; };
const CentroidLabel = (state = initialState, action, sharedNextState) => { const pointDialation = (state = initialState, action, sharedNextState) => {
const { categoricalSelection } = sharedNextState; const { categoricalSelection } = sharedNextState;
const { metadataField, categoryIndex } = action; const { metadataField, categoryIndex } = action;
const categoryField = const categoryField =
action.categoryField ||
categoricalSelection?.[metadataField]?.categoryValues[categoryIndex]; categoricalSelection?.[metadataField]?.categoryValues[categoryIndex];
switch (action.type) { switch (action.type) {
case "category value mouse hover start": case "category value mouse hover start":
return { return {
...state, ...state,
metadataField, metadataField,
categoryIndex, categoryField
categoryField,
centroidXY: null
}; };
case "category value mouse hover end": case "category value mouse hover end":
if ( if (
metadataField === state.metadataField && metadataField === state.metadataField &&
categoryIndex === state.categoryIndex categoryField === state.categoryField
) { ) {
return initialState; return initialState;
} }
@@ -34,4 +32,4 @@ const CentroidLabel = (state = initialState, action, sharedNextState) => {
} }
}; };
export default CentroidLabel; export default pointDialation;
+3
View File
@@ -16,6 +16,7 @@ const skipOnActions = new Set([
"window resize", "window resize",
"user reset start", "user reset start",
"reset colorscale", "reset colorscale",
"reset centroid labels",
"graph brush change", "graph brush change",
"continuous metadata histogram brush", "continuous metadata histogram brush",
@@ -68,6 +69,8 @@ const saveOnActions = new Set([
"color by continuous metadata", "color by continuous metadata",
"color by expression", "color by expression",
"show centroid labels for category",
"set scatterplot x", "set scatterplot x",
"set scatterplot y", "set scatterplot y",
+101 -44
View File
@@ -1,61 +1,118 @@
import quantile from "./quantile"; import quantile from "./quantile";
import { memoize } from "./dataframe/util";
import { unassignedCategoryLabel } from "../globals";
/* /*
Centroid coordinate calculation Centroid coordinate calculation
*/ */
/* Unused - please cleanup /*
const calcMeanCentroid = (world, annoName, annoValue, layoutDimNames) => { calcMedianCentroid goes through a given metadata category
const centroid = { x: 0, y: 0, size: 0 }; fetches each cell's coordinates grouping by category value.
const annoArray = world.obsAnnotations.col(annoName).asArray();
const layoutXArray = world.obsLayout.col(layoutDimNames[0]).asArray();
const layoutYArray = world.obsLayout.col(layoutDimNames[1]).asArray();
for (let i = 0, len = annoArray.length; i < len; i += 1) { It then calculates the median value and puts that in the array
if (annoArray[i] === annoValue) {
centroid.x += layoutXArray[i];
centroid.y += layoutYArray[i];
centroid.size += 1;
}
}
if (centroid[2] !== 0) {
centroid.x /= centroid.size;
centroid.y /= centroid.size;
}
return [centroid.x, centroid.y];
};
*/ */
const calcMedianCentroid = (world, annoName, annoValue, layoutDimNames) => { const calcMedianCentroid = (
const centroidX = []; obsAnnotations,
const centroidY = []; obsLayout,
let hasFinite = false; categoryName,
layoutDimNames,
categoricalSelection,
schemaObsByName
) => {
const categoryArray = obsAnnotations.col(categoryName).asArray();
const annoArray = world.obsAnnotations.col(annoName).asArray(); const layoutXArray = obsLayout.col(layoutDimNames[0]).asArray();
const layoutXArray = world.obsLayout.col(layoutDimNames[0]).asArray(); const layoutYArray = obsLayout.col(layoutDimNames[1]).asArray();
const layoutYArray = world.obsLayout.col(layoutDimNames[1]).asArray(); const coordinates = new Map();
// Iterate over all the cells in the category
for (let i = 0, len = categoryArray.length; i < len; i += 1) {
const categoryValue = categoryArray[i];
// Get the index of the categoryValue within the category
// If the category is truncated and this value is removed,
// it will not be assigned a category value and will not be
// labeled on the graph
const categoryValueIndex = categoricalSelection[
categoryName
].categoryValueIndices.get(categoryValue);
// Check to see if the current category is a user created annotation
// if the user created this category, do not create a label for the `unassigned` value
const isUserAnno = schemaObsByName[categoryName].writable;
if (
categoryValueIndex !== undefined &&
!(isUserAnno && categoryValue === unassignedCategoryLabel)
) {
// Get the number of cells which are in the category value
const numInCategoryValue =
categoricalSelection[categoryName].categoryValueCounts[
categoryValueIndex
];
// Create/fetch the valueArray,
// which is what the key points to in the `coordinates` hashmap
const valueArray = coordinates.get(categoryValue) || [
false, // hasFinite
0, // index
new Float32Array(numInCategoryValue), // x coordinates
new Float32Array(numInCategoryValue) // y coordinates
];
const index = valueArray[1];
let hasFinite = valueArray[0];
for (let i = 0, len = annoArray.length; i < len; i += 1) {
if (annoArray[i] === annoValue) {
hasFinite = hasFinite =
Number.isFinite(layoutXArray[i]) || Number.isFinite(layoutYArray[i]) hasFinite ||
? true (Number.isFinite(layoutXArray[i]) && Number.isFinite(layoutYArray[i]));
: hasFinite;
centroidX.push(layoutXArray[i]); valueArray[0] = hasFinite;
centroidY.push(layoutYArray[i]); valueArray[1] = index + 1;
valueArray[2][index] = layoutXArray[i];
valueArray[3][index] = layoutYArray[i];
coordinates.set(categoryValue, valueArray);
} }
} }
if (hasFinite) { // Iterate over the recently created map
const medianX = quantile([0.5], Float64Array.from(centroidX)); coordinates.forEach((value, key) => {
const medianY = quantile([0.5], Float64Array.from(centroidY)); // If there are coordinates for this cateogrical value,
// and there is a finite coordinate for the category value
return [medianX, medianY]; if (value[2].length > 0 && value[3].length > 0 && value[0]) {
} // Find the median x and y coordinate
// and insert them into the first two indices
return null; value[0] = quantile([0.5], value[2])[0];
value[1] = quantile([0.5], value[3])[0];
// Remove the last two elements (where the arrays of coordinates were)
value.pop();
value.pop();
} else {
// remove the entry if not
coordinates.delete(key);
}
});
// return the map: categoricalValue -> [medianXCoordinate, medianYCoordinate]
return coordinates;
}; };
export default calcMedianCentroid; // A simple function to hash the parameters
// (not 100% on world hash, Bruce will have to check this one out)
const hashMedianCentroid = (
obsAnnotations,
obsLayout,
categoryName,
layoutDimNames,
categorySelection,
schemaObsByName
) => {
return `${obsAnnotations.__id}+${
obsLayout.__id
}:${categoryName}:${layoutDimNames}:${Object.keys(
categorySelection
)}:${Object.keys(schemaObsByName)}`;
};
// export the mmemoized calculation function
export default memoize(calcMedianCentroid, hashMedianCentroid);