refactor categorical controls state (#1549)

* refactor categorical controls state

* lint

* fix race condition in tests

* fix typo

* add missing update on subset

* remove obsolete code

* update jest and puppeteer major version; update all minors

* update when label changes

* remove lint from tests; increase timeouts in e2e tests

* changes in response to PR review

* lint

* more PR comment changes

* more PR comment fixes

* lint

* more PR comment resolutions
This commit is contained in:
Bruce Martin
2020-06-10 12:56:38 -07:00
committed by GitHub
parent f063708b33
commit bc7a712048
25 changed files with 5097 additions and 3510 deletions
@@ -1,13 +1,6 @@
import React from "react";
import { connect } from "react-redux";
import { Button, Tooltip, Dialog, Classes, Colors } from "@blueprintjs/core";
@connect((state) => ({
colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe,
}))
class AnnoDialog extends React.PureComponent {
constructor(props) {
super(props);
@@ -1,14 +1,7 @@
import React from "react";
import { connect } from "react-redux";
import { Button, MenuItem } from "@blueprintjs/core";
import { Select } from "@blueprintjs/select";
@connect((state) => ({
colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe,
}))
class DuplicateCategorySelect extends React.PureComponent {
constructor(props) {
super(props);
@@ -5,8 +5,6 @@ import LabelInput from "../labelInput";
import { labelPrompt, isLabelErroneous } from "../labelUtil";
@connect((state) => ({
colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe,
ontology: state.ontology,
@@ -1,5 +1,4 @@
import React from "react";
import _ from "lodash";
import { connect } from "react-redux";
import AnnoDialog from "../annoDialog";
import LabelInput from "../labelInput";
@@ -8,9 +7,9 @@ import { labelPrompt } from "../labelUtil";
import { AnnotationsHelpers } from "../../../util/stateManager";
@connect((state) => ({
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe,
schema: state.world?.schema,
ontology: state.ontology,
}))
class AnnoDialogEditCategoryName extends React.PureComponent {
@@ -36,11 +35,14 @@ class AnnoDialogEditCategoryName extends React.PureComponent {
};
handleEditCategory = (e) => {
const { dispatch, metadataField, categoricalSelection } = this.props;
const { dispatch, metadataField } = this.props;
const { newCategoryText } = this.state;
const allCategoryNames = _.keys(categoricalSelection);
/*
test for uniqueness against *all* annotation names, not just the subset
we render as categorical.
*/
const allCategoryNames = this.allCategoryNames();
if (
(allCategoryNames.indexOf(newCategoryText) > -1 &&
newCategoryText !== metadataField) ||
@@ -60,7 +62,7 @@ class AnnoDialogEditCategoryName extends React.PureComponent {
};
editedCategoryNameError = (name) => {
const { metadataField, categoricalSelection } = this.props;
const { metadataField } = this.props;
/* check for syntax errors in category name */
const error = AnnotationsHelpers.annotationNameIsErroneous(name);
@@ -69,7 +71,12 @@ class AnnoDialogEditCategoryName extends React.PureComponent {
}
/* check for duplicative categories */
const allCategoryNames = _.keys(categoricalSelection);
/*
test for uniqueness against *all* annotation names, not just the subset
we render as categorical.
*/
const allCategoryNames = this.allCategoryNames();
const categoryNameAlreadyExists = allCategoryNames.indexOf(name) > -1;
const sameName = name === metadataField;
if (categoryNameAlreadyExists && !sameName) {
@@ -88,6 +95,11 @@ class AnnoDialogEditCategoryName extends React.PureComponent {
);
};
allCategoryNames() {
const { schema } = this.props;
return schema.annotations.obs.columns.map((c) => c.name);
}
render() {
const { newCategoryText } = this.state;
const { metadataField, annotations, ontology } = this.props;
@@ -1,14 +1,9 @@
import React from "react";
import _ from "lodash";
import { connect } from "react-redux";
import { Flipper, Flipped } from "react-flip-toolkit";
import * as globals from "../../../globals";
import Value from "../value";
@connect((state) => ({
categoricalSelection: state.categoricalSelection,
}))
class Category extends React.Component {
constructor(props) {
super(props);
@@ -16,9 +11,9 @@ class Category extends React.Component {
}
renderCategoryItems(optTuples) {
const { metadataField, isUserAnno } = this.props;
const { metadataField, isUserAnno, categorySummary } = this.props;
return _.map(optTuples, (tuple, i) => {
return optTuples.map((tuple, i) => {
return (
<Flipped key={tuple[1]} flipId={tuple[1]}>
{(flippedProps) => (
@@ -30,6 +25,7 @@ class Category extends React.Component {
categoryIndex={tuple[1]}
i={i}
flippedProps={flippedProps}
categorySummary={categorySummary}
/>
)}
</Flipped>
@@ -38,16 +34,10 @@ class Category extends React.Component {
}
render() {
const {
metadataField,
categoricalSelection,
children,
isExpanded,
} = this.props;
const { isTruncated } = categoricalSelection[metadataField];
const cat = categoricalSelection[metadataField];
const optTuples = [...cat.categoryValueIndices];
const optTuplesAsKey = _.map(optTuples, (t) => t[0]).join(""); // animation
const { metadataField, categorySummary, children, isExpanded } = this.props;
const { isTruncated } = categorySummary;
const optTuples = [...categorySummary.categoryValueIndices];
const optTuplesAsKey = optTuples.map((t) => t[0]).join(""); // animation
return (
<div
@@ -1,5 +1,4 @@
import React from "react";
import _ from "lodash";
import { connect } from "react-redux";
import { FaChevronRight, FaChevronDown } from "react-icons/fa";
import { AnchorButton, Button, Tooltip } from "@blueprintjs/core";
@@ -10,6 +9,7 @@ import AnnoDialogAddLabel from "./annoDialogAddLabel";
import Truncate from "../../util/truncate";
import * as globals from "../../../globals";
import { createCategorySummary as _createCategorySummary } from "../../../util/stateManager/controlsHelpers";
const LABEL_WIDTH = globals.leftSidebarWidth - 100;
const ANNO_BUTTON_WIDTH = 50;
@@ -22,6 +22,7 @@ const LABEL_WIDTH_ANNO = LABEL_WIDTH - ANNO_BUTTON_WIDTH;
categoricalSelection: state.categoricalSelection,
annotations: state.annotations,
universe: state.universe,
world: state.world,
schema: state.world?.schema,
};
})
@@ -30,37 +31,50 @@ class Category extends React.Component {
super(props);
this.state = {
isChecked: true,
categorySummary: this.createCategorySummary(),
};
}
componentDidUpdate(prevProps) {
const { categoricalSelection, metadataField } = this.props;
const { categoricalSelection, metadataField, world } = this.props;
let { categorySummary } = this.state;
if (
world !== prevProps.world ||
metadataField !== prevProps.metadataField ||
!categorySummary
) {
const newCategorySummary = this.createCategorySummary();
if (categorySummary !== newCategorySummary) {
categorySummary = newCategorySummary;
/* eslint-disable-next-line react/no-did-update-set-state -- Contained in if statement to prevent infinite looping */
this.setState({ categorySummary });
}
}
const cat = categoricalSelection?.[metadataField];
if (
categoricalSelection !== prevProps.categoricalSelection &&
!!cat &&
!!this.checkbox
) {
const categoryCount = {
// total number of categories in this dimension
totalCatCount: cat.numCategoryValues,
// number of selected options in this category
selectedCatCount: _.reduce(
cat.categoryValueSelected,
(res, cond) => (cond ? res + 1 : res),
0
),
};
// total number of categories in this dimension
const totalCatCount = categorySummary.numCategoryValues;
// number of selected options in this category
const selectedCatCount = categorySummary.categoryValues.reduce(
(res, label) => (cat.get(label) ?? true ? res + 1 : res),
0
);
/* eslint-disable react/no-did-update-set-state -- Contained in if statement to prevent infinite looping */
if (categoryCount.selectedCatCount === categoryCount.totalCatCount) {
if (selectedCatCount === totalCatCount) {
/* everything is on, so not indeterminate */
this.checkbox.indeterminate = false;
this.setState({ isChecked: true });
} else if (categoryCount.selectedCatCount === 0) {
} else if (selectedCatCount === 0) {
/* nothing is on, so no */
this.checkbox.indeterminate = false;
this.setState({ isChecked: false });
} else if (categoryCount.selectedCatCount < categoryCount.totalCatCount) {
} else if (selectedCatCount < totalCatCount) {
/* to be explicit... */
this.checkbox.indeterminate = true;
this.setState({ isChecked: false });
@@ -87,27 +101,37 @@ class Category extends React.Component {
}
};
createCategorySummary() {
const { world, metadataField } = this.props;
if (!world || !metadataField || !world.obsAnnotations.hasCol(metadataField))
return null;
return _createCategorySummary(world, metadataField);
}
toggleNone() {
const { dispatch, metadataField } = this.props;
const { categorySummary } = this.state;
dispatch({
type: "categorical metadata filter none of these",
metadataField,
labels: categorySummary.categoryValues,
});
this.setState({ isChecked: false });
}
toggleAll() {
const { dispatch, metadataField } = this.props;
const { categorySummary } = this.state;
dispatch({
type: "categorical metadata filter all of these",
metadataField,
labels: categorySummary.categoryValues,
});
this.setState({ isChecked: true });
}
handleToggleAllClick() {
const { isChecked } = this.state;
// || this.checkbox.indeterminate === false
if (isChecked) {
this.toggleNone();
} else {
@@ -168,29 +192,19 @@ class Category extends React.Component {
}
render() {
const { isChecked } = this.state;
const {
metadataField,
categoricalSelection,
isColorAccessor,
isExpanded,
schema,
} = this.props;
const { isChecked, categorySummary } = this.state;
const { metadataField, isColorAccessor, isExpanded, schema } = this.props;
const isStillLoading = !(categoricalSelection?.[metadataField] ?? false);
const isStillLoading = !categorySummary;
if (isStillLoading) {
return this.renderIsStillLoading();
}
const checkboxID = `category-select-${metadataField}`;
const isUserAnno =
schema?.annotations?.obsByName[metadataField]?.writable ?? false;
const isTruncated = _.get(
categoricalSelection,
[metadataField, "isTruncated"],
false
);
const isUserAnno = !!schema?.annotations?.obsByName[metadataField]
?.writable;
const isTruncated = !!categorySummary?.isTruncated;
if (
!isUserAnno &&
@@ -217,6 +231,7 @@ class Category extends React.Component {
metadataField={metadataField}
isExpanded={isExpanded}
isUserAnno={isUserAnno}
categorySummary={categorySummary}
>
<div
style={{
+2 -4
View File
@@ -13,7 +13,6 @@ import { labelPrompt } from "./labelUtil";
@connect((state) => ({
writableCategoriesEnabled: state.config?.parameters?.annotations ?? false,
schema: state.world?.schema,
config: state.config,
ontology: state.ontology,
}))
class Categories extends React.Component {
@@ -126,12 +125,11 @@ class Categories extends React.Component {
newCategoryText,
expandedCats,
} = this.state;
const { writableCategoriesEnabled, schema, config, ontology } = this.props;
const { writableCategoriesEnabled, schema, ontology } = this.props;
const ontologyEnabled = ontology?.enabled ?? false;
/* all names, sorted in display order. Will be rendered in this order */
const allCategoryNames = ControlsHelpers.selectableCategoryNames(
schema,
ControlsHelpers.maxCategoryItems(config)
schema
).sort();
return (
@@ -21,11 +21,11 @@ import { AnnotationsHelpers } from "../../../util/stateManager";
import { labelPrompt, isLabelErroneous } from "../labelUtil";
/* this is defined outside of the class so we can use it in connect() */
function _currentLabel(ownProps, categoricalSelection) {
const { metadataField, categoryIndex } = ownProps;
return String(
categoricalSelection[metadataField].categoryValues[categoryIndex]
).valueOf();
function _currentLabelAsString(ownProps) {
const { categorySummary, categoryIndex } = ownProps;
// when called as a function, the String() constructor performs type conversion,
// and returns a primtive string.
return String(categorySummary.categoryValues[categoryIndex]);
}
@connect((state, ownProps) => {
@@ -33,8 +33,7 @@ function _currentLabel(ownProps, categoricalSelection) {
const { metadataField } = ownProps;
const isDilated =
pointDilation.metadataField === metadataField &&
pointDilation.categoryField ===
_currentLabel(ownProps, categoricalSelection);
pointDilation.categoryField === _currentLabelAsString(ownProps);
return {
categoricalSelection,
annotations: state.annotations,
@@ -51,28 +50,39 @@ class CategoryValue extends React.Component {
constructor(props) {
super(props);
this.state = {
editedLabelText: this.currentLabel(),
editedLabelText: this.currentLabelAsString(),
};
}
componentDidUpdate(prevProps) {
const { categoricalSelection, metadataField, categoryIndex } = this.props;
const {
categoricalSelection,
metadataField,
categoryIndex,
categorySummary,
} = this.props;
if (
prevProps.categoricalSelection !== categoricalSelection ||
prevProps.metadataField !== metadataField ||
prevProps.categoryIndex !== categoryIndex
prevProps.categoryIndex !== categoryIndex ||
prevProps.categorySummary !== categorySummary
) {
// eslint-disable-next-line react/no-did-update-set-state --- adequately checked to prevent looping
this.setState({
editedLabelText: this.currentLabel(),
editedLabelText: this.currentLabelAsString(),
});
}
}
getLabel() {
const { categoryIndex, categorySummary } = this.props;
const label = categorySummary.categoryValues[categoryIndex];
return label;
}
handleDeleteValue = () => {
const { dispatch, metadataField } = this.props;
const label = this.getLabel();
dispatch({
type: "annotation: delete label",
metadataField,
@@ -121,7 +131,7 @@ class CategoryValue extends React.Component {
labelNameError = (name) => {
const { metadataField, ontology, schema } = this.props;
if (name === this.currentLabel()) return false;
if (name === this.currentLabelAsString()) return false;
return isLabelErroneous(name, metadataField, ontology, schema);
};
@@ -131,31 +141,44 @@ class CategoryValue extends React.Component {
activateEditLabelMode = () => {
const { dispatch, metadataField, categoryIndex } = this.props;
const label = this.getLabel();
dispatch({
type: "annotation: activate edit label mode",
metadataField,
categoryIndex,
label,
});
};
cancelEditMode = () => {
const { dispatch, metadataField, categoryIndex } = this.props;
const label = this.getLabel();
this.setState({
editedLabelText: this.currentLabel(),
editedLabelText: this.currentLabelAsString(),
});
dispatch({
type: "annotation: cancel edit label mode",
metadataField,
categoryIndex,
label,
});
};
toggleOff = () => {
const { dispatch, metadataField, categoryIndex } = this.props;
const {
dispatch,
metadataField,
categoryIndex,
categorySummary,
} = this.props;
const labels = categorySummary.categoryValues;
const label = labels[categoryIndex];
dispatch({
type: "categorical metadata filter deselect",
metadataField,
categoryIndex,
label,
labels,
});
};
@@ -170,16 +193,24 @@ class CategoryValue extends React.Component {
If and only if true, update the component
*/
const { props, state } = this;
const { metadataField, categoryIndex, categoricalSelection } = props;
const { categoricalSelection: newCategoricalSelection } = nextProps;
const {
metadataField,
categoryIndex,
categoricalSelection,
categorySummary,
} = props;
const {
categoryIndex: newCategoryIndex,
categoricalSelection: newCategoricalSelection,
categorySummary: newCategorySummary,
} = nextProps;
const label = categorySummary.categoryValues[categoryIndex];
const newLabel = newCategorySummary.categoryValues[newCategoryIndex];
const labelChanged = label !== newLabel;
const valueSelectionChange =
categoricalSelection[metadataField].categoryValueSelected[
categoryIndex
] !==
newCategoricalSelection[metadataField].categoryValueSelected[
categoryIndex
];
categoricalSelection[metadataField].get(label) !==
newCategoricalSelection[metadataField].get(newLabel);
const worldChange = props.world !== nextProps.world;
const colorAccessorChange = props.colorAccessor !== nextProps.colorAccessor;
@@ -189,41 +220,60 @@ class CategoryValue extends React.Component {
const editingLabel = state.editedLabelText !== nextState.editedLabelText;
const dilationChange = props.isDilated !== nextProps.isDilated;
const count = categorySummary.categoryValueCounts[categoryIndex];
const newCount = newCategorySummary.categoryValueCounts[newCategoryIndex];
const countChanged = count !== newCount;
return (
labelChanged ||
valueSelectionChange ||
worldChange ||
colorAccessorChange ||
annotationsChange ||
crossfilterChange ||
editingLabel ||
dilationChange
dilationChange ||
countChanged
);
};
toggleOn = () => {
const { dispatch, metadataField, categoryIndex } = this.props;
const {
dispatch,
metadataField,
categoryIndex,
categorySummary,
} = this.props;
const labels = categorySummary.categoryValues;
const label = labels[categoryIndex];
dispatch({
type: "categorical metadata filter select",
metadataField,
categoryIndex,
label,
labels,
});
};
handleMouseEnter = () => {
const { dispatch, metadataField, categoryIndex } = this.props;
const label = this.getLabel();
dispatch({
type: "category value mouse hover start",
metadataField,
categoryIndex,
label,
});
};
handleMouseExit = () => {
const { dispatch, metadataField, categoryIndex } = this.props;
const label = this.getLabel();
dispatch({
type: "category value mouse hover end",
metadataField,
categoryIndex,
label,
});
};
@@ -236,17 +286,8 @@ class CategoryValue extends React.Component {
this.setState({ editedLabelText: e.target });
};
getLabel = () => {
const { metadataField, categoryIndex, categoricalSelection } = this.props;
const category = categoricalSelection[metadataField];
const label = category.categoryValues[categoryIndex];
return label;
};
currentLabel() {
const { categoricalSelection } = this.props;
return _currentLabel(this.props, categoricalSelection);
currentLabelAsString() {
return _currentLabelAsString(this.props);
}
isAddCurrentSelectionDisabled(category, value) {
@@ -294,6 +335,7 @@ class CategoryValue extends React.Component {
flippedProps,
isDilated,
world,
categorySummary,
} = this.props;
const ontologyEnabled = ontology?.enabled ?? false;
@@ -302,10 +344,10 @@ class CategoryValue extends React.Component {
if (!categoricalSelection) return null;
const category = categoricalSelection[metadataField];
const selected = category.categoryValueSelected[categoryIndex];
const count = category.categoryValueCounts[categoryIndex];
const value = category.categoryValues[categoryIndex];
const displayString = this.currentLabel();
const selected = category.get(this.getLabel()) ?? true;
const count = categorySummary.categoryValueCounts[categoryIndex];
const value = categorySummary.categoryValues[categoryIndex];
const displayString = this.currentLabelAsString();
/* this is the color scale, so add swatches below */
const isColorBy = metadataField === colorAccessor;
@@ -40,10 +40,7 @@ class CentroidLabels extends PureComponent {
if (!colorAccessor || labels.size === undefined || labels.size === 0)
return null;
const {
categoryValueIndices,
categoryValueSelected,
} = categoricalSelection?.[colorAccessor];
const category = categoricalSelection[colorAccessor];
const labelSVGS = [];
let fontSize = "15px";
@@ -57,7 +54,7 @@ class CentroidLabels extends PureComponent {
fontWeight = "800";
}
const selected = categoryValueSelected[categoryValueIndices.get(label)];
const selected = category.get(label) ?? true;
// Mirror LSB middle truncation
let displayLabel = label;
-1
View File
@@ -15,7 +15,6 @@ export const configDefaults = {
features: {},
displayNames: {},
parameters: {
"max-category-items": 1000,
"disable-diffexp": false,
"diffexp-may-be-slow": false,
},
+34 -47
View File
@@ -1,11 +1,17 @@
import { ControlsHelpers as CH } from "../util/stateManager";
const CategoricalSelection = (
state,
action,
nextSharedState,
prevSharedState
) => {
/*
State is an object, with a key for each categorical annotation, and a
value which is a Map of label->t/f, reflecting selection state for the label.
Label state default (if missing) is up to the component, but typically true.
{
"louvain": Map(),
...
}
*/
const CategoricalSelection = (state, action, nextSharedState) => {
switch (action.type) {
case "initial data load complete (universe exists)":
case "set World to current selection":
@@ -13,11 +19,7 @@ const CategoricalSelection = (
case "set clip quantiles": {
const { world } = nextSharedState;
const newState = CH.createCategoricalSelection(
world,
CH.selectableCategoryNames(
world.schema,
CH.maxCategoryItems(prevSharedState.config)
)
CH.selectableCategoryNames(world.schema)
);
return newState;
}
@@ -30,13 +32,12 @@ const CategoricalSelection = (
const { world } = nextSharedState;
const names = CH.selectableCategoryNames(
world.schema,
CH.maxCategoryItems(prevSharedState.config),
dataframe.colIndex.labels()
);
if (names.length === 0) return state;
return {
...state,
...CH.createCategoricalSelection(world, names),
...CH.createCategoricalSelection(names),
};
}
@@ -44,16 +45,12 @@ const CategoricalSelection = (
/*
Set the specific category in this field to false
*/
const newCategoryValueSelected = Array.from(
state[action.metadataField].categoryValueSelected
);
newCategoryValueSelected[action.categoryIndex] = true;
const { metadataField, label } = action;
const newSelected = new Map(state[metadataField]);
newSelected.set(label, true);
const newCategoricalSelection = {
...state,
[action.metadataField]: {
...state[action.metadataField],
categoryValueSelected: newCategoryValueSelected,
},
[action.metadataField]: newSelected,
};
return newCategoricalSelection;
}
@@ -62,16 +59,12 @@ const CategoricalSelection = (
/*
Set the specific category in this field to false
*/
const newCategoryValueSelected = Array.from(
state[action.metadataField].categoryValueSelected
);
newCategoryValueSelected[action.categoryIndex] = false;
const { metadataField, label } = action;
const newSelected = new Map(state[metadataField]);
newSelected.set(label, false);
const newCategoricalSelection = {
...state,
[action.metadataField]: {
...state[action.metadataField],
categoryValueSelected: newCategoryValueSelected,
},
[action.metadataField]: newSelected,
};
return newCategoricalSelection;
}
@@ -80,15 +73,13 @@ const CategoricalSelection = (
/*
set all categories in this field to false.
*/
const { metadataField, labels } = action;
const { selected } = state[metadataField];
const newSelected = new Map(selected);
labels.forEach((label) => newSelected.set(label, false));
const newCategoricalSelection = {
...state,
[action.metadataField]: {
...state[action.metadataField],
categorySelected: false,
categoryValueSelected: Array.from(
state[action.metadataField].categoryValueSelected
).fill(false),
},
[action.metadataField]: newSelected,
};
return newCategoricalSelection;
}
@@ -97,25 +88,22 @@ const CategoricalSelection = (
/*
set all categories in this field to true.
*/
const { metadataField, labels } = action;
const { selected } = state[metadataField];
const newSelected = new Map(selected);
labels.forEach((label) => newSelected.set(label, true));
const newCategoricalSelection = {
...state,
[action.metadataField]: {
...state[action.metadataField],
categorySelected: true,
categoryValueSelected: Array.from(
state[action.metadataField].categoryValueSelected
).fill(true),
},
[action.metadataField]: newSelected,
};
return newCategoricalSelection;
}
case "annotation: create category": {
const { world } = nextSharedState;
const name = action.data;
return {
...state,
...CH.createCategoricalSelection(world, [name]),
...CH.createCategoricalSelection([name]),
};
}
@@ -138,12 +126,11 @@ const CategoricalSelection = (
case "annotation: label edited":
case "annotation: delete label": {
/* need to rebuild the state for this annotation */
const { world } = nextSharedState;
const name = action.metadataField;
const { [name]: _, ...partialState } = state;
return {
...partialState,
...CH.createCategoricalSelection(world, [name]),
...CH.createCategoricalSelection([name]),
};
}
+3 -13
View File
@@ -26,14 +26,7 @@ const centroidLabels = (state = initialState, action, sharedNextState) => {
...state,
labels:
!!colorAccessor && showLabels && !!categoricalSelection[colorAccessor]
? calcCentroid(
world.obsAnnotations,
world.obsLayout,
colorAccessor,
layoutChoice.currentDimNames,
categoricalSelection,
world.schema.annotations.obsByName
)
? calcCentroid(world, colorAccessor, layoutChoice.currentDimNames)
: [],
};
@@ -52,12 +45,9 @@ const centroidLabels = (state = initialState, action, sharedNextState) => {
return {
...state,
labels: calcCentroid(
world.obsAnnotations,
world.obsLayout,
world,
colorAccessor,
layoutChoice.currentDimNames,
categoricalSelection,
world.schema.annotations.obsByName
layoutChoice.currentDimNames
),
showLabels,
};
+3 -4
View File
@@ -260,10 +260,9 @@ const CrossfilterReducerBase = (
case "categorical metadata filter select":
case "categorical metadata filter deselect": {
const { categoricalSelection } = nextSharedState;
const cat = categoricalSelection[action.metadataField];
const { categoryValues, categoryValueSelected } = cat;
const values = categoryValues.filter((v, i) => categoryValueSelected[i]);
const { labels, metadataField } = action;
const selected = nextSharedState.categoricalSelection[metadataField];
const values = labels.filter((label) => selected.get(label) ?? true);
return state.select(obsAnnoDimensionName(action.metadataField), {
mode: "exact",
values,
+2 -6
View File
@@ -3,12 +3,8 @@ const initialState = {
categoryField: "",
};
const pointDialation = (state = initialState, action, sharedNextState) => {
const { categoricalSelection } = sharedNextState;
const { metadataField, categoryIndex } = action;
const categoryField =
action.categoryField ||
categoricalSelection?.[metadataField]?.categoryValues[categoryIndex];
const pointDialation = (state = initialState, action) => {
const { metadataField, label: categoryField } = action;
switch (action.type) {
case "category value mouse hover start":
+22 -49
View File
@@ -1,6 +1,10 @@
import quantile from "./quantile";
import { memoize } from "./dataframe/util";
import { unassignedCategoryLabel } from "../globals";
import {
createCategorySummary,
isSelectableCategoryName,
} from "./stateManager/controlsHelpers";
/*
Centroid coordinate calculation
@@ -18,31 +22,24 @@ label -> {
yCoordinates: Float32Array
}
*/
const getCoordinatesByLabel = (
obsAnnotations,
obsLayout,
categoryName,
layoutDimNames,
categoricalSelection,
schemaObsByName
) => {
const getCoordinatesByLabel = (world, categoryName, layoutDimNames) => {
const coordsByCategoryLabel = new Map();
const categoryArray = obsAnnotations.col(categoryName).asArray();
const layoutXArray = obsLayout.col(layoutDimNames[0]).asArray();
const layoutYArray = obsLayout.col(layoutDimNames[1]).asArray();
const { categoryValueIndices, categoryValueCounts } =
categoricalSelection?.[categoryName] || {};
// If the coloredBy is not a categorical col
if (categoryValueIndices === undefined) {
if (!isSelectableCategoryName(world.schema, categoryName)) {
return coordsByCategoryLabel;
}
// Check to see if the current category is a user created annotation
const isUserAnno = schemaObsByName[categoryName].writable;
const { obsAnnotations, obsLayout } = world;
const categoryArray = obsAnnotations.col(categoryName).asArray();
const layoutXArray = obsLayout.col(layoutDimNames[0]).asArray();
const layoutYArray = obsLayout.col(layoutDimNames[1]).asArray();
const categorySummary = createCategorySummary(world, categoryName);
const {
isUserAnno,
categoryValueIndices,
categoryValueCounts,
} = categorySummary;
// Iterate over all cells
for (let i = 0, len = categoryArray.length; i < len; i += 1) {
@@ -96,23 +93,9 @@ const getCoordinatesByLabel = (
label -> [x-Coordinate, y-Coordinate]
*/
const calcMedianCentroid = (
obsAnnotations,
obsLayout,
categoryName,
layoutDimNames,
categoricalSelection,
schemaObsByName
) => {
const calcMedianCentroid = (world, categoryName, layoutDimNames) => {
// generate a map describing the coordinates for each label within the given category
const dataMap = getCoordinatesByLabel(
obsAnnotations,
obsLayout,
categoryName,
layoutDimNames,
categoricalSelection,
schemaObsByName
);
const dataMap = getCoordinatesByLabel(world, categoryName, layoutDimNames);
// label => [medianXCoordinate, medianYCoordinate]
const coordinates = new Map();
@@ -137,19 +120,9 @@ const calcMedianCentroid = (
};
// A simple function to hash the parameters
const hashMedianCentroid = (
obsAnnotations,
obsLayout,
categoryName,
layoutDimNames,
categorySelection,
schemaObsByName
) => {
return `${obsAnnotations.__id}+${
obsLayout.__id
}:${categoryName}:${layoutDimNames}:${Object.keys(
categorySelection
)}:${Object.keys(schemaObsByName)}`;
const hashMedianCentroid = (world, categoryName, layoutDimNames) => {
const { obsAnnotations, obsLayout } = world;
return `${obsAnnotations.__id}+${obsLayout.__id}:${categoryName}:${layoutDimNames}`;
};
// export the memoized calculation function
export default memoize(calcMedianCentroid, hashMedianCentroid);
+39 -51
View File
@@ -6,17 +6,12 @@ import _ from "lodash";
import * as globals from "../../globals";
import { rangeFill as fillRange } from "../range";
import fromEntries from "../fromEntries";
import {
userDefinedDimensionName,
diffexpDimensionName,
} from "../nameCreators";
export function maxCategoryItems(config) {
return (
config.parameters?.["max-category-items"] ??
globals.configDefaults.parameters["max-category-items"]
);
}
import { isCategoricalAnnotation } from "./annotationsHelpers";
/*
Selection state for categoricals are tracked in an Object that
@@ -69,7 +64,12 @@ function topNCategories(colSchema, summary, N) {
return [_topNCategories, topNCounts];
}
export function selectableCategoryNames(schema, maxCatItems, names) {
export function isSelectableCategoryName(schema, name) {
const { index } = schema.annotations.obs;
return name && name !== index && isCategoricalAnnotation(schema, name);
}
export function selectableCategoryNames(schema, names) {
/*
return all obs annotation names that are categorical AND have a
"reasonably" small number of categories AND are not the index column.
@@ -77,56 +77,44 @@ export function selectableCategoryNames(schema, maxCatItems, names) {
If the initial name list not provided, use everything in the schema.
*/
if (!schema) return [];
const { index, columns } = schema.annotations.obs;
return columns
.filter((colSchema) => !names || names.indexOf(colSchema.name) !== -1)
.filter((colSchema) => {
const { type, name } = colSchema;
const isSelectableType =
type === "string" || type === "boolean" || type === "categorical";
return isSelectableType && name !== index;
})
.map((v) => v.name);
if (!names) names = schema.annotations.obs.columns.map((c) => c.name);
return names.filter((name) => isSelectableCategoryName(schema, name));
}
export function createCategoricalSelection(world, names) {
export function createCategorySummary(world, name) {
const N = globals.maxCategoricalOptionsToDisplay;
const { obsAnnotations, schema } = world;
const res = names.reduce((acc, name) => {
const colSchema = schema.annotations.obsByName[name];
const { writable: isUserAnno } = colSchema;
const colSchema = schema.annotations.obsByName[name];
const { writable: isUserAnno } = colSchema;
/*
Summarize the annotation data currently in world. Must return categoryValues
in sorted order, and must include all category values even if they are not
actively used in the current world.
*/
const summary = obsAnnotations.col(name).summarizeCategorical();
const [categoryValues, categoryValueCounts] = topNCategories(
colSchema,
summary,
N
);
const categoryValueIndices = new Map(categoryValues.map((v, i) => [v, i]));
const numCategoryValues = categoryValueIndices.size;
const categoryValueSelected = new Array(numCategoryValues).fill(true);
const isTruncated = categoryValues.length < summary.numCategories;
/*
Summarize the annotation data currently in world. Must return categoryValues
in sorted order, and must include all category values even if they are not
actively used in the current world.
*/
const summary = obsAnnotations.col(name).summarizeCategorical();
const [categoryValues, categoryValueCounts] = topNCategories(
colSchema,
summary,
N
);
const categoryValueIndices = new Map(categoryValues.map((v, i) => [v, i]));
const numCategoryValues = categoryValueIndices.size;
const isTruncated = categoryValues.length < summary.numCategories;
acc[name] = {
categoryValues, // array: of natively typed category values
categoryValueIndices, // map: category value (native type) -> category index
categoryValueSelected, // array: t/f selection state
numCategoryValues, // number: of values in the category
isTruncated, // bool: true if list was truncated
categoryValueCounts, // array: cardinality of each category,
categorySelected: true, // bool - default state for entire category
isUserAnno, // bool
};
return acc;
}, {});
return res;
return {
categoryValues, // array: of natively typed category values
categoryValueIndices, // map: category value (native type) -> category index
numCategoryValues, // number: of values in the category
isTruncated, // bool: true if list was truncated
categoryValueCounts, // array: cardinality of each category,
isUserAnno, // bool
};
}
export function createCategoricalSelection(names) {
return fromEntries(names.map((name) => [name, new Map()]));
}
/*