From eaa4e6c7a4e86b486322ba2a94ff249a2f20802e Mon Sep 17 00:00:00 2001 From: bkmartinjr Date: Tue, 29 May 2018 20:34:59 -0700 Subject: [PATCH 01/14] fix bug in selectOne and deselectOne --- src/util/typedCrossfilter/bitArray.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/util/typedCrossfilter/bitArray.js b/src/util/typedCrossfilter/bitArray.js index cff83569..dd7c1eb3 100644 --- a/src/util/typedCrossfilter/bitArray.js +++ b/src/util/typedCrossfilter/bitArray.js @@ -140,7 +140,7 @@ class BitArray { const col = dim >>> 5; const before = this.bitarray[col * this.length + index]; const after = before | (1 << (dim % 32)); - this.bitarray[col] = after; + this.bitarray[col * this.length + index] = after; } // deselect index on dimension @@ -149,7 +149,7 @@ class BitArray { const col = dim >>> 5; const before = this.bitarray[col * this.length + index]; const after = before & ~(1 << (dim % 32)); - this.bitarray[col] = after; + this.bitarray[col * this.length + index] = after; } // select all indices on dimension. From 92dfc5f2ee73ac6d36a2db9e77bede47b024b3cc Mon Sep 17 00:00:00 2001 From: bkmartinjr Date: Tue, 29 May 2018 21:16:48 -0700 Subject: [PATCH 02/14] add Jest tests --- __tests__/util/bitArray.test.js | 137 +++++++++++++++++++++++ __tests__/util/positiveInterval.test.js | 140 ++++++++++++++++++++++++ package.json | 13 ++- 3 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 __tests__/util/bitArray.test.js create mode 100644 __tests__/util/positiveInterval.test.js diff --git a/__tests__/util/bitArray.test.js b/__tests__/util/bitArray.test.js new file mode 100644 index 00000000..d5ffffba --- /dev/null +++ b/__tests__/util/bitArray.test.js @@ -0,0 +1,137 @@ +// jshint esversion: 6 + +const BitArray = require("../../src/util/typedCrossfilter/bitArray"); +const defaultTestLength = 8; + +describe("default select state", () => { + test("newly created Bitarray should be deselected", () => { + const ba = new BitArray(defaultTestLength); + expect(ba).toBeDefined(); + + for (let i = 0; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(false); + } + + const dim = ba.allocDimension(); + expect(dim).toBeDefined(); + for (let i = 0; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(false); + } + + ba.freeDimension(dim); + for (let i = 0; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(false); + } + }); +}); + +describe("select and deselect", () => { + test("selectAll and deselectAll", () => { + const ba = new BitArray(defaultTestLength); + expect(ba).toBeDefined(); + const dim1 = ba.allocDimension(); + expect(dim1).toBeDefined(); + ba.selectAll(dim1); + + for (let i = 0; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(true); + } + + const dim2 = ba.allocDimension(); + expect(dim2).toBeDefined(); + for (let i = 0; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(false); + } + + ba.selectAll(dim2); + for (let i = 0; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(true); + } + + ba.deselectAll(dim1); + for (let i = 0; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(false); + } + + ba.deselectAll(dim2); + for (let i = 0; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(false); + } + + ba.selectAll(dim1); + ba.selectAll(dim2); + for (let i = 0; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(true); + } + + ba.freeDimension(dim1); + for (let i = 0; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(true); + } + + ba.freeDimension(dim2); + for (let i = 0; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(false); + } + }); + + test("selectOne and deselectOne", () => { + const ba = new BitArray(defaultTestLength); + expect(ba).toBeDefined(); + const dim = ba.allocDimension(); + expect(dim).toBeDefined(); + + ba.selectOne(dim, 0); + expect(ba.isSelected(0)).toEqual(true); + for (let i = 1; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(false); + } + + ba.deselectOne(dim, 0); + for (let i = 0; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(false); + } + + ba.selectOne(dim, 1); + expect(ba.isSelected(1)).toEqual(true); + expect(ba.isSelected(0)).toEqual(false); + for (let i = 2; i < defaultTestLength; i++) { + expect(ba.isSelected(i)).toEqual(false); + } + + ba.selectAll(dim); + console.log(ba); + console.log(defaultTestLength - 1); + ba.deselectOne(dim, defaultTestLength - 1); + console.log(ba); + expect(ba.isSelected(defaultTestLength - 1)).toEqual(false); + for (let i = 0; i < defaultTestLength - 1; i++) { + expect(ba.isSelected(i)).toEqual(true); + } + }); +}); + +describe("selectionCount", () => { + test("simple", () => { + const ba = new BitArray(defaultTestLength); + expect(ba).toBeDefined(); + const dim1 = ba.allocDimension(); + expect(dim1).toBeDefined(); + const dim2 = ba.allocDimension(); + expect(dim2).toBeDefined(); + + expect(ba.selectionCount).toEqual(0); + ba.selectAll(dim1); + expect(ba.selectionCount).toEqual(0); + ba.selectAll(dim2); + expect(ba.selectionCount).toEqual(defaultTestLength); + + for (let i = 0; i < defaultTestLength; i++) { + ba.deselectOne(dim1, i); + expect(ba.selectionCount).toEqual(defaultTestLength - i - 1); + } + + ba.freeDimension(dim1); + ba.freeDimension(dim2); + }); +}); diff --git a/__tests__/util/positiveInterval.test.js b/__tests__/util/positiveInterval.test.js new file mode 100644 index 00000000..81c44496 --- /dev/null +++ b/__tests__/util/positiveInterval.test.js @@ -0,0 +1,140 @@ +// jshint esversion: 6 + +const PositiveIntervals = require("../../src/util/typedCrossfilter/positiveIntervals"); + +describe("canonicalize", () => { + test("empty", () => { + expect(PositiveIntervals.canonicalize([])).toEqual([]); + }); + + test("simple, already correct", () => { + expect(PositiveIntervals.canonicalize([[0, 1]])).toEqual([[0, 1]]); + expect(PositiveIntervals.canonicalize([[0, 1], [2, 3]])).toEqual([ + [0, 1], + [2, 3] + ]); + }); + + test("non-canonical, need to be canonicalized", () => { + expect(PositiveIntervals.canonicalize([[0, 1], [1, 2]])).toEqual([[0, 2]]); + expect(PositiveIntervals.canonicalize([[1, 2], [2, 3]])).toEqual([[1, 3]]); + }); +}); + +describe("union", () => { + test("empty range", () => { + expect(PositiveIntervals.union([], [])).toEqual([]); + expect(PositiveIntervals.union([], [[1, 2]])).toEqual([[1, 2]]); + expect(PositiveIntervals.union([], [[1, 2], [3, 4]])).toEqual([ + [1, 2], + [3, 4] + ]); + expect(PositiveIntervals.union([[3, 4]], [])).toEqual([[3, 4]]); + expect(PositiveIntervals.union([[1, 2], [3, 4]], [])).toEqual([ + [1, 2], + [3, 4] + ]); + expect(PositiveIntervals.union([[3, 3]], [])).toEqual([[3, 3]]); + expect(PositiveIntervals.union([], [[3, 3]])).toEqual([[3, 3]]); + }); + + test("simple ranges", () => { + expect(PositiveIntervals.union([[1, 2]], [[2, 3]])).toEqual([[1, 3]]); + expect(PositiveIntervals.union([[2, 3]], [[1, 2]])).toEqual([[1, 3]]); + expect(PositiveIntervals.union([[1, 2]], [[3, 4]])).toEqual([ + [1, 2], + [3, 4] + ]); + expect( + PositiveIntervals.union([[1, 2], [3, 4]], [[6, 7], [19, 40]]) + ).toEqual([[1, 2], [3, 4], [6, 7], [19, 40]]); + expect(PositiveIntervals.union([[1, 4]], [[1, 1], [3, 4]])).toEqual([ + [1, 4] + ]); + expect(PositiveIntervals.union([[3, 3]], [[4, 4]])).toEqual([ + [3, 3], + [4, 4] + ]); + }); +}); + +describe("intersection", () => { + test("empty range", () => { + expect(PositiveIntervals.intersection([], [])).toEqual([]); + expect(PositiveIntervals.intersection([], [[1, 2]])).toEqual([]); + expect(PositiveIntervals.intersection([[1, 2]], [])).toEqual([]); + }); + + test("simple", () => { + expect(PositiveIntervals.intersection([[1, 2]], [[2, 3]])).toEqual([]); + expect(PositiveIntervals.intersection([[2, 3]], [[1, 2]])).toEqual([]); + expect(PositiveIntervals.intersection([[1, 10]], [[1, 10]])).toEqual([ + [1, 10] + ]); + expect(PositiveIntervals.intersection([[1, 10]], [[2, 8]])).toEqual([ + [2, 8] + ]); + expect(PositiveIntervals.intersection([[2, 8]], [[1, 10]])).toEqual([ + [2, 8] + ]); + expect(PositiveIntervals.intersection([[1, 10]], [[2, 12]])).toEqual([ + [2, 10] + ]); + expect(PositiveIntervals.intersection([[2, 12]], [[1, 10]])).toEqual([ + [2, 10] + ]); + expect(PositiveIntervals.intersection([[1, 10]], [[1, 8]])).toEqual([ + [1, 8] + ]); + expect(PositiveIntervals.intersection([[1, 8]], [[1, 10]])).toEqual([ + [1, 8] + ]); + expect(PositiveIntervals.intersection([[1, 10]], [[1, 2], [6, 9]])).toEqual( + [[1, 2], [6, 9]] + ); + expect(PositiveIntervals.intersection([[0, 2638]], [[1363, 2638]])).toEqual( + [[1363, 2638]] + ); + expect(PositiveIntervals.intersection([[1, 2]], [[1, 2]])).toEqual([ + [1, 2] + ]); + }); +}); + +describe("difference", () => { + test("empty", () => { + expect(PositiveIntervals.difference([], [])).toEqual([]); + expect(PositiveIntervals.difference([], [[1, 10]])).toEqual([]); + expect(PositiveIntervals.difference([[1, 10]], [])).toEqual([[1, 10]]); + }); + + test("simple", () => { + expect(PositiveIntervals.difference([[1, 2], [3, 4]], [])).toEqual([ + [1, 2], + [3, 4] + ]); + expect(PositiveIntervals.difference([[1, 2], [3, 10]], [[5, 10]])).toEqual([ + [1, 2], + [3, 5] + ]); + expect(PositiveIntervals.difference([[1, 2], [3, 10]], [[0, 5]])).toEqual([ + [5, 10] + ]); + expect( + PositiveIntervals.difference([[0, 2638]], [[0, 1363], [2055, 2638]]) + ).toEqual([[1363, 2055]]); + expect( + PositiveIntervals.difference([[0, 1363], [2055, 2638]], [[0, 2638]]) + ).toEqual([]); + expect(PositiveIntervals.difference([[0, 10]], [[0, 1]])).toEqual([ + [1, 10] + ]); + expect(PositiveIntervals.difference([[0, 10]], [[1, 2]])).toEqual([ + [0, 1], + [2, 10] + ]); + expect(PositiveIntervals.difference([[0, 10]], [[9, 10]])).toEqual([ + [0, 9] + ]); + }); +}); diff --git a/package.json b/package.json index 03568a9c..0698a3cb 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,15 @@ "license": "MIT", "repository": "https://github.com/chanzuckerberg/cellxgene", "scripts": { - "build": "npm run clean && webpack --config configuration/webpack/webpack.config.prod.js", - "dev": "npm run clean && webpack --config configuration/webpack/webpack.config.dev.js", + "build": + "npm run clean && webpack --config configuration/webpack/webpack.config.prod.js", + "dev": + "npm run clean && webpack --config configuration/webpack/webpack.config.dev.js", "clean": "rimraf build", "start": "node server/development.js", "lint": "eslint src", - "prod": "cross-env NODE_ENV=production PORT=3000 node server/production.js" + "prod": "cross-env NODE_ENV=production PORT=3000 node server/production.js", + "test": "jest" }, "engineStrict": true, "engines": { @@ -90,6 +93,7 @@ "gzip-size": "^3.0.0", "html-webpack-inline-source-plugin": "0.0.6", "html-webpack-plugin": "^2.22.0", + "jest": "^23.0.1", "jsdom": "^9.4.1", "json-loader": "^0.5.4", "nyc": "^10.0.0", @@ -106,5 +110,8 @@ "webpack-dev-middleware": "^1.6.1", "webpack-hot-middleware": "^2.12.2", "whatwg-fetch": "^2.0.1" + }, + "jest": { + "testMatch": ["**/__tests__/**/?(*.)(spec|test).js?(x)"] } } From a0c6e3102b656f981c0d6cf2c833f3fbf5570834 Mon Sep 17 00:00:00 2001 From: bkmartinjr Date: Wed, 30 May 2018 09:03:03 -0700 Subject: [PATCH 03/14] fix bug in fillBySelection --- src/util/typedCrossfilter/bitArray.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/util/typedCrossfilter/bitArray.js b/src/util/typedCrossfilter/bitArray.js index dd7c1eb3..49258aca 100644 --- a/src/util/typedCrossfilter/bitArray.js +++ b/src/util/typedCrossfilter/bitArray.js @@ -214,7 +214,8 @@ class BitArray { const bitmask = this.bitmask[0]; const bitarray = this.bitarray; for (let i = 0, len = this.length; i < len; i++) { - result[i] = bitarray[i] === bitmask ? selectedValue : deselectedValue; + result[i] = + bitmask && bitarray[i] === bitmask ? selectedValue : deselectedValue; } } else { for (let i = 0, len = this.length; i < len; i++) { From 0b50f02d55cda46b51916999d7b0272d53bf8bff Mon Sep 17 00:00:00 2001 From: bkmartinjr Date: Wed, 30 May 2018 09:23:25 -0700 Subject: [PATCH 04/14] fix id() bug in ScalarDimension --- src/util/typedCrossfilter/index.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/util/typedCrossfilter/index.js b/src/util/typedCrossfilter/index.js index ae2d03e6..48cc0425 100644 --- a/src/util/typedCrossfilter/index.js +++ b/src/util/typedCrossfilter/index.js @@ -65,7 +65,7 @@ class TypedCrossfilter { _freeDimension(id) { this.selection.freeDimension(id); - this.filters = this.filters.filter(f => f.id != id); + this.filters = this.filters.filter(f => f._id != id); } // return array of all records that are selected/filtered @@ -105,7 +105,7 @@ class TypedCrossfilter { class ScalarDimension { constructor(value, valueArrayType, crossfilter, id) { this.crossfilter = crossfilter; - this.id = id; + this._id = id; // current selection filter, expressed as PostiveIntervals. this.currentFilter = []; @@ -133,11 +133,11 @@ class ScalarDimension { } dispose() { - this.crossfilter._freeDimension(this.id); + this.crossfilter._freeDimension(this._id); } id() { - return this.id; + return this._id; } _updateFilters(newFilter) { @@ -147,26 +147,26 @@ class ScalarDimension { // more complex work and just clobber everything. // if (newFilter.length === 0) { - this.crossfilter.selection.deselectAll(this.id); + this.crossfilter.selection.deselectAll(this._id); } else if ( newFilter.length === 1 && newFilter[0][0] === 0 && newFilter[0][1] == this.index.length ) { - this.crossfilter.selection.selectAll(this.id); + this.crossfilter.selection.selectAll(this._id); } else { const adds = PositiveIntervals.difference(newFilter, this.currentFilter); const dels = PositiveIntervals.difference(this.currentFilter, newFilter); dels.forEach(interval => this.crossfilter.selection.deselectIndirectFromRange( - this.id, + this._id, this.index, interval ) ); adds.forEach(interval => this.crossfilter.selection.selectIndirectFromRange( - this.id, + this._id, this.index, interval ) From 9e8d48d630bf8d2e91c9ae14e1e1cae6c74a3464 Mon Sep 17 00:00:00 2001 From: bkmartinjr Date: Wed, 30 May 2018 10:35:49 -0700 Subject: [PATCH 05/14] more Jest tests for TypedCrossfilter --- __tests__/util/bitArray.test.js | 53 ++++- __tests__/util/typedCrossfilter.test.js | 276 ++++++++++++++++++++++++ 2 files changed, 326 insertions(+), 3 deletions(-) create mode 100644 __tests__/util/typedCrossfilter.test.js diff --git a/__tests__/util/bitArray.test.js b/__tests__/util/bitArray.test.js index d5ffffba..72e91882 100644 --- a/__tests__/util/bitArray.test.js +++ b/__tests__/util/bitArray.test.js @@ -100,10 +100,7 @@ describe("select and deselect", () => { } ba.selectAll(dim); - console.log(ba); - console.log(defaultTestLength - 1); ba.deselectOne(dim, defaultTestLength - 1); - console.log(ba); expect(ba.isSelected(defaultTestLength - 1)).toEqual(false); for (let i = 0; i < defaultTestLength - 1; i++) { expect(ba.isSelected(i)).toEqual(true); @@ -129,9 +126,59 @@ describe("selectionCount", () => { for (let i = 0; i < defaultTestLength; i++) { ba.deselectOne(dim1, i); expect(ba.selectionCount).toEqual(defaultTestLength - i - 1); + expect(ba.selectionCount).toEqual(ba.countAllOnes()); } ba.freeDimension(dim1); ba.freeDimension(dim2); }); }); + +describe("fillBySelection", () => { + test("sets values correctly", () => { + const ba = new BitArray(defaultTestLength); + expect(ba).toBeDefined(); + const dim = ba.allocDimension(); + expect(dim).toBeDefined(); + + const arr = new Int32Array(defaultTestLength); + arr.fill(0); + const truth = new Int32Array(defaultTestLength); + truth.fill(0); + + // initial state should be deselected + ba.fillBySelection(arr, 1, 0); + expect(arr).toEqual(expect.not.arrayContaining([1])); + + // selectAll + ba.selectAll(dim); + ba.fillBySelection(arr, 1, 0); + expect(arr).toEqual(expect.not.arrayContaining([0])); + + // deselectOne + ba.deselectOne(dim, 3); + ba.fillBySelection(arr, 1, 0); + truth.fill(1); + truth[3] = 0; + expect(arr).toEqual(truth); + + // deselectAll + ba.deselectAll(dim); + ba.fillBySelection(arr, 1, 0); + truth.fill(0); + expect(arr).toEqual(truth); + + // selectOne + ba.selectOne(dim, 5); + ba.fillBySelection(arr, 6, 1); + truth.fill(1); + truth[5] = 6; + expect(arr).toEqual(truth); + + // should be deselected after dimension disposal + ba.freeDimension(dim); + ba.fillBySelection(arr, 3, 9); + truth.fill(9); + expect(arr).toEqual(truth); + }); +}); diff --git a/__tests__/util/typedCrossfilter.test.js b/__tests__/util/typedCrossfilter.test.js new file mode 100644 index 00000000..00e68495 --- /dev/null +++ b/__tests__/util/typedCrossfilter.test.js @@ -0,0 +1,276 @@ +// jshint esversion: 6 +const _ = require("lodash"); +const crossfilter = require("../../src/util/typedCrossfilter"); + +const someData = [ + { + date: "2011-11-14T16:17:54Z", + quantity: 2, + total: 190, + tip: 100, + type: "tab", + productIDs: ["001"] + }, + { + date: "2011-11-14T16:20:19Z", + quantity: 2, + total: 190, + tip: 100, + type: "tab", + productIDs: ["001", "005"] + }, + { + date: "2011-11-14T16:28:54Z", + quantity: 1, + total: 300, + tip: 200, + type: "visa", + productIDs: ["004", "005"] + }, + { + date: "2011-11-14T16:30:43Z", + quantity: 2, + total: 90, + tip: 0, + type: "tab", + productIDs: ["001", "002"] + }, + { + date: "2011-11-14T16:48:46Z", + quantity: 2, + total: 90, + tip: 0, + type: "tab", + productIDs: ["005"] + }, + { + date: "2011-11-14T16:53:41Z", + quantity: 2, + total: 90, + tip: 0, + type: "tab", + productIDs: ["001", "004", "005"] + }, + { + date: "2011-11-14T16:54:06Z", + quantity: 1, + total: 100, + tip: 0, + type: "cash", + productIDs: ["001", "002", "003", "004", "005"] + }, + { + date: "2011-11-14T16:58:03Z", + quantity: 2, + total: 90, + tip: 0, + type: "tab", + productIDs: ["001"] + }, + { + date: "2011-11-14T17:07:21Z", + quantity: 2, + total: 90, + tip: 0, + type: "tab", + productIDs: ["004", "005"] + }, + { + date: "2011-11-14T17:22:59Z", + quantity: 2, + total: 90, + tip: 0, + type: "tab", + productIDs: ["001", "002", "004", "005"] + }, + { + date: "2011-11-14T17:25:45Z", + quantity: 2, + total: 200, + tip: 0, + type: "cash", + productIDs: ["002"] + }, + { + date: "2011-11-14T17:29:52Z", + quantity: 1, + total: 200, + tip: 100, + type: "visa", + productIDs: ["004"] + } +]; + +var payments = null; +beforeEach(() => { + payments = crossfilter(someData); +}); + +describe("typedCrossfilter", () => { + test("alloc and free", () => { + expect(payments).toBeDefined(); + expect(payments.size()).toEqual(someData.length); + expect(payments.all()).toEqual(someData); + + const quantity = payments.dimension(r => r.quantity, Int32Array); + expect(quantity).toBeDefined(); + expect(quantity.id()).toBeDefined(); + + quantity.dispose(); + expect(payments.size()).toEqual(someData.length); + expect(payments.all()).toEqual(someData); + }); + + test("filterAll and filterNone", () => { + expect(payments).toBeDefined(); + const quantity = payments.dimension(r => r.quantity, Int32Array); + const tip = payments.dimension(r => r.tip, Float32Array); + const total = payments.dimension(r => r.total, Float32Array); + const type = payments.dimension(r => r.type, "enum"); + + expect(quantity).toBeDefined(); + expect(tip).toBeDefined(); + expect(total).toBeDefined(); + expect(type).toBeDefined(); + + // initially, all should be filtered + expect(payments.allFiltered().length).toEqual(payments.size()); + expect(payments.allFiltered()).toEqual(payments.all()); + expect(payments.countFiltered()).toEqual(someData.length); + + // filterAll + tip.filterAll(); // should change nothing + expect(payments.allFiltered()).toEqual(payments.all()); + expect(payments.countFiltered()).toEqual(someData.length); + + // ditto + total.filterAll(); + expect(payments.allFiltered()).toEqual(payments.all()); + expect(payments.countFiltered()).toEqual(someData.length); + + // filterNone + type.filterNone(); + expect(payments.allFiltered()).toEqual([]); + expect(payments.countFiltered()).toEqual(0); + + quantity.filterNone(); + expect(payments.allFiltered()).toEqual([]); + expect(payments.countFiltered()).toEqual(0); + + // invert the first none; should have no effect because type is + // still not filtered + quantity.filterAll(); + expect(payments.allFiltered()).toEqual([]); + expect(payments.countFiltered()).toEqual(0); + + // filter all of type; should select all + type.filterAll(); + expect(payments.allFiltered()).toEqual(payments.all()); + expect(payments.countFiltered()).toEqual(payments.size()); + }); + + test("filterExact", () => { + expect(payments).toBeDefined(); + const quantity = payments.dimension(r => r.quantity, Int32Array); + const tip = payments.dimension(r => r.tip, Float32Array); + const total = payments.dimension(r => r.total, Float32Array); + const type = payments.dimension(r => r.type, "enum"); + + quantity.filterExact(1); + expect(payments.countFiltered()).toEqual( + _.countBy(someData, "quantity")[1] + ); + expect(payments.allFiltered()).toEqual(_.filter(someData, { quantity: 1 })); + + tip.filterExact(0); + expect(payments.allFiltered()).toEqual( + _.filter(someData, { tip: 0, quantity: 1 }) + ); + + type.filterExact("cash"); + expect(payments.allFiltered()).toEqual( + _.filter(someData, { tip: 0, quantity: 1, type: "cash" }) + ); + }); + + test("filterRange", () => { + expect(payments).toBeDefined(); + const quantity = payments.dimension(r => r.quantity, Int32Array); + const tip = payments.dimension(r => r.tip, Float32Array); + const total = payments.dimension(r => r.total, Float32Array); + const type = payments.dimension(r => r.type, "enum"); + + tip.filterRange([0, 91]); + expect(payments.allFiltered()).toEqual( + _(someData) + .filter(r => r.tip >= 0 && r.tip < 91) + .value() + ); + + tip.filterRange([0, 90]); + expect(payments.allFiltered()).toEqual( + _(someData) + .filter(r => r.tip >= 0 && r.tip < 90) + .value() + ); + + tip.filterRange([1, 90]); + expect(payments.allFiltered()).toEqual( + _(someData) + .filter(r => r.tip >= 1 && r.tip < 91) + .value() + ); + }); + + test("filterEnum", () => { + expect(payments).toBeDefined(); + const quantity = payments.dimension(r => r.quantity, Int32Array); + const tip = payments.dimension(r => r.tip, Float32Array); + const total = payments.dimension(r => r.total, Float32Array); + const type = payments.dimension(r => r.type, "enum"); + + type.filterEnum(["tab", "cash"]); + expect(payments.allFiltered()).toEqual( + _(someData) + .filter(r => r.type === "cash" || r.type === "tab") + .value() + ); + + tip.filterEnum([0, 100]); + expect(payments.allFiltered()).toEqual( + _(someData) + .filter(r => r.type === "cash" || r.type === "tab") + .filter(r => r.tip === 0 || r.tip === 100) + .value() + ); + }); + + test("more than 32 dimensions", () => { + expect(payments).toBeDefined(); + const quantity = payments.dimension(r => r.quantity, Int32Array); + const tip = payments.dimension(r => r.tip, Float32Array); + const total = payments.dimension(r => r.total, Float32Array); + const type = payments.dimension(r => r.type, "enum"); + + // 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(r => Math.random(), Float32Array); + expect(dimMap[i]).toBeDefined(); + expect(dimMap[i].id()).toBeDefined(); + } + + // everything should start as selected/filtered + expect(payments.countFiltered()).toEqual(someData.length); + + dimMap[0].filterAll(); + dimMap[64].filterAll(); + expect(payments.countFiltered()).toEqual(someData.length); + + dimMap[33].filterNone(); + expect(payments.allFiltered()).toEqual([]); + + dimMap[33].filterAll(); + expect(payments.allFiltered()).toEqual(someData); + }); +}); From ebb1bb93cfc56aac1e6c84aa09549bf07d02ec80 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Mon, 4 Jun 2018 23:40:22 -0400 Subject: [PATCH 06/14] don't rerender continuous scale on responsive --- src/components/continuousLegend/index.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/continuousLegend/index.js b/src/components/continuousLegend/index.js index e3f6f14a..34b89161 100644 --- a/src/components/continuousLegend/index.js +++ b/src/components/continuousLegend/index.js @@ -103,7 +103,11 @@ class ContinuousLegend extends React.Component { this.state = {}; } componentWillReceiveProps(nextProps) { - if (nextProps.colorAccessor !== this.props.colorAccessor) { + if ( + nextProps.colorAccessor !== this.props.colorAccessor || + nextProps.responsive.height !== this.props.responsive.height || + nextProps.responsive.width !== this.props.responsive.width + ) { /* always remove it, if it's not continuous we don't put it back. */ d3 .select("#continuous_legend") From 7ecd61ce0f0058e9ceac01b803c7f88a5624cb9e Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Tue, 5 Jun 2018 12:04:26 -0400 Subject: [PATCH 07/14] render scatterplot on mount, handle switch tabs case --- src/components/scatterplot/scatterplot.js | 112 +++++++++++++--------- 1 file changed, 66 insertions(+), 46 deletions(-) diff --git a/src/components/scatterplot/scatterplot.js b/src/components/scatterplot/scatterplot.js index c69a4acc..8966d20a 100644 --- a/src/components/scatterplot/scatterplot.js +++ b/src/components/scatterplot/scatterplot.js @@ -51,6 +51,7 @@ class Scatterplot extends React.Component { constructor(props) { super(props); this.count = 0; + this.axes = false; this.state = { svg: null, // ctx: null, @@ -63,8 +64,27 @@ class Scatterplot extends React.Component { componentDidMount() { const { svg } = setupScatterplot(width, height, margin); + let scales; + + /* if we've already got the data, user clicked back and forth between tabs, so render the scatterplot */ + if ( + this.props.expression && + this.props.expression.data && + this.props.scatterplotXXaccessor && + this.props.scatterplotYYaccessor + ) { + scales = this.setupScales( + this.props.expression, + this.props.scatterplotXXaccessor, + this.props.scatterplotYYaccessor + ); + this.drawAxesSVG(scales.xScale, scales.yScale, svg); + } + this.setState({ - svg + svg, + xScale: scales ? scales.xScale : null, + yScale: scales ? scales.yScale : null }); const camera = _camera(this.reglCanvas, { scale: true, rotate: false }); @@ -104,21 +124,36 @@ class Scatterplot extends React.Component { }); } componentWillReceiveProps(nextProps) { - this.maybeSetupScalesAndDrawAxes(nextProps); + if ( + nextProps.expression && + nextProps.expression.data && + nextProps.scatterplotXXaccessor && + nextProps.scatterplotYYaccessor + ) { + const scales = this.setupScales( + nextProps.expression, + nextProps.scatterplotXXaccessor, + nextProps.scatterplotYYaccessor + ); + this.setState(scales); + } } componentDidUpdate(prevProps) { if ( + this.state.svg && this.state.xScale && this.state.yScale && this.props.scatterplotXXaccessor && this.props.scatterplotYYaccessor && (this.props.scatterplotXXaccessor !== prevProps.scatterplotXXaccessor || // was CLU now FTH1 etc - this.props.scatterplotYYaccessor !== prevProps.scatterplotYYaccessor) + this.props.scatterplotYYaccessor !== prevProps.scatterplotYYaccessor || // was CLU now FTH1 etc + !this.axes) // clicked off the tab and back again, rerender ) { - this.drawAxesSVG(this.state.xScale, this.state.yScale); + this.drawAxesSVG(this.state.xScale, this.state.yScale, this.state.svg); } if ( + this.props.metadata && this.state.regl && this.state.pointBuffer && this.state.colorBuffer && @@ -179,47 +214,32 @@ class Scatterplot extends React.Component { this.count = cellCount; } } - maybeSetupScalesAndDrawAxes(nextProps) { - if ( - nextProps.expression && - nextProps.expression.data && - nextProps.scatterplotXXaccessor && - nextProps.scatterplotYYaccessor - ) { - const xScale = d3 - .scaleLinear() - .domain( - d3.extent(nextProps.expression.data.cells, (cell, i) => { - return cell.e[ - nextProps.expression.data.genes.indexOf( - nextProps.scatterplotXXaccessor - ) - ]; - }) - ) - .range([0, width]); + setupScales(expression, scatterplotXXaccessor, scatterplotYYaccessor) { + const xScale = d3 + .scaleLinear() + .domain( + d3.extent(expression.data.cells, (cell, i) => { + return cell.e[expression.data.genes.indexOf(scatterplotXXaccessor)]; + }) + ) + .range([0, width]); - const yScale = d3 - .scaleLinear() - .domain( - d3.extent(nextProps.expression.data.cells, cell => { - return cell.e[ - nextProps.expression.data.genes.indexOf( - nextProps.scatterplotYYaccessor - ) - ]; - }) - ) - .range([height, 0]); + const yScale = d3 + .scaleLinear() + .domain( + d3.extent(expression.data.cells, cell => { + return cell.e[expression.data.genes.indexOf(scatterplotYYaccessor)]; + }) + ) + .range([height, 0]); - this.setState({ - xScale, - yScale - }); - } + return { + xScale, + yScale + }; } - drawAxesSVG(xScale, yScale) { - this.state.svg.selectAll("*").remove(); + drawAxesSVG(xScale, yScale, svg) { + svg.selectAll("*").remove(); // the axes are much cleaner and easier now. No need to rotate and orient the axis, just call axisBottom, axisLeft etc. var xAxis = d3.axisBottom().scale(xScale); @@ -227,28 +247,28 @@ class Scatterplot extends React.Component { var yAxis = d3.axisLeft().scale(yScale); // adding axes is also simpler now, just translate x-axis to (0,height) and it's alread defined to be a bottom axis. - this.state.svg + svg .append("g") .attr("transform", "translate(0," + height + ")") .attr("class", "x axis") .call(xAxis); // y-axis is translated to (0,0) - this.state.svg + svg .append("g") .attr("transform", "translate(0,0)") .attr("class", "y axis") .call(yAxis); // adding label. For x-axis, it's at (10, 10), and for y-axis at (width, height-10). - this.state.svg + svg .append("text") .attr("x", 10) .attr("y", 10) .attr("class", "label") .text(this.props.scatterplotYYaccessor); - this.state.svg + svg .append("text") .attr("x", width) .attr("y", height - 10) From d1d335313074c772b168005c05e77f2e725c8b8f Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Thu, 7 Jun 2018 13:00:03 -0400 Subject: [PATCH 08/14] checkbox intermediate state --- src/components/categorical/categorical.js | 37 +++++++++++++++++++---- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/src/components/categorical/categorical.js b/src/components/categorical/categorical.js index 1b04c388..997b3889 100644 --- a/src/components/categorical/categorical.js +++ b/src/components/categorical/categorical.js @@ -15,7 +15,8 @@ import FaPaintBrush from "react-icons/lib/fa/paint-brush"; @connect(state => { return { - colorAccessor: state.controls.colorAccessor + colorAccessor: state.controls.colorAccessor, + categoricalAsBooleansMap: state.controls.categoricalAsBooleansMap }; }) class Category extends React.Component { @@ -26,7 +27,24 @@ class Category extends React.Component { isExpanded: false }; } + componentDidUpdate() { + const valuesAsBool = _.values( + this.props.categoricalAsBooleansMap[this.props.metadataField] + ) + /* count categories toggled on by counting true values */ + const categoriesToggledOn = _.values(valuesAsBool).filter(v => v).length; + if (categoriesToggledOn === valuesAsBool.length) { + /* everything is on, so not indeterminate */ + this.checkbox.indeterminate = false; + } else if (categoriesToggledOn === 0) { + /* nothing is on, so no */ + this.checkbox.indeterminate = false; + } else if (categoriesToggledOn < valuesAsBool.length) { + /* to be explicit... */ + this.checkbox.indeterminate = true; + } + } handleColorChange() { this.props.dispatch({ type: "color by categorical metadata", @@ -61,6 +79,16 @@ class Category extends React.Component { ); }); } + handleToggleAllClick() { + // || this.checkbox.indeterminate === false + if (this.state.isChecked) { + console.log('checked, firing toggle none') + this.toggleNone(); + } else if (!this.state.isChecked) { + console.log('!checked, firing toggle all') + this.toggleAll() + } + } render() { return (
{this.props.metadataField} this.checkbox = el} checked={this.state.isChecked} type="checkbox" /> From a4e75fceba88a1d3cd0799044830a666a44a3aad Mon Sep 17 00:00:00 2001 From: freeman-lab Date: Sat, 9 Jun 2018 14:38:09 -0700 Subject: [PATCH 09/14] incorporate distance inverse term --- src/components/graph/graph.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/graph/graph.js b/src/components/graph/graph.js index 4156f935..aa4b5fcb 100644 --- a/src/components/graph/graph.js +++ b/src/components/graph/graph.js @@ -204,7 +204,7 @@ class Graph extends React.Component { 2 * (1 - pin[1] / (this.props.responsive.height - this.graphPaddingTop)) - 1; - const pout = [x + inverse[12], y + inverse[13]]; + const pout = [x * inverse[14] + inverse[12], y * inverse[14] + inverse[13]]; return [(pout[0] + 1) / 2, (pout[1] + 1) / 2]; }; From f0e008eb6643ce6596b8ef772f67a273ef35ba24 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Wed, 13 Jun 2018 12:09:53 -0700 Subject: [PATCH 10/14] reinstitute color by continuous --- src/components/continuous/histogramBrush.js | 39 ++++++++++++++++++--- src/globals.js | 4 +-- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/components/continuous/histogramBrush.js b/src/components/continuous/histogramBrush.js index 1b67e687..17ab9cec 100644 --- a/src/components/continuous/histogramBrush.js +++ b/src/components/continuous/histogramBrush.js @@ -7,8 +7,11 @@ https://bl.ocks.org/SpaceActuary/2f004899ea1b2bd78d6f1dbb2febf771 import React from "react"; import _ from "lodash"; import { connect } from "react-redux"; +import FaPaintBrush from "react-icons/lib/fa/paint-brush"; +import * as globals from "../../globals"; @connect(state => { + console.log("state in histo brush", state) const ranges = state.cells.cells && state.cells.cells.data.ranges ? state.cells.cells.data.ranges @@ -26,7 +29,8 @@ import { connect } from "react-redux"; return { colorAccessor: state.controls.colorAccessor, colorScale: state.controls.colorScale, - cellsMetadata: state.controls.cellsMetadata + cellsMetadata: state.controls.cellsMetadata, + initializeRanges }; }) class HistogramBrush extends React.Component { @@ -154,7 +158,7 @@ class HistogramBrush extends React.Component { ) .call(d3.axisBottom(x).ticks(5)) .append("text") - .attr("x", 300) + .attr("x", this.width - 2) .attr("y", -6) .attr("fill", "#000") .attr("text-anchor", "end") @@ -164,13 +168,40 @@ class HistogramBrush extends React.Component { this.setState({ brush, xAxis }); } } - + handleColorAction() { + this.props.dispatch({ + type: "color by continuous metadata", + colorAccessor: this.props.metadataField, + rangeMaxForColorAccessor: this.props.initializeRanges[this.props.metadataField].range.max + }); + } render() { return (
+ + + Date: Thu, 28 Jun 2018 01:10:05 -0400 Subject: [PATCH 11/14] return brush independently from container --- src/components/graph/setupSVGandBrush.js | 30 +++++++++++++----------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/components/graph/setupSVGandBrush.js b/src/components/graph/setupSVGandBrush.js index ec86c587..3234748b 100644 --- a/src/components/graph/setupSVGandBrush.js +++ b/src/components/graph/setupSVGandBrush.js @@ -23,21 +23,23 @@ export const setupSVGandBrushElements = ( .attr("height", side) .attr("class", `${styles.graphSVG}`); - svg.append("g").call( - d3 - .brush() - .extent([ - [0, 0], - [ - responsive.height - graphPaddingTop, - responsive.height - graphPaddingTop - ] - ]) - .on("brush", handleBrushSelectAction) - .on("end", handleBrushDeselectAction) - ); + const brush = d3 + .brush() + .extent([ + [0, 0], + [responsive.height - graphPaddingTop, responsive.height - graphPaddingTop] + ]) + .on("brush", handleBrushSelectAction) + .on("end", handleBrushDeselectAction); + + const brushContainer = svg + .append("g") + .attr("class", "graph_brush") + .call(brush); return { - svg + svg, + brushContainer, + brush }; }; From b24b71692cf8b6068cdfe40e1bf56d118456e324 Mon Sep 17 00:00:00 2001 From: Colin Megill Date: Thu, 28 Jun 2018 01:10:29 -0400 Subject: [PATCH 12/14] brush on zoom procedural deselect --- src/components/graph/graph.js | 74 +++++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 29 deletions(-) diff --git a/src/components/graph/graph.js b/src/components/graph/graph.js index aa4b5fcb..8df6fcfb 100644 --- a/src/components/graph/graph.js +++ b/src/components/graph/graph.js @@ -164,28 +164,29 @@ class Graph extends React.Component { nextProps.responsive.width !== this.props.responsive.width ) { /* clear out whatever was on the div, even if nothing, but usually the brushes etc */ - d3 - .select("#graphAttachPoint") + d3.select("#graphAttachPoint") .selectAll("*") .remove(); - const { svg } = setupSVGandBrushElements( + const { svg, brush, brushContainer } = setupSVGandBrushElements( this.handleBrushSelectAction.bind(this), this.handleBrushDeselectAction.bind(this), nextProps.responsive, this.graphPaddingTop ); - this.setState({ svg }); + this.setState({ svg, brush, brushContainer }); } } handleBrushSelectAction() { - /* + /* This conditional handles procedural brush deselect. Brush emits an event on procedural deselect because it is move: null */ + if (d3.event.selection) { + /* No idea why d3 event scope works like this but apparently it does https://bl.ocks.org/EfratVil/0e542f5fc426065dd1d4b6daaa345a9f */ - const s = d3.event.selection; - /* + const s = d3.event.selection; + /* event describing brush position: @-------| | | @@ -193,33 +194,47 @@ class Graph extends React.Component { |-------@ */ - // compute inverse view matrix - const inverse = mat4.invert([], this.state.camera.view()); + // compute inverse view matrix + const inverse = mat4.invert([], this.state.camera.view()); - // transform screen coordinates -> cell coordinates - const invert = pin => { - const x = - 2 * pin[0] / (this.props.responsive.height - this.graphPaddingTop) - 1; - const y = - 2 * - (1 - pin[1] / (this.props.responsive.height - this.graphPaddingTop)) - - 1; - const pout = [x * inverse[14] + inverse[12], y * inverse[14] + inverse[13]]; - return [(pout[0] + 1) / 2, (pout[1] + 1) / 2]; - }; + // transform screen coordinates -> cell coordinates + const invert = pin => { + const x = + (2 * pin[0]) / (this.props.responsive.height - this.graphPaddingTop) - + 1; + const y = + 2 * + (1 - + pin[1] / (this.props.responsive.height - this.graphPaddingTop)) - + 1; + const pout = [ + x * inverse[14] + inverse[12], + y * inverse[14] + inverse[13] + ]; + return [(pout[0] + 1) / 2, (pout[1] + 1) / 2]; + }; - const brushCoords = { - northwest: invert([s[0][0], s[0][1]]), - southeast: invert([s[1][0], s[1][1]]) - }; + const brushCoords = { + northwest: invert([s[0][0], s[0][1]]), + southeast: invert([s[1][0], s[1][1]]) + }; - this.props.dispatch({ - type: "graph brush selection change", - brushCoords - }); + this.props.dispatch({ + type: "graph brush selection change", + brushCoords + }); + } } handleBrushDeselectAction() { - if (!d3.event.selection) { + if (d3.event && !d3.event.selection) { + this.props.dispatch({ + type: "graph brush deselect" + }); + } + + if (!d3.event) { + /* this line clears the brush procedurally, ie., zoom button clicked, not a click away from brush on svg */ + this.state.svg.select(".graph_brush").call(this.state.brush.move, null); this.props.dispatch({ type: "graph brush deselect" }); @@ -302,6 +317,7 @@ class Graph extends React.Component {