diff --git a/client/src/actions/index.js b/client/src/actions/index.js index 76f5745b..0bea93f4 100644 --- a/client/src/actions/index.js +++ b/client/src/actions/index.js @@ -323,6 +323,9 @@ const resetInterface = () => (dispatch, getState) => { dispatch({ type: "reset colorscale" }); + dispatch({ + type: "reset centroid labels" + }); dispatch({ type: "clear scatterplot" }); diff --git a/client/src/components/categorical/categorical.css b/client/src/components/categorical/categorical.css index 7f6051de..683bdc1b 100644 --- a/client/src/components/categorical/categorical.css +++ b/client/src/components/categorical/categorical.css @@ -1,3 +1,4 @@ +:local(.hover), :local(.value):hover { background: rgba(167, 182, 194, 0.3); } diff --git a/client/src/components/categorical/value.js b/client/src/components/categorical/value.js index 643ea6e6..fe9a9a17 100644 --- a/client/src/components/categorical/value.js +++ b/client/src/components/categorical/value.js @@ -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", diff --git a/client/src/components/graph/graph.js b/client/src/components/graph/graph.js index b72a8e8b..a5bcf6b5 100644 --- a/client/src/components/graph/graph.js +++ b/client/src/components/graph/graph.js @@ -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 (
@@ -757,7 +736,30 @@ class Graph extends React.PureComponent { right: globals.leftSidebarWidth }} > -
+
+ + + + + +
({ + 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( + + + 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} + + + ); + }); + + return <>{labelSVGS}; + } +} diff --git a/client/src/components/graph/overlays/graphOverlayLayer.js b/client/src/components/graph/overlays/graphOverlayLayer.js new file mode 100644 index 00000000..5834102a --- /dev/null +++ b/client/src/components/graph/overlays/graphOverlayLayer.js @@ -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 ( + + + + + + + {newChildren.map(child => + cloneElement(child, { inverseTransform }) + )} + + + + + + + ); + } +} diff --git a/client/src/components/graph/setupCentroidSVG.js b/client/src/components/graph/setupCentroidSVG.js deleted file mode 100644 index 68b7a253..00000000 --- a/client/src/components/graph/setupCentroidSVG.js +++ /dev/null @@ -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; -}; diff --git a/client/src/components/graph/setupSVGandBrush.js b/client/src/components/graph/setupSVGandBrush.js index 04e38e4f..5e3b10c2 100644 --- a/client/src/components/graph/setupSVGandBrush.js +++ b/client/src/components/graph/setupSVGandBrush.js @@ -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 diff --git a/client/src/components/menubar/clip.js b/client/src/components/menubar/clip.js index b0049085..8ed8799f 100644 --- a/client/src/components/menubar/clip.js +++ b/client/src/components/menubar/clip.js @@ -37,7 +37,7 @@ function Clip(props) {
{ + 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 ( -
+ {!differential.diffExp ? ( @@ -288,7 +299,6 @@ class MenuBar extends React.Component { loading={differential.loading} icon="left-join" fill - type="button" onClick={this.computeDiffExp} /> @@ -310,7 +320,7 @@ class MenuBar extends React.Component { ) : null} -
+ ); } @@ -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} > -
+ -
-
+ +
+ +
diff --git a/client/src/components/menubar/undoRedoReset.js b/client/src/components/menubar/undoRedoReset.js index 7a337dcb..243056ff 100644 --- a/client/src/components/menubar/undoRedoReset.js +++ b/client/src/components/menubar/undoRedoReset.js @@ -13,7 +13,7 @@ function InformationMenu(props) { dispatch } = props; return ( -
+
{ + (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; diff --git a/client/src/reducers/centroidLabels.js b/client/src/reducers/centroidLabels.js new file mode 100644 index 00000000..504b3171 --- /dev/null +++ b/client/src/reducers/centroidLabels.js @@ -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; diff --git a/client/src/reducers/index.js b/client/src/reducers/index.js index 64deaa3d..ffb21305 100644 --- a/client/src/reducers/index.js +++ b/client/src/reducers/index.js @@ -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)); diff --git a/client/src/reducers/centroidLabel.js b/client/src/reducers/pointDilation.js similarity index 67% rename from client/src/reducers/centroidLabel.js rename to client/src/reducers/pointDilation.js index 9a1d7ada..f5b39072 100644 --- a/client/src/reducers/centroidLabel.js +++ b/client/src/reducers/pointDilation.js @@ -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; diff --git a/client/src/reducers/undoableConfig.js b/client/src/reducers/undoableConfig.js index 7cbd8460..d29017d4 100644 --- a/client/src/reducers/undoableConfig.js +++ b/client/src/reducers/undoableConfig.js @@ -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", diff --git a/client/src/util/centroid.js b/client/src/util/centroid.js index 486ad6f3..f8c2c801 100644 --- a/client/src/util/centroid.js +++ b/client/src/util/centroid.js @@ -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);