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
+8 -4
View File
@@ -1,6 +1,6 @@
import { strict as assert } from "assert"; import { strict as assert } from "assert";
export const cellxgeneActions = (page, utils) => ({ const cellxgeneActions = (page, utils) => ({
async drag(testId, start, end, lasso = false) { async drag(testId, start, end, lasso = false) {
const layout = await utils.waitByID(testId); const layout = await utils.waitByID(testId);
const elBox = await layout.boxModel(); const elBox = await layout.boxModel();
@@ -31,14 +31,16 @@ export const cellxgeneActions = (page, utils) => ({
async getAllHistograms(testclass, testIds) { async getAllHistograms(testclass, testIds) {
const histTestIds = testIds.map((tid) => `histogram-${tid}`); const histTestIds = testIds.map((tid) => `histogram-${tid}`);
// these load asynchronously, so we need to wait for each histogram individually // these load asynchronously, so we need to wait for each histogram individually,
await utils.waitForAllByIds(histTestIds); // and they may be quite slow in some cases.
await utils.waitForAllByIds(histTestIds, { timeout: 240000 });
const allHistograms = await utils.getAllByClass(testclass); const allHistograms = await utils.getAllByClass(testclass);
return allHistograms.map((hist) => hist.replace(/^histogram-/, "")); return allHistograms.map((hist) => hist.replace(/^histogram-/, ""));
}, },
async getAllCategoriesAndCounts(category) { async getAllCategoriesAndCounts(category) {
await utils.waitByClass("categorical-row"); // these load asynchronously, so we have to wait for the specific category.
await utils.waitByID(`category-${category}`);
return page.$$eval( return page.$$eval(
`[data-testid="category-${category}"] [data-testclass='categorical-row']`, `[data-testid="category-${category}"] [data-testclass='categorical-row']`,
(rows) => (rows) =>
@@ -222,3 +224,5 @@ export const cellxgeneActions = (page, utils) => ({
await page.keyboard.press("Enter"); await page.keyboard.press("Enter");
}, },
}); });
export default cellxgeneActions;
+4 -3
View File
@@ -1,10 +1,11 @@
export const jestEnv = process.env.JEST_ENV; export const jestEnv = process.env.JEST_ENV;
export const appPort = process.env.CXG_SERVER_PORT; export const appPort = process.env.CXG_SERVER_PORT;
export const appUrlBase = export const appUrlBase =
process.env.CXG_URL_BASE || `http://localhost:${appPort}`; process.env.CXG_URL_BASE || `http://localhost:${appPort}`;
export const DEV = jestEnv === "dev"; export const DEV = jestEnv === "dev";
export const DEBUG = jestEnv === "debug"; export const DEBUG = jestEnv === "debug";
export const DATASET = "pbmc3k"; export const DATASET = "pbmc3k";
if (DEBUG) jest.setTimeout(100000); if (DEBUG) jest.setTimeout(2 * 60 * 1000);
if (DEV) jest.setTimeout(10000); if (DEV) jest.setTimeout(30 * 1000);
if (!DEBUG && !DEV) jest.setTimeout(10 * 1000);
+3 -3
View File
@@ -3,8 +3,8 @@ Smoke test suite that will be run in Travis CI
Tests included in this file are expected to be relatively stable and test core features Tests included in this file are expected to be relatively stable and test core features
*/ */
import { appUrlBase, DATASET } from "./config"; import { appUrlBase, DATASET, DEBUG } from "./config";
import { setupTestBrowser } from "./testBrowser"; import setupTestBrowser from "./testBrowser";
import { datasets } from "./data"; import { datasets } from "./data";
let browser; let browser;
@@ -22,7 +22,7 @@ beforeEach(async () => {
}); });
afterAll(() => { afterAll(() => {
if (browser !== undefined) browser.close(); if (!DEBUG && browser !== undefined) browser.close();
}); });
describe("did launch", () => { describe("did launch", () => {
+9 -12
View File
@@ -1,8 +1,8 @@
/* /*
Tests included in this file are specific to annotation features Tests included in this file are specific to annotation features
*/ */
import { appUrlBase, DATASET } from "./config"; import { appUrlBase, DATASET, DEBUG } from "./config";
import { setupTestBrowser } from "./testBrowser"; import setupTestBrowser from "./testBrowser";
import { datasets } from "./data"; import { datasets } from "./data";
let browser; let browser;
@@ -15,8 +15,8 @@ beforeAll(async () => {
[browser, page, utils, actions] = await setupTestBrowser(); [browser, page, utils, actions] = await setupTestBrowser();
}); });
afterAll(() => { afterAll(async () => {
if (browser !== undefined) browser.close(); if (!DEBUG && browser !== undefined) await browser.close();
}); });
describe.each([ describe.each([
@@ -28,6 +28,7 @@ describe.each([
beforeEach(async () => { beforeEach(async () => {
await page.goto(appUrlBase); await page.goto(appUrlBase);
// wait for the page to load // wait for the page to load
await utils.waitByClass("autosave-complete"); await utils.waitByClass("autosave-complete");
// setup the test fixtures // setup the test fixtures
@@ -238,13 +239,9 @@ describe.each([
} }
async function deleteCategoryIfExists(categoryName) { async function deleteCategoryIfExists(categoryName) {
try { const handle = await page.$(
const category = await page.waitForSelector( `[data-testid='${categoryName}:category-expand']`
`[data-testid='${categoryName}:category-expand']`, );
{ timeout: 200 } if (handle) await actions.deleteCategory(categoryName);
);
if (category !== null) return await actions.deleteCategory(categoryName);
} catch {}
return null;
} }
}); });
+10 -6
View File
@@ -1,15 +1,17 @@
export const puppeteerUtils = (page) => ({ const puppeteerUtils = (page) => ({
async waitByID(testId, props = {}) { waitByID(testId, props = {}) {
return page.waitForSelector(`[data-testid='${testId}']`, props); return page.waitForSelector(`[data-testid='${testId}']`, props);
}, },
async waitByClass(testClass, props = {}) { waitByClass(testClass, props = {}) {
return page.waitForSelector(`[data-testclass='${testClass}']`, props); return page.waitForSelector(`[data-testclass='${testClass}']`, props);
}, },
async waitForAllByIds(testIds) { async waitForAllByIds(testIds, props = {}) {
await Promise.all( return Promise.all(
testIds.map((testId) => page.waitForSelector(`[data-testid='${testId}']`)) testIds.map((testId) =>
page.waitForSelector(`[data-testid='${testId}']`, props)
)
); );
}, },
@@ -67,3 +69,5 @@ export const puppeteerUtils = (page) => ({
}); });
}, },
}); });
export default puppeteerUtils;
+14 -5
View File
@@ -1,9 +1,9 @@
import puppeteer from "puppeteer"; import puppeteer from "puppeteer";
import { DEBUG, DEV } from "./config"; import { DEBUG, DEV } from "./config";
import { puppeteerUtils } from "./puppeteerUtils"; import puppeteerUtils from "./puppeteerUtils";
import { cellxgeneActions } from "./cellxgeneActions"; import cellxgeneActions from "./cellxgeneActions";
export async function setupTestBrowser() { export default async function setupTestBrowser() {
const browserViewport = { width: 1280, height: 960 }; const browserViewport = { width: 1280, height: 960 };
const browserParams = DEV const browserParams = DEV
? { ? {
@@ -35,21 +35,30 @@ export async function setupTestBrowser() {
if (DEV || DEBUG) { if (DEV || DEBUG) {
page.on("console", async (msg) => { page.on("console", async (msg) => {
// If there is a console.error but an error is not thrown, this will ensure the test fails // If there is a console.error but an error is not thrown, this will ensure the test fails
console.log(`PAGE LOG: ${msg.text()}`);
if (msg.type() === "error") { if (msg.type() === "error") {
// TODO: chromium does not currently support the CSP directive on the // TODO: chromium does not currently support the CSP directive on the
// line below, so we swallow this error. Remove this when the test // line below, so we swallow this error. Remove this when the test
// suite uses a browser version that supports this directive. // suite uses a browser version that supports this directive.
if (msg.text() === "Unrecognized Content-Security-Policy directive 'require-trusted-types-for'.\n") return; if (
msg.text() ===
"Unrecognized Content-Security-Policy directive 'require-trusted-types-for'.\n"
)
return;
const errorMsgText = await Promise.all( const errorMsgText = await Promise.all(
// TODO can we do this without internal properties? // TODO can we do this without internal properties?
msg.args().map((arg) => arg._remoteObject.description) msg.args().map((arg) => arg._remoteObject.description)
); );
throw new Error(`Console error: ${errorMsgText}`); throw new Error(`Console error: ${errorMsgText}`);
} }
console.log(`PAGE LOG: ${msg.text()}`);
}); });
} }
page.on("pageerror", (err) => { page.on("pageerror", (err) => {
console.log(`PAGE LOG: ${msg.text()}`);
throw new Error(`Console error: ${err}`);
});
page.on("error", (err) => {
console.log(`PAGE LOG: ${msg.text()}`);
throw new Error(`Console error: ${err}`); throw new Error(`Console error: ${err}`);
}); });
const utils = puppeteerUtils(page); const utils = puppeteerUtils(page);
+5 -12
View File
@@ -35,19 +35,15 @@ describe("centroid", () => {
// Create categorical selection from world // Create categorical selection from world
categoricalSelection = CH.createCategoricalSelection( categoricalSelection = CH.createCategoricalSelection(
world, CH.selectableCategoryNames(world.schema)
CH.selectableCategoryNames(world.schema, CH.maxCategoryItems(REST.config))
); );
}); });
test("field4 (categorical obsAnnotation)", () => { test("field4 (categorical obsAnnotation)", () => {
const centroidResult = calcCentroid( const centroidResult = calcCentroid(
world.obsAnnotations, world,
world.obsLayout,
"field4", "field4",
["umap_0", "umap_1"], ["umap_0", "umap_1"]
categoricalSelection,
world.schema.annotations.obsByName
); );
// Check to see that a centroid has been calculated for every categorical value // Check to see that a centroid has been calculated for every categorical value
@@ -69,12 +65,9 @@ describe("centroid", () => {
test("field3 (boolean obsAnnotation)", () => { test("field3 (boolean obsAnnotation)", () => {
const centroidResult = calcCentroid( const centroidResult = calcCentroid(
world.obsAnnotations, world,
world.obsLayout,
"field3", "field3",
["umap_0", "umap_1"], ["umap_0", "umap_1"]
categoricalSelection,
world.schema.annotations.obsByName
); );
// Check to see that a centroid has been calculated for every categorical value // Check to see that a centroid has been calculated for every categorical value
+4753 -3144
View File
File diff suppressed because it is too large Load Diff
+29 -29
View File
@@ -26,9 +26,9 @@
"eslint-scope": "3.7.1" "eslint-scope": "3.7.1"
}, },
"dependencies": { "dependencies": {
"@blueprintjs/core": "^3.24.0", "@blueprintjs/core": "^3.28.1",
"@blueprintjs/icons": "^3.14.0", "@blueprintjs/icons": "^3.18.0",
"@blueprintjs/select": "^3.12.0", "@blueprintjs/select": "^3.13.2",
"d3": "^4.10.0", "d3": "^4.10.0",
"d3-scale-chromatic": "^1.5.0", "d3-scale-chromatic": "^1.5.0",
"flatbuffers": "^1.11.0", "flatbuffers": "^1.11.0",
@@ -43,39 +43,39 @@
"react-dom": "^16.13.1", "react-dom": "^16.13.1",
"react-flip-toolkit": "7.0.6", "react-flip-toolkit": "7.0.6",
"react-helmet": "^5.2.1", "react-helmet": "^5.2.1",
"react-icons": "^3.9.0", "react-icons": "^3.10.0",
"react-redux": "^7.2.0", "react-redux": "^7.2.0",
"redux": "^4.0.5", "redux": "^4.0.5",
"redux-thunk": "^2.3.0", "redux-thunk": "^2.3.0",
"regl": "^1.4.0" "regl": "^1.6.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.9.0", "@babel/core": "^7.10.2",
"@babel/plugin-proposal-class-properties": "^7.8.3", "@babel/plugin-proposal-class-properties": "^7.10.1",
"@babel/plugin-proposal-decorators": "^7.8.3", "@babel/plugin-proposal-decorators": "^7.10.1",
"@babel/plugin-proposal-export-namespace-from": "^7.8.3", "@babel/plugin-proposal-export-namespace-from": "^7.10.1",
"@babel/plugin-proposal-function-bind": "^7.8.3", "@babel/plugin-proposal-function-bind": "^7.10.1",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-proposal-nullish-coalescing-operator": "^7.10.1",
"@babel/plugin-proposal-optional-chaining": "^7.9.0", "@babel/plugin-proposal-optional-chaining": "^7.10.1",
"@babel/plugin-transform-react-constant-elements": "^7.9.0", "@babel/plugin-transform-react-constant-elements": "^7.10.1",
"@babel/plugin-transform-runtime": "^7.9.0", "@babel/plugin-transform-runtime": "^7.10.1",
"@babel/preset-env": "^7.9.0", "@babel/preset-env": "^7.10.2",
"@babel/preset-react": "^7.9.4", "@babel/preset-react": "^7.10.1",
"@babel/register": "^7.9.0", "@babel/register": "^7.10.1",
"@babel/runtime": "^7.9.2", "@babel/runtime": "^7.10.2",
"@sentry/webpack-plugin": "^1.11.1", "@sentry/webpack-plugin": "^1.11.1",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"babel-jest": "^25.2.6", "babel-jest": "^26.0.1",
"babel-loader": "^8.1.0", "babel-loader": "^8.1.0",
"babel-preset-modern-browsers": "^14.2.1", "babel-preset-modern-browsers": "^14.2.1",
"chalk": "^4.0.0", "chalk": "^4.0.0",
"cheerio": "^1.0.0-rc.3", "cheerio": "^1.0.0-rc.3",
"clean-css": "^4.2.3", "clean-css": "^4.2.3",
"clean-webpack-plugin": "^3.0.0", "clean-webpack-plugin": "^3.0.0",
"codecov": "^3.6.5", "codecov": "^3.7.0",
"connect-history-api-fallback": "^1.6.0", "connect-history-api-fallback": "^1.6.0",
"copy-webpack-plugin": "^5.1.1", "copy-webpack-plugin": "^5.1.1",
"css-loader": "^3.4.2", "css-loader": "^3.5.3",
"eslint": "^7.2.0", "eslint": "^7.2.0",
"eslint-config-airbnb": "^18.0.1", "eslint-config-airbnb": "^18.0.1",
"eslint-config-prettier": "^6.11.0", "eslint-config-prettier": "^6.11.0",
@@ -92,23 +92,23 @@
"favicons-webpack-plugin": "^3.0.1", "favicons-webpack-plugin": "^3.0.1",
"file-loader": "^6.0.0", "file-loader": "^6.0.0",
"html-webpack-inline-source-plugin": "^1.0.0-beta.2", "html-webpack-inline-source-plugin": "^1.0.0-beta.2",
"html-webpack-plugin": "^4.0.0", "html-webpack-plugin": "^4.3.0",
"husky": "^4.2.5", "husky": "^4.2.5",
"jest": "^25.2.7", "jest": "^26.0.1",
"jest-puppeteer": "^4.4.0", "jest-puppeteer": "^4.4.0",
"json-loader": "^0.5.7", "json-loader": "^0.5.7",
"lint-staged": "^10.2.4", "lint-staged": "^10.2.9",
"mini-css-extract-plugin": "^0.9.0", "mini-css-extract-plugin": "^0.9.0",
"optimize-css-assets-webpack-plugin": "^5.0.3", "optimize-css-assets-webpack-plugin": "^5.0.3",
"prettier": "^2.0.5", "prettier": "^2.0.5",
"puppeteer": "^2.1.1", "puppeteer": "^3.3.0",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"serve-favicon": "^2.5.0", "serve-favicon": "^2.5.0",
"style-loader": "^1.1.3", "style-loader": "^1.2.1",
"sw-precache-webpack-plugin": "^1.0.0", "sw-precache-webpack-plugin": "^1.0.0",
"terser-webpack-plugin": "^2.3.6", "terser-webpack-plugin": "^2.3.7",
"url-loader": "^4.0.0", "url-loader": "^4.1.0",
"webpack": "^4.42.1", "webpack": "^4.43.0",
"webpack-cli": "^3.3.11", "webpack-cli": "^3.3.11",
"webpack-dev-middleware": "^3.7.2" "webpack-dev-middleware": "^3.7.2"
}, },
@@ -1,13 +1,6 @@
import React from "react"; import React from "react";
import { connect } from "react-redux";
import { Button, Tooltip, Dialog, Classes, Colors } from "@blueprintjs/core"; 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 { class AnnoDialog extends React.PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
@@ -1,14 +1,7 @@
import React from "react"; import React from "react";
import { connect } from "react-redux";
import { Button, MenuItem } from "@blueprintjs/core"; import { Button, MenuItem } from "@blueprintjs/core";
import { Select } from "@blueprintjs/select"; 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 { class DuplicateCategorySelect extends React.PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
@@ -5,8 +5,6 @@ import LabelInput from "../labelInput";
import { labelPrompt, isLabelErroneous } from "../labelUtil"; import { labelPrompt, isLabelErroneous } from "../labelUtil";
@connect((state) => ({ @connect((state) => ({
colorAccessor: state.colors.colorAccessor,
categoricalSelection: state.categoricalSelection,
annotations: state.annotations, annotations: state.annotations,
universe: state.universe, universe: state.universe,
ontology: state.ontology, ontology: state.ontology,
@@ -1,5 +1,4 @@
import React from "react"; import React from "react";
import _ from "lodash";
import { connect } from "react-redux"; import { connect } from "react-redux";
import AnnoDialog from "../annoDialog"; import AnnoDialog from "../annoDialog";
import LabelInput from "../labelInput"; import LabelInput from "../labelInput";
@@ -8,9 +7,9 @@ import { labelPrompt } from "../labelUtil";
import { AnnotationsHelpers } from "../../../util/stateManager"; import { AnnotationsHelpers } from "../../../util/stateManager";
@connect((state) => ({ @connect((state) => ({
categoricalSelection: state.categoricalSelection,
annotations: state.annotations, annotations: state.annotations,
universe: state.universe, universe: state.universe,
schema: state.world?.schema,
ontology: state.ontology, ontology: state.ontology,
})) }))
class AnnoDialogEditCategoryName extends React.PureComponent { class AnnoDialogEditCategoryName extends React.PureComponent {
@@ -36,11 +35,14 @@ class AnnoDialogEditCategoryName extends React.PureComponent {
}; };
handleEditCategory = (e) => { handleEditCategory = (e) => {
const { dispatch, metadataField, categoricalSelection } = this.props; const { dispatch, metadataField } = this.props;
const { newCategoryText } = this.state; 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 ( if (
(allCategoryNames.indexOf(newCategoryText) > -1 && (allCategoryNames.indexOf(newCategoryText) > -1 &&
newCategoryText !== metadataField) || newCategoryText !== metadataField) ||
@@ -60,7 +62,7 @@ class AnnoDialogEditCategoryName extends React.PureComponent {
}; };
editedCategoryNameError = (name) => { editedCategoryNameError = (name) => {
const { metadataField, categoricalSelection } = this.props; const { metadataField } = this.props;
/* check for syntax errors in category name */ /* check for syntax errors in category name */
const error = AnnotationsHelpers.annotationNameIsErroneous(name); const error = AnnotationsHelpers.annotationNameIsErroneous(name);
@@ -69,7 +71,12 @@ class AnnoDialogEditCategoryName extends React.PureComponent {
} }
/* check for duplicative categories */ /* 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 categoryNameAlreadyExists = allCategoryNames.indexOf(name) > -1;
const sameName = name === metadataField; const sameName = name === metadataField;
if (categoryNameAlreadyExists && !sameName) { 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() { render() {
const { newCategoryText } = this.state; const { newCategoryText } = this.state;
const { metadataField, annotations, ontology } = this.props; const { metadataField, annotations, ontology } = this.props;
@@ -1,14 +1,9 @@
import React from "react"; import React from "react";
import _ from "lodash";
import { connect } from "react-redux";
import { Flipper, Flipped } from "react-flip-toolkit"; import { Flipper, Flipped } from "react-flip-toolkit";
import * as globals from "../../../globals"; import * as globals from "../../../globals";
import Value from "../value"; import Value from "../value";
@connect((state) => ({
categoricalSelection: state.categoricalSelection,
}))
class Category extends React.Component { class Category extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
@@ -16,9 +11,9 @@ class Category extends React.Component {
} }
renderCategoryItems(optTuples) { renderCategoryItems(optTuples) {
const { metadataField, isUserAnno } = this.props; const { metadataField, isUserAnno, categorySummary } = this.props;
return _.map(optTuples, (tuple, i) => { return optTuples.map((tuple, i) => {
return ( return (
<Flipped key={tuple[1]} flipId={tuple[1]}> <Flipped key={tuple[1]} flipId={tuple[1]}>
{(flippedProps) => ( {(flippedProps) => (
@@ -30,6 +25,7 @@ class Category extends React.Component {
categoryIndex={tuple[1]} categoryIndex={tuple[1]}
i={i} i={i}
flippedProps={flippedProps} flippedProps={flippedProps}
categorySummary={categorySummary}
/> />
)} )}
</Flipped> </Flipped>
@@ -38,16 +34,10 @@ class Category extends React.Component {
} }
render() { render() {
const { const { metadataField, categorySummary, children, isExpanded } = this.props;
metadataField, const { isTruncated } = categorySummary;
categoricalSelection, const optTuples = [...categorySummary.categoryValueIndices];
children, const optTuplesAsKey = optTuples.map((t) => t[0]).join(""); // animation
isExpanded,
} = this.props;
const { isTruncated } = categoricalSelection[metadataField];
const cat = categoricalSelection[metadataField];
const optTuples = [...cat.categoryValueIndices];
const optTuplesAsKey = _.map(optTuples, (t) => t[0]).join(""); // animation
return ( return (
<div <div
@@ -1,5 +1,4 @@
import React from "react"; import React from "react";
import _ from "lodash";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { FaChevronRight, FaChevronDown } from "react-icons/fa"; import { FaChevronRight, FaChevronDown } from "react-icons/fa";
import { AnchorButton, Button, Tooltip } from "@blueprintjs/core"; import { AnchorButton, Button, Tooltip } from "@blueprintjs/core";
@@ -10,6 +9,7 @@ import AnnoDialogAddLabel from "./annoDialogAddLabel";
import Truncate from "../../util/truncate"; import Truncate from "../../util/truncate";
import * as globals from "../../../globals"; import * as globals from "../../../globals";
import { createCategorySummary as _createCategorySummary } from "../../../util/stateManager/controlsHelpers";
const LABEL_WIDTH = globals.leftSidebarWidth - 100; const LABEL_WIDTH = globals.leftSidebarWidth - 100;
const ANNO_BUTTON_WIDTH = 50; const ANNO_BUTTON_WIDTH = 50;
@@ -22,6 +22,7 @@ const LABEL_WIDTH_ANNO = LABEL_WIDTH - ANNO_BUTTON_WIDTH;
categoricalSelection: state.categoricalSelection, categoricalSelection: state.categoricalSelection,
annotations: state.annotations, annotations: state.annotations,
universe: state.universe, universe: state.universe,
world: state.world,
schema: state.world?.schema, schema: state.world?.schema,
}; };
}) })
@@ -30,37 +31,50 @@ class Category extends React.Component {
super(props); super(props);
this.state = { this.state = {
isChecked: true, isChecked: true,
categorySummary: this.createCategorySummary(),
}; };
} }
componentDidUpdate(prevProps) { 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]; const cat = categoricalSelection?.[metadataField];
if ( if (
categoricalSelection !== prevProps.categoricalSelection && categoricalSelection !== prevProps.categoricalSelection &&
!!cat && !!cat &&
!!this.checkbox !!this.checkbox
) { ) {
const categoryCount = { // total number of categories in this dimension
// total number of categories in this dimension const totalCatCount = categorySummary.numCategoryValues;
totalCatCount: cat.numCategoryValues, // number of selected options in this category
// number of selected options in this category const selectedCatCount = categorySummary.categoryValues.reduce(
selectedCatCount: _.reduce( (res, label) => (cat.get(label) ?? true ? res + 1 : res),
cat.categoryValueSelected, 0
(res, cond) => (cond ? res + 1 : res), );
0
),
};
/* eslint-disable react/no-did-update-set-state -- Contained in if statement to prevent infinite looping */ /* 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 */ /* everything is on, so not indeterminate */
this.checkbox.indeterminate = false; this.checkbox.indeterminate = false;
this.setState({ isChecked: true }); this.setState({ isChecked: true });
} else if (categoryCount.selectedCatCount === 0) { } else if (selectedCatCount === 0) {
/* nothing is on, so no */ /* nothing is on, so no */
this.checkbox.indeterminate = false; this.checkbox.indeterminate = false;
this.setState({ isChecked: false }); this.setState({ isChecked: false });
} else if (categoryCount.selectedCatCount < categoryCount.totalCatCount) { } else if (selectedCatCount < totalCatCount) {
/* to be explicit... */ /* to be explicit... */
this.checkbox.indeterminate = true; this.checkbox.indeterminate = true;
this.setState({ isChecked: false }); 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() { toggleNone() {
const { dispatch, metadataField } = this.props; const { dispatch, metadataField } = this.props;
const { categorySummary } = this.state;
dispatch({ dispatch({
type: "categorical metadata filter none of these", type: "categorical metadata filter none of these",
metadataField, metadataField,
labels: categorySummary.categoryValues,
}); });
this.setState({ isChecked: false }); this.setState({ isChecked: false });
} }
toggleAll() { toggleAll() {
const { dispatch, metadataField } = this.props; const { dispatch, metadataField } = this.props;
const { categorySummary } = this.state;
dispatch({ dispatch({
type: "categorical metadata filter all of these", type: "categorical metadata filter all of these",
metadataField, metadataField,
labels: categorySummary.categoryValues,
}); });
this.setState({ isChecked: true }); this.setState({ isChecked: true });
} }
handleToggleAllClick() { handleToggleAllClick() {
const { isChecked } = this.state; const { isChecked } = this.state;
// || this.checkbox.indeterminate === false
if (isChecked) { if (isChecked) {
this.toggleNone(); this.toggleNone();
} else { } else {
@@ -168,29 +192,19 @@ class Category extends React.Component {
} }
render() { render() {
const { isChecked } = this.state; const { isChecked, categorySummary } = this.state;
const { const { metadataField, isColorAccessor, isExpanded, schema } = this.props;
metadataField,
categoricalSelection,
isColorAccessor,
isExpanded,
schema,
} = this.props;
const isStillLoading = !(categoricalSelection?.[metadataField] ?? false); const isStillLoading = !categorySummary;
if (isStillLoading) { if (isStillLoading) {
return this.renderIsStillLoading(); return this.renderIsStillLoading();
} }
const checkboxID = `category-select-${metadataField}`; const checkboxID = `category-select-${metadataField}`;
const isUserAnno = const isUserAnno = !!schema?.annotations?.obsByName[metadataField]
schema?.annotations?.obsByName[metadataField]?.writable ?? false; ?.writable;
const isTruncated = _.get( const isTruncated = !!categorySummary?.isTruncated;
categoricalSelection,
[metadataField, "isTruncated"],
false
);
if ( if (
!isUserAnno && !isUserAnno &&
@@ -217,6 +231,7 @@ class Category extends React.Component {
metadataField={metadataField} metadataField={metadataField}
isExpanded={isExpanded} isExpanded={isExpanded}
isUserAnno={isUserAnno} isUserAnno={isUserAnno}
categorySummary={categorySummary}
> >
<div <div
style={{ style={{
+2 -4
View File
@@ -13,7 +13,6 @@ import { labelPrompt } from "./labelUtil";
@connect((state) => ({ @connect((state) => ({
writableCategoriesEnabled: state.config?.parameters?.annotations ?? false, writableCategoriesEnabled: state.config?.parameters?.annotations ?? false,
schema: state.world?.schema, schema: state.world?.schema,
config: state.config,
ontology: state.ontology, ontology: state.ontology,
})) }))
class Categories extends React.Component { class Categories extends React.Component {
@@ -126,12 +125,11 @@ class Categories extends React.Component {
newCategoryText, newCategoryText,
expandedCats, expandedCats,
} = this.state; } = this.state;
const { writableCategoriesEnabled, schema, config, ontology } = this.props; const { writableCategoriesEnabled, schema, ontology } = this.props;
const ontologyEnabled = ontology?.enabled ?? false; const ontologyEnabled = ontology?.enabled ?? false;
/* all names, sorted in display order. Will be rendered in this order */ /* all names, sorted in display order. Will be rendered in this order */
const allCategoryNames = ControlsHelpers.selectableCategoryNames( const allCategoryNames = ControlsHelpers.selectableCategoryNames(
schema, schema
ControlsHelpers.maxCategoryItems(config)
).sort(); ).sort();
return ( return (
@@ -21,11 +21,11 @@ import { AnnotationsHelpers } from "../../../util/stateManager";
import { labelPrompt, isLabelErroneous } from "../labelUtil"; import { labelPrompt, isLabelErroneous } from "../labelUtil";
/* this is defined outside of the class so we can use it in connect() */ /* this is defined outside of the class so we can use it in connect() */
function _currentLabel(ownProps, categoricalSelection) { function _currentLabelAsString(ownProps) {
const { metadataField, categoryIndex } = ownProps; const { categorySummary, categoryIndex } = ownProps;
return String( // when called as a function, the String() constructor performs type conversion,
categoricalSelection[metadataField].categoryValues[categoryIndex] // and returns a primtive string.
).valueOf(); return String(categorySummary.categoryValues[categoryIndex]);
} }
@connect((state, ownProps) => { @connect((state, ownProps) => {
@@ -33,8 +33,7 @@ function _currentLabel(ownProps, categoricalSelection) {
const { metadataField } = ownProps; const { metadataField } = ownProps;
const isDilated = const isDilated =
pointDilation.metadataField === metadataField && pointDilation.metadataField === metadataField &&
pointDilation.categoryField === pointDilation.categoryField === _currentLabelAsString(ownProps);
_currentLabel(ownProps, categoricalSelection);
return { return {
categoricalSelection, categoricalSelection,
annotations: state.annotations, annotations: state.annotations,
@@ -51,28 +50,39 @@ class CategoryValue extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
editedLabelText: this.currentLabel(), editedLabelText: this.currentLabelAsString(),
}; };
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
const { categoricalSelection, metadataField, categoryIndex } = this.props; const {
categoricalSelection,
metadataField,
categoryIndex,
categorySummary,
} = this.props;
if ( if (
prevProps.categoricalSelection !== categoricalSelection || prevProps.categoricalSelection !== categoricalSelection ||
prevProps.metadataField !== metadataField || 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 // eslint-disable-next-line react/no-did-update-set-state --- adequately checked to prevent looping
this.setState({ this.setState({
editedLabelText: this.currentLabel(), editedLabelText: this.currentLabelAsString(),
}); });
} }
} }
getLabel() {
const { categoryIndex, categorySummary } = this.props;
const label = categorySummary.categoryValues[categoryIndex];
return label;
}
handleDeleteValue = () => { handleDeleteValue = () => {
const { dispatch, metadataField } = this.props; const { dispatch, metadataField } = this.props;
const label = this.getLabel(); const label = this.getLabel();
dispatch({ dispatch({
type: "annotation: delete label", type: "annotation: delete label",
metadataField, metadataField,
@@ -121,7 +131,7 @@ class CategoryValue extends React.Component {
labelNameError = (name) => { labelNameError = (name) => {
const { metadataField, ontology, schema } = this.props; const { metadataField, ontology, schema } = this.props;
if (name === this.currentLabel()) return false; if (name === this.currentLabelAsString()) return false;
return isLabelErroneous(name, metadataField, ontology, schema); return isLabelErroneous(name, metadataField, ontology, schema);
}; };
@@ -131,31 +141,44 @@ class CategoryValue extends React.Component {
activateEditLabelMode = () => { activateEditLabelMode = () => {
const { dispatch, metadataField, categoryIndex } = this.props; const { dispatch, metadataField, categoryIndex } = this.props;
const label = this.getLabel();
dispatch({ dispatch({
type: "annotation: activate edit label mode", type: "annotation: activate edit label mode",
metadataField, metadataField,
categoryIndex, categoryIndex,
label,
}); });
}; };
cancelEditMode = () => { cancelEditMode = () => {
const { dispatch, metadataField, categoryIndex } = this.props; const { dispatch, metadataField, categoryIndex } = this.props;
const label = this.getLabel();
this.setState({ this.setState({
editedLabelText: this.currentLabel(), editedLabelText: this.currentLabelAsString(),
}); });
dispatch({ dispatch({
type: "annotation: cancel edit label mode", type: "annotation: cancel edit label mode",
metadataField, metadataField,
categoryIndex, categoryIndex,
label,
}); });
}; };
toggleOff = () => { toggleOff = () => {
const { dispatch, metadataField, categoryIndex } = this.props; const {
dispatch,
metadataField,
categoryIndex,
categorySummary,
} = this.props;
const labels = categorySummary.categoryValues;
const label = labels[categoryIndex];
dispatch({ dispatch({
type: "categorical metadata filter deselect", type: "categorical metadata filter deselect",
metadataField, metadataField,
categoryIndex, categoryIndex,
label,
labels,
}); });
}; };
@@ -170,16 +193,24 @@ class CategoryValue extends React.Component {
If and only if true, update the component If and only if true, update the component
*/ */
const { props, state } = this; const { props, state } = this;
const { metadataField, categoryIndex, categoricalSelection } = props; const {
const { categoricalSelection: newCategoricalSelection } = nextProps; 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 = const valueSelectionChange =
categoricalSelection[metadataField].categoryValueSelected[ categoricalSelection[metadataField].get(label) !==
categoryIndex newCategoricalSelection[metadataField].get(newLabel);
] !==
newCategoricalSelection[metadataField].categoryValueSelected[
categoryIndex
];
const worldChange = props.world !== nextProps.world; const worldChange = props.world !== nextProps.world;
const colorAccessorChange = props.colorAccessor !== nextProps.colorAccessor; const colorAccessorChange = props.colorAccessor !== nextProps.colorAccessor;
@@ -189,41 +220,60 @@ class CategoryValue extends React.Component {
const editingLabel = state.editedLabelText !== nextState.editedLabelText; const editingLabel = state.editedLabelText !== nextState.editedLabelText;
const dilationChange = props.isDilated !== nextProps.isDilated; const dilationChange = props.isDilated !== nextProps.isDilated;
const count = categorySummary.categoryValueCounts[categoryIndex];
const newCount = newCategorySummary.categoryValueCounts[newCategoryIndex];
const countChanged = count !== newCount;
return ( return (
labelChanged ||
valueSelectionChange || valueSelectionChange ||
worldChange || worldChange ||
colorAccessorChange || colorAccessorChange ||
annotationsChange || annotationsChange ||
crossfilterChange || crossfilterChange ||
editingLabel || editingLabel ||
dilationChange dilationChange ||
countChanged
); );
}; };
toggleOn = () => { toggleOn = () => {
const { dispatch, metadataField, categoryIndex } = this.props; const {
dispatch,
metadataField,
categoryIndex,
categorySummary,
} = this.props;
const labels = categorySummary.categoryValues;
const label = labels[categoryIndex];
dispatch({ dispatch({
type: "categorical metadata filter select", type: "categorical metadata filter select",
metadataField, metadataField,
categoryIndex, categoryIndex,
label,
labels,
}); });
}; };
handleMouseEnter = () => { handleMouseEnter = () => {
const { dispatch, metadataField, categoryIndex } = this.props; const { dispatch, metadataField, categoryIndex } = this.props;
const label = this.getLabel();
dispatch({ dispatch({
type: "category value mouse hover start", type: "category value mouse hover start",
metadataField, metadataField,
categoryIndex, categoryIndex,
label,
}); });
}; };
handleMouseExit = () => { handleMouseExit = () => {
const { dispatch, metadataField, categoryIndex } = this.props; const { dispatch, metadataField, categoryIndex } = this.props;
const label = this.getLabel();
dispatch({ dispatch({
type: "category value mouse hover end", type: "category value mouse hover end",
metadataField, metadataField,
categoryIndex, categoryIndex,
label,
}); });
}; };
@@ -236,17 +286,8 @@ class CategoryValue extends React.Component {
this.setState({ editedLabelText: e.target }); this.setState({ editedLabelText: e.target });
}; };
getLabel = () => { currentLabelAsString() {
const { metadataField, categoryIndex, categoricalSelection } = this.props; return _currentLabelAsString(this.props);
const category = categoricalSelection[metadataField];
const label = category.categoryValues[categoryIndex];
return label;
};
currentLabel() {
const { categoricalSelection } = this.props;
return _currentLabel(this.props, categoricalSelection);
} }
isAddCurrentSelectionDisabled(category, value) { isAddCurrentSelectionDisabled(category, value) {
@@ -294,6 +335,7 @@ class CategoryValue extends React.Component {
flippedProps, flippedProps,
isDilated, isDilated,
world, world,
categorySummary,
} = this.props; } = this.props;
const ontologyEnabled = ontology?.enabled ?? false; const ontologyEnabled = ontology?.enabled ?? false;
@@ -302,10 +344,10 @@ class CategoryValue extends React.Component {
if (!categoricalSelection) return null; if (!categoricalSelection) return null;
const category = categoricalSelection[metadataField]; const category = categoricalSelection[metadataField];
const selected = category.categoryValueSelected[categoryIndex]; const selected = category.get(this.getLabel()) ?? true;
const count = category.categoryValueCounts[categoryIndex]; const count = categorySummary.categoryValueCounts[categoryIndex];
const value = category.categoryValues[categoryIndex]; const value = categorySummary.categoryValues[categoryIndex];
const displayString = this.currentLabel(); const displayString = this.currentLabelAsString();
/* this is the color scale, so add swatches below */ /* this is the color scale, so add swatches below */
const isColorBy = metadataField === colorAccessor; const isColorBy = metadataField === colorAccessor;
@@ -40,10 +40,7 @@ class CentroidLabels extends PureComponent {
if (!colorAccessor || labels.size === undefined || labels.size === 0) if (!colorAccessor || labels.size === undefined || labels.size === 0)
return null; return null;
const { const category = categoricalSelection[colorAccessor];
categoryValueIndices,
categoryValueSelected,
} = categoricalSelection?.[colorAccessor];
const labelSVGS = []; const labelSVGS = [];
let fontSize = "15px"; let fontSize = "15px";
@@ -57,7 +54,7 @@ class CentroidLabels extends PureComponent {
fontWeight = "800"; fontWeight = "800";
} }
const selected = categoryValueSelected[categoryValueIndices.get(label)]; const selected = category.get(label) ?? true;
// Mirror LSB middle truncation // Mirror LSB middle truncation
let displayLabel = label; let displayLabel = label;
-1
View File
@@ -15,7 +15,6 @@ export const configDefaults = {
features: {}, features: {},
displayNames: {}, displayNames: {},
parameters: { parameters: {
"max-category-items": 1000,
"disable-diffexp": false, "disable-diffexp": false,
"diffexp-may-be-slow": false, "diffexp-may-be-slow": false,
}, },
+34 -47
View File
@@ -1,11 +1,17 @@
import { ControlsHelpers as CH } from "../util/stateManager"; import { ControlsHelpers as CH } from "../util/stateManager";
const CategoricalSelection = ( /*
state, State is an object, with a key for each categorical annotation, and a
action, value which is a Map of label->t/f, reflecting selection state for the label.
nextSharedState,
prevSharedState Label state default (if missing) is up to the component, but typically true.
) => {
{
"louvain": Map(),
...
}
*/
const CategoricalSelection = (state, action, nextSharedState) => {
switch (action.type) { switch (action.type) {
case "initial data load complete (universe exists)": case "initial data load complete (universe exists)":
case "set World to current selection": case "set World to current selection":
@@ -13,11 +19,7 @@ const CategoricalSelection = (
case "set clip quantiles": { case "set clip quantiles": {
const { world } = nextSharedState; const { world } = nextSharedState;
const newState = CH.createCategoricalSelection( const newState = CH.createCategoricalSelection(
world, CH.selectableCategoryNames(world.schema)
CH.selectableCategoryNames(
world.schema,
CH.maxCategoryItems(prevSharedState.config)
)
); );
return newState; return newState;
} }
@@ -30,13 +32,12 @@ const CategoricalSelection = (
const { world } = nextSharedState; const { world } = nextSharedState;
const names = CH.selectableCategoryNames( const names = CH.selectableCategoryNames(
world.schema, world.schema,
CH.maxCategoryItems(prevSharedState.config),
dataframe.colIndex.labels() dataframe.colIndex.labels()
); );
if (names.length === 0) return state; if (names.length === 0) return state;
return { return {
...state, ...state,
...CH.createCategoricalSelection(world, names), ...CH.createCategoricalSelection(names),
}; };
} }
@@ -44,16 +45,12 @@ const CategoricalSelection = (
/* /*
Set the specific category in this field to false Set the specific category in this field to false
*/ */
const newCategoryValueSelected = Array.from( const { metadataField, label } = action;
state[action.metadataField].categoryValueSelected const newSelected = new Map(state[metadataField]);
); newSelected.set(label, true);
newCategoryValueSelected[action.categoryIndex] = true;
const newCategoricalSelection = { const newCategoricalSelection = {
...state, ...state,
[action.metadataField]: { [action.metadataField]: newSelected,
...state[action.metadataField],
categoryValueSelected: newCategoryValueSelected,
},
}; };
return newCategoricalSelection; return newCategoricalSelection;
} }
@@ -62,16 +59,12 @@ const CategoricalSelection = (
/* /*
Set the specific category in this field to false Set the specific category in this field to false
*/ */
const newCategoryValueSelected = Array.from( const { metadataField, label } = action;
state[action.metadataField].categoryValueSelected const newSelected = new Map(state[metadataField]);
); newSelected.set(label, false);
newCategoryValueSelected[action.categoryIndex] = false;
const newCategoricalSelection = { const newCategoricalSelection = {
...state, ...state,
[action.metadataField]: { [action.metadataField]: newSelected,
...state[action.metadataField],
categoryValueSelected: newCategoryValueSelected,
},
}; };
return newCategoricalSelection; return newCategoricalSelection;
} }
@@ -80,15 +73,13 @@ const CategoricalSelection = (
/* /*
set all categories in this field to false. 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 = { const newCategoricalSelection = {
...state, ...state,
[action.metadataField]: { [action.metadataField]: newSelected,
...state[action.metadataField],
categorySelected: false,
categoryValueSelected: Array.from(
state[action.metadataField].categoryValueSelected
).fill(false),
},
}; };
return newCategoricalSelection; return newCategoricalSelection;
} }
@@ -97,25 +88,22 @@ const CategoricalSelection = (
/* /*
set all categories in this field to true. 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 = { const newCategoricalSelection = {
...state, ...state,
[action.metadataField]: { [action.metadataField]: newSelected,
...state[action.metadataField],
categorySelected: true,
categoryValueSelected: Array.from(
state[action.metadataField].categoryValueSelected
).fill(true),
},
}; };
return newCategoricalSelection; return newCategoricalSelection;
} }
case "annotation: create category": { case "annotation: create category": {
const { world } = nextSharedState;
const name = action.data; const name = action.data;
return { return {
...state, ...state,
...CH.createCategoricalSelection(world, [name]), ...CH.createCategoricalSelection([name]),
}; };
} }
@@ -138,12 +126,11 @@ const CategoricalSelection = (
case "annotation: label edited": case "annotation: label edited":
case "annotation: delete label": { case "annotation: delete label": {
/* need to rebuild the state for this annotation */ /* need to rebuild the state for this annotation */
const { world } = nextSharedState;
const name = action.metadataField; const name = action.metadataField;
const { [name]: _, ...partialState } = state; const { [name]: _, ...partialState } = state;
return { return {
...partialState, ...partialState,
...CH.createCategoricalSelection(world, [name]), ...CH.createCategoricalSelection([name]),
}; };
} }
+3 -13
View File
@@ -26,14 +26,7 @@ const centroidLabels = (state = initialState, action, sharedNextState) => {
...state, ...state,
labels: labels:
!!colorAccessor && showLabels && !!categoricalSelection[colorAccessor] !!colorAccessor && showLabels && !!categoricalSelection[colorAccessor]
? calcCentroid( ? calcCentroid(world, colorAccessor, layoutChoice.currentDimNames)
world.obsAnnotations,
world.obsLayout,
colorAccessor,
layoutChoice.currentDimNames,
categoricalSelection,
world.schema.annotations.obsByName
)
: [], : [],
}; };
@@ -52,12 +45,9 @@ const centroidLabels = (state = initialState, action, sharedNextState) => {
return { return {
...state, ...state,
labels: calcCentroid( labels: calcCentroid(
world.obsAnnotations, world,
world.obsLayout,
colorAccessor, colorAccessor,
layoutChoice.currentDimNames, layoutChoice.currentDimNames
categoricalSelection,
world.schema.annotations.obsByName
), ),
showLabels, showLabels,
}; };
+3 -4
View File
@@ -260,10 +260,9 @@ const CrossfilterReducerBase = (
case "categorical metadata filter select": case "categorical metadata filter select":
case "categorical metadata filter deselect": { case "categorical metadata filter deselect": {
const { categoricalSelection } = nextSharedState; const { labels, metadataField } = action;
const cat = categoricalSelection[action.metadataField]; const selected = nextSharedState.categoricalSelection[metadataField];
const { categoryValues, categoryValueSelected } = cat; const values = labels.filter((label) => selected.get(label) ?? true);
const values = categoryValues.filter((v, i) => categoryValueSelected[i]);
return state.select(obsAnnoDimensionName(action.metadataField), { return state.select(obsAnnoDimensionName(action.metadataField), {
mode: "exact", mode: "exact",
values, values,
+2 -6
View File
@@ -3,12 +3,8 @@ const initialState = {
categoryField: "", categoryField: "",
}; };
const pointDialation = (state = initialState, action, sharedNextState) => { const pointDialation = (state = initialState, action) => {
const { categoricalSelection } = sharedNextState; const { metadataField, label: categoryField } = action;
const { metadataField, categoryIndex } = action;
const categoryField =
action.categoryField ||
categoricalSelection?.[metadataField]?.categoryValues[categoryIndex];
switch (action.type) { switch (action.type) {
case "category value mouse hover start": case "category value mouse hover start":
+22 -49
View File
@@ -1,6 +1,10 @@
import quantile from "./quantile"; import quantile from "./quantile";
import { memoize } from "./dataframe/util"; import { memoize } from "./dataframe/util";
import { unassignedCategoryLabel } from "../globals"; import { unassignedCategoryLabel } from "../globals";
import {
createCategorySummary,
isSelectableCategoryName,
} from "./stateManager/controlsHelpers";
/* /*
Centroid coordinate calculation Centroid coordinate calculation
@@ -18,31 +22,24 @@ label -> {
yCoordinates: Float32Array yCoordinates: Float32Array
} }
*/ */
const getCoordinatesByLabel = ( const getCoordinatesByLabel = (world, categoryName, layoutDimNames) => {
obsAnnotations,
obsLayout,
categoryName,
layoutDimNames,
categoricalSelection,
schemaObsByName
) => {
const coordsByCategoryLabel = new Map(); 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 the coloredBy is not a categorical col
if (categoryValueIndices === undefined) { if (!isSelectableCategoryName(world.schema, categoryName)) {
return coordsByCategoryLabel; return coordsByCategoryLabel;
} }
// Check to see if the current category is a user created annotation const { obsAnnotations, obsLayout } = world;
const isUserAnno = schemaObsByName[categoryName].writable; 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 // Iterate over all cells
for (let i = 0, len = categoryArray.length; i < len; i += 1) { for (let i = 0, len = categoryArray.length; i < len; i += 1) {
@@ -96,23 +93,9 @@ const getCoordinatesByLabel = (
label -> [x-Coordinate, y-Coordinate] label -> [x-Coordinate, y-Coordinate]
*/ */
const calcMedianCentroid = ( const calcMedianCentroid = (world, categoryName, layoutDimNames) => {
obsAnnotations,
obsLayout,
categoryName,
layoutDimNames,
categoricalSelection,
schemaObsByName
) => {
// generate a map describing the coordinates for each label within the given category // generate a map describing the coordinates for each label within the given category
const dataMap = getCoordinatesByLabel( const dataMap = getCoordinatesByLabel(world, categoryName, layoutDimNames);
obsAnnotations,
obsLayout,
categoryName,
layoutDimNames,
categoricalSelection,
schemaObsByName
);
// label => [medianXCoordinate, medianYCoordinate] // label => [medianXCoordinate, medianYCoordinate]
const coordinates = new Map(); const coordinates = new Map();
@@ -137,19 +120,9 @@ const calcMedianCentroid = (
}; };
// A simple function to hash the parameters // A simple function to hash the parameters
const hashMedianCentroid = ( const hashMedianCentroid = (world, categoryName, layoutDimNames) => {
obsAnnotations, const { obsAnnotations, obsLayout } = world;
obsLayout, return `${obsAnnotations.__id}+${obsLayout.__id}:${categoryName}:${layoutDimNames}`;
categoryName,
layoutDimNames,
categorySelection,
schemaObsByName
) => {
return `${obsAnnotations.__id}+${
obsLayout.__id
}:${categoryName}:${layoutDimNames}:${Object.keys(
categorySelection
)}:${Object.keys(schemaObsByName)}`;
}; };
// export the memoized calculation function // export the memoized calculation function
export default memoize(calcMedianCentroid, hashMedianCentroid); export default memoize(calcMedianCentroid, hashMedianCentroid);
+39 -51
View File
@@ -6,17 +6,12 @@ import _ from "lodash";
import * as globals from "../../globals"; import * as globals from "../../globals";
import { rangeFill as fillRange } from "../range"; import { rangeFill as fillRange } from "../range";
import fromEntries from "../fromEntries";
import { import {
userDefinedDimensionName, userDefinedDimensionName,
diffexpDimensionName, diffexpDimensionName,
} from "../nameCreators"; } from "../nameCreators";
import { isCategoricalAnnotation } from "./annotationsHelpers";
export function maxCategoryItems(config) {
return (
config.parameters?.["max-category-items"] ??
globals.configDefaults.parameters["max-category-items"]
);
}
/* /*
Selection state for categoricals are tracked in an Object that Selection state for categoricals are tracked in an Object that
@@ -69,7 +64,12 @@ function topNCategories(colSchema, summary, N) {
return [_topNCategories, topNCounts]; 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 return all obs annotation names that are categorical AND have a
"reasonably" small number of categories AND are not the index column. "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 the initial name list not provided, use everything in the schema.
*/ */
if (!schema) return []; if (!schema) return [];
const { index, columns } = schema.annotations.obs; if (!names) names = schema.annotations.obs.columns.map((c) => c.name);
return names.filter((name) => isSelectableCategoryName(schema, name));
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);
} }
export function createCategoricalSelection(world, names) { export function createCategorySummary(world, name) {
const N = globals.maxCategoricalOptionsToDisplay; const N = globals.maxCategoricalOptionsToDisplay;
const { obsAnnotations, schema } = world; const { obsAnnotations, schema } = world;
const res = names.reduce((acc, name) => { const colSchema = schema.annotations.obsByName[name];
const colSchema = schema.annotations.obsByName[name]; const { writable: isUserAnno } = colSchema;
const { writable: isUserAnno } = colSchema;
/* /*
Summarize the annotation data currently in world. Must return categoryValues Summarize the annotation data currently in world. Must return categoryValues
in sorted order, and must include all category values even if they are not in sorted order, and must include all category values even if they are not
actively used in the current world. actively used in the current world.
*/ */
const summary = obsAnnotations.col(name).summarizeCategorical(); const summary = obsAnnotations.col(name).summarizeCategorical();
const [categoryValues, categoryValueCounts] = topNCategories( const [categoryValues, categoryValueCounts] = topNCategories(
colSchema, colSchema,
summary, summary,
N N
); );
const categoryValueIndices = new Map(categoryValues.map((v, i) => [v, i])); const categoryValueIndices = new Map(categoryValues.map((v, i) => [v, i]));
const numCategoryValues = categoryValueIndices.size; const numCategoryValues = categoryValueIndices.size;
const categoryValueSelected = new Array(numCategoryValues).fill(true); const isTruncated = categoryValues.length < summary.numCategories;
const isTruncated = categoryValues.length < summary.numCategories;
acc[name] = { return {
categoryValues, // array: of natively typed category values categoryValues, // array: of natively typed category values
categoryValueIndices, // map: category value (native type) -> category index categoryValueIndices, // map: category value (native type) -> category index
categoryValueSelected, // array: t/f selection state numCategoryValues, // number: of values in the category
numCategoryValues, // number: of values in the category isTruncated, // bool: true if list was truncated
isTruncated, // bool: true if list was truncated categoryValueCounts, // array: cardinality of each category,
categoryValueCounts, // array: cardinality of each category, isUserAnno, // bool
categorySelected: true, // bool - default state for entire category };
isUserAnno, // bool }
};
return acc; export function createCategoricalSelection(names) {
}, {}); return fromEntries(names.map((name) => [name, new Map()]));
return res;
} }
/* /*