diff --git a/client/src/components/categorical/value.js b/client/src/components/categorical/value.js index fc454887..d77c4556 100644 --- a/client/src/components/categorical/value.js +++ b/client/src/components/categorical/value.js @@ -17,13 +17,16 @@ import * as globals from "../../globals"; import styles from "./categorical.css"; import { Tooltip } from "@blueprintjs/core"; +import { AnnotationsHelpers } from "../../util/stateManager"; + @connect(state => ({ categoricalSelection: state.categoricalSelection, annotations: state.annotations, colorScale: state.colors.scale, colorAccessor: state.colors.colorAccessor, schema: state.world?.schema, - world: state.world + world: state.world, + crossfilter: state.crossfilter })) class CategoryValue extends React.Component { constructor(props) { @@ -119,6 +122,7 @@ class CategoryValue extends React.Component { * world state * the color accessor (what is currently being colored by) * if this catagorical value's selection status has changed + * the crossfilter (ie, global selection state) If and only if true, update the component */ @@ -137,12 +141,14 @@ class CategoryValue extends React.Component { const worldChange = props.world !== nextProps.world; const colorAccessorChange = props.colorAccessor !== nextProps.colorAccessor; const annotationsChange = props.annotations !== nextProps.annotations; + const crossfilterChange = props.crossfilter !== nextProps.crossfilter; return ( valueSelectionChange || worldChange || 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() { const { categoricalSelection, @@ -407,7 +441,26 @@ class CategoryValue extends React.Component { data-testclass="handleAddCurrentSelectionToThisLabel" data-testid={`handleAddCurrentSelectionToThisLabel-${metadataField}`} onClick={this.handleAddCurrentSelectionToThisLabel} - text={`Re-label currently selected cells as ${displayString}`} + text={ + + Re-label currently selected cells as + + {` ${displayString}`} + + + } + disabled={this.isAddCurrentSelectionDisabled( + metadataField, + value + )} /> {displayString !== globals.unassignedCategoryLabel ? ( ) : null} diff --git a/client/src/reducers/crossfilter.js b/client/src/reducers/crossfilter.js index 156def8a..d4733a17 100644 --- a/client/src/reducers/crossfilter.js +++ b/client/src/reducers/crossfilter.js @@ -3,8 +3,8 @@ import _ from "lodash"; import Crossfilter from "../util/typedCrossfilter"; import { World, - ControlsHelpers as CH, - AnnotationsHelpers as AH + ControlsHelpers, + AnnotationsHelpers } from "../util/stateManager"; import { layoutDimensionName, @@ -37,13 +37,16 @@ const CrossfilterReducerBase = ( const { userDefinedGenes, diffexpGenes } = prevSharedState.controls; const { world } = nextSharedState; let { crossfilter } = prevSharedState.resetCache; - crossfilter = CH.createGeneDimensions( + crossfilter = ControlsHelpers.createGeneDimensions( userDefinedGenes, diffexpGenes, world, crossfilter ); - crossfilter = AH.createWritableAnnotationDimensions(world, crossfilter); + crossfilter = AnnotationsHelpers.createWritableAnnotationDimensions( + world, + crossfilter + ); return crossfilter; } @@ -57,7 +60,7 @@ const CrossfilterReducerBase = ( world, layoutChoice.currentDimNames ); - crossfilter = CH.createGeneDimensions( + crossfilter = ControlsHelpers.createGeneDimensions( userDefinedGenes, diffexpGenes, world, diff --git a/client/src/reducers/universe.js b/client/src/reducers/universe.js index 4c7da1a7..70e252de 100644 --- a/client/src/reducers/universe.js +++ b/client/src/reducers/universe.js @@ -1,8 +1,8 @@ import { unassignedCategoryLabel } from "../globals"; import { World, - ControlsHelpers as CH, - AnnotationsHelpers as AH + ControlsHelpers, + AnnotationsHelpers } from "../util/stateManager"; const Universe = (state = null, action, nextSharedState, prevSharedState) => { @@ -35,7 +35,7 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => { Object.keys(action.expressionData) ) ]; - varData = CH.pruneVarDataCache(varData, allTheGenesWeNeed); + varData = ControlsHelpers.pruneVarDataCache(varData, allTheGenesWeNeed); return { ...state, varData }; } @@ -63,9 +63,14 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => { let data; if (categoryToDuplicate) { /* duplicate the named annotation */ - schema = AH.dupObsAnnoSchema(state.schema, categoryToDuplicate, name, { - writable: true - }); + schema = AnnotationsHelpers.dupObsAnnoSchema( + state.schema, + categoryToDuplicate, + name, + { + writable: true + } + ); /* if we are duplicating a non-writable annotation, it may not have an unassigned category */ const s = schema.annotations.obsByName[categoryToDuplicate]; if (s.categories.indexOf(unassignedCategoryLabel) === -1) { @@ -75,7 +80,7 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => { } else { /* else, all are unassined */ const categories = [unassignedCategoryLabel]; - schema = AH.addObsAnnoSchema(state.schema, name, { + schema = AnnotationsHelpers.addObsAnnoSchema(state.schema, name, { name, categories, type: "categorical", @@ -92,7 +97,7 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => { /* change the name of an obs annotation category */ const name = action.metadataField; const newName = action.newCategoryText; - if (!AH.isUserAnnotation(state, name)) + if (!AnnotationsHelpers.isUserAnnotation(state, name)) throw new Error("unable to edit read-only annotation"); if (typeof newName !== "string" || newName.length === 0) throw new Error("user annotations require string name"); @@ -101,8 +106,8 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => { ...state.schema.annotations.obsByName[name], name: newName }; - const schema = AH.addObsAnnoSchema( - AH.removeObsAnnoSchema(state.schema, name), + const schema = AnnotationsHelpers.addObsAnnoSchema( + AnnotationsHelpers.removeObsAnnoSchema(state.schema, name), newName, colSchema ); @@ -113,10 +118,10 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => { case "annotation: delete category": { /* delete annotation category from schema and obsAnnotations */ const name = action.metadataField; - if (!AH.isUserAnnotation(state, name)) + if (!AnnotationsHelpers.isUserAnnotation(state, name)) 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); return { ...state, schema, obsAnnotations }; } @@ -124,7 +129,7 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => { case "annotation: add new label to category": { const annotationName = action.metadataField; const newLabelName = action.newLabelText; - if (!AH.isUserAnnotation(state, annotationName)) + if (!AnnotationsHelpers.isUserAnnotation(state, annotationName)) throw new Error("unable to modify read-only annotation"); if (typeof newLabelName !== "string" || newLabelName.length === 0) throw new Error( @@ -132,7 +137,7 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => { ); /* add the new label to the annotation */ - const schema = AH.addObsAnnoCategory( + const schema = AnnotationsHelpers.addObsAnnoCategory( state.schema, annotationName, newLabelName @@ -144,7 +149,7 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => { const annotationName = action.metadataField; const oldLabelName = action.label; const newLabelName = action.editedLabel; - if (!AH.isUserAnnotation(state, annotationName)) + if (!AnnotationsHelpers.isUserAnnotation(state, annotationName)) throw new Error("unable to modify read-only annotation"); if (typeof newLabelName !== "string" || newLabelName.length === 0) throw new Error( @@ -152,14 +157,18 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => { ); /* remove old label, add new label */ - const schema = AH.addObsAnnoCategory( - AH.removeObsAnnoCategory(state.schema, annotationName, oldLabelName), + const schema = AnnotationsHelpers.addObsAnnoCategory( + AnnotationsHelpers.removeObsAnnoCategory( + state.schema, + annotationName, + oldLabelName + ), annotationName, newLabelName ); /* change all values in obsAnnotation */ - const obsAnnotations = AH.setLabelByValue( + const obsAnnotations = AnnotationsHelpers.setLabelByValue( state.obsAnnotations, annotationName, 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 */ const annotationName = action.metadataField; const labelName = action.label; - if (!AH.isUserAnnotation(state, annotationName)) + if (!AnnotationsHelpers.isUserAnnotation(state, annotationName)) throw new Error("unable to modify read-only annotation"); if (labelName === unassignedCategoryLabel) throw new Error("may not remove the unassigned label"); /* remove the category from the schema */ - const schema = AH.removeObsAnnoCategory( + const schema = AnnotationsHelpers.removeObsAnnoCategory( state.schema, annotationName, labelName ); /* set all values to unassigned in obsAnnotations */ - const obsAnnotations = AH.setLabelByValue( + const obsAnnotations = AnnotationsHelpers.setLabelByValue( state.obsAnnotations, annotationName, labelName, @@ -207,8 +216,12 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => { const worldMask = crossfilter.allSelectedMask(); const mask = World.worldEqUniverse(world, state) ? worldMask - : AH.worldToUniverseMask(worldMask, world.obsAnnotations, state.nObs); - const obsAnnotations = AH.setLabelByMask( + : AnnotationsHelpers.worldToUniverseMask( + worldMask, + world.obsAnnotations, + state.nObs + ); + const obsAnnotations = AnnotationsHelpers.setLabelByMask( state.obsAnnotations, metadataField, mask, diff --git a/client/src/reducers/world.js b/client/src/reducers/world.js index 47576e1f..52aa0f43 100644 --- a/client/src/reducers/world.js +++ b/client/src/reducers/world.js @@ -1,8 +1,8 @@ import { unassignedCategoryLabel } from "../globals"; import { World, - ControlsHelpers as CH, - AnnotationsHelpers as AH + ControlsHelpers, + AnnotationsHelpers } from "../util/stateManager"; import clip from "../util/clip"; import quantile from "../util/quantile"; @@ -92,7 +92,7 @@ const WorldReducer = ( Object.keys(action.expressionData) ) ]; - unclippedVarData = CH.pruneVarDataCache( + unclippedVarData = ControlsHelpers.pruneVarDataCache( unclippedVarData, allTheGenesWeNeed ); @@ -208,7 +208,7 @@ const WorldReducer = ( /* set all values to to new label */ const unclipped = { ...state.unclipped, - obsAnnotations: AH.setLabelByValue( + obsAnnotations: AnnotationsHelpers.setLabelByValue( state.unclipped.obsAnnotations, metadataField, oldLabelName, @@ -229,7 +229,7 @@ const WorldReducer = ( /* set all values to unassigned in obsAnnotations */ const unclipped = { ...state.unclipped, - obsAnnotations: AH.setLabelByValue( + obsAnnotations: AnnotationsHelpers.setLabelByValue( state.unclipped.obsAnnotations, metadataField, label, @@ -249,7 +249,7 @@ const WorldReducer = ( const mask = crossfilter.allSelectedMask(); const unclipped = { ...state.unclipped, - obsAnnotations: AH.setLabelByMask( + obsAnnotations: AnnotationsHelpers.setLabelByMask( state.unclipped.obsAnnotations, metadataField, mask, diff --git a/client/src/util/stateManager/annotationsHelpers.js b/client/src/util/stateManager/annotationsHelpers.js index e764222a..53b2c796 100644 --- a/client/src/util/stateManager/annotationsHelpers.js +++ b/client/src/util/stateManager/annotationsHelpers.js @@ -132,6 +132,22 @@ export function setLabelByMask(df, colName, mask, label) { 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) { /* given world seleciton mask, return a selection mask for entire universe diff --git a/client/src/util/typedCrossfilter/crossfilter.js b/client/src/util/typedCrossfilter/crossfilter.js index 60ca6093..79244055 100644 --- a/client/src/util/typedCrossfilter/crossfilter.js +++ b/client/src/util/typedCrossfilter/crossfilter.js @@ -23,7 +23,7 @@ class NotImplementedError extends Error { } export default class ImmutableTypedCrossfilter { - constructor(data, dimensions = {}, selectionCache = null) { + constructor(data, dimensions = {}, selectionCache = {}) { /* Typically, parameter 'data' is one of: - Array of objects/records @@ -32,12 +32,14 @@ export default class ImmutableTypedCrossfilter { Object field description: - data: reference to the array of records in the crossfilter - - selectionBitArray: bit array containing the flatted selection state - of all dimensions. This is lazily created and is effectively + - selectionCache: object which may contains a bit array indicating + 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, such as select(), addDimention() and delDimension(), will pass the cache forward to the new object, as the typical "immutable API" 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: - id: bit offset in the cached bit array - dim: the dimension object @@ -45,7 +47,7 @@ export default class ImmutableTypedCrossfilter { - selection: the dimension's current selection */ this.data = data; - this.selectionCache = selectionCache; /* BitArray */ + this.selectionCache = selectionCache; /* { BitArray, ... }*/ this.dimensions = dimensions; /* name: { id, dim, name, selection } */ Object.preventExtensions(this); } @@ -59,11 +61,9 @@ export default class ImmutableTypedCrossfilter { } setData(data) { - return new ImmutableTypedCrossfilter( - data, - this.dimensions, - this.selectionCache - ); + const { selectionCache } = this; + this.selectionCache = null; + return new ImmutableTypedCrossfilter(data, this.dimensions, selectionCache); } dimensionNames() { @@ -80,18 +80,19 @@ export default class ImmutableTypedCrossfilter { Add a new dimension to this crossfilter, of type DimensionType. Remainder of parameters are dimension-type-specific. */ - const { data, selectionCache } = this; + const { data } = this; + const { bitArray } = this.selectionCache; if (this.dimensions[name] !== undefined) { throw new Error(`Adding duplicate dimension name ${name}`); } - this.selectionCache = null; // pass ownership to new crossfilter + this._clearSelectionCache(); let id; - if (selectionCache) { - id = selectionCache.allocDimension(); - selectionCache.selectAll(id); + if (bitArray) { + id = bitArray.allocDimension(); + bitArray.selectAll(id); } const DimensionType = DimTypes[type]; const dim = new DimensionType(name, data, ...rest); @@ -105,11 +106,15 @@ export default class ImmutableTypedCrossfilter { selection: dim.select({ mode: "all" }) } }; - return new ImmutableTypedCrossfilter(data, dimensions, selectionCache); + + return new ImmutableTypedCrossfilter(data, dimensions, { + bitArray: bitArray + }); } delDimension(name) { - const { data, selectionCache } = this; + const { data } = this; + const { bitArray } = this.selectionCache; const dimensions = { ...this.dimensions }; if (dimensions[name] === undefined) { throw new ReferenceError(`Unable to delete unknown dimension ${name}`); @@ -117,11 +122,14 @@ export default class ImmutableTypedCrossfilter { const { id } = dimensions[name]; delete dimensions[name]; - this.selectionCache = null; // pass ownership to new crossfilter - if (selectionCache) { - selectionCache.freeDimension(id); + this._clearSelectionCache(); + if (bitArray) { + bitArray.freeDimension(id); } - return new ImmutableTypedCrossfilter(data, dimensions, selectionCache); + + return new ImmutableTypedCrossfilter(data, dimensions, { + bitArray: bitArray + }); } renameDimension(oldName, newName) { @@ -157,13 +165,13 @@ export default class ImmutableTypedCrossfilter { const newSelection = dim.select(spec); newSelection.ranges = PositiveIntervals.canonicalize(newSelection.ranges); dimensions[name] = { id, dim, name, selection: newSelection }; - ImmutableTypedCrossfilter._dimSelnHasUpdated( + const newSelectionCache = ImmutableTypedCrossfilter._dimSelnHasUpdated( selectionCache, id, newSelection, oldSelection ); - return new ImmutableTypedCrossfilter(data, dimensions, selectionCache); + return new ImmutableTypedCrossfilter(data, dimensions, newSelectionCache); } 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 needed. */ - if (selectionCache) { - /* + if (!selectionCache || !selectionCache.bitArray) return {}; + + const { bitArray } = selectionCache; + + /* if both new and old selection use the same index, we can perform an incremental update. If the index changed, we have to do a suboptimal full deselect/select. */ - let adds; - let dels; - if (newSeln.index === oldSeln.index) { - adds = PositiveIntervals.difference(newSeln.ranges, oldSeln.ranges); - dels = PositiveIntervals.difference(oldSeln.ranges, newSeln.ranges); - } else { - // console.log("suboptimal selection update - index changed"); - adds = newSeln.ranges; - dels = oldSeln.ranges; - } + let adds; + let dels; + if (newSeln.index === oldSeln.index) { + adds = PositiveIntervals.difference(newSeln.ranges, oldSeln.ranges); + dels = PositiveIntervals.difference(oldSeln.ranges, newSeln.ranges); + } else { + // console.log("suboptimal selection update - index changed"); + adds = newSeln.ranges; + dels = oldSeln.ranges; + } - /* + /* allow dimensions to return selected ranges in either dimension sort order (indirect via index), or in original record order. If sort index exists in the dimension, assume sort ordered ranges. */ - if (oldSeln.index) { - dels.forEach(interval => - selectionCache.deselectIndirectFromRange(id, oldSeln.index, interval) - ); - } else { - dels.forEach(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 (oldSeln.index) { + dels.forEach(interval => + bitArray.deselectIndirectFromRange(id, oldSeln.index, interval) + ); + } else { + dels.forEach(interval => bitArray.deselectFromRange(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() { - if (!this.selectionCache) { + if (!this.selectionCache) this.selectionCache = {}; + + if (!this.selectionCache.bitArray) { // 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 => { const { selection } = this.dimensions[name]; - const id = selectionCache.allocDimension(); + const id = bitArray.allocDimension(); this.dimensions[name].id = id; const { ranges, index } = selection; ranges.forEach(range => { if (index) { - selectionCache.selectIndirectFromRange(id, index, range); + bitArray.selectIndirectFromRange(id, index, range); } else { - selectionCache.selectFromRange(id, range); + bitArray.selectFromRange(id, range); } }); }); - this.selectionCache = selectionCache; + this.selectionCache.bitArray = bitArray; } return this.selectionCache; } + _clearSelectionCache() { + this.selectionCache = {}; + return this.selectionCache; + } + + _setSelectionCache(vals = {}) { + Object.assign(this.selectionCache, vals); + return this.selectionCache; + } + allSelected() { /* return array of all records currently selected by all dimensions */ const selectionCache = this._getSelectionCache(); + const { bitArray } = selectionCache; const { data } = this; if (Array.isArray(data)) { const res = []; for (let i = 0, len = data.length; i < len; i += 1) { - if (selectionCache.isSelected(i)) { + if (bitArray.isSelected(i)) { res.push(data[i]); } } @@ -261,11 +284,17 @@ export default class ImmutableTypedCrossfilter { return Uint8Array containing selection state (truthy/falsey) for each record. */ const selectionCache = this._getSelectionCache(); - return selectionCache.fillBySelection( + let { allSelectedMask } = selectionCache; + + if (allSelectedMask !== undefined) return allSelectedMask; + + allSelectedMask = selectionCache.bitArray.fillBySelection( new Uint8Array(this.data.length), 1, 0 ); + this._setSelectionCache({ allSelectedMask }); + return allSelectedMask; } countSelected() { @@ -273,7 +302,13 @@ export default class ImmutableTypedCrossfilter { return number of records selected on all dimensions */ 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) { @@ -281,7 +316,7 @@ export default class ImmutableTypedCrossfilter { return truthy/falsey if this record is selected on all dimensions */ const selectionCache = this._getSelectionCache(); - return selectionCache.isSelected(i); + return selectionCache.bitArray.isSelected(i); } fillByIsSelected(array, selectedValue, deselectedValue) { @@ -289,7 +324,7 @@ export default class ImmutableTypedCrossfilter { fill array with one of two values, based upon selection state. */ const selectionCache = this._getSelectionCache(); - return selectionCache.fillBySelection( + return selectionCache.bitArray.fillBySelection( array, selectedValue, deselectedValue