subset embedding UI improvement (#1667)

* add user flag feature to annomatrix

* add implicit subsetting for partial embeddings

* lint

* add embedding cell counts to embedding choice menu

* layout
This commit is contained in:
Bruce Martin
2020-07-27 09:58:14 -07:00
committed by GitHub
parent c25dd33d28
commit 0a626537d5
9 changed files with 295 additions and 71 deletions
+33
View File
@@ -0,0 +1,33 @@
/*
action creators related to embeddings choice
*/
import { AnnoMatrixObsCrossfilter } from "../annoMatrix";
import { _setEmbeddingSubset } from "../util/stateManager/viewStackHelpers";
export const layoutChoiceAction = (newLayoutChoice) => async (
dispatch,
getState
) => {
/*
On layout choice, make sure we have selected all on the previous layout, AND the new
layout.
*/
const { annoMatrix: prevAnnoMatrix } = getState();
const embeddingDf = await prevAnnoMatrix.base().fetch("emb", newLayoutChoice);
const annoMatrix = _setEmbeddingSubset(prevAnnoMatrix, embeddingDf);
const obsCrossfilter = await new AnnoMatrixObsCrossfilter(annoMatrix).select(
"emb",
newLayoutChoice,
{
mode: "all",
}
);
dispatch({
type: "set layout choice",
layoutChoice: newLayoutChoice,
obsCrossfilter,
annoMatrix,
});
};
+13 -1
View File
@@ -12,6 +12,7 @@ import { loadUserColorConfig } from "../util/stateManager/colorHelpers";
import * as selnActions from "./selection"; import * as selnActions from "./selection";
import * as annoActions from "./annotation"; import * as annoActions from "./annotation";
import * as viewActions from "./viewStack"; import * as viewActions from "./viewStack";
import * as embActions from "./embedding";
/* /*
return promise fetching user-configured colors return promise fetching user-configured colors
@@ -40,6 +41,15 @@ async function configFetch(dispatch) {
}); });
} }
function prefetchEmbeddings(annoMatrix) {
/*
prefetch requests for all embeddings
*/
const { schema } = annoMatrix;
const available = schema.layout.obs.map((v) => v.name);
available.forEach((embName) => annoMatrix.prefetch("emb", embName));
}
/* /*
Application bootstrap Application bootstrap
*/ */
@@ -57,6 +67,8 @@ const doInitialDataLoad = () =>
const baseDataUrl = `${globals.API.prefix}${globals.API.version}`; const baseDataUrl = `${globals.API.prefix}${globals.API.version}`;
const annoMatrix = new AnnoMatrixLoader(baseDataUrl, schema.schema); const annoMatrix = new AnnoMatrixLoader(baseDataUrl, schema.schema);
const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix); const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix);
prefetchEmbeddings(annoMatrix);
dispatch({ dispatch({
type: "annoMatrix: init complete", type: "annoMatrix: init complete",
annoMatrix, annoMatrix,
@@ -210,6 +222,6 @@ export default {
annotationLabelCurrentSelection: annoActions.annotationLabelCurrentSelection, annotationLabelCurrentSelection: annoActions.annotationLabelCurrentSelection,
saveObsAnnotationsAction: annoActions.saveObsAnnotationsAction, saveObsAnnotationsAction: annoActions.saveObsAnnotationsAction,
needToSaveObsAnnotations: annoActions.needToSaveObsAnnotations, needToSaveObsAnnotations: annoActions.needToSaveObsAnnotations,
layoutChoiceAction: selnActions.layoutChoiceAction, layoutChoiceAction: embActions.layoutChoiceAction,
setCellSetFromSelection: selnActions.setCellSetFromSelection, setCellSetFromSelection: selnActions.setCellSetFromSelection,
}; };
-25
View File
@@ -185,31 +185,6 @@ export const graphLassoEndAction = (embName, polygon) => async (
}); });
}; };
export const layoutChoiceAction = (newLayoutChoice) => async (
dispatch,
getState
) => {
/*
On layout choice, make sure we have selected all on the previous layout, AND the new
layout.
*/
const { obsCrossfilter: prevObsCrossfilter, layoutChoice } = getState();
let obsCrossfilter = await prevObsCrossfilter.select(
"emb",
layoutChoice.current,
{ mode: "all" }
);
obsCrossfilter = await obsCrossfilter.select("emb", newLayoutChoice, {
mode: "all",
});
dispatch({
type: "set layout choice",
layoutChoice: newLayoutChoice,
obsCrossfilter,
});
};
/* /*
Differential expression set selection Differential expression set selection
*/ */
+12 -36
View File
@@ -11,7 +11,12 @@ stack multiple subsets.
If these conventions change, code elsewhere (eg. menubar/clip.js) will need to If these conventions change, code elsewhere (eg. menubar/clip.js) will need to
change as well. change as well.
*/ */
import { AnnoMatrixObsCrossfilter, clip, isubsetMask } from "../annoMatrix"; import { AnnoMatrixObsCrossfilter } from "../annoMatrix";
import {
_clipAnnoMatrix,
_userSubsetAnnoMatrix,
_userResetSubsetAnnoMatrix,
} from "../util/stateManager/viewStackHelpers";
export const clipAction = (min, max) => (dispatch, getState) => { export const clipAction = (min, max) => (dispatch, getState) => {
/* /*
@@ -19,9 +24,7 @@ export const clipAction = (min, max) => (dispatch, getState) => {
view is ALWAYS the top view. view is ALWAYS the top view.
*/ */
const { annoMatrix: prevAnnoMatrix } = getState(); const { annoMatrix: prevAnnoMatrix } = getState();
const annoMatrix = prevAnnoMatrix.isClipped const annoMatrix = _clipAnnoMatrix(prevAnnoMatrix, min, max);
? clip(prevAnnoMatrix.viewOf, min, max)
: clip(prevAnnoMatrix, min, max);
const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix); const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix);
dispatch({ dispatch({
type: "set clip quantiles", type: "set clip quantiles",
@@ -43,24 +46,10 @@ export const subsetAction = () => (dispatch, getState) => {
annoMatrix: prevAnnoMatrix, annoMatrix: prevAnnoMatrix,
obsCrossfilter: prevObsCrossfilter, obsCrossfilter: prevObsCrossfilter,
} = getState(); } = getState();
const annoMatrix = _userSubsetAnnoMatrix(
let annoMatrix; prevAnnoMatrix,
if (prevAnnoMatrix.isClipped) { prevObsCrossfilter.allSelectedMask()
// if there is a clip view, pop it and reapply after we subset );
const { clipRange } = prevAnnoMatrix;
annoMatrix = isubsetMask(
prevAnnoMatrix.viewOf,
prevObsCrossfilter.allSelectedMask()
);
annoMatrix = clip(annoMatrix, ...clipRange);
} else {
// else just push a subset view.
annoMatrix = isubsetMask(
prevAnnoMatrix,
prevObsCrossfilter.allSelectedMask()
);
}
const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix); const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix);
dispatch({ dispatch({
type: "subset to selection", type: "subset to selection",
@@ -77,20 +66,7 @@ export const resetSubsetAction = () => (dispatch, getState) => {
*/ */
const { annoMatrix: prevAnnoMatrix } = getState(); const { annoMatrix: prevAnnoMatrix } = getState();
const annoMatrix = _userResetSubsetAnnoMatrix(prevAnnoMatrix);
const clipRange = prevAnnoMatrix.isClipped ? prevAnnoMatrix.clipRange : null;
/* pop all views */
let annoMatrix = prevAnnoMatrix;
while (annoMatrix.isView) {
annoMatrix = annoMatrix.viewOf;
}
/* re-apply the clip, if any */
if (clipRange !== null) {
annoMatrix = clip(annoMatrix, ...clipRange);
}
const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix); const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix);
dispatch({ dispatch({
type: "reset subset", type: "reset subset",
+3
View File
@@ -70,6 +70,8 @@ export default class AnnoMatrix {
The row index labels are as defined by the base dataset from the server. The row index labels are as defined by the base dataset from the server.
* isView - true if this is a view, false if not. * isView - true if this is a view, false if not.
* viewOf - pointer to parent annomatrix if a view, undefined/null if not a view. * viewOf - pointer to parent annomatrix if a view, undefined/null if not a view.
* userFlags - container for any additional state a user of this API wants to hang
off of an annoMatrix, and have propagated by the (shallow) cloning protocol.
*/ */
this.schema = indexEntireSchema(schema); this.schema = indexEntireSchema(schema);
this.nObs = nObs; this.nObs = nObs;
@@ -77,6 +79,7 @@ export default class AnnoMatrix {
this.rowIndex = rowIndex || new IdentityInt32Index(nObs); this.rowIndex = rowIndex || new IdentityInt32Index(nObs);
this.isView = false; this.isView = false;
this.viewOf = undefined; this.viewOf = undefined;
this.userFlags = {};
/* /*
Private instance variables. Private instance variables.
+8 -1
View File
@@ -33,6 +33,13 @@ export function subset(annoMatrix, obsLabels) {
return new AnnoMatrixRowSubsetView(annoMatrix, obsIndex); return new AnnoMatrixRowSubsetView(annoMatrix, obsIndex);
} }
export function subsetByIndex(annoMatrix, obsIndex) {
/*
subset based upon the new obs index.
*/
return new AnnoMatrixRowSubsetView(annoMatrix, obsIndex);
}
export function clip(annoMatrix, qmin, qmax) { export function clip(annoMatrix, qmin, qmax) {
/* /*
Create a view that clips all continuous data to the [min, max] range. Create a view that clips all continuous data to the [min, max] range.
@@ -59,5 +66,5 @@ function _maskToList(mask) {
elems += 1; elems += 1;
} }
} }
return new Int32Array(list.buffer, 0, elems); return list.subarray(0, elems);
} }
+45 -5
View File
@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import Async from "react-async";
import { import {
ButtonGroup, ButtonGroup,
Popover, Popover,
@@ -11,10 +12,11 @@ import {
} from "@blueprintjs/core"; } from "@blueprintjs/core";
import * as globals from "../../globals"; import * as globals from "../../globals";
import actions from "../../actions"; import actions from "../../actions";
import { getDiscreteCellEmbeddingRowIndex } from "../../util/stateManager/viewStackHelpers";
@connect((state) => { @connect((state) => {
return { return {
layoutChoice: state.layoutChoice, layoutChoice: state.layoutChoice, // TODO: really should clean up naming, s/layout/embedding/g
schema: state.annoMatrix?.schema, schema: state.annoMatrix?.schema,
crossfilter: state.obsCrossfilter, crossfilter: state.obsCrossfilter,
}; };
@@ -32,6 +34,7 @@ class Embedding extends React.PureComponent {
render() { render() {
const { layoutChoice, schema, crossfilter } = this.props; const { layoutChoice, schema, crossfilter } = this.props;
const { annoMatrix } = crossfilter;
return ( return (
<ButtonGroup <ButtonGroup
style={{ style={{
@@ -61,7 +64,6 @@ class Embedding extends React.PureComponent {
> >
{layoutChoice?.current}: {crossfilter.countSelected()} out of{" "} {layoutChoice?.current}: {crossfilter.countSelected()} out of{" "}
{crossfilter.size()} cells {crossfilter.size()} cells
{/* BRUCE to extend 1559 */}
</Button> </Button>
</Tooltip> </Tooltip>
} }
@@ -87,9 +89,9 @@ class Embedding extends React.PureComponent {
selectedValue={layoutChoice.current} selectedValue={layoutChoice.current}
> >
{layoutChoice.available.map((name) => ( {layoutChoice.available.map((name) => (
<Radio <LayoutChoice
label={`${name} ${schema?.dataframe?.nObs} cells`} annoMatrix={annoMatrix}
value={name} layoutName={name}
key={name} key={name}
/> />
))} ))}
@@ -103,3 +105,41 @@ class Embedding extends React.PureComponent {
} }
export default Embedding; export default Embedding;
const loadEmbeddingCounts = async ({ annoMatrix, layoutName }) => {
const embedding = await annoMatrix.fetch("emb", layoutName);
const discreteCellIndex = getDiscreteCellEmbeddingRowIndex(embedding);
return { embedding, discreteCellIndex };
};
const LayoutChoice = ({ annoMatrix, layoutName }) => {
return (
<Async
promiseFn={loadEmbeddingCounts}
annoMatrix={annoMatrix}
layoutName={layoutName}
>
{({ data, error, isPending }) => {
if (error) {
/* log, as this is unexpected */
console.error(error);
}
if (error || isPending) {
/* still loading, or errored out - just omit counts (TODO: spinner?) */
return <Radio label={`${layoutName}`} value={layoutName} />;
}
if (data) {
const { embedding, discreteCellIndex } = data;
const isAllCells = discreteCellIndex.size() === embedding.length;
const sizeHint = `${discreteCellIndex.size()} ${
isAllCells ? "(all) " : ""
}cells`;
return (
<Radio label={`${layoutName}: ${sizeHint}`} value={layoutName} />
);
}
return null;
}}
</Async>
);
};
+6 -3
View File
@@ -10,6 +10,7 @@ import InformationMenu from "./infoMenu";
import Subset from "./subset"; import Subset from "./subset";
import UndoRedoReset from "./undoRedo"; import UndoRedoReset from "./undoRedo";
import DiffexpButtons from "./diffexpButtons"; import DiffexpButtons from "./diffexpButtons";
import { getEmbSubsetView } from "../../util/stateManager/viewStackHelpers";
@connect((state) => { @connect((state) => {
const { annoMatrix } = state; const { annoMatrix } = state;
@@ -17,9 +18,11 @@ import DiffexpButtons from "./diffexpButtons";
const selectedCount = crossfilter.countSelected(); const selectedCount = crossfilter.countSelected();
const subsetPossible = const subsetPossible =
selectedCount !== 0 && selectedCount !== crossfilter.size(); // ie, not all are selected selectedCount !== 0 && selectedCount !== crossfilter.size(); // ie, not all and not none are selected
const subsetResetPossible = const embSubsetView = getEmbSubsetView(annoMatrix);
annoMatrix.nObs !== annoMatrix.schema.dataframe.nObs; const subsetResetPossible = !embSubsetView
? annoMatrix.nObs !== annoMatrix.schema.dataframe.nObs
: annoMatrix.nObs !== embSubsetView.nObs;
return { return {
subsetPossible, subsetPossible,
@@ -0,0 +1,175 @@
/*
The annoMatrix view stack has a set of conventions which are assumed elsewhere in the
application. These helper functions make it simple for action creators to manage
the stack.
The annoMatrix module does not care about this order, but we maintain it as
a convention to make it simpler to manipulate the views.
Terminology:
- clip view: AnnoMatrixClipView
- subset view: AnnoMatrixRowSubsetView
- user subset view: create by the user explicitly subsetting by selection
- embedding subset view: implicitly created by switching the current embedding
- loader, or base annoMatrix: the root, which loads data
Rules:
1. there will be zero or one clip view
2. there will be zero or more subset views
3. there will be zero or one embedding view
4. there will be one loader/base, which is always the bottom view
5. the view ordering MUST be (top to bottom):
[clip] -> [user subset] -> [embedding subset] -> loader
There is code elsewhere in the app (eg, menubar/clip.js) which assumes this order.
Views can be interogated for their type with the following:
* is a view: annoMatrix.isView
* is the loader: !anonMatrix.isView (or annoMatrix === annoMatrix.base())
* is a clip view: annoMatrix.isClipped (or annoMatrix.clipRange)
* is a subset view: (annoMatrix.isView && !annoMatrix.isClipped)
* is a user subset view: annoMatrix.userFlags?.isUserSubsetView
* is an embedding subset view: annomatrix.userFlags?.isEmbSubsetView
*/
import { clip, isubsetMask, isubset } from "../../annoMatrix";
import { memoize } from "../dataframe/util";
export function _clipAnnoMatrix(annoMatrix, min, max) {
/*
clip the annoMatrix.
*/
return annoMatrix.isClipped
? clip(annoMatrix.viewOf, min, max)
: clip(annoMatrix, min, max);
}
export function _userSubsetAnnoMatrix(annoMatrix, mask) {
/*
user-requested row subset of annoMatrix, to be added on top of any
other previous row subsets.
*/
const { clipRange } = annoMatrix;
if (clipRange) {
annoMatrix = annoMatrix.viewOf;
}
annoMatrix = isubsetMask(annoMatrix, mask);
annoMatrix.userFlags.isUserSubsetView = true;
if (clipRange) {
annoMatrix = clip(annoMatrix, ...clipRange);
}
return annoMatrix;
}
export function _userResetSubsetAnnoMatrix(annoMatrix) {
/*
Reset/remove all user-requested subsets. Do not remove clip or embedding subset.
*/
/* stash clipping info, if any */
const { clipRange } = annoMatrix;
if (clipRange) {
annoMatrix = annoMatrix.viewOf;
}
/* pop all views except embedding subset and loader */
while (annoMatrix.isView && annoMatrix.userFlags.isUserSubsetView) {
annoMatrix = annoMatrix.viewOf;
}
/* re-apply the clip, if any */
if (clipRange) {
annoMatrix = clip(annoMatrix, ...clipRange);
}
return annoMatrix;
}
export function _setEmbeddingSubset(annoMatrix, embeddingDf) {
/*
Set the embedding subset view. Only create a subset view for the embedding
when it is needed, ie, there are NaN values in the embeddings.
*/
const embRowOffsets = _getEmbeddingRowOffsets(
annoMatrix.rowIndex,
embeddingDf
);
const curEmbSubsetView = getEmbSubsetView(annoMatrix);
/* if no current embedding subset, and no new embedding subset, just noop */
if (!embRowOffsets && !curEmbSubsetView) return annoMatrix;
// ... otherwise, do the work
/* stash clipping info, if any */
const clipRange = annoMatrix.isClipped ? annoMatrix.clipRange : null;
/* pop all subsets, user or embedding */
while (annoMatrix.isView) {
annoMatrix = annoMatrix.viewOf;
}
/* apply new embedding row index, if needed */
if (embRowOffsets) {
annoMatrix = isubset(annoMatrix, embRowOffsets);
annoMatrix.userFlags.isEmbSubsetView = true;
}
/* re-apply clip, if needed */
if (clipRange) {
annoMatrix = clip(annoMatrix, ...clipRange);
}
return annoMatrix;
}
function _getEmbeddingRowOffsets(baseRowIndex, embeddingDf) {
/*
given a dataframe containing an embedding:
- if the embedding contains no NaN coordinates, return null
- if the embedding contains NaN coordinates, return a rowIndex
that contains only the rows with discrete valued coordinates.
Currently assumes that there will be onl two dimensions in the embedding.
*/
const X = embeddingDf.icol(0).asArray();
const Y = embeddingDf.icol(1).asArray();
const offsets = new Int32Array(X.length);
let numOffsets = 0;
for (let i = 0, l = X.length; i < l; i += 1) {
if (!Number.isNaN(X[i]) && !Number.isNaN(Y[i])) {
offsets[numOffsets] = i;
numOffsets += 1;
}
}
if (numOffsets === X.length) return null;
return offsets.subarray(0, numOffsets);
}
export function _getDiscreteCellEmbeddingRowIndex(embeddingDf) {
const idx = _getEmbeddingRowOffsets(embeddingDf.rowIndex, embeddingDf);
if (idx === null) return embeddingDf.rowIndex;
return embeddingDf.rowIndex.isubset(idx);
}
export const getDiscreteCellEmbeddingRowIndex = memoize(
_getDiscreteCellEmbeddingRowIndex,
(df) => df.__id
);
export function getEmbSubsetView(annoMatrix) {
/* if there is an embedding subset in the view stack, return it. Falsish if not. */
while (annoMatrix.isView) {
if (annoMatrix.userFlags.isEmbSubsetView) return annoMatrix;
annoMatrix = annoMatrix.viewOf;
}
return undefined;
}