From 6b33315cbed3b1cd7e7d0feb32dc6e006dd2b979 Mon Sep 17 00:00:00 2001 From: Bruce Martin Date: Fri, 22 Feb 2019 11:31:34 -0800 Subject: [PATCH] Dataframe (#576) * initial dataframe commit * initial dataframe port of core app * rename variables for clarity * remove unused import * comment out unused code * fix array handling bug in crossfilter dimension creation * allow creation of empty dataframes * handle non-existent columns * handle non-existent columns * revise tests for new dataframe * comments for clarity * comments for clarity * generate bulk add placeholder with real gene names * fix bug in gene name adding * more dataframe unit tests * fix bug - subset from current world, not universe * put cut and pasted code into a single function * improve caching of crossfilter * remove cascading update bug from graph * more performance work * improve state handling for scatterplot * performance optimization of critical path * add column summarization * dataframe utils * add callOnceLazy * fix tests * minor updates found during review * fix misspelling * remove RESTv02 from function names * comment cleanup * cut/icut col parameter defaults to null * break up large test * improve tests and comments on dataframe at/has functions --- .../util/dataframe/dataframe.test.js | 377 ++++++++++++++ .../util/stateManager/sampleResponses.js | 12 +- .../stateManager/summarizeAnnotations.test.js | 181 +++---- .../util/stateManager/universe.test.js | 38 +- .../__tests__/util/stateManager/world.test.js | 68 ++- .../util/stateManager/worldUtil.test.js | 39 +- .../util/typedCrossfilter/bitArray.test.js | 10 +- .../typedCrossfilter/typedCrossfilter.test.js | 110 +++-- client/src/actions/index.js | 24 +- .../components/brushableHistogram/index.js | 8 +- client/src/components/categorical/value.js | 6 +- .../src/components/continuous/continuous.js | 4 +- .../geneExpression/cellSetButtons.js | 4 +- client/src/components/geneExpression/index.js | 72 ++- client/src/components/graph/graph.js | 66 ++- .../src/components/scatterplot/scatterplot.js | 102 ++-- client/src/middleware/updateCellColors.js | 11 +- client/src/reducers/controls.js | 152 +++--- client/src/util/dataframe/dataframe.js | 465 ++++++++++++++++++ client/src/util/dataframe/index.js | 2 + client/src/util/dataframe/labelIndex.js | 188 +++++++ client/src/util/dataframe/summarize.js | 57 +++ client/src/util/dataframe/util.js | 28 ++ .../util/stateManager/summarizeAnnotations.js | 48 +- client/src/util/stateManager/universe.js | 94 ++-- client/src/util/stateManager/world.js | 123 ++--- client/src/util/stateManager/worldUtil.js | 20 +- client/src/util/typedCrossfilter/bitArray.js | 43 +- client/src/util/typedCrossfilter/index.js | 57 ++- 29 files changed, 1798 insertions(+), 611 deletions(-) create mode 100644 client/__tests__/util/dataframe/dataframe.test.js create mode 100644 client/src/util/dataframe/dataframe.js create mode 100644 client/src/util/dataframe/index.js create mode 100644 client/src/util/dataframe/labelIndex.js create mode 100644 client/src/util/dataframe/summarize.js create mode 100644 client/src/util/dataframe/util.js diff --git a/client/__tests__/util/dataframe/dataframe.test.js b/client/__tests__/util/dataframe/dataframe.test.js new file mode 100644 index 00000000..b1139984 --- /dev/null +++ b/client/__tests__/util/dataframe/dataframe.test.js @@ -0,0 +1,377 @@ +import * as Dataframe from "../../../src/util/dataframe"; + +describe("dataframe constructor", () => { + test("empty dataframe", () => { + const df = new Dataframe.Dataframe([0, 0], []); + expect(df).toBeDefined(); + expect(df.dims).toEqual([0, 0]); + expect(df).toHaveLength(0); + expect(df.icol(0)).not.toBeDefined(); + }); + + test("create with default indices", () => { + const df = new Dataframe.Dataframe( + [3, 2], + [new Int32Array(3).fill(0), new Int32Array(3).fill(1)] + ); + + expect(df).toBeDefined(); + expect(df.dims).toEqual([3, 2]); + expect(df.rowIndex).toBeInstanceOf(Dataframe.IdentityInt32Index); + expect(df.colIndex).toBeInstanceOf(Dataframe.IdentityInt32Index); + expect(df.at(0, 0)).toEqual(0); + expect(df.at(2, 1)).toEqual(1); + expect(df.iat(0, 0)).toEqual(0); + expect(df.iat(2, 1)).toEqual(1); + }); + + test("create with labelled indices", () => { + const df = new Dataframe.Dataframe( + [3, 2], + [new Int32Array([0, 1, 2]), new Int32Array([3, 4, 5])], + new Dataframe.DenseInt32Index([2, 1, 0]), + new Dataframe.KeyIndex(["A", "B"]) + ); + + expect(df).toBeDefined(); + expect(df.dims).toEqual([3, 2]); + + expect(df.rowIndex).toBeInstanceOf(Dataframe.DenseInt32Index); + expect(df.colIndex).toBeInstanceOf(Dataframe.KeyIndex); + expect(df.rowIndex.keys()).toEqual(new Int32Array([2, 1, 0])); + expect(df.colIndex.keys()).toEqual(["A", "B"]); + + expect(df.at(0, "A")).toEqual(2); + expect(df.at(2, "B")).toEqual(3); + expect(df.iat(0, 0)).toEqual(0); + expect(df.iat(2, 1)).toEqual(5); + }); +}); + +describe("simple data access", () => { + const df = new Dataframe.Dataframe( + [4, 2], + [ + new Float64Array([0.0, Number.NaN, Number.POSITIVE_INFINITY, 3.14159]), + ["red", "blue", "green", "nan"] + ], + new Dataframe.DenseInt32Index([3, 2, 1, 0]), + new Dataframe.KeyIndex(["numbers", "colors"]) + ); + + test("iat", () => { + expect(df).toBeDefined(); + + // present + expect(df.iat(0, 0)).toEqual(0.0); + expect(df.iat(0, 1)).toEqual("red"); + expect(df.iat(1, 0)).toEqual(Number.NaN); + expect(df.iat(1, 1)).toEqual("blue"); + expect(df.iat(2, 0)).toEqual(Number.POSITIVE_INFINITY); + expect(df.iat(2, 1)).toEqual("green"); + expect(df.iat(3, 0)).toEqual(3.14159); + expect(df.iat(3, 1)).toEqual("nan"); + + // labels out of range have no defined behavior + }); + + test("at", () => { + expect(df).toBeDefined(); + + // present + expect(df.at(3, "numbers")).toEqual(0.0); + expect(df.at(3, "colors")).toEqual("red"); + expect(df.at(2, "numbers")).toEqual(Number.NaN); + expect(df.at(2, "colors")).toEqual("blue"); + expect(df.at(1, "numbers")).toEqual(Number.POSITIVE_INFINITY); + expect(df.at(1, "colors")).toEqual("green"); + expect(df.at(0, "numbers")).toEqual(3.14159); + expect(df.at(0, "colors")).toEqual("nan"); + + // labels out of range have no defined behavior + }); + + test("ihas", () => { + expect(df).toBeDefined(); + + // present + expect(df.ihas(0, 0)).toBeTruthy(); + expect(df.ihas(1, 1)).toBeTruthy(); + expect(df.ihas(3, 1)).toBeTruthy(); + + // not present + expect(df.ihas(-1, -1)).toBeFalsy(); + expect(df.ihas(0, 99)).toBeFalsy(); + expect(df.ihas(99, 0)).toBeFalsy(); + expect(df.ihas(99, 99)).toBeFalsy(); + expect(df.ihas(-1, 0)).toBeFalsy(); + expect(df.ihas(0, -1)).toBeFalsy(); + }); + + test("has", () => { + expect(df).toBeDefined(); + + // present + expect(df.has(3, "numbers")).toBeTruthy(); + expect(df.has(0, "numbers")).toBeTruthy(); + expect(df.has(3, "colors")).toBeTruthy(); + expect(df.has(0, "colors")).toBeTruthy(); + + // not present + expect(df.has(3, "foo")).toBeFalsy(); + expect(df.has(-1, "numbers")).toBeFalsy(); + expect(df.has(-1, -1)).toBeFalsy(); + expect(df.has(null, null)).toBeFalsy(); + expect(df.has(0, "foo")).toBeFalsy(); + expect(df.has(99, "numbers")).toBeFalsy(); + expect(df.has(99, "foo")).toBeFalsy(); + }); +}); + +describe("dataframe subsetting", () => { + describe("cutByList", () => { + const sourceDf = new Dataframe.Dataframe( + [3, 4], + [ + new Int32Array([0, 1, 2]), + ["A", "B", "C"], + new Float32Array([4.4, 5.5, 6.6]), + ["red", "green", "blue"] + ], + null, + new Dataframe.KeyIndex(["int32", "string", "float32", "colors"]) + ); + + test("all rows, one column", () => { + const dfA = sourceDf.cutByList(null, ["colors"]); + expect(dfA).toBeDefined(); + expect(dfA.dims).toEqual([3, 1]); + expect(dfA.iat(0, 0)).toEqual("red"); + expect(dfA.at(2, "colors")).toEqual("blue"); + expect(dfA.col("colors").asArray()).toEqual(["red", "green", "blue"]); + expect(dfA.icol(0).asArray()).toEqual(["red", "green", "blue"]); + expect(dfA.col("colors").asArray()).toEqual( + sourceDf.col("colors").asArray() + ); + expect(dfA.rowIndex.keys()).toEqual(sourceDf.rowIndex.keys()); + expect(dfA.colIndex.keys()).toEqual(["colors"]); + }); + + test("all rows, two columns", () => { + const dfB = sourceDf.cutByList(null, ["colors", "float32"]); + expect(dfB).toBeDefined(); + expect(dfB.dims).toEqual([3, 2]); + expect(dfB.iat(0, 0)).toBeCloseTo(4.4); + expect(dfB.iat(0, 1)).toEqual("red"); + expect(dfB.at(2, "colors")).toEqual("blue"); + expect(dfB.at(2, "float32")).toBeCloseTo(6.6); + expect(dfB.col("colors").asArray()).toEqual(["red", "green", "blue"]); + expect(dfB.col("float32").asArray()).toEqual( + new Float32Array([4.4, 5.5, 6.6]) + ); + expect(dfB.icol(0).asArray()).toEqual(dfB.col("float32").asArray()); + expect(dfB.icol(1).asArray()).toEqual(dfB.col("colors").asArray()); + expect(dfB.col("colors").asArray()).toEqual( + sourceDf.col("colors").asArray() + ); + expect(dfB.col("float32").asArray()).toEqual( + sourceDf.col("float32").asArray() + ); + expect(dfB.rowIndex.keys()).toEqual(sourceDf.rowIndex.keys()); + expect(dfB.colIndex.keys()).toEqual(["float32", "colors"]); + }); + + test("one row, all columns", () => { + const dfC = sourceDf.cutByList([1], null); + expect(dfC).toBeDefined(); + expect(dfC.dims).toEqual([1, 4]); + expect(dfC.iat(0, 0)).toEqual(1); + expect(dfC.iat(0, 1)).toEqual("B"); + expect(dfC.iat(0, 2)).toBeCloseTo(5.5); + expect(dfC.iat(0, 3)).toEqual("green"); + expect(dfC.rowIndex.keys()).toEqual(new Int32Array([1])); + expect(dfC.colIndex.keys()).toEqual(sourceDf.colIndex.keys()); + }); + + test("two rows, all columns", () => { + const dfD = sourceDf.cutByList([0, 2], null); + expect(dfD).toBeDefined(); + expect(dfD.dims).toEqual([2, 4]); + expect(dfD.icol(0).asArray()).toEqual(new Int32Array([0, 2])); + expect(dfD.icol(1).asArray()).toEqual(["A", "C"]); + expect(dfD.icol(2).asArray()).toEqual(new Float32Array([4.4, 6.6])); + expect(dfD.icol(3).asArray()).toEqual(["red", "blue"]); + expect(dfD.rowIndex.keys()).toEqual(new Int32Array([0, 2])); + expect(dfD.colIndex.keys()).toEqual(sourceDf.colIndex.keys()); + }); + + test("all rows, all columns", () => { + const dfE = sourceDf.cutByList(null, null); + expect(dfE).toBeDefined(); + expect(dfE.dims).toEqual([3, 4]); + expect(dfE.icol(0).asArray()).toEqual(sourceDf.icol(0).asArray()); + expect(dfE.icol(1).asArray()).toEqual(sourceDf.icol(1).asArray()); + expect(dfE.icol(2).asArray()).toEqual(sourceDf.icol(2).asArray()); + expect(dfE.icol(3).asArray()).toEqual(sourceDf.icol(3).asArray()); + expect(dfE.rowIndex.keys()).toEqual(sourceDf.rowIndex.keys()); + expect(dfE.colIndex.keys()).toEqual(sourceDf.colIndex.keys()); + }); + + test("two rows, two colums", () => { + const dfF = sourceDf.cutByList([0, 2], ["int32", "float32"]); + expect(dfF).toBeDefined(); + expect(dfF.dims).toEqual([2, 2]); + expect(dfF.icol(0).asArray()).toEqual(new Int32Array([0, 2])); + expect(dfF.icol(1).asArray()).toEqual(new Float32Array([4.4, 6.6])); + expect(dfF.rowIndex.keys()).toEqual(new Int32Array([0, 2])); + expect(dfF.colIndex.keys()).toEqual(["int32", "float32"]); + }); + }); + + test("icutByMask", () => { + const sourceDf = new Dataframe.Dataframe( + [3, 4], + [ + new Int32Array([0, 1, 2]), + ["A", "B", "C"], + new Float32Array([4.4, 5.5, 6.6]), + ["red", "green", "blue"] + ], + new Dataframe.DenseInt32Index([2, 4, 6]), + new Dataframe.KeyIndex(["int32", "string", "float32", "colors"]) + ); + + const dfA = sourceDf.icutByMask( + new Uint8Array([0, 1, 1]), + new Uint8Array([1, 0, 0, 1]) + ); + expect(dfA.dims).toEqual([2, 2]); + expect(dfA.icol(0).asArray()).toEqual(new Int32Array([1, 2])); + expect(dfA.icol(1).asArray()).toEqual(["green", "blue"]); + expect(dfA.rowIndex.keys()).toEqual(new Int32Array([4, 6])); + expect(dfA.colIndex.keys()).toEqual(["int32", "colors"]); + }); +}); + +describe("dataframe factories", () => { + test("create", () => { + const df = Dataframe.Dataframe.create( + [3, 3], + [ + new Array(3).fill(0), + new Int16Array(3).fill(99), + new Float64Array(3).fill(1.1) + ] + ); + + expect(df).toBeDefined(); + expect(df.dims).toEqual([3, 3]); + expect(df).toHaveLength(3); + expect(df.iat(0, 0)).toEqual(0); + expect(df.iat(1, 1)).toEqual(99); + expect(df.iat(2, 2)).toBeCloseTo(1.1); + expect(df.iat(0, 0)).toEqual(df.at(0, 0)); + expect(df.iat(1, 1)).toEqual(df.at(1, 1)); + expect(df.iat(2, 2)).toEqual(df.at(2, 2)); + }); + + test("clone", () => { + const dfA = new Dataframe.Dataframe( + [3, 2], + [new Int32Array([0, 1, 2]), new Int32Array([3, 4, 5])], + new Dataframe.DenseInt32Index([2, 1, 0]), + new Dataframe.KeyIndex(["A", "B"]) + ); + + const dfB = dfA.clone(); + expect(dfB).not.toBe(dfA); + expect(dfB.dims).toEqual(dfA.dims); + expect(dfB).toHaveLength(dfA.length); + expect(dfB.rowIndex.keys()).toEqual(dfA.rowIndex.keys()); + expect(dfB.colIndex.keys()).toEqual(dfA.colIndex.keys()); + for (let i = 0, l = dfB.dims[1]; i < l; i += 1) { + expect(dfB.icol(i).asArray()).toEqual(dfA.icol(i).asArray()); + } + }); +}); + +describe("dataframe col", () => { + let df = null; + beforeEach(() => { + df = new Dataframe.Dataframe( + [2, 2], + [[true, false], [1, 0]], + null, + new Dataframe.KeyIndex(["A", "B"]) + ); + }); + + test("col", () => { + expect(df).toBeDefined(); + expect(df.col("A")).toBe(df.icol(0)); + expect(df.col("B")).toBe(df.icol(1)); + expect(df.col("undefined")).toBeUndefined(); + expect(df.icol("undefined")).toBeUndefined(); + + const colA = df.col("A"); + expect(colA).toBeInstanceOf(Function); + expect(colA.asArray).toBeInstanceOf(Function); + expect(colA.has).toBeInstanceOf(Function); + expect(colA.ihas).toBeInstanceOf(Function); + expect(colA.indexOf).toBeInstanceOf(Function); + expect(colA.iget).toBeInstanceOf(Function); + }); + + test("col.asArray", () => { + expect(df).toBeDefined(); + expect(df.col("A").asArray()).toEqual([true, false]); + expect(df.icol(0).asArray()).toEqual([true, false]); + expect(df.col("B").asArray()).toEqual([1, 0]); + expect(df.icol(1).asArray()).toEqual([1, 0]); + }); + + test("col.has", () => { + expect(df).toBeDefined(); + expect(df.col("A").has(-1)).toBe(false); + expect(df.col("A").has(0)).toBe(true); + expect(df.col("A").has(1)).toBe(true); + expect(df.col("A").has(2)).toBe(false); + expect(df.col("B").has(-1)).toBe(false); + expect(df.col("B").has(0)).toBe(true); + expect(df.col("B").has(1)).toBe(true); + expect(df.col("B").has(2)).toBe(false); + }); + + test("col.ihas", () => { + expect(df).toBeDefined(); + expect(df.col("A").ihas(-1)).toBe(false); + expect(df.col("A").ihas(0)).toBe(true); + expect(df.col("A").ihas(1)).toBe(true); + expect(df.col("A").ihas(2)).toBe(false); + expect(df.col("B").ihas(-1)).toBe(false); + expect(df.col("B").ihas(0)).toBe(true); + expect(df.col("B").ihas(1)).toBe(true); + expect(df.col("B").ihas(2)).toBe(false); + }); + + test("col.iget", () => { + expect(df).toBeDefined(); + expect(df.col("A").iget(0)).toEqual(df.iat(0, 0)); + expect(df.col("B").iget(1)).toEqual(df.iat(1, 1)); + }); + + test("col.indexOf", () => { + expect(df).toBeDefined(); + expect(df.col("A").indexOf(true)).toEqual(0); + expect(df.col("A").indexOf(false)).toEqual(1); + expect(df.col("A").indexOf(99)).toBeUndefined(); + expect(df.col("A").indexOf(undefined)).toBeUndefined(); + expect(df.col("A").indexOf(1)).toBeUndefined(); + + expect(df.col("B").indexOf(1)).toEqual(0); + expect(df.col("B").indexOf(0)).toEqual(1); + expect(df.col("B").indexOf(99)).toBeUndefined(); + expect(df.col("B").indexOf(undefined)).toBeUndefined(); + expect(df.col("B").indexOf(true)).toBeUndefined(); + }); +}); diff --git a/client/__tests__/util/stateManager/sampleResponses.js b/client/__tests__/util/stateManager/sampleResponses.js index d3a8d137..e04a810f 100644 --- a/client/__tests__/util/stateManager/sampleResponses.js +++ b/client/__tests__/util/stateManager/sampleResponses.js @@ -157,16 +157,6 @@ const anAnnotationsVarFBSResponse = (() => { return encodeMatrix(columns, anAnnotationsVarJSONResponse.names); })(); -const aLayoutJSONResponse = { - layout: { - ndims: 2, - coordinates: _() - .range(nObs) - .map(idx => [idx, Math.random(), Math.random()]) - .value() - } -}; - const aLayoutFBSResponse = (() => { const coords = [ new Float32Array(nObs).fill(Math.random()), @@ -190,7 +180,7 @@ const aLayoutFBSResponse = (() => { NetEncoding.Matrix.startMatrix(builder); NetEncoding.Matrix.addNRows(builder, nObs); - NetEncoding.Matrix.addNCols(builder, nVar); + NetEncoding.Matrix.addNCols(builder, coords.length); NetEncoding.Matrix.addColumns(builder, columns); const matrix = NetEncoding.Matrix.endMatrix(builder); builder.finish(matrix); diff --git a/client/__tests__/util/stateManager/summarizeAnnotations.test.js b/client/__tests__/util/stateManager/summarizeAnnotations.test.js index 93c1375a..2ebc2e4a 100644 --- a/client/__tests__/util/stateManager/summarizeAnnotations.test.js +++ b/client/__tests__/util/stateManager/summarizeAnnotations.test.js @@ -1,4 +1,9 @@ import summarizeAnnotations from "../../../src/util/stateManager/summarizeAnnotations"; +import * as Dataframe from "../../../src/util/dataframe"; + +function float32Conversion(f) { + return new Float32Array([39.3])[0]; +} describe("summarizeAnnotations", () => { const schema = { @@ -20,7 +25,8 @@ describe("summarizeAnnotations", () => { }; test("empty test", () => { - const summary = summarizeAnnotations(schema, [], []); + const df = Dataframe.Dataframe.empty(); + const summary = summarizeAnnotations(schema, df, df.clone()); expect(summary).toEqual( expect.objectContaining({ obs: { @@ -69,18 +75,27 @@ describe("summarizeAnnotations", () => { }); test("simple test", () => { - const obsAnnotations = [ - { - __index__: 0, - name: "n1", - nameString: "hi", - nameBoolean: true, - nameFloat32: 39.3, - nameInt32: 99, - nameCategorical: 1 - } - ]; - const varAnnotations = []; + const obsAnnotations = new Dataframe.Dataframe( + [1, 6], + [ + ["n1"], + ["hi"], + [true], + new Float32Array([39.3]), + new Int32Array([99]), + [1] + ], + null, + new Dataframe.KeyIndex([ + "name", + "nameString", + "nameBoolean", + "nameFloat32", + "nameInt32", + "nameCategorical" + ]) + ); + const varAnnotations = Dataframe.Dataframe.empty(); const summary = summarizeAnnotations( schema, @@ -105,7 +120,13 @@ describe("summarizeAnnotations", () => { }, nameFloat32: { categorical: false, - range: { min: 39.3, max: 39.3, nan: 0, ninf: 0, pinf: 0 } + range: { + min: float32Conversion(39.3), + max: float32Conversion(39.3), + nan: 0, + ninf: 0, + pinf: 0 + } }, nameInt32: { categorical: false, @@ -124,36 +145,27 @@ describe("summarizeAnnotations", () => { }); test("multi test", () => { - const obsAnnotations = [ - { - __index__: 0, - name: "n0", - nameString: "hi", - nameBoolean: false, - nameFloat32: 39.3, - nameInt32: 99, - nameCategorical: 1 - }, - { - __index__: 1, - name: "n1", - nameString: "hi", - nameBoolean: true, - nameFloat32: 39.3, - nameInt32: 99, - nameCategorical: false - }, - { - __index__: 2, - name: "n2", - nameString: "bye", - nameBoolean: true, - nameFloat32: 0, - nameInt32: 99, - nameCategorical: "0" - } - ]; - const varAnnotations = []; + const obsAnnotations = new Dataframe.Dataframe( + [3, 6], + [ + ["n0", "n1", "n2"], + ["hi", "hi", "bye"], + [false, true, true], + new Float32Array([39.3, 39.3, 0]), + new Int32Array([99, 99, 99]), + [1, false, "0"] + ], + null, + new Dataframe.KeyIndex([ + "name", + "nameString", + "nameBoolean", + "nameFloat32", + "nameInt32", + "nameCategorical" + ]) + ); + const varAnnotations = Dataframe.Dataframe.empty(); const summary = summarizeAnnotations( schema, @@ -178,7 +190,13 @@ describe("summarizeAnnotations", () => { }, nameFloat32: { categorical: false, - range: { min: 0, max: 39.3, nan: 0, ninf: 0, pinf: 0 } + range: { + min: 0, + max: float32Conversion(39.3), + nan: 0, + ninf: 0, + pinf: 0 + } }, nameInt32: { categorical: false, @@ -197,45 +215,32 @@ describe("summarizeAnnotations", () => { }); test("non-finite numbers", () => { - const obsAnnotations = [ - { - __index__: 0, - name: "n0", - nameString: "hi", - nameBoolean: false, - nameFloat32: 39.3, - nameInt32: 99, - nameCategorical: 1 - }, - { - __index__: 1, - name: "n1", - nameString: "hi", - nameBoolean: true, - nameFloat32: Number.NEGATIVE_INFINITY, - nameInt32: 99, - nameCategorical: false - }, - { - __index__: 2, - name: "n2", - nameString: "bye", - nameBoolean: true, - nameFloat32: Number.NaN, - nameInt32: 99, - nameCategorical: "0" - }, - { - __index__: 3, - name: "n2", - nameString: "bye", - nameBoolean: true, - nameFloat32: Number.POSITIVE_INFINITY, - nameInt32: 99, - nameCategorical: "0" - } - ]; - const varAnnotations = []; + const obsAnnotations = new Dataframe.Dataframe( + [4, 6], + [ + ["n0", "n1", "n2", "n2"], + ["hi", "hi", "bye", "bye"], + [false, true, true, true], + new Float32Array([ + 39.3, + Number.NEGATIVE_INFINITY, + Number.NaN, + Number.POSITIVE_INFINITY + ]), + new Int32Array([99, 99, 99, 99]), + [1, false, "0", "0"] + ], + null, + new Dataframe.KeyIndex([ + "name", + "nameString", + "nameBoolean", + "nameFloat32", + "nameInt32", + "nameCategorical" + ]) + ); + const varAnnotations = Dataframe.Dataframe.empty(); const summary = summarizeAnnotations( schema, @@ -260,7 +265,13 @@ describe("summarizeAnnotations", () => { }, nameFloat32: { categorical: false, - range: { min: 39.3, max: 39.3, nan: 1, ninf: 1, pinf: 1 } + range: { + min: float32Conversion(39.3), + max: float32Conversion(39.3), + nan: 1, + ninf: 1, + pinf: 1 + } }, nameInt32: { categorical: false, diff --git a/client/__tests__/util/stateManager/universe.test.js b/client/__tests__/util/stateManager/universe.test.js index 5a3be73d..f45fd7c5 100644 --- a/client/__tests__/util/stateManager/universe.test.js +++ b/client/__tests__/util/stateManager/universe.test.js @@ -1,13 +1,13 @@ -import _ from "lodash"; import * as Universe from "../../../src/util/stateManager/universe"; +import * as Dataframe from "../../../src/util/dataframe"; import * as REST from "./sampleResponses"; -describe("createUniverseFromRestV02Response", () => { +describe("createUniverseFromResponse", () => { /* - test createUniverseFromRestV02Response - this function converts + test createUniverseFromResponse - this function converts a set of REST 0.2 responses into a "new" Universe. - createUniverseFromRestV02Response( + createUniverseFromResponse( configResponse, schemaResponse, annotationsObsResponse, @@ -30,7 +30,7 @@ describe("createUniverseFromRestV02Response", () => { create a universe from sample data nad validate its shape & contents */ const { nObs, nVar } = REST.schema.schema.dataframe; - const universe = Universe.createUniverseFromRestV02Response( + const universe = Universe.createUniverseFromResponse( REST.config, REST.schema, REST.annotationsObs, @@ -45,23 +45,23 @@ describe("createUniverseFromRestV02Response", () => { nObs, nVar, schema: REST.schema.schema, - obsAnnotations: expect.any(Array), - varAnnotations: expect.any(Array), - obsNameToIndexMap: expect.any(Object), - varNameToIndexMap: expect.any(Object), - obsLayout: expect.objectContaining({ - X: expect.any(Float32Array), - Y: expect.any(Float32Array) - }), + obsAnnotations: expect.any(Dataframe.Dataframe), + varAnnotations: expect.any(Dataframe.Dataframe), + obsLayout: expect.any(Dataframe.Dataframe), + summary: expect.any(Object), varDataCache: expect.any(Object) }) ); - expect(universe.obsAnnotations).toHaveLength(nObs); - expect(_.keys(universe.obsNameToIndexMap)).toHaveLength(nObs); - expect(universe.obsLayout.X).toHaveLength(nObs); - expect(universe.obsLayout.Y).toHaveLength(nObs); - expect(universe.varAnnotations).toHaveLength(nVar); - expect(_.keys(universe.varNameToIndexMap)).toHaveLength(nVar); + expect(universe.obsAnnotations.dims).toEqual([ + nObs, + REST.schema.schema.annotations.obs.length + ]); + expect(universe.obsLayout.dims).toEqual([nObs, 2]); + expect(universe.obsLayout.colIndex.keys()).toEqual(["X", "Y"]); + expect(universe.varAnnotations.dims).toEqual([ + nVar, + REST.schema.schema.annotations.var.length + ]); }); }); diff --git a/client/__tests__/util/stateManager/world.test.js b/client/__tests__/util/stateManager/world.test.js index 321ccafa..c0aafb28 100644 --- a/client/__tests__/util/stateManager/world.test.js +++ b/client/__tests__/util/stateManager/world.test.js @@ -1,6 +1,7 @@ import _ from "lodash"; import * as Universe from "../../../src/util/stateManager/universe"; import * as World from "../../../src/util/stateManager/world"; +import * as Dataframe from "../../../src/util/dataframe"; import Crossfilter from "../../../src/util/typedCrossfilter"; import * as REST from "./sampleResponses"; import { @@ -16,7 +17,7 @@ the default REST test response. const defaultBigBang = () => { /* create unverse, world, crossfilter and dimensionMap */ /* create universe */ - const universe = Universe.createUniverseFromRestV02Response( + const universe = Universe.createUniverseFromResponse( REST.config, REST.schema, REST.annotationsObs, @@ -40,7 +41,7 @@ const defaultBigBang = () => { describe("createWorldFromEntireUniverse", () => { test("create from REST sample", () => { - const universe = Universe.createUniverseFromRestV02Response( + const universe = Universe.createUniverseFromResponse( REST.config, REST.schema, REST.annotationsObs, @@ -75,10 +76,7 @@ describe("createWorldFromEntireUniverse", () => { .value() }), - varDataCache: expect.any(Object), - - obsIndex: null, // null indicating full universe - obsBackIndex: null + varDataCache: expect.any(Object) }) ); }); @@ -111,51 +109,43 @@ describe("createWorldFromCurrentSelection", () => { */ /* matchFilter must match the dimension filters above */ - const matchFilter = val => val.field1 >= 0 && val.field1 < 5 && !val.field3; - const universeIndices = _() - .range(universe.nObs) - .filter(idx => matchFilter(universe.obsAnnotations[idx])) - .value(); - - const expected = { - nObs: universeIndices.length, - obsAnnotations: _.map(universeIndices, i => universe.obsAnnotations[i]), - obsLayout: { - X: new Float32Array( - _.map(universeIndices, i => universe.obsLayout.X[i]) - ), - Y: new Float32Array( - _.map(universeIndices, i => universe.obsLayout.Y[i]) - ) - }, - obsBackIndex: _.transform( - universeIndices, - (result, univIdx, worldIdx) => { - result[univIdx] = worldIdx; - }, - new Uint32Array(universe.nObs).fill(-1) - ), - obsIndex: new Uint32Array(universeIndices) + const matchFilter = (df, row) => { + const field1 = df.at(row, "field1"); + const field3 = df.at(row, "field3"); + return field1 >= 0 && field1 < 5 && !field3; }; + const matchingIndices = _() + .range(universe.nObs) + .filter(idx => matchFilter(universe.obsAnnotations, idx)) + .value(); expect(world).toMatchObject( expect.objectContaining({ api: "0.2", - nObs: expected.nObs, + nObs: matchingIndices.length, nVar: universe.nVar, schema: universe.schema, - obsAnnotations: expected.obsAnnotations, + obsAnnotations: expect.any(Dataframe.Dataframe), varAnnotations: universe.varAnnotations, - obsLayout: expected.obsLayout, + obsLayout: expect.any(Dataframe.Dataframe), summary: { obs: expect.any(Object) /* we could do better! */, var: expect.any(Object) /* we could do better! */ }, - varDataCache: expect.any(Object), - obsIndex: expected.obsIndex, - obsBackIndex: expected.obsBackIndex + varDataCache: expect.any(Object) }) ); + + expect(world.obsAnnotations.rowIndex.keys()).toEqual( + new Int32Array(matchingIndices) + ); + expect(world.obsAnnotations.colIndex.keys()).toEqual( + universe.obsAnnotations.colIndex.keys() + ); + expect(world.obsLayout.rowIndex.keys()).toEqual( + new Int32Array(matchingIndices) + ); + expect(world.obsLayout.colIndex.keys()).toEqual(["X", "Y"]); }); }); @@ -219,7 +209,9 @@ describe("subsetVarData", () => { world, crossfilter ); - expect(newWorld.obsIndex).toMatchObject(new Uint32Array([0, 2])); + expect(newWorld.obsAnnotations.rowIndex.keys()).toEqual( + new Int32Array([0, 2]) + ); /* expect a subset */ const result = World.subsetVarData(newWorld, universe, sourceVarData); diff --git a/client/__tests__/util/stateManager/worldUtil.test.js b/client/__tests__/util/stateManager/worldUtil.test.js index b20a7b0a..59ddc974 100644 --- a/client/__tests__/util/stateManager/worldUtil.test.js +++ b/client/__tests__/util/stateManager/worldUtil.test.js @@ -2,16 +2,27 @@ import { countCategoryValues2D, clearCaches } from "../../../src/util/stateManager/worldUtil"; +import * as Dataframe from "../../../src/util/dataframe"; describe("WorldUtil cache management", () => { test("empty", () => { - const count = countCategoryValues2D("a", "b", []); + const count = countCategoryValues2D( + "a", + "b", + new Dataframe.Dataframe([0, 0], []) + ); expect(count).toMatchObject(new Map()); + expect(count.size).toBe(0); }); test("simple couts", () => { - const rows = [{ a: 0, b: false }, { a: 0, b: true }, { a: 1, b: false }]; - const count = countCategoryValues2D("a", "b", rows); + const df = new Dataframe.Dataframe( + [3, 2], + [[0, 0, 1], [false, true, false]], + null, + new Dataframe.KeyIndex(["a", "b"]) + ); + const count = countCategoryValues2D("a", "b", df); expect(count).toMatchObject( new Map([ [0, new Map([[true, 1], [false, 1]])], @@ -22,16 +33,22 @@ describe("WorldUtil cache management", () => { test("memo cache clear", () => { clearCaches(); - const row1 = []; - const row2 = [{ a: 0, b: false }, { a: 0, b: true }, { a: 1, b: false }]; - const count1 = countCategoryValues2D("a", "b", row1); - const count2 = countCategoryValues2D("a", "b", row1); - const count3 = countCategoryValues2D("a", "b", []); - const count4 = countCategoryValues2D("a", "b", row2); + const df1 = new Dataframe.Dataframe([0, 0], []); + const df2 = new Dataframe.Dataframe( + [3, 2], + [[0, 0, 1], [false, true, false]], + null, + new Dataframe.KeyIndex(["a", "b"]) + ); + + const count1 = countCategoryValues2D("a", "b", df1); + const count2 = countCategoryValues2D("a", "b", df1); + const count3 = countCategoryValues2D("a", "b", df1.clone()); + const count4 = countCategoryValues2D("a", "b", df2); clearCaches(); - const count10 = countCategoryValues2D("a", "b", row1); - const count11 = countCategoryValues2D("a", "b", row2); + const count10 = countCategoryValues2D("a", "b", df1); + const count11 = countCategoryValues2D("a", "b", df2); expect(count1).toEqual(count2); expect(count1).toEqual(count3); diff --git a/client/__tests__/util/typedCrossfilter/bitArray.test.js b/client/__tests__/util/typedCrossfilter/bitArray.test.js index 5558c05c..e4b3e305 100644 --- a/client/__tests__/util/typedCrossfilter/bitArray.test.js +++ b/client/__tests__/util/typedCrossfilter/bitArray.test.js @@ -118,16 +118,16 @@ describe("selectionCount", () => { const dim2 = ba.allocDimension(); expect(dim2).toBeDefined(); - expect(ba.selectionCount).toEqual(0); + expect(ba.selectionCount()).toEqual(0); ba.selectAll(dim1); - expect(ba.selectionCount).toEqual(0); + expect(ba.selectionCount()).toEqual(0); ba.selectAll(dim2); - expect(ba.selectionCount).toEqual(defaultTestLength); + expect(ba.selectionCount()).toEqual(defaultTestLength); for (let i = 0; i < defaultTestLength; i += 1) { ba.deselectOne(dim1, i); - expect(ba.selectionCount).toEqual(defaultTestLength - i - 1); - expect(ba.selectionCount).toEqual(ba.countAllOnes()); + expect(ba.selectionCount()).toEqual(defaultTestLength - i - 1); + expect(ba.selectionCount()).toEqual(ba.countAllOnes()); } ba.freeDimension(dim1); diff --git a/client/__tests__/util/typedCrossfilter/typedCrossfilter.test.js b/client/__tests__/util/typedCrossfilter/typedCrossfilter.test.js index 6e72ce1a..f7fa2f12 100644 --- a/client/__tests__/util/typedCrossfilter/typedCrossfilter.test.js +++ b/client/__tests__/util/typedCrossfilter/typedCrossfilter.test.js @@ -119,14 +119,22 @@ function groupReduce(data, valueMap, valueReduce, valueInit) { } function groupCount(data, map) { - return groupReduce(data, map, (p, v) => p + 1, () => 0); + return groupReduce(data, map, p => p + 1, () => 0); } function groupSum(data, map) { - return groupReduce(data, map, (p, v) => (p += map(v)), () => 0); + return groupReduce( + data, + map, + (p, v) => { + p += map(v); + return p; + }, + () => 0 + ); } -var payments = null; +let payments = null; beforeEach(() => { payments = crossfilter(someData); }); @@ -139,7 +147,7 @@ describe("typedCrossfilter", () => { const quantity = payments.dimension( crossfilter.ScalarDimension, - r => r.quantity, + (i, data) => data[i].quantity, Int32Array ); expect(quantity).toBeDefined(); @@ -154,20 +162,23 @@ describe("typedCrossfilter", () => { expect(payments).toBeDefined(); const quantity = payments.dimension( crossfilter.ScalarDimension, - r => r.quantity, + (i, data) => data[i].quantity, Int32Array ); const tip = payments.dimension( crossfilter.ScalarDimension, - r => r.tip, + (i, data) => data[i].tip, Float32Array ); const total = payments.dimension( crossfilter.ScalarDimension, - r => r.total, + (i, data) => data[i].total, Float32Array ); - const type = payments.dimension(crossfilter.EnumDimension, r => r.type); + const type = payments.dimension( + crossfilter.EnumDimension, + (i, data) => data[i].type + ); expect(quantity).toBeDefined(); expect(tip).toBeDefined(); @@ -214,20 +225,18 @@ describe("typedCrossfilter", () => { expect(payments).toBeDefined(); const quantity = payments.dimension( crossfilter.ScalarDimension, - r => r.quantity, + (i, data) => data[i].quantity, Int32Array ); const tip = payments.dimension( crossfilter.ScalarDimension, - r => r.tip, + (i, data) => data[i].tip, Float32Array ); - const total = payments.dimension( - crossfilter.ScalarDimension, - r => r.total, - Float32Array + const type = payments.dimension( + crossfilter.EnumDimension, + (i, data) => data[i].type ); - const type = payments.dimension(crossfilter.EnumDimension, r => r.type); quantity.filterExact(1); expect(payments.countFiltered()).toEqual( @@ -250,20 +259,23 @@ describe("typedCrossfilter", () => { expect(payments).toBeDefined(); const quantity = payments.dimension( crossfilter.ScalarDimension, - r => r.quantity, + (i, data) => data[i].quantity, Int32Array ); const tip = payments.dimension( crossfilter.ScalarDimension, - r => r.tip, + (i, data) => data[i].tip, Float32Array ); const total = payments.dimension( crossfilter.ScalarDimension, - r => r.total, + (i, data) => data[i].total, Float32Array ); - const type = payments.dimension(crossfilter.EnumDimension, r => r.type); + const type = payments.dimension( + crossfilter.EnumDimension, + (i, data) => data[i].type + ); tip.filterRange([0, 91]); expect(payments.allFiltered()).toEqual( @@ -291,20 +303,23 @@ describe("typedCrossfilter", () => { expect(payments).toBeDefined(); const quantity = payments.dimension( crossfilter.ScalarDimension, - r => r.quantity, + (i, data) => data[i].quantity, Int32Array ); const tip = payments.dimension( crossfilter.ScalarDimension, - r => r.tip, + (i, data) => data[i].tip, Float32Array ); const total = payments.dimension( crossfilter.ScalarDimension, - r => r.total, + (i, data) => data[i].total, Float32Array ); - const type = payments.dimension(crossfilter.EnumDimension, r => r.type); + const type = payments.dimension( + crossfilter.EnumDimension, + (i, data) => data[i].type + ); type.filterEnum(["tab", "cash"]); expect(payments.allFiltered()).toEqual( @@ -326,27 +341,30 @@ describe("typedCrossfilter", () => { expect(payments).toBeDefined(); const quantity = payments.dimension( crossfilter.ScalarDimension, - r => r.quantity, + (i, data) => data[i].quantity, Int32Array ); const tip = payments.dimension( crossfilter.ScalarDimension, - r => r.tip, + (i, data) => data[i].tip, Float32Array ); const total = payments.dimension( crossfilter.ScalarDimension, - r => r.total, + (i, data) => data[i].total, Float32Array ); - const type = payments.dimension(crossfilter.EnumDimension, r => r.type); + const type = payments.dimension( + crossfilter.EnumDimension, + (i, data) => data[i].type + ); // Create a bunch of fake dimensions to ensure we can handle > 32 let dimMap = {}; for (let i = 0; i < 65; i++) { dimMap[i] = payments.dimension( crossfilter.ScalarDimension, - r => Math.random(), + () => Math.random(), Float32Array ); expect(dimMap[i]).toBeDefined(); @@ -372,18 +390,21 @@ describe("typedCrossfilter", () => { const quantity = payments.dimension( crossfilter.ScalarDimension, - r => r.quantity, + (i, data) => data[i].quantity, Int32Array ); const tip = payments.dimension( crossfilter.ScalarDimension, - r => r.tip, + (i, data) => data[i].tip, Int32Array ); - const type = payments.dimension(crossfilter.EnumDimension, r => r.type); + const type = payments.dimension( + crossfilter.EnumDimension, + (i, data) => data[i].type + ); const total = payments.dimension( crossfilter.ScalarDimension, - r => r.total, + (i, data) => data[i].total, Int32Array ); @@ -411,15 +432,18 @@ describe("typedCrossfilter", () => { const tip = payments.dimension( crossfilter.ScalarDimension, - r => r.tip, + (i, data) => data[i].tip, Int32Array ); const totalX10 = payments.dimension( crossfilter.ScalarDimension, - r => r.total * 10, + (i, data) => data[i].total * 10, Int32Array ); - const type = payments.dimension(crossfilter.EnumDimension, r => r.type); + const type = payments.dimension( + crossfilter.EnumDimension, + (i, data) => data[i].type + ); const paymentsByTip_A = tip.group(); const paymentsByTip_B = tip.group(r => 10 * r); @@ -458,10 +482,13 @@ describe("typedCrossfilter", () => { const total = payments.dimension( crossfilter.ScalarDimension, - r => r.total, + (i, data) => data[i].total, Float32Array ); - const type = payments.dimension(crossfilter.EnumDimension, r => r.type); + const type = payments.dimension( + crossfilter.EnumDimension, + (i, data) => data[i].type + ); const paymentsByTotal = total.group(); const paymentsByType = type.group(); @@ -499,15 +526,18 @@ describe("typedCrossfilter", () => { const tip = payments.dimension( crossfilter.ScalarDimension, - r => r.tip, + (i, data) => data[i].tip, Int32Array ); const total = payments.dimension( crossfilter.ScalarDimension, - r => r.total, + (i, data) => data[i].total, Int32Array ); - const type = payments.dimension(crossfilter.EnumDimension, r => r.type); + const type = payments.dimension( + crossfilter.EnumDimension, + (i, data) => data[i].type + ); const paymentsByTip = tip.group(); const paymentsByTotal = total.group(); diff --git a/client/src/actions/index.js b/client/src/actions/index.js index 45ce436b..7964fadf 100644 --- a/client/src/actions/index.js +++ b/client/src/actions/index.js @@ -40,7 +40,7 @@ const doInitialDataLoad = () => /* set config defaults */ const config = { ...globals.configDefaults, ...results[0].config }; const [, schema, obsAnno, varAnno, obsLayout] = [...results]; - const universe = Universe.createUniverseFromRestV02Response( + const universe = Universe.createUniverseFromResponse( config, schema, obsAnno, @@ -242,12 +242,14 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async ( */ const state = getState(); const { universe } = state.controls; - const set1ByIndex = rangeEncodeIndices( - _.map(set1, s => universe.obsNameToIndexMap[s]) - ); - const set2ByIndex = rangeEncodeIndices( - _.map(set2, s => universe.obsNameToIndexMap[s]) - ); + + // These lines ensure that we convert any TypedArray to an Array. + // This is necessary because JSON.stringify() does some very strange + // things with TypedArrays (they are marshalled to JSON objects, rather + // than being marshalled as a JSON array). + const aset1 = Array.isArray(set1) ? set1 : Array.from(set1); + const aset2 = Array.isArray(set2) ? set2 : Array.from(set2); + const res = await fetch( `${globals.API.prefix}${globals.API.version}diffexp/obs`, { @@ -259,8 +261,8 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async ( body: JSON.stringify({ mode: "topN", count: num_genes, - set1: { filter: { obs: { index: set1ByIndex } } }, - set2: { filter: { obs: { index: set2ByIndex } } } + set1: { filter: { obs: { index: aset1 } } }, + set2: { filter: { obs: { index: aset2 } } } }) } ); @@ -271,7 +273,9 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async ( const data = await res.json(); // result is [ [varIdx, ...], ... ] - const topNGenes = _.map(data, r => universe.varAnnotations[r[0]].name); + const topNGenes = _.map(data, r => + universe.varAnnotations.at(r[0], "name") + ); /* Kick off secondary action to fetch all of the expression data for the diff --git a/client/src/components/brushableHistogram/index.js b/client/src/components/brushableHistogram/index.js index b38e8705..800880db 100644 --- a/client/src/components/brushableHistogram/index.js +++ b/client/src/components/brushableHistogram/index.js @@ -35,9 +35,11 @@ class HistogramBrush extends React.Component { .scaleLinear() .range([this.height - this.marginBottom, 0]); - if (obsAnnotations[0][field] !== undefined) { + if (obsAnnotations.col(field)) { // recalculate expensive stuff - const allValuesForContinuousFieldAsArray = _.map(obsAnnotations, field); + const allValuesForContinuousFieldAsArray = obsAnnotations + .col(field) + .asArray(); histogramCache.x = d3 .scaleLinear() @@ -149,7 +151,7 @@ class HistogramBrush extends React.Component { initializeRanges } = this.props; - if (obsAnnotations[0][field]) { + if (obsAnnotations.col(field)) { dispatch({ type: "color by continuous metadata", colorAccessor: field, diff --git a/client/src/components/categorical/value.js b/client/src/components/categorical/value.js index dd7fafe7..e91bfb86 100644 --- a/client/src/components/categorical/value.js +++ b/client/src/components/categorical/value.js @@ -65,7 +65,11 @@ class CategoryValue extends React.Component { })[0].categories; } - if (colorAccessor && !isColorBy) { + if ( + colorAccessor && + !isColorBy && + categoricalSelectionState[colorAccessor] + ) { occupancy = countCategoryValues2D( metadataField, colorAccessor, diff --git a/client/src/components/continuous/continuous.js b/client/src/components/continuous/continuous.js index 3dc1f157..27b2d509 100644 --- a/client/src/components/continuous/continuous.js +++ b/client/src/components/continuous/continuous.js @@ -10,7 +10,6 @@ import HistogramBrush from "../brushableHistogram"; @connect(state => ({ ranges: _.get(state.controls.world, "summary.obs", null), - metadata: _.get(state.controls.world, "obsAnnotations", null), colorAccessor: state.controls.colorAccessor, colorScale: state.controls.colorScale, selectionUpdate: _.get(state.controls, "crossfilter.updateTime", null), @@ -39,7 +38,7 @@ class Continuous extends React.Component { } render() { - const { ranges, obsAnnotations, schema } = this.props; + const { ranges, schema } = this.props; if (schema && !this.continuousChecked) { this.hasContinuous = _.some( schema.annotations.obs, @@ -73,7 +72,6 @@ class Continuous extends React.Component { field={key} isObs zebra={zebra % 2 === 0} - fieldValues={obsAnnotations} ranges={value.range} handleColorAction={this.handleColorAction(key).bind(this)} /> diff --git a/client/src/components/geneExpression/cellSetButtons.js b/client/src/components/geneExpression/cellSetButtons.js index c1ed037c..d9417363 100644 --- a/client/src/components/geneExpression/cellSetButtons.js +++ b/client/src/components/geneExpression/cellSetButtons.js @@ -1,8 +1,8 @@ // jshint esversion: 6 import React from "react"; -import _ from "lodash"; import { AnchorButton, Tooltip } from "@blueprintjs/core"; import { connect } from "react-redux"; +import { World } from "../../util/stateManager"; @connect() class CellSetButton extends React.Component { @@ -14,7 +14,7 @@ class CellSetButton extends React.Component { eitherCellSetOneOrTwo } = this.props; - const set = _.map(crossfilter.allFiltered(), "name"); + const set = World.getSelectedByIndex(crossfilter); if (!differential.diffExp) { /* diffexp needs to be cleared before we store a new set */ diff --git a/client/src/components/geneExpression/index.js b/client/src/components/geneExpression/index.js index 43981bcb..60af2e40 100644 --- a/client/src/components/geneExpression/index.js +++ b/client/src/components/geneExpression/index.js @@ -29,8 +29,7 @@ const renderGene = (fuzzySortResult, { handleClick, modifiers, query }) => { return null; } /* the fuzzysort wraps the object with other properties, like a score */ - const gene = fuzzySortResult.obj; - const text = gene.name; + const geneName = fuzzySortResult.target; return ( { // Use of annotations in this way is incorrect and dataset specific. // See https://github.com/chanzuckerberg/cellxgene/issues/483 // label={gene.n_counts} - key={gene.name} - onClick={g => { + key={geneName} + onClick={g => /* this fires when user clicks a menu item */ - handleClick(g); - }} - text={text} + handleClick(g) + } + text={geneName} /> ); }; -const filterGenes = (query, genes) => { +const filterGenes = (query, genes) => /* fires on load, once, and then for each character typed into the input */ - return fuzzysort.go(query, genes, { - key: "name", + fuzzysort.go(query, genes, { limit: 5, threshold: -10000 // don't return bad results }); -}; @connect(state => { - const metadata = _.get(state.controls.world, "obsAnnotations", null); const ranges = _.get(state.controls.world, "summary.obs", null); const initializeRanges = _.get(state.controls.world, "summary.obs"); return { ranges, - metadata, initializeRanges, userDefinedGenes: state.controls.userDefinedGenes, userDefinedGenesLoading: state.controls.userDefinedGenesLoading, world: state.controls.world, colorAccessor: state.controls.colorAccessor, - allGeneNames: state.controls.allGeneNames, differential: state.differential }; }) @@ -84,6 +78,37 @@ class GeneExpression extends React.Component { }; } + placeholderGeneNames() { + /* + return a string containing gene name suggestions for use as a user hint. + Eg., Apod, Cd74, ... + Will return a max of 3 genes, totalling 15 characters in length. + Randomly selects gene names. + + NOTE: the random selection means it will re-render constantly. + */ + const { world } = this.props; + const { varAnnotations } = world; + const geneNames = varAnnotations.col("name").asArray(); + if (geneNames.length > 0) { + const placeholder = []; + let len = geneNames.length; + const maxGeneNameCount = 3; + const maxStrLength = 15; + len = len < maxGeneNameCount ? len : maxGeneNameCount; + for (let i = 0, strLen = 0; i < len && strLen < maxStrLength; i += 1) { + const deal = Math.floor(Math.random() * geneNames.length); + const geneName = geneNames[deal]; + placeholder.push(geneName); + strLen += geneName.length + 2; // '2' is the length of a comma and space + } + placeholder.push("..."); + return placeholder.join(", "); + } + // default - should never happen. + return "Apod, Cd74, ..."; + } + handleClick(g) { const { world, dispatch, userDefinedGenes } = this.props; const gene = g.target; @@ -93,7 +118,7 @@ class GeneExpression extends React.Component { postUserErrorToast( "That's too many genes, you can have at most 15 user defined genes" ); - } else if (!_.find(world.varAnnotations, { name: gene })) { + } else if (world.varAnnotations.col("name").indexOf(gene) === undefined) { postUserErrorToast("That doesn't appear to be a valid gene name."); } else { dispatch(actions.requestUserDefinedGene(gene)); @@ -116,9 +141,13 @@ class GeneExpression extends React.Component { const genes = _.pull(_.uniq(bulkAdd.split(/[ ,]+/)), ""); genes.forEach(gene => { - if (userDefinedGenes.indexOf(gene) !== -1) { + if (gene.length === 0) { + keepAroundErrorToast("Must enter a gene name."); + } else if (userDefinedGenes.indexOf(gene) !== -1) { keepAroundErrorToast("That gene already exists"); - } else if (!_.find(world.varAnnotations, { name: gene })) { + } else if ( + world.varAnnotations.col("name").indexOf(gene) === undefined + ) { keepAroundErrorToast( `${gene} doesn't appear to be a valid gene name.` ); @@ -214,8 +243,8 @@ class GeneExpression extends React.Component { itemRenderer={renderGene.bind(this)} items={ world && world.varAnnotations - ? world.varAnnotations - : [{ name: "No genes" }] + ? world.varAnnotations.col("name").asArray() + : ["No genes"] } popoverProps={{ minimal: true }} /> @@ -245,7 +274,7 @@ class GeneExpression extends React.Component { this.setState({ bulkAdd: e.target.value }); }} id="text-input-bulk-add" - placeholder="Apod, Cd74, ..." + placeholder={this.placeholderGeneNames()} value={bulkAdd} />