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:
Bruce Martin
2019-11-04 14:22:38 -08:00
committed by GitHub
parent 823d819eee
commit 7599af451f
6 changed files with 223 additions and 101 deletions
+59 -4
View File
@@ -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>
+8 -5
View File
@@ -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,
+36 -23
View File
@@ -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,
+6 -6
View File
@@ -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
+98 -63
View File
@@ -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