mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-10-03 07:48:12 +08:00
add context sensitivity to annotation "label" menu item (#1015)
* cache common results to reduce jank * add util function in support of annotation labelling * enable/disable annotation edit menus per issue 972; improve formatting * PR review requests
This commit is contained in:
@@ -17,13 +17,16 @@ import * as globals from "../../globals";
|
|||||||
import styles from "./categorical.css";
|
import styles from "./categorical.css";
|
||||||
import { Tooltip } from "@blueprintjs/core";
|
import { Tooltip } from "@blueprintjs/core";
|
||||||
|
|
||||||
|
import { AnnotationsHelpers } from "../../util/stateManager";
|
||||||
|
|
||||||
@connect(state => ({
|
@connect(state => ({
|
||||||
categoricalSelection: state.categoricalSelection,
|
categoricalSelection: state.categoricalSelection,
|
||||||
annotations: state.annotations,
|
annotations: state.annotations,
|
||||||
colorScale: state.colors.scale,
|
colorScale: state.colors.scale,
|
||||||
colorAccessor: state.colors.colorAccessor,
|
colorAccessor: state.colors.colorAccessor,
|
||||||
schema: state.world?.schema,
|
schema: state.world?.schema,
|
||||||
world: state.world
|
world: state.world,
|
||||||
|
crossfilter: state.crossfilter
|
||||||
}))
|
}))
|
||||||
class CategoryValue extends React.Component {
|
class CategoryValue extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -119,6 +122,7 @@ class CategoryValue extends React.Component {
|
|||||||
* world state
|
* world state
|
||||||
* the color accessor (what is currently being colored by)
|
* the color accessor (what is currently being colored by)
|
||||||
* if this catagorical value's selection status has changed
|
* if this catagorical value's selection status has changed
|
||||||
|
* the crossfilter (ie, global selection state)
|
||||||
|
|
||||||
If and only if true, update the component
|
If and only if true, update the component
|
||||||
*/
|
*/
|
||||||
@@ -137,12 +141,14 @@ class CategoryValue extends React.Component {
|
|||||||
const worldChange = props.world !== nextProps.world;
|
const worldChange = props.world !== nextProps.world;
|
||||||
const colorAccessorChange = props.colorAccessor !== nextProps.colorAccessor;
|
const colorAccessorChange = props.colorAccessor !== nextProps.colorAccessor;
|
||||||
const annotationsChange = props.annotations !== nextProps.annotations;
|
const annotationsChange = props.annotations !== nextProps.annotations;
|
||||||
|
const crossfilterChange = props.crossfilter !== nextProps.crossfilter;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
valueSelectionChange ||
|
valueSelectionChange ||
|
||||||
worldChange ||
|
worldChange ||
|
||||||
colorAccessorChange ||
|
colorAccessorChange ||
|
||||||
annotationsChange
|
annotationsChange ||
|
||||||
|
crossfilterChange
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -173,6 +179,34 @@ class CategoryValue extends React.Component {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
isAddCurrentSelectionDisabled(category, value) {
|
||||||
|
/*
|
||||||
|
disable "add current selection to label", if one of the following is true:
|
||||||
|
1. no cells are selected
|
||||||
|
2. all currently selected cells already have this label, on this category
|
||||||
|
*/
|
||||||
|
const { crossfilter, world } = this.props;
|
||||||
|
|
||||||
|
// 1. no cells selected?
|
||||||
|
if (crossfilter.countSelected() === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 2. all selected cells already have the label
|
||||||
|
const mask = crossfilter.allSelectedMask();
|
||||||
|
if (
|
||||||
|
AnnotationHelpers.allHaveLabelByMask(
|
||||||
|
world.obsAnnotations,
|
||||||
|
category,
|
||||||
|
value,
|
||||||
|
mask
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// else, don't disable
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
categoricalSelection,
|
categoricalSelection,
|
||||||
@@ -407,7 +441,26 @@ class CategoryValue extends React.Component {
|
|||||||
data-testclass="handleAddCurrentSelectionToThisLabel"
|
data-testclass="handleAddCurrentSelectionToThisLabel"
|
||||||
data-testid={`handleAddCurrentSelectionToThisLabel-${metadataField}`}
|
data-testid={`handleAddCurrentSelectionToThisLabel-${metadataField}`}
|
||||||
onClick={this.handleAddCurrentSelectionToThisLabel}
|
onClick={this.handleAddCurrentSelectionToThisLabel}
|
||||||
text={`Re-label currently selected cells as ${displayString}`}
|
text={
|
||||||
|
<span>
|
||||||
|
Re-label currently selected cells as
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontStyle:
|
||||||
|
displayString ===
|
||||||
|
globals.unassignedCategoryLabel
|
||||||
|
? "italic"
|
||||||
|
: "auto"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{` ${displayString}`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
disabled={this.isAddCurrentSelectionDisabled(
|
||||||
|
metadataField,
|
||||||
|
value
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
{displayString !== globals.unassignedCategoryLabel ? (
|
{displayString !== globals.unassignedCategoryLabel ? (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
@@ -425,7 +478,9 @@ class CategoryValue extends React.Component {
|
|||||||
data-testclass="handleDeleteValue"
|
data-testclass="handleDeleteValue"
|
||||||
data-testid={`handleDeleteValue-${metadataField}`}
|
data-testid={`handleDeleteValue-${metadataField}`}
|
||||||
onClick={this.handleDeleteValue}
|
onClick={this.handleDeleteValue}
|
||||||
text="Delete this label, and reassign all cells to type 'unknown'"
|
text={`Delete this label, and reassign all cells to type '${
|
||||||
|
globals.unassignedCategoryLabel
|
||||||
|
}'`}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import _ from "lodash";
|
|||||||
import Crossfilter from "../util/typedCrossfilter";
|
import Crossfilter from "../util/typedCrossfilter";
|
||||||
import {
|
import {
|
||||||
World,
|
World,
|
||||||
ControlsHelpers as CH,
|
ControlsHelpers,
|
||||||
AnnotationsHelpers as AH
|
AnnotationsHelpers
|
||||||
} from "../util/stateManager";
|
} from "../util/stateManager";
|
||||||
import {
|
import {
|
||||||
layoutDimensionName,
|
layoutDimensionName,
|
||||||
@@ -37,13 +37,16 @@ const CrossfilterReducerBase = (
|
|||||||
const { userDefinedGenes, diffexpGenes } = prevSharedState.controls;
|
const { userDefinedGenes, diffexpGenes } = prevSharedState.controls;
|
||||||
const { world } = nextSharedState;
|
const { world } = nextSharedState;
|
||||||
let { crossfilter } = prevSharedState.resetCache;
|
let { crossfilter } = prevSharedState.resetCache;
|
||||||
crossfilter = CH.createGeneDimensions(
|
crossfilter = ControlsHelpers.createGeneDimensions(
|
||||||
userDefinedGenes,
|
userDefinedGenes,
|
||||||
diffexpGenes,
|
diffexpGenes,
|
||||||
world,
|
world,
|
||||||
crossfilter
|
crossfilter
|
||||||
);
|
);
|
||||||
crossfilter = AH.createWritableAnnotationDimensions(world, crossfilter);
|
crossfilter = AnnotationsHelpers.createWritableAnnotationDimensions(
|
||||||
|
world,
|
||||||
|
crossfilter
|
||||||
|
);
|
||||||
return crossfilter;
|
return crossfilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +60,7 @@ const CrossfilterReducerBase = (
|
|||||||
world,
|
world,
|
||||||
layoutChoice.currentDimNames
|
layoutChoice.currentDimNames
|
||||||
);
|
);
|
||||||
crossfilter = CH.createGeneDimensions(
|
crossfilter = ControlsHelpers.createGeneDimensions(
|
||||||
userDefinedGenes,
|
userDefinedGenes,
|
||||||
diffexpGenes,
|
diffexpGenes,
|
||||||
world,
|
world,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { unassignedCategoryLabel } from "../globals";
|
import { unassignedCategoryLabel } from "../globals";
|
||||||
import {
|
import {
|
||||||
World,
|
World,
|
||||||
ControlsHelpers as CH,
|
ControlsHelpers,
|
||||||
AnnotationsHelpers as AH
|
AnnotationsHelpers
|
||||||
} from "../util/stateManager";
|
} from "../util/stateManager";
|
||||||
|
|
||||||
const Universe = (state = null, action, nextSharedState, prevSharedState) => {
|
const Universe = (state = null, action, nextSharedState, prevSharedState) => {
|
||||||
@@ -35,7 +35,7 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => {
|
|||||||
Object.keys(action.expressionData)
|
Object.keys(action.expressionData)
|
||||||
)
|
)
|
||||||
];
|
];
|
||||||
varData = CH.pruneVarDataCache(varData, allTheGenesWeNeed);
|
varData = ControlsHelpers.pruneVarDataCache(varData, allTheGenesWeNeed);
|
||||||
return { ...state, varData };
|
return { ...state, varData };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,9 +63,14 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => {
|
|||||||
let data;
|
let data;
|
||||||
if (categoryToDuplicate) {
|
if (categoryToDuplicate) {
|
||||||
/* duplicate the named annotation */
|
/* duplicate the named annotation */
|
||||||
schema = AH.dupObsAnnoSchema(state.schema, categoryToDuplicate, name, {
|
schema = AnnotationsHelpers.dupObsAnnoSchema(
|
||||||
writable: true
|
state.schema,
|
||||||
});
|
categoryToDuplicate,
|
||||||
|
name,
|
||||||
|
{
|
||||||
|
writable: true
|
||||||
|
}
|
||||||
|
);
|
||||||
/* if we are duplicating a non-writable annotation, it may not have an unassigned category */
|
/* if we are duplicating a non-writable annotation, it may not have an unassigned category */
|
||||||
const s = schema.annotations.obsByName[categoryToDuplicate];
|
const s = schema.annotations.obsByName[categoryToDuplicate];
|
||||||
if (s.categories.indexOf(unassignedCategoryLabel) === -1) {
|
if (s.categories.indexOf(unassignedCategoryLabel) === -1) {
|
||||||
@@ -75,7 +80,7 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => {
|
|||||||
} else {
|
} else {
|
||||||
/* else, all are unassined */
|
/* else, all are unassined */
|
||||||
const categories = [unassignedCategoryLabel];
|
const categories = [unassignedCategoryLabel];
|
||||||
schema = AH.addObsAnnoSchema(state.schema, name, {
|
schema = AnnotationsHelpers.addObsAnnoSchema(state.schema, name, {
|
||||||
name,
|
name,
|
||||||
categories,
|
categories,
|
||||||
type: "categorical",
|
type: "categorical",
|
||||||
@@ -92,7 +97,7 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => {
|
|||||||
/* change the name of an obs annotation category */
|
/* change the name of an obs annotation category */
|
||||||
const name = action.metadataField;
|
const name = action.metadataField;
|
||||||
const newName = action.newCategoryText;
|
const newName = action.newCategoryText;
|
||||||
if (!AH.isUserAnnotation(state, name))
|
if (!AnnotationsHelpers.isUserAnnotation(state, name))
|
||||||
throw new Error("unable to edit read-only annotation");
|
throw new Error("unable to edit read-only annotation");
|
||||||
if (typeof newName !== "string" || newName.length === 0)
|
if (typeof newName !== "string" || newName.length === 0)
|
||||||
throw new Error("user annotations require string name");
|
throw new Error("user annotations require string name");
|
||||||
@@ -101,8 +106,8 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => {
|
|||||||
...state.schema.annotations.obsByName[name],
|
...state.schema.annotations.obsByName[name],
|
||||||
name: newName
|
name: newName
|
||||||
};
|
};
|
||||||
const schema = AH.addObsAnnoSchema(
|
const schema = AnnotationsHelpers.addObsAnnoSchema(
|
||||||
AH.removeObsAnnoSchema(state.schema, name),
|
AnnotationsHelpers.removeObsAnnoSchema(state.schema, name),
|
||||||
newName,
|
newName,
|
||||||
colSchema
|
colSchema
|
||||||
);
|
);
|
||||||
@@ -113,10 +118,10 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => {
|
|||||||
case "annotation: delete category": {
|
case "annotation: delete category": {
|
||||||
/* delete annotation category from schema and obsAnnotations */
|
/* delete annotation category from schema and obsAnnotations */
|
||||||
const name = action.metadataField;
|
const name = action.metadataField;
|
||||||
if (!AH.isUserAnnotation(state, name))
|
if (!AnnotationsHelpers.isUserAnnotation(state, name))
|
||||||
throw new Error("unable to delete read-only annotation");
|
throw new Error("unable to delete read-only annotation");
|
||||||
|
|
||||||
const schema = AH.removeObsAnnoSchema(state.schema, name);
|
const schema = AnnotationsHelpers.removeObsAnnoSchema(state.schema, name);
|
||||||
const obsAnnotations = state.obsAnnotations.dropCol(name);
|
const obsAnnotations = state.obsAnnotations.dropCol(name);
|
||||||
return { ...state, schema, obsAnnotations };
|
return { ...state, schema, obsAnnotations };
|
||||||
}
|
}
|
||||||
@@ -124,7 +129,7 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => {
|
|||||||
case "annotation: add new label to category": {
|
case "annotation: add new label to category": {
|
||||||
const annotationName = action.metadataField;
|
const annotationName = action.metadataField;
|
||||||
const newLabelName = action.newLabelText;
|
const newLabelName = action.newLabelText;
|
||||||
if (!AH.isUserAnnotation(state, annotationName))
|
if (!AnnotationsHelpers.isUserAnnotation(state, annotationName))
|
||||||
throw new Error("unable to modify read-only annotation");
|
throw new Error("unable to modify read-only annotation");
|
||||||
if (typeof newLabelName !== "string" || newLabelName.length === 0)
|
if (typeof newLabelName !== "string" || newLabelName.length === 0)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -132,7 +137,7 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/* add the new label to the annotation */
|
/* add the new label to the annotation */
|
||||||
const schema = AH.addObsAnnoCategory(
|
const schema = AnnotationsHelpers.addObsAnnoCategory(
|
||||||
state.schema,
|
state.schema,
|
||||||
annotationName,
|
annotationName,
|
||||||
newLabelName
|
newLabelName
|
||||||
@@ -144,7 +149,7 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => {
|
|||||||
const annotationName = action.metadataField;
|
const annotationName = action.metadataField;
|
||||||
const oldLabelName = action.label;
|
const oldLabelName = action.label;
|
||||||
const newLabelName = action.editedLabel;
|
const newLabelName = action.editedLabel;
|
||||||
if (!AH.isUserAnnotation(state, annotationName))
|
if (!AnnotationsHelpers.isUserAnnotation(state, annotationName))
|
||||||
throw new Error("unable to modify read-only annotation");
|
throw new Error("unable to modify read-only annotation");
|
||||||
if (typeof newLabelName !== "string" || newLabelName.length === 0)
|
if (typeof newLabelName !== "string" || newLabelName.length === 0)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -152,14 +157,18 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/* remove old label, add new label */
|
/* remove old label, add new label */
|
||||||
const schema = AH.addObsAnnoCategory(
|
const schema = AnnotationsHelpers.addObsAnnoCategory(
|
||||||
AH.removeObsAnnoCategory(state.schema, annotationName, oldLabelName),
|
AnnotationsHelpers.removeObsAnnoCategory(
|
||||||
|
state.schema,
|
||||||
|
annotationName,
|
||||||
|
oldLabelName
|
||||||
|
),
|
||||||
annotationName,
|
annotationName,
|
||||||
newLabelName
|
newLabelName
|
||||||
);
|
);
|
||||||
|
|
||||||
/* change all values in obsAnnotation */
|
/* change all values in obsAnnotation */
|
||||||
const obsAnnotations = AH.setLabelByValue(
|
const obsAnnotations = AnnotationsHelpers.setLabelByValue(
|
||||||
state.obsAnnotations,
|
state.obsAnnotations,
|
||||||
annotationName,
|
annotationName,
|
||||||
oldLabelName,
|
oldLabelName,
|
||||||
@@ -173,20 +182,20 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => {
|
|||||||
/* delete the label from the annotation, and set all cells with this value to unassigned */
|
/* delete the label from the annotation, and set all cells with this value to unassigned */
|
||||||
const annotationName = action.metadataField;
|
const annotationName = action.metadataField;
|
||||||
const labelName = action.label;
|
const labelName = action.label;
|
||||||
if (!AH.isUserAnnotation(state, annotationName))
|
if (!AnnotationsHelpers.isUserAnnotation(state, annotationName))
|
||||||
throw new Error("unable to modify read-only annotation");
|
throw new Error("unable to modify read-only annotation");
|
||||||
if (labelName === unassignedCategoryLabel)
|
if (labelName === unassignedCategoryLabel)
|
||||||
throw new Error("may not remove the unassigned label");
|
throw new Error("may not remove the unassigned label");
|
||||||
|
|
||||||
/* remove the category from the schema */
|
/* remove the category from the schema */
|
||||||
const schema = AH.removeObsAnnoCategory(
|
const schema = AnnotationsHelpers.removeObsAnnoCategory(
|
||||||
state.schema,
|
state.schema,
|
||||||
annotationName,
|
annotationName,
|
||||||
labelName
|
labelName
|
||||||
);
|
);
|
||||||
|
|
||||||
/* set all values to unassigned in obsAnnotations */
|
/* set all values to unassigned in obsAnnotations */
|
||||||
const obsAnnotations = AH.setLabelByValue(
|
const obsAnnotations = AnnotationsHelpers.setLabelByValue(
|
||||||
state.obsAnnotations,
|
state.obsAnnotations,
|
||||||
annotationName,
|
annotationName,
|
||||||
labelName,
|
labelName,
|
||||||
@@ -207,8 +216,12 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => {
|
|||||||
const worldMask = crossfilter.allSelectedMask();
|
const worldMask = crossfilter.allSelectedMask();
|
||||||
const mask = World.worldEqUniverse(world, state)
|
const mask = World.worldEqUniverse(world, state)
|
||||||
? worldMask
|
? worldMask
|
||||||
: AH.worldToUniverseMask(worldMask, world.obsAnnotations, state.nObs);
|
: AnnotationsHelpers.worldToUniverseMask(
|
||||||
const obsAnnotations = AH.setLabelByMask(
|
worldMask,
|
||||||
|
world.obsAnnotations,
|
||||||
|
state.nObs
|
||||||
|
);
|
||||||
|
const obsAnnotations = AnnotationsHelpers.setLabelByMask(
|
||||||
state.obsAnnotations,
|
state.obsAnnotations,
|
||||||
metadataField,
|
metadataField,
|
||||||
mask,
|
mask,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { unassignedCategoryLabel } from "../globals";
|
import { unassignedCategoryLabel } from "../globals";
|
||||||
import {
|
import {
|
||||||
World,
|
World,
|
||||||
ControlsHelpers as CH,
|
ControlsHelpers,
|
||||||
AnnotationsHelpers as AH
|
AnnotationsHelpers
|
||||||
} from "../util/stateManager";
|
} from "../util/stateManager";
|
||||||
import clip from "../util/clip";
|
import clip from "../util/clip";
|
||||||
import quantile from "../util/quantile";
|
import quantile from "../util/quantile";
|
||||||
@@ -92,7 +92,7 @@ const WorldReducer = (
|
|||||||
Object.keys(action.expressionData)
|
Object.keys(action.expressionData)
|
||||||
)
|
)
|
||||||
];
|
];
|
||||||
unclippedVarData = CH.pruneVarDataCache(
|
unclippedVarData = ControlsHelpers.pruneVarDataCache(
|
||||||
unclippedVarData,
|
unclippedVarData,
|
||||||
allTheGenesWeNeed
|
allTheGenesWeNeed
|
||||||
);
|
);
|
||||||
@@ -208,7 +208,7 @@ const WorldReducer = (
|
|||||||
/* set all values to to new label */
|
/* set all values to to new label */
|
||||||
const unclipped = {
|
const unclipped = {
|
||||||
...state.unclipped,
|
...state.unclipped,
|
||||||
obsAnnotations: AH.setLabelByValue(
|
obsAnnotations: AnnotationsHelpers.setLabelByValue(
|
||||||
state.unclipped.obsAnnotations,
|
state.unclipped.obsAnnotations,
|
||||||
metadataField,
|
metadataField,
|
||||||
oldLabelName,
|
oldLabelName,
|
||||||
@@ -229,7 +229,7 @@ const WorldReducer = (
|
|||||||
/* set all values to unassigned in obsAnnotations */
|
/* set all values to unassigned in obsAnnotations */
|
||||||
const unclipped = {
|
const unclipped = {
|
||||||
...state.unclipped,
|
...state.unclipped,
|
||||||
obsAnnotations: AH.setLabelByValue(
|
obsAnnotations: AnnotationsHelpers.setLabelByValue(
|
||||||
state.unclipped.obsAnnotations,
|
state.unclipped.obsAnnotations,
|
||||||
metadataField,
|
metadataField,
|
||||||
label,
|
label,
|
||||||
@@ -249,7 +249,7 @@ const WorldReducer = (
|
|||||||
const mask = crossfilter.allSelectedMask();
|
const mask = crossfilter.allSelectedMask();
|
||||||
const unclipped = {
|
const unclipped = {
|
||||||
...state.unclipped,
|
...state.unclipped,
|
||||||
obsAnnotations: AH.setLabelByMask(
|
obsAnnotations: AnnotationsHelpers.setLabelByMask(
|
||||||
state.unclipped.obsAnnotations,
|
state.unclipped.obsAnnotations,
|
||||||
metadataField,
|
metadataField,
|
||||||
mask,
|
mask,
|
||||||
|
|||||||
@@ -132,6 +132,22 @@ export function setLabelByMask(df, colName, mask, label) {
|
|||||||
return ndf;
|
return ndf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function allHaveLabelByMask(df, colName, label, mask) {
|
||||||
|
// return true if all rows as indicated by mask have the colname set to label.
|
||||||
|
// False if not.
|
||||||
|
const col = df.col(colName);
|
||||||
|
if (!col) return false;
|
||||||
|
if (df.length !== mask.length)
|
||||||
|
throw new InternalError("mismatch on mask length");
|
||||||
|
|
||||||
|
for (let i = 0; i < df.length; i += 1) {
|
||||||
|
if (mask[i]) {
|
||||||
|
if (col.iget(i) !== label) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function worldToUniverseMask(worldMask, worldObsAnnotations, nObs) {
|
export function worldToUniverseMask(worldMask, worldObsAnnotations, nObs) {
|
||||||
/*
|
/*
|
||||||
given world seleciton mask, return a selection mask for entire universe
|
given world seleciton mask, return a selection mask for entire universe
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class NotImplementedError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default class ImmutableTypedCrossfilter {
|
export default class ImmutableTypedCrossfilter {
|
||||||
constructor(data, dimensions = {}, selectionCache = null) {
|
constructor(data, dimensions = {}, selectionCache = {}) {
|
||||||
/*
|
/*
|
||||||
Typically, parameter 'data' is one of:
|
Typically, parameter 'data' is one of:
|
||||||
- Array of objects/records
|
- Array of objects/records
|
||||||
@@ -32,12 +32,14 @@ export default class ImmutableTypedCrossfilter {
|
|||||||
|
|
||||||
Object field description:
|
Object field description:
|
||||||
- data: reference to the array of records in the crossfilter
|
- data: reference to the array of records in the crossfilter
|
||||||
- selectionBitArray: bit array containing the flatted selection state
|
- selectionCache: object which may contains a bit array indicating
|
||||||
of all dimensions. This is lazily created and is effectively
|
the flatted selection state for all dimensions, plus other state
|
||||||
|
summarizing the selection. This is lazily created and is effectively
|
||||||
a perfomance cache. Methods which return a new crossfilter,
|
a perfomance cache. Methods which return a new crossfilter,
|
||||||
such as select(), addDimention() and delDimension(), will pass
|
such as select(), addDimention() and delDimension(), will pass
|
||||||
the cache forward to the new object, as the typical "immutable API"
|
the cache forward to the new object, as the typical "immutable API"
|
||||||
usage pattern is to retain the new crossfilter and discard the old.
|
usage pattern is to retain the new crossfilter and discard the old.
|
||||||
|
If the cache object is empty, it will be rebuilt.
|
||||||
- dimensions: contains each dimension and its current state:
|
- dimensions: contains each dimension and its current state:
|
||||||
- id: bit offset in the cached bit array
|
- id: bit offset in the cached bit array
|
||||||
- dim: the dimension object
|
- dim: the dimension object
|
||||||
@@ -45,7 +47,7 @@ export default class ImmutableTypedCrossfilter {
|
|||||||
- selection: the dimension's current selection
|
- selection: the dimension's current selection
|
||||||
*/
|
*/
|
||||||
this.data = data;
|
this.data = data;
|
||||||
this.selectionCache = selectionCache; /* BitArray */
|
this.selectionCache = selectionCache; /* { BitArray, ... }*/
|
||||||
this.dimensions = dimensions; /* name: { id, dim, name, selection } */
|
this.dimensions = dimensions; /* name: { id, dim, name, selection } */
|
||||||
Object.preventExtensions(this);
|
Object.preventExtensions(this);
|
||||||
}
|
}
|
||||||
@@ -59,11 +61,9 @@ export default class ImmutableTypedCrossfilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setData(data) {
|
setData(data) {
|
||||||
return new ImmutableTypedCrossfilter(
|
const { selectionCache } = this;
|
||||||
data,
|
this.selectionCache = null;
|
||||||
this.dimensions,
|
return new ImmutableTypedCrossfilter(data, this.dimensions, selectionCache);
|
||||||
this.selectionCache
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dimensionNames() {
|
dimensionNames() {
|
||||||
@@ -80,18 +80,19 @@ export default class ImmutableTypedCrossfilter {
|
|||||||
Add a new dimension to this crossfilter, of type DimensionType.
|
Add a new dimension to this crossfilter, of type DimensionType.
|
||||||
Remainder of parameters are dimension-type-specific.
|
Remainder of parameters are dimension-type-specific.
|
||||||
*/
|
*/
|
||||||
const { data, selectionCache } = this;
|
const { data } = this;
|
||||||
|
const { bitArray } = this.selectionCache;
|
||||||
|
|
||||||
if (this.dimensions[name] !== undefined) {
|
if (this.dimensions[name] !== undefined) {
|
||||||
throw new Error(`Adding duplicate dimension name ${name}`);
|
throw new Error(`Adding duplicate dimension name ${name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.selectionCache = null; // pass ownership to new crossfilter
|
this._clearSelectionCache();
|
||||||
|
|
||||||
let id;
|
let id;
|
||||||
if (selectionCache) {
|
if (bitArray) {
|
||||||
id = selectionCache.allocDimension();
|
id = bitArray.allocDimension();
|
||||||
selectionCache.selectAll(id);
|
bitArray.selectAll(id);
|
||||||
}
|
}
|
||||||
const DimensionType = DimTypes[type];
|
const DimensionType = DimTypes[type];
|
||||||
const dim = new DimensionType(name, data, ...rest);
|
const dim = new DimensionType(name, data, ...rest);
|
||||||
@@ -105,11 +106,15 @@ export default class ImmutableTypedCrossfilter {
|
|||||||
selection: dim.select({ mode: "all" })
|
selection: dim.select({ mode: "all" })
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return new ImmutableTypedCrossfilter(data, dimensions, selectionCache);
|
|
||||||
|
return new ImmutableTypedCrossfilter(data, dimensions, {
|
||||||
|
bitArray: bitArray
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
delDimension(name) {
|
delDimension(name) {
|
||||||
const { data, selectionCache } = this;
|
const { data } = this;
|
||||||
|
const { bitArray } = this.selectionCache;
|
||||||
const dimensions = { ...this.dimensions };
|
const dimensions = { ...this.dimensions };
|
||||||
if (dimensions[name] === undefined) {
|
if (dimensions[name] === undefined) {
|
||||||
throw new ReferenceError(`Unable to delete unknown dimension ${name}`);
|
throw new ReferenceError(`Unable to delete unknown dimension ${name}`);
|
||||||
@@ -117,11 +122,14 @@ export default class ImmutableTypedCrossfilter {
|
|||||||
|
|
||||||
const { id } = dimensions[name];
|
const { id } = dimensions[name];
|
||||||
delete dimensions[name];
|
delete dimensions[name];
|
||||||
this.selectionCache = null; // pass ownership to new crossfilter
|
this._clearSelectionCache();
|
||||||
if (selectionCache) {
|
if (bitArray) {
|
||||||
selectionCache.freeDimension(id);
|
bitArray.freeDimension(id);
|
||||||
}
|
}
|
||||||
return new ImmutableTypedCrossfilter(data, dimensions, selectionCache);
|
|
||||||
|
return new ImmutableTypedCrossfilter(data, dimensions, {
|
||||||
|
bitArray: bitArray
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
renameDimension(oldName, newName) {
|
renameDimension(oldName, newName) {
|
||||||
@@ -157,13 +165,13 @@ export default class ImmutableTypedCrossfilter {
|
|||||||
const newSelection = dim.select(spec);
|
const newSelection = dim.select(spec);
|
||||||
newSelection.ranges = PositiveIntervals.canonicalize(newSelection.ranges);
|
newSelection.ranges = PositiveIntervals.canonicalize(newSelection.ranges);
|
||||||
dimensions[name] = { id, dim, name, selection: newSelection };
|
dimensions[name] = { id, dim, name, selection: newSelection };
|
||||||
ImmutableTypedCrossfilter._dimSelnHasUpdated(
|
const newSelectionCache = ImmutableTypedCrossfilter._dimSelnHasUpdated(
|
||||||
selectionCache,
|
selectionCache,
|
||||||
id,
|
id,
|
||||||
newSelection,
|
newSelection,
|
||||||
oldSelection
|
oldSelection
|
||||||
);
|
);
|
||||||
return new ImmutableTypedCrossfilter(data, dimensions, selectionCache);
|
return new ImmutableTypedCrossfilter(data, dimensions, newSelectionCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
static _dimSelnHasUpdated(selectionCache, id, newSeln, oldSeln) {
|
static _dimSelnHasUpdated(selectionCache, id, newSeln, oldSeln) {
|
||||||
@@ -172,81 +180,96 @@ export default class ImmutableTypedCrossfilter {
|
|||||||
bit array if it exists. If not, we will lazy create it when
|
bit array if it exists. If not, we will lazy create it when
|
||||||
needed.
|
needed.
|
||||||
*/
|
*/
|
||||||
if (selectionCache) {
|
if (!selectionCache || !selectionCache.bitArray) return {};
|
||||||
/*
|
|
||||||
|
const { bitArray } = selectionCache;
|
||||||
|
|
||||||
|
/*
|
||||||
if both new and old selection use the same index, we can
|
if both new and old selection use the same index, we can
|
||||||
perform an incremental update. If the index changed, we have
|
perform an incremental update. If the index changed, we have
|
||||||
to do a suboptimal full deselect/select.
|
to do a suboptimal full deselect/select.
|
||||||
*/
|
*/
|
||||||
let adds;
|
let adds;
|
||||||
let dels;
|
let dels;
|
||||||
if (newSeln.index === oldSeln.index) {
|
if (newSeln.index === oldSeln.index) {
|
||||||
adds = PositiveIntervals.difference(newSeln.ranges, oldSeln.ranges);
|
adds = PositiveIntervals.difference(newSeln.ranges, oldSeln.ranges);
|
||||||
dels = PositiveIntervals.difference(oldSeln.ranges, newSeln.ranges);
|
dels = PositiveIntervals.difference(oldSeln.ranges, newSeln.ranges);
|
||||||
} else {
|
} else {
|
||||||
// console.log("suboptimal selection update - index changed");
|
// console.log("suboptimal selection update - index changed");
|
||||||
adds = newSeln.ranges;
|
adds = newSeln.ranges;
|
||||||
dels = oldSeln.ranges;
|
dels = oldSeln.ranges;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
allow dimensions to return selected ranges in either dimension sort
|
allow dimensions to return selected ranges in either dimension sort
|
||||||
order (indirect via index), or in original record order.
|
order (indirect via index), or in original record order.
|
||||||
|
|
||||||
If sort index exists in the dimension, assume sort ordered ranges.
|
If sort index exists in the dimension, assume sort ordered ranges.
|
||||||
*/
|
*/
|
||||||
if (oldSeln.index) {
|
if (oldSeln.index) {
|
||||||
dels.forEach(interval =>
|
dels.forEach(interval =>
|
||||||
selectionCache.deselectIndirectFromRange(id, oldSeln.index, interval)
|
bitArray.deselectIndirectFromRange(id, oldSeln.index, interval)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
dels.forEach(interval =>
|
dels.forEach(interval => bitArray.deselectFromRange(id, interval));
|
||||||
selectionCache.deselectFromRange(id, interval)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newSeln.index) {
|
|
||||||
adds.forEach(interval =>
|
|
||||||
selectionCache.selectIndirectFromRange(id, newSeln.index, interval)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
adds.forEach(interval => selectionCache.selectFromRange(id, interval));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (newSeln.index) {
|
||||||
|
adds.forEach(interval =>
|
||||||
|
bitArray.selectIndirectFromRange(id, newSeln.index, interval)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
adds.forEach(interval => bitArray.selectFromRange(id, interval));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bitArray };
|
||||||
}
|
}
|
||||||
|
|
||||||
_getSelectionCache() {
|
_getSelectionCache() {
|
||||||
if (!this.selectionCache) {
|
if (!this.selectionCache) this.selectionCache = {};
|
||||||
|
|
||||||
|
if (!this.selectionCache.bitArray) {
|
||||||
// console.log("...rebuilding crossfilter cache...");
|
// console.log("...rebuilding crossfilter cache...");
|
||||||
const selectionCache = new BitArray(this.data.length);
|
const bitArray = new BitArray(this.data.length);
|
||||||
Object.keys(this.dimensions).forEach(name => {
|
Object.keys(this.dimensions).forEach(name => {
|
||||||
const { selection } = this.dimensions[name];
|
const { selection } = this.dimensions[name];
|
||||||
const id = selectionCache.allocDimension();
|
const id = bitArray.allocDimension();
|
||||||
this.dimensions[name].id = id;
|
this.dimensions[name].id = id;
|
||||||
const { ranges, index } = selection;
|
const { ranges, index } = selection;
|
||||||
ranges.forEach(range => {
|
ranges.forEach(range => {
|
||||||
if (index) {
|
if (index) {
|
||||||
selectionCache.selectIndirectFromRange(id, index, range);
|
bitArray.selectIndirectFromRange(id, index, range);
|
||||||
} else {
|
} else {
|
||||||
selectionCache.selectFromRange(id, range);
|
bitArray.selectFromRange(id, range);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
this.selectionCache = selectionCache;
|
this.selectionCache.bitArray = bitArray;
|
||||||
}
|
}
|
||||||
return this.selectionCache;
|
return this.selectionCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_clearSelectionCache() {
|
||||||
|
this.selectionCache = {};
|
||||||
|
return this.selectionCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setSelectionCache(vals = {}) {
|
||||||
|
Object.assign(this.selectionCache, vals);
|
||||||
|
return this.selectionCache;
|
||||||
|
}
|
||||||
|
|
||||||
allSelected() {
|
allSelected() {
|
||||||
/*
|
/*
|
||||||
return array of all records currently selected by all dimensions
|
return array of all records currently selected by all dimensions
|
||||||
*/
|
*/
|
||||||
const selectionCache = this._getSelectionCache();
|
const selectionCache = this._getSelectionCache();
|
||||||
|
const { bitArray } = selectionCache;
|
||||||
const { data } = this;
|
const { data } = this;
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
const res = [];
|
const res = [];
|
||||||
for (let i = 0, len = data.length; i < len; i += 1) {
|
for (let i = 0, len = data.length; i < len; i += 1) {
|
||||||
if (selectionCache.isSelected(i)) {
|
if (bitArray.isSelected(i)) {
|
||||||
res.push(data[i]);
|
res.push(data[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,11 +284,17 @@ export default class ImmutableTypedCrossfilter {
|
|||||||
return Uint8Array containing selection state (truthy/falsey) for each record.
|
return Uint8Array containing selection state (truthy/falsey) for each record.
|
||||||
*/
|
*/
|
||||||
const selectionCache = this._getSelectionCache();
|
const selectionCache = this._getSelectionCache();
|
||||||
return selectionCache.fillBySelection(
|
let { allSelectedMask } = selectionCache;
|
||||||
|
|
||||||
|
if (allSelectedMask !== undefined) return allSelectedMask;
|
||||||
|
|
||||||
|
allSelectedMask = selectionCache.bitArray.fillBySelection(
|
||||||
new Uint8Array(this.data.length),
|
new Uint8Array(this.data.length),
|
||||||
1,
|
1,
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
this._setSelectionCache({ allSelectedMask });
|
||||||
|
return allSelectedMask;
|
||||||
}
|
}
|
||||||
|
|
||||||
countSelected() {
|
countSelected() {
|
||||||
@@ -273,7 +302,13 @@ export default class ImmutableTypedCrossfilter {
|
|||||||
return number of records selected on all dimensions
|
return number of records selected on all dimensions
|
||||||
*/
|
*/
|
||||||
const selectionCache = this._getSelectionCache();
|
const selectionCache = this._getSelectionCache();
|
||||||
return selectionCache.selectionCount();
|
let { countSelected } = selectionCache;
|
||||||
|
|
||||||
|
if (countSelected !== undefined) return countSelected;
|
||||||
|
|
||||||
|
countSelected = selectionCache.bitArray.selectionCount();
|
||||||
|
this._setSelectionCache({ countSelected });
|
||||||
|
return countSelected;
|
||||||
}
|
}
|
||||||
|
|
||||||
isElementSelected(i) {
|
isElementSelected(i) {
|
||||||
@@ -281,7 +316,7 @@ export default class ImmutableTypedCrossfilter {
|
|||||||
return truthy/falsey if this record is selected on all dimensions
|
return truthy/falsey if this record is selected on all dimensions
|
||||||
*/
|
*/
|
||||||
const selectionCache = this._getSelectionCache();
|
const selectionCache = this._getSelectionCache();
|
||||||
return selectionCache.isSelected(i);
|
return selectionCache.bitArray.isSelected(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
fillByIsSelected(array, selectedValue, deselectedValue) {
|
fillByIsSelected(array, selectedValue, deselectedValue) {
|
||||||
@@ -289,7 +324,7 @@ export default class ImmutableTypedCrossfilter {
|
|||||||
fill array with one of two values, based upon selection state.
|
fill array with one of two values, based upon selection state.
|
||||||
*/
|
*/
|
||||||
const selectionCache = this._getSelectionCache();
|
const selectionCache = this._getSelectionCache();
|
||||||
return selectionCache.fillBySelection(
|
return selectionCache.bitArray.fillBySelection(
|
||||||
array,
|
array,
|
||||||
selectedValue,
|
selectedValue,
|
||||||
deselectedValue
|
deselectedValue
|
||||||
|
|||||||
Reference in New Issue
Block a user