From a08e19bbd04f11346203733b53fe83ab4331c0ca Mon Sep 17 00:00:00 2001 From: Sidney Bell Date: Tue, 30 Apr 2019 16:20:10 -0700 Subject: [PATCH] Clip continuous values based on percentile cutoffs (#672) * Add numeric inputs for percentiles * Define initial values for percentile cutoffs in world reducer * add percentil to crossfilter dimensions * worldEqUniverse now handles cloned worlds * add Dataframe.mapColumns * Wire up handlers for percentile inputs * World reducer and stateManager know about continuousPercentileMin/Max * Create world as universe clone (not pointer) to avoid clobbering vals * Define basic actions for setting continuousPercentileMin/Max * Under the hood, deal with percentiles between 0 and 1 * Move percentile inputs to visualization settings menu * Fix padding for undo/redo buttons * Trigger world rebuild from percentile actions * BROKEN - pseudocode for clamping dataframe by percentiles upon world rebuild * fix error handling on clip quantiles; start world clipping implementation * more unclipped reorg * rename crossfilter.percentile to quantile * simplify schema access * update continuous legend when scale changes * update color cache when clip changes * clip obs annotations and var data when clip quantile changes * use own fromEntries * fix tests * stable non-finite float sort/search * clarify comments * fix syntax typo * use new stand-alone clip * clip expresssion data * add select tests for non-finite scalars * basic styles * clip UI now requires explicit commit * reset enable/disable accounts for clip percentiles * better error messages * fix bug in undo interaction with programatic min brush selection * small refactoring * support clipping of int data * do not perform unnecessary summarizations * improve caching of dataframe compiled columns * add percentile precompute to Dataframe.summarize * use Dataframe.summarize for clip percentiles * remove obsolete quantile code from corssfilter * histogram scale and label Y axis, add unclipped X range labels * layout tweaks * scatterplot now updates when clip changes * improve comments * remove debugging comment * rework clip number entry validation for usability * ui tweaks to histogram colors and layout * enable undo/redo for clip user action * refine UI on clip value entry * api cleanup * update confusing comment * clarify purpose of isValidDigitKeyEvent * fix misleading comment * apply appropriate button-group classes; do not mix span and div * variable name and comment changes suggested in PR review * rename sort to sortArray; remove unused and dead code path * naming changes suggested in PR review * code review improvements for clarity * more small changes from PR review * lint fixes for PR review * fix spelling error * clarify that function performs in-place modification of world * add comment to clarify intent of range operation * fix bad indents in comments * clean up __columnsAccessor comments and code * improve comments around clipPredicate * field name consistency * improve comment on quantiles params --- .../util/dataframe/dataframe.test.js | 36 ++ client/__tests__/util/quantile.test.js | 29 ++ .../__tests__/util/stateManager/world.test.js | 26 +- .../util/typedCrossfilter/crossfilter.test.js | 128 +++++- .../util/typedCrossfilter/sort.test.js | 255 ++++++++++-- .../util/typedCrossfilter/util.test.js | 5 +- client/package-lock.json | 62 ++- client/package.json | 2 +- .../components/brushableHistogram/index.js | 194 ++++----- .../src/components/continuous/continuous.js | 13 +- .../src/components/continuousLegend/index.js | 1 + client/src/components/geneExpression/index.js | 1 - client/src/components/graph/graph.js | 383 +++++++++++++++--- .../src/components/scatterplot/scatterplot.js | 13 +- client/src/reducers/colors.js | 1 + client/src/reducers/continuousSelection.js | 8 + client/src/reducers/crossfilter.js | 1 + client/src/reducers/resetCache.js | 7 +- client/src/reducers/undoable.js | 15 +- client/src/reducers/undoableConfig.js | 7 +- client/src/reducers/universe.js | 14 +- client/src/reducers/world.js | 73 +++- client/src/util/clip.js | 25 ++ client/src/util/dataframe/dataframe.js | 245 ++++++----- client/src/util/dataframe/summarize.js | 59 ++- client/src/util/dataframe/util.js | 11 +- client/src/util/fromEntries.js | 13 + client/src/util/quantile.js | 29 ++ client/src/util/stateManager/universe.js | 66 ++- client/src/util/stateManager/world.js | 202 +++++++-- client/src/util/typeHelpers.js | 29 ++ .../src/util/typedCrossfilter/crossfilter.js | 18 +- client/src/util/typedCrossfilter/sort.js | 333 ++++++++++++++- client/src/util/typedCrossfilter/util.js | 91 ----- 34 files changed, 1843 insertions(+), 552 deletions(-) create mode 100644 client/__tests__/util/quantile.test.js create mode 100644 client/src/util/clip.js create mode 100644 client/src/util/fromEntries.js create mode 100644 client/src/util/quantile.js create mode 100644 client/src/util/typeHelpers.js diff --git a/client/__tests__/util/dataframe/dataframe.test.js b/client/__tests__/util/dataframe/dataframe.test.js index a91ca893..a89b3486 100644 --- a/client/__tests__/util/dataframe/dataframe.test.js +++ b/client/__tests__/util/dataframe/dataframe.test.js @@ -532,6 +532,42 @@ describe("dataframe factories", () => { expect(df.rowIndex.keys()).toEqual(dfA.rowIndex.keys()); }); }); + + describe("mapColumns", () => { + test("identity", () => { + const dfA = Dataframe.Dataframe.create( + [3, 3], + [ + new Array(3).fill(0), + new Int16Array(3).fill(99), + new Float64Array(3).fill(1.1) + ] + ); + const dfB = dfA.mapColumns((col, idx) => { + expect(dfA.icol(idx).asArray()).toBe(col); + return col; + }); + expect(dfA).not.toBe(dfB); + expect(dfA.dims).toEqual(dfB.dims); + for (let c = 0; c < dfA.dims[1]; c += 1) { + expect(dfA.icol(c).asArray()).toBe(dfB.icol(c).asArray()); + } + }); + + test("transform", () => { + const dfA = Dataframe.Dataframe.create( + [3, 3], + [new Array(3).fill(0), new Array(3).fill(0), new Array(3).fill(0)] + ); + const dfB = dfA.mapColumns(() => { + return new Array(3).fill(1); + }); + expect(dfA).not.toBe(dfB); + expect(dfB.iat(0, 0)).toEqual(1); + expect(dfB.iat(0, 1)).toEqual(1); + expect(dfB.iat(0, 2)).toEqual(1); + }); + }); }); describe("dataframe col", () => { diff --git a/client/__tests__/util/quantile.test.js b/client/__tests__/util/quantile.test.js new file mode 100644 index 00000000..b8b49954 --- /dev/null +++ b/client/__tests__/util/quantile.test.js @@ -0,0 +1,29 @@ +import quantile from "../../src/util/quantile"; + +describe("quantile", () => { + test("single q", () => { + const arr = new Float32Array([9, 3, 5, 6, 0]); + expect(quantile([1.0], arr)).toMatchObject([9]); + expect(quantile([0.9], arr)).toMatchObject([9]); + expect(quantile([0.8], arr)).toMatchObject([9]); + expect(quantile([0.7], arr)).toMatchObject([6]); + expect(quantile([0.6], arr)).toMatchObject([6]); + expect(quantile([0.5], arr)).toMatchObject([5]); + expect(quantile([0.4], arr)).toMatchObject([5]); + expect(quantile([0.3], arr)).toMatchObject([3]); + expect(quantile([0.2], arr)).toMatchObject([3]); + expect(quantile([0.1], arr)).toMatchObject([0]); + expect(quantile([0], arr)).toMatchObject([0]); + }); + + test("multi q", () => { + const arr = new Float32Array([9, 3, 5, 6, 0]); + expect(quantile([0, 0.25, 0.5, 0.75, 1.0], arr)).toMatchObject([ + 0, + 3, + 5, + 6, + 9 + ]); + }); +}); diff --git a/client/__tests__/util/stateManager/world.test.js b/client/__tests__/util/stateManager/world.test.js index a4eeed0a..8cc5fc31 100644 --- a/client/__tests__/util/stateManager/world.test.js +++ b/client/__tests__/util/stateManager/world.test.js @@ -58,10 +58,15 @@ describe("createWorldFromEntireUniverse", () => { nObs: universe.nObs, nVar: universe.nVar, schema: universe.schema, - obsAnnotations: universe.obsAnnotations, - varAnnotations: universe.varAnnotations, - obsLayout: universe.obsLayout, - varData: expect.any(Dataframe.Dataframe) + obsAnnotations: expect.any(Dataframe.Dataframe), + varAnnotations: expect.any(Dataframe.Dataframe), + obsLayout: expect.any(Dataframe.Dataframe), + varData: expect.any(Dataframe.Dataframe), + clipQuantiles: { min: 0, max: 1 }, + unclipped: { + obsAnnotations: expect.any(Dataframe.Dataframe), + varData: expect.any(Dataframe.Dataframe) + } }) ); }); @@ -76,7 +81,7 @@ describe("createWorldFromCurrentSelection", () => { } = defaultBigBang(); /* mock a selection */ - let crossfilter = originalCrossfilter + const crossfilter = originalCrossfilter .select(obsAnnoDimensionName("field1"), { mode: "range", lo: 0, hi: 5 }) .select(obsAnnoDimensionName("field3"), { mode: "exact", @@ -84,7 +89,7 @@ describe("createWorldFromCurrentSelection", () => { }); /* create the world from the selection */ - const world = World.createWorldFromCurrentSelection( + const world = World.createWorldBySelection( universe, originalWorld, crossfilter @@ -112,10 +117,15 @@ describe("createWorldFromCurrentSelection", () => { nObs: matchingIndices.length, nVar: universe.nVar, schema: universe.schema, + clipQuantiles: { min: 0, max: 1 }, obsAnnotations: expect.any(Dataframe.Dataframe), - varAnnotations: universe.varAnnotations, + varAnnotations: expect.any(Dataframe.Dataframe), obsLayout: expect.any(Dataframe.Dataframe), - varData: expect.any(Dataframe.Dataframe) + varData: expect.any(Dataframe.Dataframe), + unclipped: { + obsAnnotations: expect.any(Dataframe.Dataframe), + varData: expect.any(Dataframe.Dataframe) + } }) ); diff --git a/client/__tests__/util/typedCrossfilter/crossfilter.test.js b/client/__tests__/util/typedCrossfilter/crossfilter.test.js index b3c9e5c1..c94fb3fd 100644 --- a/client/__tests__/util/typedCrossfilter/crossfilter.test.js +++ b/client/__tests__/util/typedCrossfilter/crossfilter.test.js @@ -11,7 +11,8 @@ const someData = [ tip: 100, type: "tab", productIDs: ["001"], - coords: [0, 0] + coords: [0, 0], + nonFinite: 0.0 }, { date: "2011-11-14T16:20:19Z", @@ -20,7 +21,8 @@ const someData = [ tip: 100, type: "tab", productIDs: ["001", "005"], - coords: [0.4, 0.4] + coords: [0.4, 0.4], + nonFinite: Number.NaN }, { date: "2011-11-14T16:28:54Z", @@ -29,7 +31,8 @@ const someData = [ tip: 200, type: "visa", productIDs: ["004", "005"], - coords: [0.3, 0.1] + coords: [0.3, 0.1], + nonFinite: Number.POSITIVE_INFINITY }, { date: "2011-11-14T16:30:43Z", @@ -38,7 +41,8 @@ const someData = [ tip: 0, type: "tab", productIDs: ["001", "002"], - coords: [0.392, 0.1] + coords: [0.392, 0.1], + nonFinite: Number.NEGATIVE_INFINITY }, { date: "2011-11-14T16:48:46Z", @@ -47,7 +51,8 @@ const someData = [ tip: 0, type: "tab", productIDs: ["005"], - coords: [0.7, 0.0482] + coords: [0.7, 0.0482], + nonFinite: 1.0 }, { date: "2011-11-14T16:53:41Z", @@ -56,7 +61,8 @@ const someData = [ tip: 0, type: "tab", productIDs: ["001", "004", "005"], - coords: [0.9999, 1.0] + coords: [0.9999, 1.0], + nonFinite: Number.NaN }, { date: "2011-11-14T16:54:06Z", @@ -65,7 +71,8 @@ const someData = [ tip: 0, type: "cash", productIDs: ["001", "002", "003", "004", "005"], - coords: [0.384, 0.6938] + coords: [0.384, 0.6938], + nonFinite: 99.0 }, { date: "2011-11-14T16:58:03Z", @@ -74,7 +81,8 @@ const someData = [ tip: 0, type: "tab", productIDs: ["001"], - coords: [0.4822, 0.482] + coords: [0.4822, 0.482], + nonFinite: Number.NaN }, { date: "2011-11-14T17:07:21Z", @@ -83,7 +91,8 @@ const someData = [ tip: 0, type: "tab", productIDs: ["004", "005"], - coords: [0.2234, 0] + coords: [0.2234, 0], + nonFinite: Number.NaN }, { date: "2011-11-14T17:22:59Z", @@ -92,7 +101,8 @@ const someData = [ tip: 0, type: "tab", productIDs: ["001", "002", "004", "005"], - coords: [0.382, 0.38485] + coords: [0.382, 0.38485], + nonFinite: -1 }, { date: "2011-11-14T17:25:45Z", @@ -101,7 +111,8 @@ const someData = [ tip: 0, type: "cash", productIDs: ["002"], - coords: [0.998, 0.8472] + coords: [0.998, 0.8472], + nonFinite: 0.0 }, { date: "2011-11-14T17:29:52Z", @@ -110,7 +121,8 @@ const someData = [ tip: 100, type: "visa", productIDs: ["004"], - coords: [0.8273, 0.3384] + coords: [0.8273, 0.3384], + nonFinite: 0.0 } ]; @@ -327,4 +339,96 @@ describe("ImmutableTypedCrossfilter", () => { ).toEqual(_.filter(someData, d => polygonContains(polygon, d.coords))); }); }); + + describe("non-finite scalars", () => { + let p; + beforeEach(() => { + p = payments + .addDimension("quantity", "scalar", (i, d) => d[i].quantity, Int32Array) + .addDimension( + "nonFinite", + "scalar", + (i, d) => d[i].nonFinite, + Float32Array + ) + .select("quantity", { mode: "all" }); + }); + + test("all or none", () => { + expect(p.select("nonFinite", { mode: "all" }).countSelected()).toEqual( + someData.length + ); + expect(p.select("nonFinite", { mode: "none" }).countSelected()).toEqual( + 0 + ); + }); + + test("exact", () => { + expect( + p.select("nonFinite", { mode: "exact", values: [0] }).countSelected() + ).toEqual(3); + expect( + p.select("nonFinite", { mode: "exact", values: [1] }).countSelected() + ).toEqual(1); + expect( + p + .select("nonFinite", { + mode: "exact", + values: [Number.POSITIVE_INFINITY] + }) + .countSelected() + ).toEqual(1); + expect( + p + .select("nonFinite", { + mode: "exact", + values: [Number.NEGATIVE_INFINITY] + }) + .countSelected() + ).toEqual(1); + expect( + p + .select("nonFinite", { mode: "exact", values: [Number.NaN] }) + .countSelected() + ).toEqual(4); + expect( + p + .select("nonFinite", { + mode: "exact", + values: [Number.POSITIVE_INFINITY, 0, 1, 99] + }) + .countSelected() + ).toEqual(6); + }); + + test("range", () => { + expect( + p + .select("nonFinite", { + mode: "range", + lo: 0, + hi: Number.POSITIVE_INFINITY + }) + .countSelected() + ).toEqual(5); + expect( + p + .select("nonFinite", { + mode: "range", + lo: 0, + hi: Number.NaN + }) + .countSelected() + ).toEqual(6); + expect( + p + .select("nonFinite", { + mode: "range", + lo: Number.NEGATIVE_INFINITY, + hi: Number.POSITIVE_INFINITY + }) + .countSelected() + ).toEqual(7); + }); + }); }); diff --git a/client/__tests__/util/typedCrossfilter/sort.test.js b/client/__tests__/util/typedCrossfilter/sort.test.js index 71ce948e..a1d1125d 100644 --- a/client/__tests__/util/typedCrossfilter/sort.test.js +++ b/client/__tests__/util/typedCrossfilter/sort.test.js @@ -1,4 +1,22 @@ -import { sort, sortIndex } from "../../../src/util/typedCrossfilter/sort"; +import { + sortArray, + sortIndex, + lowerBound, + upperBound, + lowerBoundIndirect, + upperBoundIndirect +} from "../../../src/util/typedCrossfilter/sort"; + +/* +Sort tests should keep in mind that there are separate code +paths for: + - small vs. large arrays (insertionsort only) + - float-only typed arrays vs. other array types (non-finite handling) + - indexed vs. direct sort +*/ + +const pInf = Number.POSITIVE_INFINITY; +const nInf = Number.NEGATIVE_INFINITY; function fillRange(arr, start = 0) { const larr = arr; @@ -15,42 +33,223 @@ function fillRand(arr) { return arr; } -describe("sort", () => { - [Array, Float32Array, Uint32Array, Int32Array, Float64Array].map(Type => - test(Type.name, () => { - expect(sort(Type.from([6, 5, 4, 3, 2, 1, 0]))).toMatchObject( - Type.from([0, 1, 2, 3, 4, 5, 6]) - ); - expect(sort(Type.from([6, 5, 4, 3, 2, 1]))).toMatchObject( - Type.from([1, 2, 3, 4, 5, 6]) - ); +describe("sortArray", () => { + describe("JS vals", () => { + [ + [true, false], + ["a", "b", "0", "1"], + [0, "a", true, null, undefined, 3.1415], + fillRand(new Array(1000)), + ["a", NaN, null, pInf] + ].map((val, idx) => + test(`JS vals ${idx}`, () => { + expect(sortArray(val)).toMatchObject(val.sort()); + }) + ); + }); - const source = fillRand(new Type(1000)); - expect(sort(Type.from(source))).toMatchObject(Type.from(source).sort()); - }) - ); + describe("finite numbers", () => { + [Array, Float32Array, Uint32Array, Int32Array, Float64Array].map(Type => + test(Type.name, () => { + expect(sortArray(Type.from([6, 5, 4, 3, 2, 1, 0]))).toMatchObject( + Type.from([0, 1, 2, 3, 4, 5, 6]) + ); + + expect(sortArray(Type.from([6, 5, 4, 3, 2, 1]))).toMatchObject( + Type.from([1, 2, 3, 4, 5, 6]) + ); + + const source = fillRand(new Type(1000)); + expect(sortArray(Type.from(source))).toMatchObject( + Type.from(source).sort() + ); + }) + ); + }); + + describe("non-finite numbers", () => { + test("inifinity", () => { + expect(sortArray(new Float32Array([pInf, nInf, 0, 1, 2]))).toMatchObject( + new Float32Array([nInf, 0, 1, 2, pInf]) + ); + expect( + sortArray(new Float32Array([pInf, nInf, pInf, nInf])) + ).toMatchObject(new Float32Array([nInf, nInf, pInf, pInf])); + expect( + sortArray(new Float32Array([pInf, nInf, pInf, nInf, pInf])) + ).toMatchObject(new Float32Array([nInf, nInf, pInf, pInf, pInf])); + expect( + sortArray( + new Float32Array(100).fill(Infinity, 0, 50).fill(-Infinity, 50, 100) + ) + ).toMatchObject( + new Float32Array(100).fill(-Infinity, 0, 50).fill(Infinity, 50, 100) + ); + }); + test("NaN", () => { + expect(sortArray(new Float64Array([NaN, 2, 1, 0]))).toMatchObject( + new Float64Array([0, 1, 2, NaN]) + ); + expect(sortArray(new Float32Array([NaN, 2, 1, 0]))).toMatchObject( + new Float32Array([0, 1, 2, NaN]) + ); + expect(sortArray(new Float32Array([NaN, 2, NaN, 1, 0]))).toMatchObject( + new Float32Array([0, 1, 2, NaN, NaN]) + ); + expect(sortArray(new Float32Array([NaN, 2, 1, NaN, 0]))).toMatchObject( + new Float32Array([0, 1, 2, NaN, NaN]) + ); + expect( + sortArray(fillRange(new Float32Array(100)).fill(NaN, 0, 10)) + ).toMatchObject(fillRange(new Float32Array(100), 10).fill(NaN, 90, 100)); + }); + + test("mixed numbers", () => { + expect( + sortArray(new Float32Array([NaN, pInf, nInf, NaN, NaN])) + ).toMatchObject(new Float32Array([nInf, pInf, NaN, NaN, NaN])); + expect( + sortArray(new Float32Array([NaN, pInf, nInf, NaN, 1, NaN, 2])) + ).toMatchObject(new Float32Array([nInf, 1, 2, pInf, NaN, NaN, NaN])); + expect( + sortArray(new Float32Array([NaN, pInf, nInf, 0, 1, NaN, 2])) + ).toMatchObject(new Float32Array([nInf, 0, 1, 2, pInf, NaN, NaN])); + expect( + sortArray( + fillRange(new Float32Array(100)) + .fill(NaN, 0, 10) + .fill(Infinity, 10, 20) + ) + ).toMatchObject( + fillRange(new Float32Array(100), 20) + .fill(Infinity, 80, 90) + .fill(NaN, 90, 100) + ); + }); + }); }); describe("sortIndex", () => { - [Array, Float32Array, Uint32Array, Int32Array, Float64Array].map(Type => - test(Type.name, () => { - const source1 = Type.from([6, 5, 4, 3, 2, 1, 0]); + describe("finite numbers", () => { + [Array, Float32Array, Uint32Array, Int32Array, Float64Array].map(Type => + test(Type.name, () => { + const source1 = Type.from([6, 5, 4, 3, 2, 1, 0]); + const index1 = fillRange(new Uint32Array(source1.length)); + expect(sortIndex(index1, source1)).toMatchObject( + index1.sort((a, b) => source1[a] - source1[b]) + ); + + const source2 = Type.from([6, 5, 4, 3, 2, 1]); + const index2 = fillRange(new Uint32Array(source2.length)); + expect(sortIndex(index2, source2)).toMatchObject( + index2.sort((a, b) => source1[a] - source1[b]) + ); + + const source3 = fillRand(new Type(1000)); + const index3 = fillRange(new Uint32Array(source3.length)); + expect(sortIndex(index3, source3)).toMatchObject( + index3.sort((a, b) => source1[a] - source1[b]) + ); + }) + ); + }); + + describe("non-finite numbers", () => { + test("mixed numbers", () => { + const source1 = new Float32Array([NaN, pInf, nInf, NaN, 1, NaN, 2]); const index1 = fillRange(new Uint32Array(source1.length)); expect(sortIndex(index1, source1)).toMatchObject( - index1.sort((a, b) => source1[a] - source1[b]) + new Uint32Array([2, 4, 6, 1, 0, 3, 5]) ); - const source2 = Type.from([6, 5, 4, 3, 2, 1]); + const source2 = new Float32Array([NaN, pInf, nInf, 0, 1, NaN, 2]); const index2 = fillRange(new Uint32Array(source2.length)); expect(sortIndex(index2, source2)).toMatchObject( - index2.sort((a, b) => source1[a] - source1[b]) + new Uint32Array([2, 3, 4, 6, 1, 0, 5]) ); - - const source3 = fillRand(new Type(1000)); - const index3 = fillRange(new Uint32Array(source3.length)); - expect(sortIndex(index3, source3)).toMatchObject( - index3.sort((a, b) => source1[a] - source1[b]) - ); - }) - ); + }); + }); +}); + +describe("lowerBound", () => { + test("non-float path", () => { + expect(lowerBound([], 0, 0, 0)).toEqual(0); + + expect(lowerBound([0, 1, 2, 3], -1, 0, 4)).toEqual(0); + expect(lowerBound([0, 1, 2, 3], 0, 0, 4)).toEqual(0); + 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], -1, 0, 5)).toEqual(0); + expect(lowerBound([0, 1, 2, 3, 4], 0, 0, 5)).toEqual(0); + expect(lowerBound([0, 1, 2, 3, 4], 2, 0, 5)).toEqual(2); + expect(lowerBound([0, 1, 2, 3, 4], 4, 0, 5)).toEqual(4); + expect(lowerBound([0, 1, 2, 3, 4], 5, 0, 5)).toEqual(5); + + expect(lowerBound([0, 2, 4, 6, 8], 5, 0, 5)).toEqual(3); + expect(lowerBound([0, 2, 2, 2, 8], 5, 0, 5)).toEqual(4); + + expect(lowerBound([0, 1, 2, 3, 4, 5, 6, 7, 8], 3, 2, 4)).toEqual(3); + expect(lowerBound([0, 1, 2, 3, 4, 5, 6, 7, 8], 99, 2, 4)).toEqual(4); + }); + + test("float path, finites", () => { + expect(lowerBound(new Float32Array([0, 1, 2, 3]), 1, 0, 4)).toEqual(1); + + expect(lowerBound(new Float32Array([]), 0, 0, 0)).toEqual(0); + + expect(lowerBound(new Float32Array([0, 1, 2, 3]), -1, 0, 4)).toEqual(0); + expect(lowerBound(new Float32Array([0, 1, 2, 3]), 0, 0, 4)).toEqual(0); + 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]), -1, 0, 5)).toEqual(0); + expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 0, 0, 5)).toEqual(0); + expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 2, 0, 5)).toEqual(2); + expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 4, 0, 5)).toEqual(4); + expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 5, 0, 5)).toEqual(5); + + expect(lowerBound(new Float32Array([0, 2, 4, 6, 8]), 5, 0, 5)).toEqual(3); + expect(lowerBound(new Float32Array([0, 2, 2, 2, 8]), 5, 0, 5)).toEqual(4); + + expect( + lowerBound(new Float32Array([0, 1, 2, 3, 4, 5, 6, 7, 8]), 3, 2, 4) + ).toEqual(3); + expect( + lowerBound(new Float32Array([0, 1, 2, 3, 4, 5, 6, 7, 8]), 99, 2, 4) + ).toEqual(4); + }); + + test("float path, non-finite", () => { + expect( + lowerBound( + new Float32Array([-Infinity, 0, 1, Infinity, NaN]), + -Infinity, + 0, + 5 + ) + ).toEqual(0); + expect( + lowerBound(new Float32Array([-Infinity, 0, 1, Infinity, NaN]), 0, 0, 5) + ).toEqual(1); + expect( + lowerBound(new Float32Array([-Infinity, 0, 1, Infinity, NaN]), 1, 0, 5) + ).toEqual(2); + expect( + lowerBound(new Float32Array([-Infinity, 0, 1, Infinity, NaN]), 2, 0, 5) + ).toEqual(3); + expect( + lowerBound( + new Float32Array([-Infinity, 0, 1, Infinity, NaN]), + Infinity, + 0, + 5 + ) + ).toEqual(3); + expect( + lowerBound(new Float32Array([-Infinity, 0, 1, Infinity, NaN]), NaN, 0, 5) + ).toEqual(4); + }); }); diff --git a/client/__tests__/util/typedCrossfilter/util.test.js b/client/__tests__/util/typedCrossfilter/util.test.js index b002782a..39ae43d2 100644 --- a/client/__tests__/util/typedCrossfilter/util.test.js +++ b/client/__tests__/util/typedCrossfilter/util.test.js @@ -1,10 +1,7 @@ import { fillRange, sliceByIndex, - makeSortIndex, - lowerBound, - lowerBoundIndirect, - upperBoundIndirect + makeSortIndex } from "../../../src/util/typedCrossfilter/util"; describe("fillRange", () => { diff --git a/client/package-lock.json b/client/package-lock.json index 85ef7cc8..a423ca6d 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1049,12 +1049,12 @@ } }, "@blueprintjs/core": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@blueprintjs/core/-/core-3.8.0.tgz", - "integrity": "sha512-maw63uME+spdyDUOJH47r+YRc8kDeux8kQz4poiAQKk4eJavc160/aU7w5k4sV29mVputp7P22qNY1oBNXjtDQ==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/@blueprintjs/core/-/core-3.15.0.tgz", + "integrity": "sha512-znXO0UaWBJO7Nm2qEvZzURCDuogsJ9DJlIw3N2XnIi+k/c8mQ1xlvtg0TT2pS88lMG9HUEKky3b0UQaSXBw4LA==", "requires": { - "@blueprintjs/icons": "^3.3.0", - "@types/dom4": "^2.0.0", + "@blueprintjs/icons": "^3.7.0", + "@types/dom4": "^2.0.1", "classnames": "^2.2", "dom4": "^2.0.1", "normalize.css": "^8.0.0", @@ -1063,6 +1063,17 @@ "react-transition-group": "^2.2.1", "resize-observer-polyfill": "^1.5.0", "tslib": "^1.9.0" + }, + "dependencies": { + "@blueprintjs/icons": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@blueprintjs/icons/-/icons-3.8.0.tgz", + "integrity": "sha512-yHaRQ3vfV9Gf3foZ4ONtxddz+u5ufkHqHj8Ia5VhPbFgG4el+cPdmsGGIIM72rgKS1KQa5Ay+ggjpByUlXvrKg==", + "requires": { + "classnames": "^2.2", + "tslib": "^1.9.0" + } + } } }, "@blueprintjs/icons": { @@ -5524,7 +5535,8 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -5545,12 +5557,14 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5565,17 +5579,20 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -5692,7 +5709,8 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -5704,6 +5722,7 @@ "version": "1.0.0", "bundled": true, "dev": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -5718,6 +5737,7 @@ "version": "3.0.4", "bundled": true, "dev": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -5725,12 +5745,14 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "minipass": { "version": "2.2.4", "bundled": true, "dev": true, + "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -5749,6 +5771,7 @@ "version": "0.5.1", "bundled": true, "dev": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -5829,7 +5852,8 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -5841,6 +5865,7 @@ "version": "1.4.0", "bundled": true, "dev": true, + "optional": true, "requires": { "wrappy": "1" } @@ -5926,7 +5951,8 @@ "safe-buffer": { "version": "5.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -5962,6 +5988,7 @@ "version": "1.0.2", "bundled": true, "dev": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -5981,6 +6008,7 @@ "version": "3.0.1", "bundled": true, "dev": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -6024,12 +6052,14 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "yallist": { "version": "3.0.2", "bundled": true, - "dev": true + "dev": true, + "optional": true } } }, diff --git a/client/package.json b/client/package.json index 90b8849c..ff505f45 100644 --- a/client/package.json +++ b/client/package.json @@ -31,7 +31,7 @@ "eslint-scope": "3.7.1" }, "dependencies": { - "@blueprintjs/core": "^3.8.0", + "@blueprintjs/core": "^3.15.0", "@blueprintjs/icons": "^3.3.0", "@blueprintjs/select": "^3.8.0", "canvas-fit": "^1.5.0", diff --git a/client/src/components/brushableHistogram/index.js b/client/src/components/brushableHistogram/index.js index a49ca8a2..f12063a0 100644 --- a/client/src/components/brushableHistogram/index.js +++ b/client/src/components/brushableHistogram/index.js @@ -5,14 +5,12 @@ https://bl.ocks.org/SpaceActuary/2f004899ea1b2bd78d6f1dbb2febf771 */ // jshint esversion: 6 import React from "react"; -import _ from "lodash"; import { Button, ButtonGroup, Tooltip } from "@blueprintjs/core"; import { connect } from "react-redux"; import * as d3 from "d3"; import memoize from "memoize-one"; import * as globals from "../../globals"; import actions from "../../actions"; -import finiteExtent from "../../util/finiteExtent"; import { makeContinuousDimensionName } from "../../util/nameCreators"; @connect(state => ({ @@ -21,53 +19,52 @@ import { makeContinuousDimensionName } from "../../util/nameCreators"; scatterplotYYaccessor: state.controls.scatterplotYYaccessor, continuousSelection: state.continuousSelection, differential: state.differential, - colorAccessor: state.colors.colorAccessor, - obsAnnotations: _.get(state.world, "obsAnnotations", null) + colorAccessor: state.colors.colorAccessor })) class HistogramBrush extends React.Component { - calcHistogramCache = memoize((obsAnnotations, field, rangeMin, rangeMax) => { - const { world } = this.props; - const histogramCache = {}; + static getColumn(world, field, clipped = true) { + /* + Return the underlying Dataframe column for our field. By default, + returns the clipped column. If clipped===false, will return the + unclipped column. + */ + const obsAnnotations = clipped + ? world.obsAnnotations + : world.unclipped.obsAnnotations; + const varData = clipped ? world.varData : world.unclipped.varData; + if (obsAnnotations.hasCol(field)) { + return obsAnnotations.col(field); + } + return varData.col(field); + } + calcHistogramCache = memoize((world, field) => { + /* + recalculate expensive stuff, notably bins, summaries, etc. + */ + const histogramCache = {}; + const col = HistogramBrush.getColumn(world, field); + const values = col.asArray(); + const summary = col.summarize(); + const { min: domainMin, max: domainMax } = summary; + histogramCache.x = d3 + .scaleLinear() + .domain([domainMin, domainMax]) + .range([0, this.width - this.marginRight]); + + histogramCache.bins = d3 + .histogram() + .domain(histogramCache.x.domain()) + .thresholds(40)(values); + + const yMax = histogramCache.bins + .map(b => b.length) + .reduce((a, b) => Math.max(a, b)); histogramCache.y = d3 .scaleLinear() + .domain([0, yMax]) .range([this.height - this.marginBottom, 0]); - if (obsAnnotations.hasCol(field)) { - // recalculate expensive stuff - const allValuesForContinuousFieldAsArray = obsAnnotations - .col(field) - .asArray(); - - histogramCache.x = d3 - .scaleLinear() - .domain([rangeMin, rangeMax]) - .range([0, this.width]); - - histogramCache.bins = d3 - .histogram() - .domain(histogramCache.x.domain()) - .thresholds(40)(allValuesForContinuousFieldAsArray); - - histogramCache.numValues = allValuesForContinuousFieldAsArray.length; - } else if (world.varData.hasCol(field)) { - const varValues = world.varData.col(field).asArray(); - - histogramCache.x = d3 - .scaleLinear() - .domain( - finiteExtent(varValues) - ) /* replace this if we have ranges for genes back from server like we do for annotations on cells */ - .range([0, this.width]); - - histogramCache.bins = d3 - .histogram() - .domain(histogramCache.x.domain()) - .thresholds(40)(varValues); - - histogramCache.numValues = varValues.length; - } - return histogramCache; }); @@ -76,22 +73,23 @@ class HistogramBrush extends React.Component { this.width = 340; this.height = 100; - this.marginBottom = 20; + this.marginBottom = 20; // space for X axis & labels + this.marginRight = 40; // space for Y axis & labels } componentDidMount() { const { field } = this.props; - const { x, y, bins, numValues, svgRef } = this._histogram; + const { x, y, bins, svgRef } = this._histogram; - this.renderAxesBrushBins(x, y, bins, numValues, svgRef, field); + this.renderAxesBrushBins(x, y, bins, svgRef, field); } componentDidUpdate(prevProps) { - const { field, obsAnnotations, continuousSelection } = this.props; - const { x, y, bins, numValues, svgRef } = this._histogram; + const { field, world, continuousSelection } = this.props; + const { x, y, bins, svgRef } = this._histogram; - if (obsAnnotations !== prevProps.obsAnnotations) { - this.renderAxesBrushBins(x, y, bins, numValues, svgRef, field); + if (world !== prevProps.world) { + this.renderAxesBrushBins(x, y, bins, svgRef, field); } /* @@ -172,7 +170,6 @@ class HistogramBrush extends React.Component { onBrushEnd(selection, x) { return () => { const { dispatch, field, isObs, isUserDefined, isDiffExp } = this.props; - const { brushXselection } = this.state; const minAllowedBrushSize = 10; const smallAmountToAvoidInfiniteLoop = 0.1; @@ -197,11 +194,6 @@ class HistogramBrush extends React.Component { smallAmountToAvoidInfiniteLoop; // _range = [x(d3.event.selection[0]), x(procedurallyResizedBrushWidth)]; - - d3.event.target.move(brushXselection, [ - d3.event.selection[0], - procedurallyResizedBrushWidth - ]); } dispatch({ @@ -229,23 +221,16 @@ class HistogramBrush extends React.Component { } drawHistogram(svgRef) { - const { obsAnnotations, field, ranges } = this.props; - const histogramCache = this.calcHistogramCache( - obsAnnotations, - field, - ranges.min, - ranges.max - ); - - const { x, y, bins, numValues } = histogramCache; - - this._histogram = { x, y, bins, numValues, svgRef }; + const { field, world } = this.props; + const histogramCache = this.calcHistogramCache(world, field); + const { x, y, bins } = histogramCache; + this._histogram = { x, y, bins, svgRef }; } handleColorAction() { - const { obsAnnotations, dispatch, field, world, ranges } = this.props; + const { dispatch, field, world, ranges } = this.props; - if (obsAnnotations.hasCol(field)) { + if (world.obsAnnotations.hasCol(field)) { dispatch({ type: "color by continuous metadata", colorAccessor: field, @@ -307,29 +292,29 @@ class HistogramBrush extends React.Component { }; } - renderAxesBrushBins(x, y, bins, numValues, svgRef, field) { + renderAxesBrushBins(x, y, bins, svgRef, field) { + const svg = d3.select(svgRef); + /* Remove everything */ - d3.select(svgRef) - .selectAll("*") - .remove(); + svg.selectAll("*").remove(); /* BINS */ - d3.select(svgRef) + svg .insert("g", "*") .attr("fill", "#bbb") .selectAll("rect") .data(bins) .enter() .append("rect") - .attr("class", "bar") .attr("x", d => x(d.x0) + 1) - .attr("y", d => y(d.length / numValues)) + .attr("y", d => y(d.length)) .attr("width", d => Math.abs(x(d.x1) - x(d.x0) - 1)) - .attr("height", d => y(0) - y(d.length / numValues)); + .attr("height", d => y(0) - y(d.length)); /* BRUSH */ const brushX = d3 .brushX() + .extent([[0, 0], [this.width - this.marginRight, this.height]]) /* emit start so that the Undoable history can save an undo point upon drag start, and ignore the subsequent intermediate drag events. @@ -344,24 +329,24 @@ class HistogramBrush extends React.Component { .attr("data-testid", `${svgRef.dataset.testid}-brush`) .call(brushX); - /* AXIS */ - d3.select(svgRef) + /* X AXIS */ + svg .append("g") .attr("class", "axis axis--x") .attr("transform", `translate(0,${this.height - this.marginBottom})`) .call(d3.axisBottom(x).ticks(5)); - d3.select(svgRef) - .selectAll(".axis--x text") - .style("fill", "rgb(80,80,80)"); + /* Y AXIS */ + svg + .append("g") + .attr("class", "axis axis--y") + .attr("transform", `translate(${this.width - this.marginRight},0)`) + .call(d3.axisRight(y).ticks(3)); - d3.select(svgRef) - .selectAll(".axis--x path") - .style("stroke", "rgb(230,230,230)"); - - d3.select(svgRef) - .selectAll(".axis--x line") - .style("stroke", "rgb(230,230,230)"); + /* axis style */ + svg.selectAll(".axis text").style("fill", "rgb(80,80,80)"); + svg.selectAll(".axis path").style("stroke", "rgb(230,230,230)"); + svg.selectAll(".axis line").style("stroke", "rgb(230,230,230)"); this.setState({ brushX, brushXselection }); } @@ -369,20 +354,29 @@ class HistogramBrush extends React.Component { render() { const { field, + world, colorAccessor, isUserDefined, isDiffExp, logFoldChange, - pval, pvalAdj, scatterplotXXaccessor, scatterplotYYaccessor, zebra } = this.props; - const field_for_id = field.replace(/\s/g, "_"); + const fieldForId = field.replace(/\s/g, "_"); + const { + min: unclippedRangeMin, + max: unclippedRangeMax + } = HistogramBrush.getColumn(world, field, false).summarize(); + const unclippedRangeMinColor = + world.clipQuantiles.min === 0 ? "#bbb" : globals.blue; + const unclippedRangeMaxColor = + world.clipQuantiles.max === 1 ? "#bbb" : globals.blue; + return (
-
+
{isDiffExp || isUserDefined ? ( { @@ -460,15 +460,21 @@ class HistogramBrush extends React.Component {
+ + min {unclippedRangeMin.toPrecision(4)} + {field} + + max {unclippedRangeMax.toPrecision(4)} +
{isDiffExp ? ( diff --git a/client/src/components/continuous/continuous.js b/client/src/components/continuous/continuous.js index 8e169a56..8e8438ba 100644 --- a/client/src/components/continuous/continuous.js +++ b/client/src/components/continuous/continuous.js @@ -64,18 +64,15 @@ class Continuous extends React.Component { ) : null} {obsAnnotations ? _.map(obsAnnotations.colIndex.keys(), key => { - const summary = obsAnnotations.col(key).summarize(); const isColorField = key.includes("color") || key.includes("Color"); + if (key === "name" || isColorField) return null; + + const summary = obsAnnotations.col(key).summarize(); const nonFiniteExtent = summary.min === undefined || summary.max === undefined; - zebra += 1; - if ( - !summary.categorical && - key !== "name" && - !isColorField && - !nonFiniteExtent - ) { + if (!summary.categorical && !nonFiniteExtent) { + zebra += 1; return ( { if (!modifiers.matchesPredicate) { diff --git a/client/src/components/graph/graph.js b/client/src/components/graph/graph.js index a1d42498..7fc3ad80 100644 --- a/client/src/components/graph/graph.js +++ b/client/src/components/graph/graph.js @@ -1,6 +1,5 @@ // jshint esversion: 6 import React from "react"; -import _ from "lodash"; import * as d3 from "d3"; import { connect } from "react-redux"; import mat4 from "gl-mat4"; @@ -12,7 +11,9 @@ import { Popover, Menu, MenuItem, - Position + Position, + NumericInput, + Icon } from "@blueprintjs/core"; import * as globals from "../../globals"; @@ -29,6 +30,8 @@ import { World } from "../../util/stateManager"; world: state.world, universe: state.universe, crossfilter: state.crossfilter, + clipPercentileMin: Math.round(100 * (state.world?.clipQuantiles?.min ?? 0)), + clipPercentileMax: Math.round(100 * (state.world?.clipQuantiles?.max ?? 1)), responsive: state.responsive, colorRGB: state.colors.rgb, opacityForDeselectedCells: state.controls.opacityForDeselectedCells, @@ -47,6 +50,30 @@ import { World } from "../../util/stateManager"; currentSelection: state.graphSelection.selection })) class Graph extends React.Component { + static isValidDigitKeyEvent(e) { + /* + Return true if this event is necessary to enter a percent number input. + Return false if not. + + Returns true for events with keys: backspace, control, alt, meta, [0-9], + or events that don't have a key. + */ + if (e.key === null) return true; + if (e.ctrlKey || e.altKey || e.metaKey) return true; + + // concept borrowed from blueprint's numericInputUtils: + // keys that print a single character when pressed have a `key` name of + // length 1. every other key has a longer `key` name (e.g. "Backspace", + // "ArrowUp", "Shift"). since none of those keys can print a character + // to the field--and since they may have important native behaviors + // beyond printing a character--we don't want to disable their effects. + const isSingleCharKey = e.key.length === 1; + if (!isSingleCharKey) return true; + + const key = e.key.charCodeAt(0) - 48; /* "0" */ + return key >= 0 && key <= 9; + } + constructor(props) { super(props); this.count = 0; @@ -62,7 +89,8 @@ class Graph extends React.Component { svg: null, tool: null, container: null, - mode: "select" + mode: "select", + pendingClipPercentiles: null }; } @@ -265,6 +293,7 @@ class Graph extends React.Component { * there are no userDefinedGenes or diffexpGenes displayed * scatterplot is not displayed * nothing in cellset1 or cellset2 + * clip percentiles are [0,100] */ const { crossfilter, @@ -276,7 +305,9 @@ class Graph extends React.Component { scatterplotXXaccessor, scatterplotYYaccessor, celllist1, - celllist2 + celllist2, + clipPercentileMin, + clipPercentileMax } = this.props; if (!crossfilter || !world || !universe) { @@ -294,7 +325,9 @@ class Graph extends React.Component { nothingColoredBy && noGenes && scatterNotDpl && - nothingInCellsets + nothingInCellsets && + clipPercentileMax === 100 && + clipPercentileMin === 0 ); }; @@ -306,6 +339,102 @@ class Graph extends React.Component { dispatch(actions.resetInterface()); }; + isClipDisabled = () => { + /* + return true if clip button should be disabled. + */ + const { pendingClipPercentiles } = this.state; + const clipPercentileMin = pendingClipPercentiles?.clipPercentileMin; + const clipPercentileMax = pendingClipPercentiles?.clipPercentileMax; + + const { world } = this.props; + const currentClipMin = 100 * world?.clipQuantiles?.min; + const currentClipMax = 100 * world?.clipQuantiles?.max; + + // if you change this test, be careful with logic around + // comparisons between undefined / NaN handling. + const isDisabled = + !(clipPercentileMin < clipPercentileMax) || + (clipPercentileMin === currentClipMin && + clipPercentileMax === currentClipMax); + + return isDisabled; + }; + + handleClipOnKeyPress = e => { + /* + allow only numbers, plus other critical keys which + may be required to make a number + */ + if (!Graph.isValidDigitKeyEvent(e)) { + e.preventDefault(); + } + }; + + handleClipPercentileMinValueChange = v => { + /* + Ignore anything that isn't a legit number + */ + if (!Number.isFinite(v)) return; + + const { pendingClipPercentiles } = this.state; + const clipPercentileMax = pendingClipPercentiles?.clipPercentileMax; + + /* + clamp to [0, currentClipPercentileMax] + */ + if (v <= 0) v = 0; + if (v > 100) v = 100; + const clipPercentileMin = Math.round(v); // paranoia + this.setState({ + pendingClipPercentiles: { clipPercentileMin, clipPercentileMax } + }); + }; + + handleClipPercentileMaxValueChange = v => { + /* + Ignore anything that isn't a legit number + */ + if (!Number.isFinite(v)) return; + + const { pendingClipPercentiles } = this.state; + const clipPercentileMin = pendingClipPercentiles?.clipPercentileMin; + + /* + clamp to [0, 100] + */ + if (v < 0) v = 0; + if (v > 100) v = 100; + const clipPercentileMax = Math.round(v); // paranoia + + this.setState({ + pendingClipPercentiles: { clipPercentileMin, clipPercentileMax } + }); + }; + + handleClipCommit = () => { + const { dispatch } = this.props; + const { pendingClipPercentiles } = this.state; + const { clipPercentileMin, clipPercentileMax } = pendingClipPercentiles; + const min = clipPercentileMin / 100; + const max = clipPercentileMax / 100; + dispatch({ + type: "set clip quantiles", + clipQuantiles: { min, max } + }); + }; + + handleClipOpening = () => { + const { clipPercentileMin, clipPercentileMax } = this.props; + this.setState({ + pendingClipPercentiles: { clipPercentileMin, clipPercentileMax } + }); + }; + + handleClipClosing = () => { + this.setState({ pendingClipPercentiles: null }); + }; + brushToolUpdate(tool, container, offset) { /* this is called from componentDidUpdate(), so be very careful using @@ -614,9 +743,20 @@ class Graph extends React.Component { libraryVersions, undoDisabled, redoDisabled, - selectionTool + selectionTool, + clipPercentileMin, + clipPercentileMax } = this.props; - const { mode } = this.state; + const { mode, pendingClipPercentiles } = this.state; + + const clipMin = + pendingClipPercentiles?.clipPercentileMin ?? clipPercentileMin; + const clipMax = + pendingClipPercentiles?.clipPercentileMax ?? clipPercentileMax; + const activeClipClass = + clipPercentileMin > 0 || clipPercentileMax < 100 + ? " bp3-intent-warning" + : ""; // constants used to create selection tool button let selectionTooltip; @@ -684,66 +824,175 @@ class Graph extends React.Component { reset -
-
- -
+
+ +
-
+
+ + { + dispatch({ type: "@@undoable/undo" }); + }} + style={{ + cursor: "pointer" + }} + /> + + + { + dispatch({ type: "@@undoable/redo" }); + }} + style={{ + cursor: "pointer" + }} + /> + +
+
+ + + } + onOpening={this.handleClipOpening} + onClosing={this.handleClipClosing} + content={ +
+
Clip all continuous values to percentile range
+
+ + +
+ } + /> + + {" "} + -{" "} + + + +
+ } + /> + + +
+
+ } + /> + +
+ +
@@ -786,7 +1035,7 @@ class Graph extends React.Component { >