mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-10-06 09:38:12 +08:00
draw labels marking the centroids of category value clusters (#809)
* Connect mouse over events to reducer actions * Change Styling on hover * Rename reducer actions to be more descriptive * Reorder reducer in cascade * Create centroid calculation util * Whitespace * Typo fix, use correct action * Create centroid calc util * Create centroid svg setup * Refactor existing svg layer to toolSVG * Change calcCentroid signature and centroidXY to match mapPointToScreen * Add id and styling * Run prettier * Set z-index to 999 * Draw the label * Introduce the centroid SVG, refactor code to allow both SVG layers * Add text label and compute radius based on population * Implement optional chaining * Update font family * Optimize calcMeanCentroid() * Create and utilize calcMedianCentroid() * Remove mass circle from label * Remove styling change on hover * Remove reducer action logs * Prettier * Swap out binds for arrow functions * Style text * switch from selectAll() to select() * Reflect centroid container's purpose in id * Remove mass from the output * Swap to obj * Add finite check * Don't draw centroid if no finite values * Fix finite check * Remove log * Toggle label coloring based on colorBy state * Pass cursor events through centroid svg
This commit is contained in:
@@ -13,23 +13,41 @@ import * as globals from "../../globals";
|
|||||||
world: state.world
|
world: state.world
|
||||||
}))
|
}))
|
||||||
class CategoryValue extends React.Component {
|
class CategoryValue extends React.Component {
|
||||||
toggleOff() {
|
toggleOff = () => {
|
||||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
const { dispatch, metadataField, categoryIndex } = this.props;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "categorical metadata filter deselect",
|
type: "categorical metadata filter deselect",
|
||||||
metadataField,
|
metadataField,
|
||||||
categoryIndex
|
categoryIndex
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
toggleOn() {
|
toggleOn = () => {
|
||||||
const { dispatch, metadataField, categoryIndex } = this.props;
|
const { dispatch, metadataField, categoryIndex } = this.props;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "categorical metadata filter select",
|
type: "categorical metadata filter select",
|
||||||
metadataField,
|
metadataField,
|
||||||
categoryIndex
|
categoryIndex
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
|
handleMouseEnter = () => {
|
||||||
|
const { dispatch, metadataField, categoryIndex } = this.props;
|
||||||
|
dispatch({
|
||||||
|
type: "category value mouse hover start",
|
||||||
|
metadataField,
|
||||||
|
categoryIndex
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
handleMouseExit = () => {
|
||||||
|
const { dispatch, metadataField, categoryIndex } = this.props;
|
||||||
|
dispatch({
|
||||||
|
type: "category value mouse hover end",
|
||||||
|
metadataField,
|
||||||
|
categoryIndex
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
@@ -79,6 +97,8 @@ class CategoryValue extends React.Component {
|
|||||||
justifyContent: "space-between"
|
justifyContent: "space-between"
|
||||||
}}
|
}}
|
||||||
data-testclass="categorical-row"
|
data-testclass="categorical-row"
|
||||||
|
onMouseEnter={this.handleMouseEnter}
|
||||||
|
onMouseLeave={this.handleMouseExit}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -92,9 +112,7 @@ class CategoryValue extends React.Component {
|
|||||||
>
|
>
|
||||||
<label className="bp3-control bp3-checkbox">
|
<label className="bp3-control bp3-checkbox">
|
||||||
<input
|
<input
|
||||||
onChange={
|
onChange={selected ? this.toggleOff : this.toggleOn}
|
||||||
selected ? this.toggleOff.bind(this) : this.toggleOn.bind(this)
|
|
||||||
}
|
|
||||||
data-testclass="categorical-value-select"
|
data-testclass="categorical-value-select"
|
||||||
data-testid={`categorical-value-select-${metadataField}-${displayString}`}
|
data-testid={`categorical-value-select-${metadataField}-${displayString}`}
|
||||||
checked={selected}
|
checked={selected}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ 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 actions from "../../actions";
|
||||||
import _camera from "../../util/camera";
|
import _camera from "../../util/camera";
|
||||||
import _drawPoints from "./drawPointsRegl";
|
import _drawPoints from "./drawPointsRegl";
|
||||||
import scaleLinear from "../../util/scaleLinear";
|
import scaleLinear from "../../util/scaleLinear";
|
||||||
@@ -22,7 +24,9 @@ import scaleLinear from "../../util/scaleLinear";
|
|||||||
selectionTool: state.graphSelection.tool,
|
selectionTool: state.graphSelection.tool,
|
||||||
currentSelection: state.graphSelection.selection,
|
currentSelection: state.graphSelection.selection,
|
||||||
layoutChoice: state.layoutChoice,
|
layoutChoice: state.layoutChoice,
|
||||||
graphInteractionMode: state.controls.graphInteractionMode
|
centroidLabel: state.centroidLabel,
|
||||||
|
graphInteractionMode: state.controls.graphInteractionMode,
|
||||||
|
colorAccessor: state.colors.colorAccessor
|
||||||
}))
|
}))
|
||||||
class Graph extends React.Component {
|
class Graph extends React.Component {
|
||||||
computePointPositions = memoize((X, Y, scaleX, scaleY) => {
|
computePointPositions = memoize((X, Y, scaleX, scaleY) => {
|
||||||
@@ -70,7 +74,8 @@ class Graph extends React.Component {
|
|||||||
sizes: null
|
sizes: null
|
||||||
};
|
};
|
||||||
this.state = {
|
this.state = {
|
||||||
svg: null,
|
toolSVG: null,
|
||||||
|
centroidSVG: null,
|
||||||
tool: null,
|
tool: null,
|
||||||
container: null
|
container: null
|
||||||
};
|
};
|
||||||
@@ -139,9 +144,11 @@ class Graph extends React.Component {
|
|||||||
selectionTool,
|
selectionTool,
|
||||||
currentSelection,
|
currentSelection,
|
||||||
layoutChoice,
|
layoutChoice,
|
||||||
graphInteractionMode
|
graphInteractionMode,
|
||||||
|
colorAccessor,
|
||||||
|
centroidLabel
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { reglRender, regl, svg } = this.state;
|
const { reglRender, mode, regl, toolSVG, centroidSVG } = this.state;
|
||||||
let stateChanges = {};
|
let stateChanges = {};
|
||||||
|
|
||||||
if (reglRender) {
|
if (reglRender) {
|
||||||
@@ -216,16 +223,10 @@ class Graph extends React.Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const createToolSVG = () => {
|
||||||
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 */
|
/* clear out whatever was on the div, even if nothing, but usually the brushes etc */
|
||||||
d3.select("#graphAttachPoint")
|
d3.select("#graphAttachPoint")
|
||||||
.selectAll("svg")
|
.select("#tool")
|
||||||
.remove();
|
.remove();
|
||||||
|
|
||||||
let handleStart;
|
let handleStart;
|
||||||
@@ -241,16 +242,63 @@ class Graph extends React.Component {
|
|||||||
handleEnd = this.handleLassoEnd.bind(this);
|
handleEnd = this.handleLassoEnd.bind(this);
|
||||||
handleCancel = this.handleLassoCancel.bind(this);
|
handleCancel = this.handleLassoCancel.bind(this);
|
||||||
}
|
}
|
||||||
const { svg: newSvg, tool, container } = setupSVGandBrushElements(
|
|
||||||
|
const { svg: newToolSVG, tool, container } = setupSVGandBrushElements(
|
||||||
selectionTool,
|
selectionTool,
|
||||||
handleStart,
|
handleStart,
|
||||||
handleDrag,
|
handleDrag,
|
||||||
handleEnd,
|
handleEnd,
|
||||||
handleCancel,
|
handleCancel,
|
||||||
responsive,
|
responsive,
|
||||||
this.graphPaddingRight
|
this.graphPaddingRight,
|
||||||
|
graphInteractionMode
|
||||||
);
|
);
|
||||||
stateChanges = { ...stateChanges, svg: newSvg, tool, container };
|
|
||||||
|
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 };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
prevProps.responsive.height !== responsive.height ||
|
||||||
|
prevProps.responsive.width !== responsive.width
|
||||||
|
) {
|
||||||
|
// If the window size has changed we want to recreate all SVGs
|
||||||
|
createToolSVG();
|
||||||
|
createCentroidSVG();
|
||||||
|
} else if (
|
||||||
|
(responsive.height && responsive.width && !toolSVG) ||
|
||||||
|
selectionTool !== prevProps.selectionTool ||
|
||||||
|
prevProps.graphInteractionMode !== graphInteractionMode
|
||||||
|
) {
|
||||||
|
// first time or change of selection tool6
|
||||||
|
createToolSVG();
|
||||||
|
} else if (
|
||||||
|
centroidLabel !== prevProps.centroidLabel ||
|
||||||
|
(responsive.height && responsive.width && !centroidSVG)
|
||||||
|
) {
|
||||||
|
// First time for centroid or label change
|
||||||
|
createCentroidSVG();
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -260,7 +308,7 @@ class Graph extends React.Component {
|
|||||||
if (
|
if (
|
||||||
currentSelection !== prevProps.currentSelection ||
|
currentSelection !== prevProps.currentSelection ||
|
||||||
graphInteractionMode !== prevProps.graphInteractionMode ||
|
graphInteractionMode !== prevProps.graphInteractionMode ||
|
||||||
stateChanges.svg
|
stateChanges.toolSVG
|
||||||
) {
|
) {
|
||||||
const { tool, container } = this.state;
|
const { tool, container } = this.state;
|
||||||
this.selectionToolUpdate(
|
this.selectionToolUpdate(
|
||||||
@@ -588,12 +636,7 @@ class Graph extends React.Component {
|
|||||||
right: 0
|
right: 0
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div id="graphAttachPoint" />
|
||||||
style={{
|
|
||||||
display: graphInteractionMode === "select" ? "inherit" : "none"
|
|
||||||
}}
|
|
||||||
id="graphAttachPoint"
|
|
||||||
/>
|
|
||||||
<div style={{ padding: 0, margin: 0 }}>
|
<div style={{ padding: 0, margin: 0 }}>
|
||||||
<canvas
|
<canvas
|
||||||
width={responsive.width - this.graphPaddingRight}
|
width={responsive.width - this.graphPaddingRight}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
@@ -16,15 +16,19 @@ export default (
|
|||||||
handleEndAction,
|
handleEndAction,
|
||||||
handleCancelAction,
|
handleCancelAction,
|
||||||
responsive,
|
responsive,
|
||||||
graphPaddingRight
|
graphPaddingRight,
|
||||||
|
graphInteractionMode
|
||||||
) => {
|
) => {
|
||||||
const svg = d3
|
const svg = d3
|
||||||
.select("#graphAttachPoint")
|
.select("#graphAttachPoint")
|
||||||
.append("svg")
|
.append("svg")
|
||||||
|
.attr("id", "tool")
|
||||||
.attr("data-testid", "layout-overlay")
|
.attr("data-testid", "layout-overlay")
|
||||||
.attr("width", responsive.width - graphPaddingRight)
|
.attr("width", responsive.width - graphPaddingRight)
|
||||||
.attr("height", responsive.height)
|
.attr("height", responsive.height)
|
||||||
.attr("class", `${styles.graphSVG}`);
|
.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
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import calcCentroid from "../util/centroid";
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
metadataField: "",
|
||||||
|
categoryIndex: -1,
|
||||||
|
categoryField: "",
|
||||||
|
centroidXY: [-1, -1]
|
||||||
|
};
|
||||||
|
|
||||||
|
const CentroidLabel = (state = initialState, action, sharedNextState) => {
|
||||||
|
const { categoricalSelection, world, layoutChoice } = sharedNextState;
|
||||||
|
const { metadataField, categoryIndex } = action;
|
||||||
|
const categoryField =
|
||||||
|
categoricalSelection?.[metadataField]?.categoryValues[categoryIndex];
|
||||||
|
switch (action.type) {
|
||||||
|
case "category value mouse hover start":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
metadataField,
|
||||||
|
categoryIndex,
|
||||||
|
categoryField,
|
||||||
|
centroidXY: calcCentroid(
|
||||||
|
world,
|
||||||
|
metadataField,
|
||||||
|
categoryField,
|
||||||
|
layoutChoice.currentDimNames
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
case "category value mouse hover end":
|
||||||
|
if (
|
||||||
|
metadataField === state.metadataField &&
|
||||||
|
categoryIndex === state.categoryIndex
|
||||||
|
) {
|
||||||
|
return initialState;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CentroidLabel;
|
||||||
@@ -16,37 +16,39 @@ 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 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],
|
||||||
["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],
|
||||||
["resetCache", resetCache]
|
["centroidLabel", centroidLabel],
|
||||||
]),
|
["resetCache", resetCache]
|
||||||
[
|
]),
|
||||||
"world",
|
[
|
||||||
"categoricalSelection",
|
"world",
|
||||||
"continuousSelection",
|
"categoricalSelection",
|
||||||
"graphSelection",
|
"continuousSelection",
|
||||||
"crossfilter",
|
"graphSelection",
|
||||||
"colors",
|
"crossfilter",
|
||||||
"controls",
|
"colors",
|
||||||
"differential",
|
"controls",
|
||||||
"layoutChoice"
|
"differential",
|
||||||
],
|
"layoutChoice"
|
||||||
undoableConfig
|
],
|
||||||
|
undoableConfig
|
||||||
);
|
);
|
||||||
|
|
||||||
const store = createStore(Reducer, applyMiddleware(thunk));
|
const store = createStore(Reducer, applyMiddleware(thunk));
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ const skipOnActions = new Set([
|
|||||||
"clear all user defined genes",
|
"clear all user defined genes",
|
||||||
|
|
||||||
"get single gene expression for coloring started",
|
"get single gene expression for coloring started",
|
||||||
"get single gene expression for coloring error"
|
"get single gene expression for coloring error",
|
||||||
|
|
||||||
|
"category value mouse hover start",
|
||||||
|
"category value mouse hover end"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import quantile from "./quantile";
|
||||||
|
|
||||||
|
/*
|
||||||
|
Centroid coordinate calculation
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
|
||||||
|
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];
|
||||||
|
};
|
||||||
|
|
||||||
|
const calcMedianCentroid = (world, annoName, annoValue, layoutDimNames) => {
|
||||||
|
const centroidX = [];
|
||||||
|
const centroidY = [];
|
||||||
|
let hasFinite = false;
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if (annoArray[i] === annoValue) {
|
||||||
|
hasFinite =
|
||||||
|
Number.isFinite(layoutXArray[i]) || Number.isFinite(layoutYArray[i])
|
||||||
|
? true
|
||||||
|
: hasFinite;
|
||||||
|
centroidX.push(layoutXArray[i]);
|
||||||
|
centroidY.push(layoutYArray[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasFinite) {
|
||||||
|
const medianX = quantile([0.5], Float64Array.from(centroidX));
|
||||||
|
const medianY = quantile([0.5], Float64Array.from(centroidY));
|
||||||
|
|
||||||
|
return [medianX, medianY];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default calcMedianCentroid;
|
||||||
Reference in New Issue
Block a user