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({
type: "reset colorscale"
});
dispatch({
type: "reset centroid labels"
});
dispatch({
type: "clear scatterplot"
});
@@ -1,3 +1,4 @@
:local(.hover),
:local(.value):hover {
background: rgba(167, 182, 194, 0.3);
}
+15 -3
View File
@@ -24,6 +24,7 @@ import { AnnotationsHelpers } from "../../util/stateManager";
annotations: state.annotations,
colorScale: state.colors.scale,
colorAccessor: state.colors.colorAccessor,
pointDilation: state.pointDilation,
schema: state.world?.schema,
world: state.world,
crossfilter: state.crossfilter
@@ -194,6 +195,7 @@ class CategoryValue extends React.Component {
const crossfilterChange =
props.isUserAnno && props.crossfilter !== nextProps.crossfilter;
const editingLabel = state.editedLabelText !== nextState.editedLabelText;
const dilationChange = props.pointDilation !== nextProps.pointDilation;
return (
valueSelectionChange ||
@@ -201,7 +203,8 @@ class CategoryValue extends React.Component {
colorAccessorChange ||
annotationsChange ||
crossfilterChange ||
editingLabel
editingLabel ||
dilationChange
);
};
@@ -288,7 +291,8 @@ class CategoryValue extends React.Component {
annotations,
// 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
flippedProps
flippedProps,
pointDilation
} = this.props;
const { editedLabelText } = this.state;
@@ -341,7 +345,15 @@ class CategoryValue extends React.Component {
data-flip-config={flippedProps["data-flip-config"]}
data-flip-id={flippedProps["data-flip-id"]}
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"
style={{
padding: "4px 7px",
+70 -68
View File
@@ -8,10 +8,13 @@ import memoize from "memoize-one";
import * as globals from "../../globals";
import setupSVGandBrushElements from "./setupSVGandBrush";
import setupCentroidSVG from "./setupCentroidSVG";
import _camera from "../../util/camera";
import _drawPoints from "./drawPointsRegl";
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:
@@ -79,9 +82,10 @@ function renderThrottle(callback) {
selectionTool: state.graphSelection.tool,
currentSelection: state.graphSelection.selection,
layoutChoice: state.layoutChoice,
centroidLabel: state.centroidLabel,
centroidLabels: state.centroidLabels,
graphInteractionMode: state.controls.graphInteractionMode,
colorAccessor: state.colors.colorAccessor
colorAccessor: state.colors.colorAccessor,
pointDilation: state.pointDilation
}))
class Graph extends React.PureComponent {
computePointPositions = memoize((X, Y, modelTF) => {
@@ -121,7 +125,7 @@ class Graph extends React.PureComponent {
);
computePointFlags = memoize(
(world, crossfilter, colorAccessor, centroidLabel) => {
(world, crossfilter, colorAccessor, pointDilation) => {
/*
We communicate with the shader using three flags:
- isNaN -- the value is a NaN. Only makes sense when we have a colorAccessor
@@ -146,7 +150,7 @@ class Graph extends React.PureComponent {
0
).slice();
const { metadataField, categoryField } = centroidLabel;
const { metadataField, categoryField } = pointDilation;
const highlightData = metadataField
? world.obsAnnotations.col(metadataField)?.asArray()
: null;
@@ -186,9 +190,9 @@ class Graph extends React.PureComponent {
};
this.state = {
toolSVG: null,
centroidSVG: null,
tool: null,
container: null
container: null,
cameraRender: 0
};
}
@@ -245,23 +249,17 @@ class Graph extends React.PureComponent {
currentSelection,
layoutChoice,
graphInteractionMode,
colorAccessor,
centroidLabel
pointDilation,
colorAccessor
} = this.props;
const { regl, toolSVG, centroidSVG } = this.state;
const { regl, toolSVG, camera, modelTF } = this.state;
let stateChanges = {};
if (regl && world) {
/* update the regl and point rendering state */
const { obsLayout, nObs } = world;
const {
drawPoints,
camera,
pointBuffer,
colorBuffer,
flagBuffer,
modelTF
} = this.state;
const { drawPoints, pointBuffer, colorBuffer, flagBuffer } = this.state;
let { projectionTF } = this.state;
let needsRepaint = false;
@@ -305,12 +303,12 @@ class Graph extends React.PureComponent {
world,
crossfilter,
colorAccessor,
centroidLabel
pointDilation
);
if (renderCache.flags !== newFlags) {
renderCache.flags = newFlags;
flagBuffer({ data: newFlags, dimension: 1 });
needsRepaint = true;
flagBuffer({ data: newFlags, dimension: 1 });
}
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 (
prevProps.responsive.height !== responsive.height ||
prevProps.responsive.width !== responsive.width
) {
// If the window size has changed we want to recreate all SVGs
stateChanges = { ...stateChanges, ...this.createToolSVG() };
stateChanges = {
...stateChanges,
...this.createToolSVG()
};
} else if (
(responsive.height && responsive.width && !toolSVG) ||
selectionTool !== prevProps.selectionTool ||
prevProps.graphInteractionMode !== graphInteractionMode
selectionTool !== prevProps.selectionTool
) {
// first time or change of selection tool6
stateChanges = { ...stateChanges, ...this.createToolSVG() };
} else if (
centroidLabel !== prevProps.centroidLabel ||
(responsive.height && responsive.width && !centroidSVG)
) {
// First time for centroid or label change
// first time or change of selection tool
stateChanges = { ...stateChanges, ...this.createToolSVG(true) };
} else if (prevProps.graphInteractionMode !== graphInteractionMode) {
// If lasso/zoom is switched
stateChanges = {
...stateChanges,
...this.createToolSVG()
};
}
/*
@@ -365,7 +364,6 @@ class Graph extends React.PureComponent {
stateChanges.container ? stateChanges.container : container
);
}
if (Object.keys(stateChanges).length > 0) {
this.setState(stateChanges);
}
@@ -376,10 +374,13 @@ class Graph extends React.PureComponent {
if (e.type !== "wheel") e.preventDefault();
if (camera.handleEvent(e, projectionTF)) {
this.renderCanvas();
this.setState(state => {
return { ...state, updateOverlay: !state.updateOverlay };
});
}
};
createToolSVG() {
createToolSVG = () => {
/*
Called from componentDidUpdate. Create the tool SVG, and return any
state changes that should be passed to setState().
@@ -387,10 +388,14 @@ class Graph extends React.PureComponent {
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")
d3.select("#lasso-layer")
.selectAll(".lasso-group")
.remove();
// Don't render or recreate toolSVG if currently in zoom mode
if (graphInteractionMode !== "select") return { toolSVG: undefined };
let handleStart;
let handleDrag;
let handleEnd;
@@ -417,36 +422,7 @@ class Graph extends React.PureComponent {
);
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) {
/*
@@ -745,7 +721,10 @@ class Graph extends React.PureComponent {
});
render() {
const { responsive } = this.props;
const { responsive, graphInteractionMode } = this.props;
const { modelTF, projectionTF, camera } = this.state;
const cameraTF = camera?.view();
return (
<div id="graphWrapper">
@@ -757,7 +736,30 @@ class Graph extends React.PureComponent {
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 }}>
<canvas
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
import * as d3 from "d3";
import styles from "./graph.css";
import Lasso from "./setupLasso";
/******************************************
@@ -16,19 +15,9 @@ export default (
handleEndAction,
handleCancelAction,
responsive,
graphPaddingRight,
graphInteractionMode
graphPaddingRight
) => {
const svg = d3
.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");
const svg = d3.select("#graphAttachPoint").select("#lasso-layer");
if (selectionToolType === "brush") {
const brush = d3
+1 -1
View File
@@ -37,7 +37,7 @@ function Clip(props) {
<div
className="bp3-button-group"
style={{
marginLeft: 10
marginRight: 10
}}
>
<Popover
+46 -21
View File
@@ -3,6 +3,7 @@ import React from "react";
import { connect } from "react-redux";
import {
Button,
ButtonGroup,
AnchorButton,
Tooltip,
Popover,
@@ -42,7 +43,8 @@ import * as globals from "../../globals";
redoDisabled: state["@@undoable/future"].length === 0,
aboutLink: state.config?.links?.["about-dataset"],
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 {
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() {
/* diffexp-related buttons may be disabled */
const { disableDiffexp, differential, diffexpMayBeSlow } = this.props;
@@ -272,7 +283,7 @@ class MenuBar extends React.Component {
const tipMessage = `See top 10 differentially expressed genes${slowMsg}`;
return (
<div className="bp3-button-group" style={{ marginRight: 10 }}>
<ButtonGroup style={{ marginRight: 10 }}>
<CellSetButton {...this.props} eitherCellSetOneOrTwo={1} />
<CellSetButton {...this.props} eitherCellSetOneOrTwo={2} />
{!differential.diffExp ? (
@@ -288,7 +299,6 @@ class MenuBar extends React.Component {
loading={differential.loading}
icon="left-join"
fill
type="button"
onClick={this.computeDiffExp}
/>
</Tooltip>
@@ -310,7 +320,7 @@ class MenuBar extends React.Component {
</Button>
</Tooltip>
) : null}
</div>
</ButtonGroup>
);
}
@@ -327,19 +337,20 @@ class MenuBar extends React.Component {
clipPercentileMax,
layoutChoice,
graphInteractionMode,
aboutLink
aboutLink,
showCentroidLabels
} = this.props;
const { pendingClipPercentiles } = this.state;
// constants used to create selection tool button
let selectionTooltip;
let selectionButtonClass;
let selectionButtonIcon;
if (selectionTool === "brush") {
selectionTooltip = "Brush selection";
selectionButtonClass = "bp3-icon-select";
selectionButtonIcon = "select";
} else {
selectionTooltip = "Lasso selection";
selectionButtonClass = "bp3-icon-polygon-filter";
selectionButtonIcon = "polygon-filter";
}
return (
@@ -347,7 +358,8 @@ class MenuBar extends React.Component {
style={{
position: "fixed",
right: globals.leftSidebarWidth + 8,
top: 8
top: 8,
display: "flex"
}}
>
{this.renderDiffExp()}
@@ -357,7 +369,6 @@ class MenuBar extends React.Component {
hoverOpenDelay={globals.tooltipHoverOpenDelay}
>
<AnchorButton
type="button"
data-testid="subset-button"
disabled={
crossfilter &&
@@ -375,16 +386,16 @@ class MenuBar extends React.Component {
<Icon icon="double-chevron-down" />
</AnchorButton>
</Tooltip>
<div className="bp3-button-group">
<ButtonGroup style={{ marginRight: "10px" }}>
<Tooltip
content={selectionTooltip}
position="bottom"
hoverOpenDelay={globals.tooltipHoverOpenDelay}
>
<Button
<AnchorButton
type="button"
data-testid="mode-lasso"
className={`bp3-button ${selectionButtonClass}`}
icon={selectionButtonIcon}
active={graphInteractionMode === "select"}
onClick={() => {
dispatch({
@@ -402,10 +413,10 @@ class MenuBar extends React.Component {
position="bottom"
hoverOpenDelay={globals.tooltipHoverOpenDelay}
>
<Button
<AnchorButton
type="button"
data-testid="mode-pan-zoom"
className="bp3-button bp3-icon-zoom-in"
icon="zoom-in"
active={graphInteractionMode === "zoom"}
onClick={() => {
dispatch({
@@ -418,11 +429,25 @@ class MenuBar extends React.Component {
}}
/>
</Tooltip>
</div>
<div
className="bp3-button-group"
</ButtonGroup>
<Tooltip
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={{
marginLeft: 10
marginRight: 10
}}
>
<Popover
@@ -435,7 +460,7 @@ class MenuBar extends React.Component {
<Button
type="button"
data-testid="layout-choice"
className="bp3-button bp3-icon-heatmap"
icon="heatmap"
style={{
cursor: "pointer"
}}
@@ -465,7 +490,7 @@ class MenuBar extends React.Component {
</div>
}
/>
</div>
</ButtonGroup>
<Clip
pendingClipPercentiles={pendingClipPercentiles}
clipPercentileMin={clipPercentileMin}
+1 -1
View File
@@ -5,7 +5,7 @@ import { Button, Popover, Menu, MenuItem, Position } from "@blueprintjs/core";
function InformationMenu(props) {
const { libraryVersions, aboutLink } = props;
return (
<div style={{ marginLeft: 10 }} className="bp3-button-group">
<div style={{}} className="bp3-button-group">
<Popover
content={
<Menu>
@@ -13,7 +13,7 @@ function InformationMenu(props) {
dispatch
} = props;
return (
<div style={{ marginLeft: 10 }} className="bp3-button-group">
<div style={{ marginRight: 10 }} className="bp3-button-group">
<Tooltip
content="Undo"
position="bottom"
@@ -40,7 +40,7 @@ function createProjectionTF(viewportWidth, viewportHeight) {
colorScale: state.colors.scale,
colorAccessor: state.colors.colorAccessor,
centroidLabel: state.centroidLabel,
pointDilation: state.pointDilation,
// Accessors are var/gene names (strings)
scatterplotXXaccessor,
@@ -90,7 +90,7 @@ class Scatterplot extends React.PureComponent {
);
computePointFlags = memoize(
(world, crossfilter, colorAccessor, centroidLabel) => {
(world, crossfilter, colorAccessor, pointDilation) => {
const flagSelected = 1;
const flagNaN = 2;
const flagHighlight = 4;
@@ -101,7 +101,7 @@ class Scatterplot extends React.PureComponent {
0
).slice();
const { metadataField, categoryField } = centroidLabel;
const { metadataField, categoryField } = pointDilation;
const highlightData = metadataField
? world.obsAnnotations.col(metadataField)?.asArray()
: null;
@@ -198,7 +198,7 @@ class Scatterplot extends React.PureComponent {
expressionY,
colorRGB,
colorAccessor,
centroidLabel
pointDilation
} = this.props;
const {
regl,
@@ -249,7 +249,7 @@ class Scatterplot extends React.PureComponent {
world,
crossfilter,
colorAccessor,
centroidLabel
pointDilation
);
if (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 controls from "./controls";
import resetCache from "./resetCache";
import centroidLabel from "./centroidLabel";
import annotations from "./annotations";
import autosave from "./autosave";
import centroidLabels from "./centroidLabels";
import pointDialation from "./pointDilation";
import undoableConfig from "./undoableConfig";
const Reducer = undoable(
cascadeReducers([
["config", config],
["universe", universe],
["world", world],
["annotations", annotations],
["layoutChoice", layoutChoice],
["categoricalSelection", categoricalSelection],
["continuousSelection", continuousSelection],
["graphSelection", graphSelection],
["crossfilter", crossfilter],
["colors", colors],
["controls", controls],
["differential", differential],
["responsive", responsive],
["centroidLabel", centroidLabel],
["autosave", autosave],
["resetCache", resetCache]
]),
[
"universe",
"world",
"categoricalSelection",
"continuousSelection",
"graphSelection",
"crossfilter",
"colors",
"controls",
"differential",
"layoutChoice",
"annotations"
],
undoableConfig
cascadeReducers([
["config", config],
["universe", universe],
["world", world],
["annotations", annotations],
["layoutChoice", layoutChoice],
["categoricalSelection", categoricalSelection],
["continuousSelection", continuousSelection],
["graphSelection", graphSelection],
["crossfilter", crossfilter],
["colors", colors],
["controls", controls],
["differential", differential],
["responsive", responsive],
["centroidLabels", centroidLabels],
["pointDilation", pointDialation],
["autosave", autosave],
["resetCache", resetCache]
]),
[
"universe",
"categoricalSelection",
"world",
"continuousSelection",
"graphSelection",
"crossfilter",
"layoutChoice",
"controls",
"differential",
"colors",
"centroidLabels",
"annotations"
],
undoableConfig
);
const store = createStore(Reducer, applyMiddleware(thunk));
@@ -1,29 +1,27 @@
const initialState = {
metadataField: "",
categoryIndex: -1,
categoryField: "",
centroidXY: [-1, -1]
categoryField: ""
};
const CentroidLabel = (state = initialState, action, sharedNextState) => {
const pointDialation = (state = initialState, action, sharedNextState) => {
const { categoricalSelection } = sharedNextState;
const { metadataField, categoryIndex } = action;
const categoryField =
action.categoryField ||
categoricalSelection?.[metadataField]?.categoryValues[categoryIndex];
switch (action.type) {
case "category value mouse hover start":
return {
...state,
metadataField,
categoryIndex,
categoryField,
centroidXY: null
categoryField
};
case "category value mouse hover end":
if (
metadataField === state.metadataField &&
categoryIndex === state.categoryIndex
categoryField === state.categoryField
) {
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",
"user reset start",
"reset colorscale",
"reset centroid labels",
"graph brush change",
"continuous metadata histogram brush",
@@ -68,6 +69,8 @@ const saveOnActions = new Set([
"color by continuous metadata",
"color by expression",
"show centroid labels for category",
"set scatterplot x",
"set scatterplot y",
+101 -44
View File
@@ -1,61 +1,118 @@
import quantile from "./quantile";
import { memoize } from "./dataframe/util";
import { unassignedCategoryLabel } from "../globals";
/*
Centroid coordinate calculation
*/
/* Unused - please cleanup
const calcMeanCentroid = (world, annoName, annoValue, layoutDimNames) => {
const centroid = { x: 0, y: 0, size: 0 };
const annoArray = world.obsAnnotations.col(annoName).asArray();
const layoutXArray = world.obsLayout.col(layoutDimNames[0]).asArray();
const layoutYArray = world.obsLayout.col(layoutDimNames[1]).asArray();
/*
calcMedianCentroid goes through a given metadata category
fetches each cell's coordinates grouping by category value.
for (let i = 0, len = annoArray.length; i < len; i += 1) {
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];
};
It then calculates the median value and puts that in the array
*/
const calcMedianCentroid = (world, annoName, annoValue, layoutDimNames) => {
const centroidX = [];
const centroidY = [];
let hasFinite = false;
const calcMedianCentroid = (
obsAnnotations,
obsLayout,
categoryName,
layoutDimNames,
categoricalSelection,
schemaObsByName
) => {
const categoryArray = obsAnnotations.col(categoryName).asArray();
const annoArray = world.obsAnnotations.col(annoName).asArray();
const layoutXArray = world.obsLayout.col(layoutDimNames[0]).asArray();
const layoutYArray = world.obsLayout.col(layoutDimNames[1]).asArray();
const layoutXArray = obsLayout.col(layoutDimNames[0]).asArray();
const layoutYArray = 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 =
Number.isFinite(layoutXArray[i]) || Number.isFinite(layoutYArray[i])
? true
: hasFinite;
centroidX.push(layoutXArray[i]);
centroidY.push(layoutYArray[i]);
hasFinite ||
(Number.isFinite(layoutXArray[i]) && Number.isFinite(layoutYArray[i]));
valueArray[0] = hasFinite;
valueArray[1] = index + 1;
valueArray[2][index] = layoutXArray[i];
valueArray[3][index] = layoutYArray[i];
coordinates.set(categoryValue, valueArray);
}
}
if (hasFinite) {
const medianX = quantile([0.5], Float64Array.from(centroidX));
const medianY = quantile([0.5], Float64Array.from(centroidY));
return [medianX, medianY];
}
return null;
// Iterate over the recently created map
coordinates.forEach((value, key) => {
// If there are coordinates for this cateogrical value,
// and there is a finite coordinate for the category value
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
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);