diff --git a/client/__tests__/e2e/data.js b/client/__tests__/e2e/data.js
index 9a5ab836..2d8ebeaf 100644
--- a/client/__tests__/e2e/data.js
+++ b/client/__tests__/e2e/data.js
@@ -87,7 +87,13 @@ export const datasets = {
categorical: {
louvain: {
"B cells": "342",
- Megakaryocytes: "15"
+ "CD14+ Monocytes": "0",
+ "CD4 T cells": "0",
+ "CD8 T cells": "0",
+ "Dendritic cells": "0",
+ "FCGR3A+ Monocytes": "0",
+ Megakaryocytes: "15",
+ "NK cells": "0"
}
},
lasso: {
diff --git a/client/__tests__/util/dataframe/dataframe.test.js b/client/__tests__/util/dataframe/dataframe.test.js
index a89b3486..7bf54114 100644
--- a/client/__tests__/util/dataframe/dataframe.test.js
+++ b/client/__tests__/util/dataframe/dataframe.test.js
@@ -452,6 +452,59 @@ describe("dataframe factories", () => {
});
});
+ describe("withColsFrom", () => {
+ test("error conditions", () => {
+ /*
+ make sure we catch common errors:
+ - duplicate column names
+ - dimensionality difference
+ */
+ const dfA = new Dataframe.Dataframe(
+ [2, 3],
+ [["red", "blue"], [true, false], [1, 0]],
+ null,
+ new Dataframe.KeyIndex(["colors", "bools", "numbers"])
+ );
+
+ /* different dimensionality should throw error */
+ const dfB = new Dataframe.Dataframe(
+ [3, 1],
+ [["red", "blue", "green"]],
+ null,
+ new Dataframe.KeyIndex(["colorsA"])
+ );
+ expect(() => dfA.withColsFrom(dfB)).toThrow(RangeError);
+
+ /* duplicate labels should throw an error */
+ expect(() => dfA.withColsFrom(dfA)).toThrow(Error);
+ });
+
+ test("simple", () => {
+ /* simple test that it works as expected in common case */
+ const dfA = new Dataframe.Dataframe(
+ [2, 1],
+ [["red", "blue"]],
+ null,
+ new Dataframe.KeyIndex(["colors"])
+ );
+ const dfB = new Dataframe.Dataframe(
+ [2, 1],
+ [[true, false]],
+ null,
+ new Dataframe.KeyIndex(["bools"])
+ );
+
+ const dfC = dfA.withColsFrom(dfB);
+ expect(dfC).toBeDefined();
+ expect(dfC.dims).toEqual([2, 2]);
+ expect(dfC.colIndex.keys()).toEqual(["colors", "bools"]);
+ expect(dfC.rowIndex).toEqual(dfA.rowIndex);
+ expect(dfC.rowIndex.keys()).toEqual(dfA.rowIndex.keys());
+ expect(dfC.col("colors").asArray()).toEqual(["red", "blue"]);
+ expect(dfC.col("bools").asArray()).toEqual([true, false]);
+ });
+ });
+
describe("dropCol", () => {
test("KeyIndex", () => {
const df = new Dataframe.Dataframe(
@@ -567,6 +620,32 @@ describe("dataframe factories", () => {
expect(dfB.iat(0, 1)).toEqual(1);
expect(dfB.iat(0, 2)).toEqual(1);
});
+
+ test("columns", () => {
+ const df = Dataframe.Dataframe.create(
+ [3, 3],
+ [new Array(3).fill(0), new Array(3).fill(0), new Array(3).fill(0)]
+ );
+
+ expect(df).toBeDefined();
+ expect(df.columns()).toHaveLength(3);
+ expect(df.columns()[0]).toEqual(df.icol(0));
+ expect(df.columns()[2]).toEqual(df.icol(2));
+ });
+
+ test("renameCol", () => {
+ const dfA = new Dataframe.Dataframe(
+ [2, 2],
+ [[true, false], [1, 0]],
+ null,
+ new Dataframe.KeyIndex(["A", "B"])
+ );
+ const dfB = dfA.renameCol("B", "C");
+ expect(dfA.colIndex.keys()).toEqual(["A", "B"]);
+ expect(dfB.colIndex.keys()).toEqual(["A", "C"]);
+ expect(dfA.dims).toMatchObject(dfB.dims);
+ expect(dfA.columns()).toMatchObject(dfB.columns());
+ });
});
});
diff --git a/client/__tests__/util/stateManager/fbs.test.js b/client/__tests__/util/stateManager/fbs.test.js
new file mode 100644
index 00000000..612a2316
--- /dev/null
+++ b/client/__tests__/util/stateManager/fbs.test.js
@@ -0,0 +1,34 @@
+/*
+test FBS encode/decode API
+*/
+import { Dataframe, KeyIndex } from "../../../src/util/dataframe";
+import {
+ decodeMatrixFBS,
+ encodeMatrixFBS
+} from "../../../src/util/stateManager/matrix";
+
+describe("encode/decode", () => {
+ test("round trip", () => {
+ const columns = [
+ ["red", "green", "blue"],
+ new Int32Array(3).fill(0),
+ new Uint32Array(3).fill(1),
+ new Float32Array(3).fill(2)
+ ];
+
+ const dfNoColIdx = new Dataframe([3, 4], columns);
+ const dfA = decodeMatrixFBS(encodeMatrixFBS(dfNoColIdx));
+ expect([dfA.nRows, dfA.nCols]).toEqual(dfNoColIdx.dims);
+ expect(dfA.colIdx).toBeNull();
+ expect(dfA.rowIdx).toBeNull();
+ expect(dfA.columns).toEqual(columns);
+
+ const colIndex = new KeyIndex(["a", "b", "c", "d"]);
+ const dfWithColIdx = new Dataframe([3, 4], columns, null, colIndex);
+ const dfB = decodeMatrixFBS(encodeMatrixFBS(dfWithColIdx));
+ expect([dfB.nRows, dfB.nCols]).toEqual(dfWithColIdx.dims);
+ expect(dfB.colIdx).toEqual(colIndex.keys());
+ expect(dfB.rowIdx).toBeNull();
+ expect(dfB.columns).toEqual(columns);
+ });
+});
diff --git a/client/__tests__/util/stateManager/sampleResponses.js b/client/__tests__/util/stateManager/sampleResponses.js
index 2b66bac0..17fb0e8b 100644
--- a/client/__tests__/util/stateManager/sampleResponses.js
+++ b/client/__tests__/util/stateManager/sampleResponses.js
@@ -110,6 +110,14 @@ function encodeTypedArray(builder, uType, uData) {
}
function encodeMatrix(columns, colIndex = undefined) {
+ /*
+ IMPORTANT: this is not a general purpose encoder. in particular,
+ it doesn't correctly handle all column index types, nor does it
+ handle all column typedarray types.
+
+ encodeMatrixFBS in matrix.py is more general. This is used only
+ as a testing santity check (alt implementation).
+ */
const utf8Encoder = new TextEncoder("utf-8");
const builder = new flatbuffers.Builder(1024);
const cols = _.map(columns, carr => {
diff --git a/client/__tests__/util/stateManager/world.test.js b/client/__tests__/util/stateManager/world.test.js
index 6ba3424b..528fc9a9 100644
--- a/client/__tests__/util/stateManager/world.test.js
+++ b/client/__tests__/util/stateManager/world.test.js
@@ -18,11 +18,11 @@ const defaultBigBang = () => {
/* create unverse, world, crossfilter and dimensionMap */
/* create universe */
const universe = Universe.createUniverseFromResponse(
- REST.config,
- REST.schema,
- REST.annotationsObs,
- REST.annotationsVar,
- REST.layoutObs
+ _.cloneDeep(REST.config),
+ _.cloneDeep(REST.schema),
+ _.cloneDeep(REST.annotationsObs),
+ _.cloneDeep(REST.annotationsVar),
+ _.cloneDeep(REST.layoutObs)
);
/* create world */
const world = World.createWorldFromEntireUniverse(universe);
@@ -43,11 +43,11 @@ const defaultBigBang = () => {
describe("createWorldFromEntireUniverse", () => {
test("create from REST sample", () => {
const universe = Universe.createUniverseFromResponse(
- REST.config,
- REST.schema,
- REST.annotationsObs,
- REST.annotationsVar,
- REST.layoutObs
+ _.cloneDeep(REST.config),
+ _.cloneDeep(REST.schema),
+ _.cloneDeep(REST.annotationsObs),
+ _.cloneDeep(REST.annotationsVar),
+ _.cloneDeep(REST.layoutObs)
);
expect(universe).toBeDefined();
diff --git a/client/__tests__/util/typedCrossfilter/sort.test.js b/client/__tests__/util/typedCrossfilter/sort.test.js
index a1d1125d..2114c1b5 100644
--- a/client/__tests__/util/typedCrossfilter/sort.test.js
+++ b/client/__tests__/util/typedCrossfilter/sort.test.js
@@ -180,6 +180,7 @@ describe("lowerBound", () => {
expect(lowerBound([0, 1, 2, 3], 1, 0, 4)).toEqual(1);
expect(lowerBound([0, 1, 2, 3], 3, 0, 4)).toEqual(3);
expect(lowerBound([0, 1, 2, 3], 4, 0, 4)).toEqual(4);
+ expect(lowerBound([0, 1, 2, 3], 4, 0, 3)).toEqual(3);
expect(lowerBound([0, 1, 2, 3, 4], -1, 0, 5)).toEqual(0);
expect(lowerBound([0, 1, 2, 3, 4], 0, 0, 5)).toEqual(0);
@@ -204,6 +205,7 @@ describe("lowerBound", () => {
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 1, 0, 4)).toEqual(1);
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 3, 0, 4)).toEqual(3);
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 4, 0, 4)).toEqual(4);
+ expect(lowerBound(new Float32Array([0, 1, 2, 3]), 4, 0, 3)).toEqual(3);
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), -1, 0, 5)).toEqual(0);
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 0, 0, 5)).toEqual(0);
diff --git a/client/package.json b/client/package.json
index a11a99e5..b4d6486e 100644
--- a/client/package.json
+++ b/client/package.json
@@ -6,6 +6,7 @@
"repository": "https://github.com/chanzuckerberg/cellxgene",
"scripts": {
"backend-dev": "python3.6 -m venv cellxgene && source cellxgene/bin/activate && yes | pip uninstall cellxgene || true && pip install -e .. && cellxgene launch ",
+ "backend-dev-anno": "python3.6 -m venv cellxgene && source cellxgene/bin/activate && yes | pip uninstall cellxgene || true && pip install -e .. && cellxgene launch --experimental-label-file labels.csv ",
"build": "npm run clean && webpack --config configuration/webpack/webpack.config.prod.js",
"clean": "rimraf build",
"dev": "npm run clean && webpack --config configuration/webpack/webpack.config.dev.js",
diff --git a/client/src/actions/index.js b/client/src/actions/index.js
index d8c9c5ca..2751c106 100644
--- a/client/src/actions/index.js
+++ b/client/src/actions/index.js
@@ -1,7 +1,7 @@
// jshint esversion: 6
import _ from "lodash";
import * as globals from "../globals";
-import { Universe } from "../util/stateManager";
+import { Universe, MatrixFBS } from "../util/stateManager";
import {
catchErrorsWrap,
doJsonRequest,
@@ -340,11 +340,57 @@ const resetInterface = () => (dispatch, getState) => {
});
};
+const saveObsAnnotations = () => async (dispatch, getState) => {
+ const { universe } = getState();
+ const { obsAnnotations, schema } = universe;
+
+ dispatch({
+ type: "writable obs annotations - save started"
+ });
+
+ const writableAnnotations = schema.annotations.obs.columns
+ .filter(s => s.writable)
+ .map(s => s.name);
+ const df = obsAnnotations.subset(null, writableAnnotations);
+ const matrix = MatrixFBS.encodeMatrixFBS(df);
+ try {
+ const res = await fetch(
+ `${globals.API.prefix}${globals.API.version}annotations/obs`,
+ {
+ method: "PUT",
+ body: matrix,
+ headers: new Headers({
+ "Content-Type": "application/octet-stream"
+ })
+ }
+ );
+ if (res.ok) {
+ dispatch({
+ type: "writable obs annotations - save complete",
+ obsAnnotations
+ });
+ } else {
+ dispatch({
+ type: "writable obs annotations - save error",
+ message: `HTTP error ${res.status} - ${res.statusText}`,
+ res
+ });
+ }
+ } catch (error) {
+ dispatch({
+ type: "writable obs annotations - save error",
+ message: error.toString(),
+ error
+ });
+ }
+};
+
export default {
regraph,
resetInterface,
requestSingleGeneExpressionCountsForColoringPOST,
requestDifferentialExpression,
requestUserDefinedGene,
- doInitialDataLoad
+ doInitialDataLoad,
+ saveObsAnnotations
};
diff --git a/client/src/components/app.js b/client/src/components/app.js
index 8c2f1a11..a9c30877 100644
--- a/client/src/components/app.js
+++ b/client/src/components/app.js
@@ -8,6 +8,7 @@ import LeftSideBar from "./leftSidebar";
import Legend from "./continuousLegend";
import Graph from "./graph/graph";
import MenuBar from "./menubar";
+import Autosave from "./autosave";
import actions from "../actions";
@@ -89,6 +90,7 @@ class App extends React.Component {
{loading ? null : }
{loading ? null : }
{loading ? null : }
+ {loading ? null : }
diff --git a/client/src/components/autosave/index.js b/client/src/components/autosave/index.js
new file mode 100644
index 00000000..78038ecc
--- /dev/null
+++ b/client/src/components/autosave/index.js
@@ -0,0 +1,81 @@
+import React from "react";
+import { connect } from "react-redux";
+
+import actions from "../../actions";
+
+@connect(state => ({
+ universe: state.universe,
+ obsAnnotations: state.universe.obsAnnotations,
+ saveInProgress: state.autosave?.saveInProgress ?? false,
+ lastSavedObsAnnotations: state.autosave?.lastSavedObsAnnotations,
+ error: state.autosave?.error,
+ writableCategoriesEnabled: state.config?.parameters?.["label_file"] ?? false
+}))
+class Autosave extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ timer: null
+ };
+ }
+
+ componentDidMount() {
+ const { writableCategoriesEnabled } = this.props;
+
+ let { timer } = this.state;
+ if (timer) clearInterval(timer);
+ if (writableCategoriesEnabled) {
+ timer = setInterval(this.tick, 2500);
+ } else {
+ timer = null;
+ }
+ this.setState({ timer });
+ }
+
+ componentWillUnmount() {
+ const { timer } = this.state;
+ if (timer) this.clearInterval(timer);
+ }
+
+ tick = () => {
+ const { dispatch, saveInProgress } = this.props;
+ if (this.needToSave() && !saveInProgress) {
+ dispatch(actions.saveObsAnnotations());
+ }
+ };
+
+ needToSave = () => {
+ /* return true if we need to save, false if we don't */
+ const { obsAnnotations, lastSavedObsAnnotations } = this.props;
+ return (
+ lastSavedObsAnnotations && obsAnnotations !== lastSavedObsAnnotations
+ );
+ };
+
+ statusMessage() {
+ const { error } = this.props;
+ if (error) {
+ return `Autosave error: ${error}`;
+ }
+ return this.needToSave() ? "Unsaved" : "All saved";
+ }
+
+ render() {
+ const { writableCategoriesEnabled } = this.props;
+ return writableCategoriesEnabled ? (
+
+ {this.statusMessage()}
+
+ ) : null;
+ }
+}
+
+export default Autosave;
diff --git a/client/src/components/categorical/categorical.js b/client/src/components/categorical/categorical.js
index 437cf037..49512bf4 100644
--- a/client/src/components/categorical/categorical.js
+++ b/client/src/components/categorical/categorical.js
@@ -1,27 +1,161 @@
// jshint esversion: 6
import React from "react";
import _ from "lodash";
+import {
+ Button,
+ Tooltip,
+ InputGroup,
+ Dialog,
+ Classes,
+ MenuItem
+} from "@blueprintjs/core";
+import { Select } from "@blueprintjs/select";
import { connect } from "react-redux";
import * as globals from "../../globals";
import Category from "./category";
@connect(state => ({
- categoricalSelection: state.categoricalSelection
+ categoricalSelection: state.categoricalSelection,
+ writableCategoriesEnabled: state.config?.parameters?.["label_file"] ?? false,
+ schema: state.world?.schema
}))
class Categories extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ createAnnoModeActive: false,
+ newCategoryText: "",
+ categoryToDuplicate: null
+ };
+ }
+
+ handleCreateUserAnno = () => {
+ const { dispatch } = this.props;
+ const { newCategoryText, categoryToDuplicate } = this.state;
+ dispatch({
+ type: "annotation: create category",
+ data: newCategoryText,
+ categoryToDuplicate
+ });
+ this.setState({
+ createAnnoModeActive: false,
+ categoryToDuplicate: null,
+ newCategoryText: ""
+ });
+ };
+
+ handleEnableAnnoMode = () => {
+ this.setState({ createAnnoModeActive: true });
+ };
+
+ handleDisableAnnoMode = () => {
+ this.setState({ createAnnoModeActive: false });
+ };
+
+ handleModalDuplicateCategorySelection = d => {
+ this.setState({ categoryToDuplicate: d });
+ };
+
render() {
- const { categoricalSelection } = this.props;
+ const { createAnnoModeActive, categoryToDuplicate } = this.state;
+ const {
+ categoricalSelection,
+ writableCategoriesEnabled,
+ schema
+ } = this.props;
if (!categoricalSelection) return null;
+ /* all names, sorted in display order. Will be rendered in this order */
+ const allCategoryNames = Object.keys(categoricalSelection).sort();
+
return (
- {_.map(categoricalSelection, (catState, catName) => (
-
- ))}
+ {/* READ ONLY CATEGORICAL FIELDS */}
+ {/* this is duplicative but flat, could be abstracted */}
+ {_.map(allCategoryNames, catName =>
+ !schema.annotations.obsByName[catName].writable ? (
+
+ ) : null
+ )}
+ {/* WRITEABLE FIELDS */}
+ {_.map(allCategoryNames, catName =>
+ schema.annotations.obsByName[catName].writable ? (
+
+ ) : null
+ )}
+ {writableCategoriesEnabled ? (
+
+
+
+
+ ) : null}
);
}
diff --git a/client/src/components/categorical/category.js b/client/src/components/categorical/category.js
index f7fe4711..34cfbb37 100644
--- a/client/src/components/categorical/category.js
+++ b/client/src/components/categorical/category.js
@@ -2,7 +2,19 @@ import React from "react";
import _ from "lodash";
import { connect } from "react-redux";
import { FaChevronRight, FaChevronDown } from "react-icons/fa";
-import { Button, Tooltip } from "@blueprintjs/core";
+import {
+ Button,
+ Tooltip,
+ InputGroup,
+ Menu,
+ Dialog,
+ MenuItem,
+ Popover,
+ Classes,
+ Icon,
+ Position,
+ PopoverInteractionKind
+} from "@blueprintjs/core";
import * as globals from "../../globals";
import Value from "./value";
@@ -10,14 +22,18 @@ import sortedCategoryValues from "./util";
@connect(state => ({
colorAccessor: state.colors.colorAccessor,
- categoricalSelection: state.categoricalSelection
+ categoricalSelection: state.categoricalSelection,
+ annotations: state.annotations,
+ universe: state.universe
}))
class Category extends React.Component {
constructor(props) {
super(props);
this.state = {
isChecked: true,
- isExpanded: false
+ isExpanded: false,
+ newCategoryText: "",
+ newLabelText: ""
};
}
@@ -51,6 +67,74 @@ class Category extends React.Component {
}
}
+ activateAddNewLabelMode = () => {
+ const { dispatch, metadataField } = this.props;
+ dispatch({
+ type: "annotation: activate add new label mode",
+ data: metadataField
+ });
+ };
+
+ disableAddNewLabelMode = () => {
+ const { dispatch } = this.props;
+ dispatch({
+ type: "annotation: disable add new label mode"
+ });
+ };
+
+ handleAddNewLabelToCategory = () => {
+ const { dispatch, metadataField } = this.props;
+ const { newLabelText } = this.state;
+ /*
+ XXX TODO - temporary code generates random label string. Remove
+ when the label creation UI is implemented.
+
+ const { newLabelText } = this.state;
+ */
+ // const newLabelText = `label${Math.random()}`;
+ dispatch({
+ type: "annotation: add new label to category",
+ metadataField,
+ newLabelText
+ });
+ this.setState({ newLabelText: "" });
+ };
+
+ activateEditCategoryMode = () => {
+ const { dispatch, metadataField } = this.props;
+ dispatch({
+ type: "annotation: activate category edit mode",
+ data: metadataField
+ });
+ };
+
+ disableEditCategoryMode = () => {
+ const { dispatch } = this.props;
+ dispatch({
+ type: "annotation: disable category edit mode"
+ });
+ };
+
+ handleEditCategory = () => {
+ const { dispatch, metadataField } = this.props;
+ const { newCategoryText } = this.state;
+
+ dispatch({
+ type: "annotation: category edited",
+ metadataField,
+ newCategoryText,
+ data: newCategoryText
+ });
+ };
+
+ handleDeleteCategory = () => {
+ const { dispatch, metadataField } = this.props;
+ dispatch({
+ type: "annotation: delete category",
+ metadataField
+ });
+ };
+
handleColorChange = () => {
const { dispatch, metadataField } = this.props;
dispatch({
@@ -88,12 +172,13 @@ class Category extends React.Component {
}
renderCategoryItems() {
- const { categoricalSelection, metadataField } = this.props;
+ const { categoricalSelection, metadataField, isUserAnno } = this.props;
const cat = categoricalSelection[metadataField];
const optTuples = sortedCategoryValues([...cat.categoryValueIndices]);
return _.map(optTuples, (tuple, i) => (
{""}
-
+ {/* Dialog uses portal, can be anywhere/factored out */}
+
+ {isUserAnno ? (
+
+ ) : null}
{metadataField}
{isExpanded ? (
-
-
-
+
+ {isUserAnno ? (
+ <>
+
+
+
+
+
+
+ }
+ >
+
+
+ >
+ ) : null}
+
+
+
+
{isExpanded ? this.renderCategoryItems() : null}
diff --git a/client/src/components/categorical/occupancy.js b/client/src/components/categorical/occupancy.js
index aeb61553..68f6e269 100644
--- a/client/src/components/categorical/occupancy.js
+++ b/client/src/components/categorical/occupancy.js
@@ -45,7 +45,10 @@ class Occupancy extends React.Component {
groupBy
); /* Because the signature changes we really need different names for histogram to differentiate signatures */
- const bins = histogramMap.get(category.categoryValues[categoryIndex]);
+ const categoryValue = category.categoryValues[categoryIndex];
+ const bins = histogramMap.has(categoryValue)
+ ? histogramMap.get(categoryValue)
+ : new Array(50).fill(0);
const xScale = d3
.scaleLinear()
@@ -102,30 +105,34 @@ class Occupancy extends React.Component {
const occupancy = occupancyMap.get(category.categoryValues[categoryIndex]);
- const x = d3
- .scaleLinear()
- /* get all the keys d[1] as an array, then find the sum */
- .domain([0, d3.sum(Array.from(occupancy.values()))])
- .range([0, this._WIDTH]);
- const categories = schema.annotations.obsByName[colorAccessor]?.categories;
+ if (occupancy && occupancy.size > 0) {
+ // not all categories have occupancy, so occupancy may be undefined.
+ const x = d3
+ .scaleLinear()
+ /* get all the keys d[1] as an array, then find the sum */
+ .domain([0, d3.sum(Array.from(occupancy.values()))])
+ .range([0, this._WIDTH]);
+ const categories =
+ schema.annotations.obsByName[colorAccessor]?.categories;
- let currentOffset = 0;
- const dfColumn = world.obsAnnotations.col(colorAccessor);
- const categoryValues = dfColumn.summarize().categories;
+ let currentOffset = 0;
+ const dfColumn = world.obsAnnotations.col(colorAccessor);
+ const categoryValues = dfColumn.summarize().categories;
- let o;
- let scaledValue;
- let value;
+ let o;
+ let scaledValue;
+ let value;
- for (let i = 0, { length } = categoryValues; i < length; i += 1) {
- value = categoryValues[i];
- o = occupancy.get(value);
- scaledValue = x(o);
- ctx.fillStyle = o
- ? colorScale(categories.indexOf(value))
- : "rgb(255,255,255)";
- ctx.fillRect(currentOffset, 0, o ? scaledValue : 0, this._HEIGHT);
- currentOffset += o ? scaledValue : 0;
+ for (let i = 0, { length } = categoryValues; i < length; i += 1) {
+ value = categoryValues[i];
+ o = occupancy.get(value);
+ scaledValue = x(o);
+ ctx.fillStyle = o
+ ? colorScale(categories.indexOf(value))
+ : "rgb(255,255,255)";
+ ctx.fillRect(currentOffset, 0, o ? scaledValue : 0, this._HEIGHT);
+ currentOffset += o ? scaledValue : 0;
+ }
}
};
diff --git a/client/src/components/categorical/value.js b/client/src/components/categorical/value.js
index 713964e3..fbb6cd4c 100644
--- a/client/src/components/categorical/value.js
+++ b/client/src/components/categorical/value.js
@@ -1,18 +1,108 @@
// jshint esversion: 6
import { connect } from "react-redux";
import React from "react";
+
+import {
+ Button,
+ InputGroup,
+ Menu,
+ MenuItem,
+ Popover,
+ Position,
+ Icon,
+ PopoverInteractionKind
+} from "@blueprintjs/core";
import Occupancy from "./occupancy";
import * as globals from "../../globals";
import styles from "./categorical.css";
@connect(state => ({
categoricalSelection: state.categoricalSelection,
+ annotations: state.annotations,
colorScale: state.colors.scale,
colorAccessor: state.colors.colorAccessor,
schema: state.world?.schema,
world: state.world
}))
class CategoryValue extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ editedLabelText: ""
+ };
+ }
+
+ handleDeleteValue = () => {
+ const {
+ dispatch,
+ metadataField,
+ categoryIndex,
+ categoricalSelection
+ } = this.props;
+ const category = categoricalSelection[metadataField];
+ const label = category.categoryValues[categoryIndex];
+ dispatch({
+ type: "annotation: delete label",
+ metadataField,
+ label
+ });
+ };
+
+ handleAddCurrentSelectionToThisLabel = () => {
+ const {
+ dispatch,
+ metadataField,
+ categoryIndex,
+ categoricalSelection
+ } = this.props;
+ const category = categoricalSelection[metadataField];
+ const label = category.categoryValues[categoryIndex];
+ dispatch({
+ type: "annotation: label current cell selection",
+ metadataField,
+ categoryIndex,
+ label
+ });
+ };
+
+ handleEditValue = () => {
+ const {
+ dispatch,
+ metadataField,
+ categoryIndex,
+ categoricalSelection
+ } = this.props;
+ const { editedLabelText } = this.state;
+ const category = categoricalSelection[metadataField];
+ const label = category.categoryValues[categoryIndex];
+ dispatch({
+ type: "annotation: label edited",
+ editedLabel: editedLabelText,
+ metadataField,
+ categoryIndex,
+ label
+ });
+ this.setState({ editedLabelText: "" });
+ };
+
+ activateEditLabelMode = () => {
+ const { dispatch, metadataField, categoryIndex } = this.props;
+ dispatch({
+ type: "annotation: activate edit label mode",
+ metadataField,
+ categoryIndex
+ });
+ };
+
+ cancelEdit = () => {
+ const { dispatch, metadataField, categoryIndex } = this.props;
+ dispatch({
+ type: "annotation: cancel edit label mode",
+ metadataField,
+ categoryIndex
+ });
+ };
+
toggleOff = () => {
const { dispatch, metadataField, categoryIndex } = this.props;
dispatch({
@@ -23,12 +113,12 @@ class CategoryValue extends React.Component {
};
shouldComponentUpdate = nextProps => {
- /*
+ /*
Checks to see if at least one of the following changed:
* world state
* the color accessor (what is currently being colored by)
* if this catagorical value's selection status has changed
-
+
If and only if true, update the component
*/
const { props } = this;
@@ -45,8 +135,14 @@ class CategoryValue extends React.Component {
const worldChange = props.world !== nextProps.world;
const colorAccessorChange = props.colorAccessor !== nextProps.colorAccessor;
+ const annotationsChange = props.annotations !== nextProps.annotations;
- return valueSelectionChange || worldChange || colorAccessorChange;
+ return (
+ valueSelectionChange ||
+ worldChange ||
+ colorAccessorChange ||
+ annotationsChange
+ );
};
toggleOn = () => {
@@ -84,7 +180,9 @@ class CategoryValue extends React.Component {
colorAccessor,
colorScale,
i,
- schema
+ schema,
+ isUserAnno,
+ annotations
} = this.props;
if (!categoricalSelection) return null;
@@ -121,41 +219,103 @@ class CategoryValue extends React.Component {
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseExit}
>
-
-
-