mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-28 01:28:11 +08:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user