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
This commit is contained in:
Sidney Bell
2019-04-30 16:20:10 -07:00
committed by Bruce Martin
parent 9f10d8095a
commit a08e19bbd0
34 changed files with 1843 additions and 552 deletions

View File

@@ -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", () => {

View File

@@ -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
]);
});
});

View File

@@ -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)
}
})
);

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});

View File

@@ -1,10 +1,7 @@
import {
fillRange,
sliceByIndex,
makeSortIndex,
lowerBound,
lowerBoundIndirect,
upperBoundIndirect
makeSortIndex
} from "../../../src/util/typedCrossfilter/util";
describe("fillRange", () => {

View File

@@ -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
}
}
},

View File

@@ -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",

View File

@@ -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 (
<div
id={`histogram_${field_for_id}`}
id={`histogram_${fieldForId}`}
data-testid={`histogram-${field}`}
data-testclass={
isDiffExp
@@ -396,7 +390,13 @@ class HistogramBrush extends React.Component {
backgroundColor: zebra ? globals.lightestGrey : "white"
}}
>
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<div
style={{
display: "flex",
justifyContent: "flex-end",
paddingBottom: "8px"
}}
>
{isDiffExp || isUserDefined ? (
<span>
<span
@@ -450,7 +450,7 @@ class HistogramBrush extends React.Component {
<svg
width={this.width}
height={this.height}
id={`histogram_${field_for_id}_svg`}
id={`histogram_${fieldForId}_svg`}
data-testclass="histogram-plot"
data-testid={`histogram-${field}-plot`}
ref={svgRef => {
@@ -460,15 +460,21 @@ class HistogramBrush extends React.Component {
<div
style={{
display: "flex",
justifyContent: "center"
justifyContent: "space-between"
}}
>
<span style={{ color: unclippedRangeMinColor }}>
min {unclippedRangeMin.toPrecision(4)}
</span>
<span
data-testclass="brushable-histogram-field-name"
style={{ fontStyle: "italic" }}
>
{field}
</span>
<span style={{ color: unclippedRangeMaxColor }}>
max {unclippedRangeMax.toPrecision(4)}
</span>
</div>
{isDiffExp ? (

View File

@@ -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 (
<HistogramBrush
key={key}

View File

@@ -112,6 +112,7 @@ class ContinuousLegend extends React.Component {
const { colorAccessor, responsive, colorScale } = this.props;
if (
prevProps.colorAccessor !== colorAccessor ||
prevProps.colorScale !== colorScale ||
prevProps.responsive.height !== responsive.height ||
prevProps.responsive.width !== responsive.width
) {

View File

@@ -22,7 +22,6 @@ import {
keepAroundErrorToast
} from "../framework/toasters";
import ExpressionButtons from "./expressionButtons";
import finiteExtent from "../../util/finiteExtent";
const renderGene = (fuzzySortResult, { handleClick, modifiers, query }) => {
if (!modifiers.matchesPredicate) {

View File

@@ -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
</AnchorButton>
</Tooltip>
<div>
<div className="bp3-button-group">
<Tooltip content={selectionTooltip} position="left">
<Button
type="button"
data-testid="mode-lasso"
className={`bp3-button ${selectionButtonClass}`}
active={mode === "select"}
onClick={() => {
this.setState({ mode: "select" });
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
<Tooltip content="Pan and zoom" position="left">
<Button
type="button"
data-testid="mode-pan-zoom"
className="bp3-button bp3-icon-zoom-in"
active={mode === "zoom"}
onClick={() => {
this.restartReglLoop();
this.setState({ mode: "zoom" });
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
<Tooltip content="Undo" position="left">
<AnchorButton
type="button"
className="bp3-button bp3-icon-undo"
disabled={undoDisabled}
onClick={() => {
dispatch({ type: "@@undoable/undo" });
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
<Tooltip content="Redo" position="left">
<AnchorButton
type="button"
className="bp3-button bp3-icon-redo"
disabled={redoDisabled}
onClick={() => {
dispatch({ type: "@@undoable/redo" });
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
</div>
<div className="bp3-button-group">
<Tooltip content={selectionTooltip} position="left">
<Button
type="button"
data-testid="mode-lasso"
className={`bp3-button ${selectionButtonClass}`}
active={mode === "select"}
onClick={() => {
this.setState({ mode: "select" });
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
<Tooltip content="Pan and zoom" position="left">
<Button
type="button"
data-testid="mode-pan-zoom"
className="bp3-button bp3-icon-zoom-in"
active={mode === "zoom"}
onClick={() => {
this.restartReglLoop();
this.setState({ mode: "zoom" });
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
</div>
<div style={{ marginLeft: 10 }}>
<div
className="bp3-button-group"
style={{
marginLeft: 10
}}
>
<Tooltip content="Undo" position="left">
<AnchorButton
type="button"
className="bp3-button bp3-icon-undo"
disabled={undoDisabled}
onClick={() => {
dispatch({ type: "@@undoable/undo" });
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
<Tooltip content="Redo" position="left">
<AnchorButton
type="button"
className="bp3-button bp3-icon-redo"
disabled={redoDisabled}
onClick={() => {
dispatch({ type: "@@undoable/redo" });
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
</div>
<div
className="bp3-button-group"
style={{
marginLeft: 10
}}
>
<Tooltip content="Visualization settings" position="left">
<Popover
target={
<Button
type="button"
className={`bp3-button bp3-icon-timeline-bar-chart ${activeClipClass}`}
style={{
cursor: "pointer"
}}
/>
}
onOpening={this.handleClipOpening}
onClosing={this.handleClipClosing}
content={
<div
style={{
display: "flex",
justifyContent: "flex-start",
alignItems: "flex-start",
flexDirection: "column",
padding: 10
}}
>
<div>Clip all continuous values to percentile range</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
paddingTop: 5,
paddingBottom: 5
}}
>
<NumericInput
style={{ width: 50 }}
onValueChange={
this.handleClipPercentileMinValueChange
}
onKeyPress={this.handleClipOnKeyPress}
value={clipMin}
min={0}
max={100}
fill={false}
minorStepSize={null}
rightElement={
<div style={{ padding: "4px 2px" }}>
<Icon
icon="percentage"
intent="primary"
iconSize={14}
/>
</div>
}
/>
<span style={{ marginRight: 5, marginLeft: 5 }}>
{" "}
-{" "}
</span>
<NumericInput
style={{ width: 50 }}
onValueChange={
this.handleClipPercentileMaxValueChange
}
onKeyPress={this.handleClipOnKeyPress}
value={clipMax}
min={0}
max={100}
fill={false}
minorStepSize={null}
rightElement={
<div style={{ padding: "4px 2px" }}>
<Icon
icon="percentage"
intent="primary"
iconSize={14}
/>
</div>
}
/>
<span style={{ marginRight: 5, marginLeft: 5 }}> </span>
<Button
type="button"
className="bp3-button"
disabled={this.isClipDisabled()}
style={{
cursor: "pointer"
}}
onClick={this.handleClipCommit}
>
Clip
</Button>
</div>
</div>
}
/>
</Tooltip>
</div>
<div style={{ marginLeft: 10 }} className="bp3-button-group">
<Popover
content={
<Menu>
@@ -786,7 +1035,7 @@ class Graph extends React.Component {
>
<Button
type="button"
className="bp3-button bp3-icon-cog"
className="bp3-button bp3-icon-info-sign"
style={{
cursor: "pointer"
}}

View File

@@ -145,7 +145,8 @@ class Scatterplot extends React.Component {
if (
scatterplotXXaccessor !== prevProps.scatterplotXXaccessor || // was CLU now FTH1 etc
scatterplotYYaccessor !== prevProps.scatterplotYYaccessor // was CLU now FTH1 etc
scatterplotYYaccessor !== prevProps.scatterplotYYaccessor || // was CLU now FTH1 etc
world !== prevProps.world // shape or clip of world changed
) {
const scales = Scatterplot.setupScales(expressionX, expressionY);
this.drawAxesSVG(scales.xScale, scales.yScale, svg);
@@ -263,9 +264,15 @@ class Scatterplot extends React.Component {
// the axes are much cleaner and easier now. No need to rotate and orient
// the axis, just call axisBottom, axisLeft etc.
const xAxis = d3.axisBottom().scale(xScale);
const xAxis = d3
.axisBottom()
.ticks(7)
.scale(xScale);
const yAxis = d3.axisLeft().scale(yScale);
const yAxis = d3
.axisLeft()
.ticks(7)
.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.

View File

@@ -27,6 +27,7 @@ const ColorsReducer = (
};
}
case "set clip quantiles":
case "set World to current selection": {
const { colorMode, colorAccessor } = state;
const { world } = nextSharedState;

View File

@@ -17,6 +17,14 @@ const ContinuousSelection = (state = {}, action) => {
[name]: action.range
};
}
case "continuous metadata histogram cancel": {
const name = makeContinuousDimensionName(
action.continuousNamespace,
action.selection
);
const { [name]: deletedField, ...newState } = state;
return newState;
}
default: {
return state;
}

View File

@@ -40,6 +40,7 @@ const CrossfilterReducer = (
return crossfilter;
}
case "set clip quantiles":
case "set World to current selection": {
const { userDefinedGenes, diffexpGenes } = prevSharedState.controls;
const { world } = nextSharedState;

View File

@@ -1,7 +1,10 @@
/*
Reducer which caches derived state to be used in a reset
*/
Reducer which caches derived state to be used in a reset or other
recomputation.
Currently this only caches the baseline (full universe) world & crossfilter,
for use in a Reset.
*/
const ResetCacheReducer = (
state = {
world: null,

View File

@@ -51,6 +51,7 @@ history state processing. The undoable action object contents, by key:
filter state are entirely at the discretion of the action filter.
*/
import fromEntries from "../util/fromEntries";
const historyKeyPrefix = "@@undoable/";
const pastKey = `${historyKeyPrefix}past`;
@@ -283,18 +284,4 @@ function push(arr, val, limit = undefined) {
return narr;
}
function fromEntries(arr) {
/*
Similar to Object.fromEntries, but only handles array.
This could be replaced with the standard fucnction once it
is widely available. As of 3/20/2019, it has not yet
been released in the Chrome stable channel.
*/
const obj = {};
for (let i = 0, l = arr.length; i < l; i += 1) {
obj[arr[i][0]] = arr[i][1];
}
return obj;
}
export default Undoable;

View File

@@ -71,7 +71,8 @@ const saveOnActions = new Set([
"store current cell selection as differential set 1",
"store current cell selection as differential set 2",
"set World to current selection"
"set World to current selection",
"set clip quantiles"
]);
/**
@@ -107,8 +108,8 @@ StateMachine when it doesn't know what to do.
Signature: (fsm, event, from) => undoableAction
*/
const onFsmError = (fsm, name, from) => {
console.error("FSM error - unexpected history state", fsm, name, from);
const onFsmError = (fsm, event, from) => {
console.error(`FSM error [event: "${event}", state: "${from}"]`, fsm);
// In production, try to recover gracefully if we have unexpected state
return clear(fsm);
};

View File

@@ -1,5 +1,3 @@
import _ from "lodash";
import { ControlsHelpers } from "../util/stateManager";
const Universe = (state = null, action, nextSharedState, prevSharedState) => {
@@ -12,9 +10,10 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => {
case "expression load success": {
let { varData } = state;
// Load new expression data into the varData dataframes, if
// Lazy load new expression data into the varData dataframe, if
// not already present.
_.forEach(action.expressionData, (val, key) => {
//
Object.entries(action.expressionData).forEach(([key, val]) => {
// If not already in universe.varData, save entire expression column
if (!varData.hasCol(key)) {
varData = varData.withCol(key, val);
@@ -22,14 +21,15 @@ const Universe = (state = null, action, nextSharedState, prevSharedState) => {
});
// Prune size of varData "cache" if getting out of hand....
//
const { userDefinedGenes, diffexpGenes } = prevSharedState;
const allTheGenesWeNeed = _.uniq(
[].concat(
const allTheGenesWeNeed = [
...new Set(
userDefinedGenes,
diffexpGenes,
Object.keys(action.expressionData)
)
);
];
varData = ControlsHelpers.pruneVarDataCache(varData, allTheGenesWeNeed);
return {

View File

@@ -1,6 +1,6 @@
import _ from "lodash";
import { World, ControlsHelpers } from "../util/stateManager";
import clip from "../util/clip";
import quantile from "../util/quantile";
const WorldReducer = (
state = null,
@@ -21,7 +21,7 @@ const WorldReducer = (
case "set World to current selection": {
/* Set viewable world to be the currently selected data */
const world = World.createWorldFromCurrentSelection(
const world = World.createWorldBySelection(
action.universe,
action.world,
action.crossfilter
@@ -29,16 +29,27 @@ const WorldReducer = (
return world;
}
case "set clip quantiles": {
const world = World.createWorldWithNewClip(
prevSharedState.universe,
state,
prevSharedState.crossfilter,
action.clipQuantiles
);
return world;
}
case "expression load success": {
const { universe } = nextSharedState;
const universeVarData = universe.varData;
let worldVarData = state.varData;
let unclippedVarData = state.unclipped.varData;
// Load new expression data into the varData dataframes, if
// Lazy load new expression data into the unclipped varData dataframe, if
// not already present.
_.forEach(action.expressionData, (val, key) => {
//
Object.entries(action.expressionData).forEach(([key, val]) => {
// If not already in world.varData, save sliced expression column
if (!worldVarData.hasCol(key)) {
if (!unclippedVarData.hasCol(key)) {
// Slice if world !== universe, else just use whole column.
// Use the obsAnnotation index as the cut key, as we keep
// all world dataframes in sync.
@@ -51,7 +62,7 @@ const WorldReducer = (
}
// Now build world's varData dataframe
worldVarData = worldVarData.withCol(
unclippedVarData = unclippedVarData.withCol(
key,
worldValSlice,
state.obsAnnotations.rowIndex
@@ -59,23 +70,55 @@ const WorldReducer = (
}
});
// Prune size of varData "cache" if getting out of hand....
// Prune size of varData unclipped dataframe if getting out of hand....
//
const { userDefinedGenes, diffexpGenes } = prevSharedState;
const allTheGenesWeNeed = _.uniq(
[].concat(
const allTheGenesWeNeed = [
...new Set(
userDefinedGenes,
diffexpGenes,
Object.keys(action.expressionData)
)
);
worldVarData = ControlsHelpers.pruneVarDataCache(
worldVarData,
];
unclippedVarData = ControlsHelpers.pruneVarDataCache(
unclippedVarData,
allTheGenesWeNeed
);
// at this point, we have the unclipped data in unclippedVarData.
// Now create clipped.
// - Drop columns no longer needed
// - Add new columns
//
let clippedVarData = state.varData;
const keysToDrop = clippedVarData.colIndex
.keys()
.filter(k => !unclippedVarData.hasCol(k));
const keysToAdd = unclippedVarData.colIndex
.keys()
.filter(k => !clippedVarData.hasCol(k));
keysToDrop.forEach(k => {
clippedVarData = clippedVarData.dropCol(k);
});
keysToAdd.forEach(k => {
const data = unclippedVarData.col(k).asArray();
const q = [state.clipQuantiles.min, state.clipQuantiles.max];
const [qMinVal, qMaxVal] = quantile(q, data);
const clippedData = clip(data, qMinVal, qMaxVal, Number.NaN);
clippedVarData = clippedVarData.withCol(
k,
clippedData,
state.obsAnnotations.rowIndex
);
});
return {
...state,
varData: worldVarData
varData: clippedVarData,
unclipped: {
...state.unclipped,
varData: unclippedVarData
}
};
}

25
client/src/util/clip.js Normal file
View File

@@ -0,0 +1,25 @@
/*
clip - clip all values in a Array or TypedArray, IN PLACE.
Values in array are clipped if less than `lower` or greater than `upper`.
If `setTo` is undefined, values less than `lower` will be set to `lower`,
and values greater than `upper` will be set to `upper`.
If `setTo` is not undefined, values outside the [lower, upper] range will be set to
`setTo`.
*/
export default function clip(arr, lower, upper, setTo) {
const lowerSet = setTo === undefined ? lower : setTo;
const upperSet = setTo === undefined ? upper : setTo;
for (let i = 0, l = arr.length; i < l; i += 1) {
const v = arr[i];
if (v < lower) {
arr[i] = lowerSet;
} else if (v > upper) {
arr[i] = upperSet;
}
}
return arr;
}

View File

@@ -1,6 +1,6 @@
import { IdentityInt32Index, isLabelIndex } from "./labelIndex";
// weird cross-dependency that we should clean up someday...
import { sort } from "../typedCrossfilter/sort";
import { sortArray } from "../typedCrossfilter/sort";
import { isTypedArray, isArrayOrTypedArray, callOnceLazy } from "./util";
import { summarizeContinuous, summarizeCategorical } from "./summarize";
@@ -63,7 +63,13 @@ class Dataframe {
Constructors & factories
**/
constructor(dims, columnarData, rowIndex = null, colIndex = null) {
constructor(
dims,
columnarData,
rowIndex = null,
colIndex = null,
__columnsAccessor = [] // private interface
) {
/*
The base constructor is relatively hard to use - as an alternative,
see factory methods and clone/slice, below.
@@ -74,6 +80,9 @@ class Dataframe {
or TypedArray of length nRows.
* rowIndex/colIndex - null (create default index using offsets as key),
or a caller-provided index.
* __columnsAccessor - private interface, do not specify. Used internally
to improve caching of column accessors when possible (eg, clone(),
dropCol(), withCol()).
All columns and indices must have appropriate dimensionality.
*/
const [nRows, nCols] = dims;
@@ -94,7 +103,7 @@ class Dataframe {
this.rowIndex = rowIndex;
this.colIndex = colIndex;
this.__compile();
this.__compile(__columnsAccessor);
}
static __errorChecks(dims, columnarData, rowIndex, colIndex) {
@@ -135,97 +144,107 @@ class Dataframe {
}
}
__compile() {
static __compileColumn(column, getOffset, getLabel) {
/*
Each column accessor is a function which will lookup data by
index (ie, is equivalent to dataframe.get(row, col), where 'col'
is fixed.
In addition, each column accessor has several functions:
asArray() -- return the entire column as a native Array or TypedArray.
Crucially, this native array only supports label indexing.
Example:
const arr = df.col('a').asArray();
has(rlabel) -- return boolean indicating of the row label
is contained within the column. Example:
const isInColumn = df.col('a').includes(99)
For the default offset indexing, this is identical to:
const isInColumn = (99 > 0) && (99 < df.nRows);
ihas(roffset) -- same as has(), but accepts a row offset
instead of a row label.
indexOf(value) -- return the label (not offset) of the first instance of
'value' in the column. If you want the offset, just use the builtin JS
indexOf() function, available on both Array and TypedArray.
iget(offset) -- return the value at 'offset'
*/
const { length } = column;
/* get value by row label */
const get = function get(rlabel) {
return column[getOffset(rlabel)];
};
/* get value by row offset */
const iget = function iget(roffset) {
return column[roffset];
};
/* full column array access */
const asArray = function asArray() {
return column;
};
/* test for row label inclusion in column */
const has = function has(rlabel) {
const offset = getOffset(rlabel);
return offset >= 0 && offset < length;
};
const ihas = function ihas(offset) {
return offset >= 0 && offset < length;
};
/*
return first label (index) at which the value is found in this column,
or undefined if not found.
NOTE: not found return is DIFFERENT than the default Array.indexOf as
-1 is a plausible Dataframe row/col label.
*/
const indexOf = function indexOf(value) {
const offset = column.indexOf(value);
if (offset === -1) {
return undefined;
}
return getLabel(offset);
};
/*
Summarize the column data. Lazy eval;
*/
const summarize = callOnceLazy(() =>
isTypedArray(column)
? summarizeContinuous(column)
: summarizeCategorical(column)
);
get.summarize = summarize;
get.asArray = asArray;
get.has = has;
get.ihas = ihas;
get.indexOf = indexOf;
get.iget = iget;
return get;
}
__compile(accessors) {
/*
Compile data accessors for each column.
Each column accessor is a function which will lookup data by
index (ie, is equivalent to dataframe.get(row, col), where 'col'
is fixed.
In addition, each column accessor has several functions:
asArray() -- return the entire column as a native Array or TypedArray.
Crucially, this native array only supports label indexing.
Example:
const arr = df.col('a').asArray();
has(rlabel) -- return boolean indicating of the row label
is contained within the column. Example:
const isInColumn = df.col('a').includes(99)
For the default offset indexing, this is identical to:
const isInColumn = (99 > 0) && (99 < df.nRows);
ihas(roffset) -- same as has(), but accepts a row offset
instead of a row label.
indexOf(value) -- return the label (not offset) of the first instance of
'value' in the column. If you want the offset, just use the builtin JS
indexOf() function, available on both Array and TypedArray.
iget(offset) -- return the value at 'offset'
Use an existing accessor if provided, else compile a new one.
*/
const { getOffset, getLabel } = this.rowIndex;
this.__columnsAccessor = this.__columns.map(column => {
const { length } = column;
/* get value by row label */
const get = function get(rlabel) {
return column[getOffset(rlabel)];
};
/* get value by row offset */
const iget = function iget(roffset) {
return column[roffset];
};
/* full column array access */
const asArray = function asArray() {
return column;
};
/* test for row label inclusion in column */
const has = function has(rlabel) {
const offset = getOffset(rlabel);
return offset >= 0 && offset < length;
};
const ihas = function ihas(offset) {
return offset >= 0 && offset < length;
};
/*
return first label (index) at which the value is found in this column,
or undefined if not found.
NOTE: not found return is DIFFERENT than the default Array.indexOf as
-1 is a plausible Dataframe row/col label.
*/
const indexOf = function indexOf(value) {
const offset = column.indexOf(value);
if (offset === -1) {
return undefined;
}
return getLabel(offset);
};
/*
Summarize the column data. Lazy eval;
*/
const summarize = callOnceLazy(() =>
isTypedArray(column)
? summarizeContinuous(column)
: summarizeCategorical(column)
);
get.summarize = summarize;
get.asArray = asArray;
get.has = has;
get.ihas = ihas;
get.indexOf = indexOf;
get.iget = iget;
return get;
this.__columnsAccessor = this.__columns.map((column, idx) => {
if (accessors[idx]) {
return accessors[idx];
}
return Dataframe.__compileColumn(column, getOffset, getLabel);
});
}
@@ -237,7 +256,8 @@ class Dataframe {
this.dims,
[...this.__columns],
this.rowIndex,
this.colIndex
this.colIndex,
[...this.__columnsAccessor]
);
}
@@ -273,7 +293,14 @@ class Dataframe {
const columns = [...this.__columns];
columns.push(colData);
const colIndex = this.colIndex.withLabel(label);
return new this.constructor(dims, columns, rowIndex, colIndex);
const columnsAccessor = [...this.__columnsAccessor];
return new this.constructor(
dims,
columns,
rowIndex,
colIndex,
columnsAccessor
);
}
dropCol(label) {
@@ -287,7 +314,15 @@ class Dataframe {
const columns = [...this.__columns];
columns.splice(coffset, 1);
const colIndex = this.colIndex.dropLabel(label);
return new this.constructor(dims, columns, this.rowIndex, colIndex);
const columnsAccessor = [...this.__columnsAccessor];
columnsAccessor.splice(coffset, 1);
return new this.constructor(
dims,
columns,
this.rowIndex,
colIndex,
columnsAccessor
);
}
static empty(rowIndex = null, colIndex = null) {
@@ -316,7 +351,7 @@ class Dataframe {
if (!offsets) {
return [null, null];
}
const sortedOffsets = sort(offsets);
const sortedOffsets = sortArray(offsets);
const sortedLabels = new Array(sortedOffsets.length);
for (let i = 0, l = sortedOffsets.length; i < l; i += 1) {
sortedLabels[i] = index.getLabel(sortedOffsets[i]);
@@ -543,14 +578,34 @@ class Dataframe {
/****
Functional (map/reduce/etc) data access
XXX: not yet implemented, as there is no clear use case. Can easily
TODO: most are not yet implemented, as there is no clear use case. Can easily
add these as useful.
****/
mapColumns(callback) {
/*
map all columns in the dataframe, returning a new dataframe comprised of the
return values, with the same index as the original dataframe.
callback MUST not modify the column, but instead return a mutated copy.
*/
const columns = this.__columns.map(callback);
const columnsAccessor = columns.map((c, idx) =>
this.__columns[idx] === c ? this.__columnsAccessor[idx] : undefined
);
return new this.constructor(
this.dims,
columns,
this.rowIndex,
this.colIndex,
columnsAccessor
);
}
/*
Map & reduce of column or row
XXX TODO remainder of map/reduce functions: mapCol, mapRow, reduceRow, ...
TODO remainder of map/reduce functions: mapCol, mapRow, reduceRow, ...
*/
/* comment out until we have a use for this

View File

@@ -1,32 +1,56 @@
/*
Private dataframe support functions
TODO / XXX: for scalar/continuous data, this uses a naive method
of computing quantiles. Would be good to switch from sort to
partition at some point.
*/
import quantile from "../quantile";
import { sortArray } from "../typedCrossfilter/sort";
// [ 0, 0.01, 0.02, ..., 1.0]
const centileNames = new Array(101).fill(0).map((v, idx) => idx / 100);
export function summarizeContinuous(col) {
let min;
let max;
let nan = 0;
let pinf = 0;
let ninf = 0;
let percentiles;
if (col) {
for (let r = 0, l = col.length; r < l; r += 1) {
const val = Number(col[r]);
if (Number.isFinite(val)) {
if (min === undefined) {
min = val;
max = val;
} else {
min = val < min ? val : min;
max = val > max ? val : max;
}
} else if (Number.isNaN(val)) {
nan += 1;
} else if (val > 0) {
pinf += 1;
} else {
ninf += 1;
// -Inf < finite < Inf < NaN
const sortedCol = sortArray(new col.constructor(col));
// count non-finites, which are at each end of sorted data
for (let i = sortedCol.length - 1; i >= 0; i -= 1) {
if (!Number.isNaN(sortedCol[i])) {
nan = sortedCol.length - i - 1;
break;
}
}
for (let i = 0, l = sortedCol.length; i < l; i += 1) {
if (sortedCol[i] !== Number.NEGATIVE_INFINITY) {
ninf = i;
break;
}
}
for (let i = sortedCol.length - nan - 1; i >= 0; i -= 1) {
if (sortedCol[i] !== Number.POSITIVE_INFINITY) {
pinf = sortedCol.length - i - nan - 1;
break;
}
}
// compute percentiles on finite data ONLY
const sortedColFiniteOnly = sortedCol.slice(
ninf,
sortedCol.length - nan - pinf
);
percentiles = quantile(centileNames, sortedColFiniteOnly, true);
min = percentiles[0];
max = percentiles[100];
}
return {
categorical: false,
@@ -34,7 +58,8 @@ export function summarizeContinuous(col) {
max,
nan,
pinf,
ninf
ninf,
percentiles
};
}

View File

@@ -2,16 +2,7 @@
Private utility code for dataframe
*/
export function isTypedArray(x) {
return (
ArrayBuffer.isView(x) &&
Object.prototype.toString.call(x) !== "[object DataView]"
);
}
export function isArrayOrTypedArray(x) {
return Array.isArray(x) || isTypedArray(x);
}
export { isTypedArray, isArrayOrTypedArray } from "../typeHelpers";
export function callOnceLazy(f) {
let value;

View File

@@ -0,0 +1,13 @@
export default function fromEntries(arr) {
/*
Similar to Object.fromEntries, but only handles array.
This could be replaced with the standard fucnction once it
is widely available. As of 3/20/2019, it has not yet
been released in the Chrome stable channel.
*/
const obj = {};
for (let i = 0, l = arr.length; i < l; i += 1) {
obj[arr[i][0]] = arr[i][1];
}
return obj;
}

View File

@@ -0,0 +1,29 @@
/*
quantiles - calculate quantiles for the typed array.
Currently interpolates to 'lower' value.
Arguments:
* quantArr - array of quantiles to compute, where values: 0 <= value <= 1.0
* tarr - a typed array
* sorted - option bool. If false (default), will assume array is not sorted.
If true, will assume it is sorted.
*/
import { sortArray } from "./typedCrossfilter/sort";
export default function quantile(quantArr, tarr, sorted = false) {
/*
start with the naive (sort) implementation. Later, use a faster partition
*/
const arr = sorted ? tarr : sortArray(new tarr.constructor(tarr)); // copy
const len = arr.length;
return quantArr.map(q => {
if (q === 1) {
return arr[len - 1];
}
return arr[Math.floor(q * len)];
});
}

View File

@@ -4,6 +4,8 @@ import _ from "lodash";
import decodeMatrixFBS from "./matrix";
import * as Dataframe from "../dataframe";
import fromEntries from "../fromEntries";
import { isFpTypedArray } from "../typeHelpers";
/*
Private helper function - create and return a template Universe
@@ -37,14 +39,57 @@ These functions are used exclusively by the actions and reducers to
build an internal POJO for use by the rendering components.
*/
function promoteTypedArray(o) {
/*
Decide what internal data type to use for the data returned from
the server.
TODO - future optimization: not all int32/uint32 data series require
promotion to float64. We COULD simply look at the data to decide.
*/
if (isFpTypedArray(o) || Array.isArray(o)) return o;
let TyepdArrayCtor;
switch (o.constructor) {
case Int8Array:
case Uint8Array:
case Uint8ClampedArray:
case Int16Array:
case Uint16Array:
TyepdArrayCtor = Float32Array;
break;
case Int32Array:
case Uint32Array:
TyepdArrayCtor = Float64Array;
break;
default:
throw new Error("Unexpected data type returned from server.");
}
if (o.constructor === TyepdArrayCtor) return o;
return new TyepdArrayCtor(o);
}
function AnnotationsFBSToDataframe(arrayBuffer) {
/*
Convert a Matrix FBS to a Dataframe.
The application has strong assumptions that all scalar data will be
stored as a float32 or float64 (regardless of underlying data types).
For example, clipping of value ranges (eg, user-selected percentiles)
All float data from the server is left as is. All non-float is promoted
to an appropriate float.
*/
const fbs = decodeMatrixFBS(arrayBuffer);
const fbs = decodeMatrixFBS(arrayBuffer, true); // leave in place
const columns = fbs.columns.map(c => {
if (isFpTypedArray(c) || Array.isArray(c)) return c;
return promoteTypedArray(c);
});
const df = new Dataframe.Dataframe(
[fbs.nRows, fbs.nCols],
fbs.columns,
columns,
null,
new Dataframe.KeyIndex(fbs.colIdx)
);
@@ -53,6 +98,10 @@ function AnnotationsFBSToDataframe(arrayBuffer) {
function LayoutFBSToDataframe(arrayBuffer) {
const fbs = decodeMatrixFBS(arrayBuffer, true);
if (fbs.columns.length !== 2 || !fbs.columns.every(isFpTypedArray)) {
// We have strong assumptions about the shape & type of layout data.
throw new Error("Unexpected layout data type returned from server");
}
const df = new Dataframe.Dataframe(
[fbs.nRows, fbs.nCols],
fbs.columns,
@@ -122,6 +171,14 @@ export function createUniverseFromResponse(
}
reconcileSchemaCategoriesWithSummary(universe);
/* Index schema for ease of use */
universe.schema.annotations.obsByName = fromEntries(
universe.schema.annotations.obs.map(v => [v.name, v])
);
universe.schema.annotations.varByName = fromEntries(
universe.schema.annotations.var.map(v => [v.name, v])
);
return universe;
}
@@ -140,6 +197,11 @@ export function convertDataFBStoObject(universe, arrayBuffer) {
const { colIdx, columns } = fbs;
const result = {};
if (!columns.every(isFpTypedArray)) {
// We have strong assumptions that all var data is float
throw new Error("Unexpected non-floating point response from server.");
}
for (let c = 0; c < colIdx.length; c += 1) {
const varName = universe.varAnnotations.at(colIdx[c], "name");
result[varName] = columns[c];

View File

@@ -1,7 +1,14 @@
// jshint esversion: 6
import { layoutDimensionName, obsAnnoDimensionName } from "../nameCreators";
import clip from "../clip";
import {
layoutDimensionName,
obsAnnoDimensionName,
diffexpDimensionName,
userDefinedDimensionName
} from "../nameCreators";
import * as Dataframe from "../dataframe";
import ImmutableTypedCrossfilter from "../typedCrossfilter/crossfilter";
/*
@@ -19,6 +26,8 @@ Notable keys in the world object:
* schema: data schema from the server
* clipQuantiles: the quantiles used to clip all data in world.
* obsAnnotations:
Dataframe containing obs annotations. Columns are indexed by annotation
@@ -37,78 +46,192 @@ Notable keys in the world object:
* varData: a cache of expression columns, stored in a Dataframe. Cache
managed by controls reducer.
* unclipped: will contain unclipped variants of all potentiall clipped
dataframes (obsAnnotations, varData).
*/
function templateWorld() {
const obsAnnotations = Dataframe.Dataframe.empty();
const varAnnotations = Dataframe.Dataframe.empty();
const obsLayout = Dataframe.Dataframe.empty();
const varData = Dataframe.Dataframe.empty(null, new Dataframe.KeyIndex());
return {
/* schema/version related */
schema: null,
nObs: 0,
nVar: 0,
clipQuantiles: { min: 0, max: 1 },
/* annotations */
obsAnnotations: Dataframe.Dataframe.empty(),
varAnnotations: Dataframe.Dataframe.empty(),
obsAnnotations,
varAnnotations,
/* layout of graph. Dataframe. */
obsLayout: Dataframe.Dataframe.empty(),
obsLayout,
/*
Var data columns - subset of all data (may be empty)
*/
varData: Dataframe.Dataframe.empty(null, new Dataframe.KeyIndex())
/* Var data columns - subset of all data (may be empty) */
varData,
/* unclipped dataframes - subset, but not value clipped */
unclipped: {
obsAnnotations,
varData
}
};
}
function clipDataframe(
df,
lowerQuantile,
upperQuantile,
quantileF,
clipPredicate = () => true,
value = Number.NaN
) {
/*
For all columns in the dataframe, clip all values above or below specified
quantiles to `value` if clipPredicate returns True for that column (if it
returns false, skip the column entirely).
Returns a clipped copy - does not mutate original.
clipPredicate must have signature: (dataframe, colIndex, colLabel) => boolean
True signifies that the column should be clipped; false indicates that the
column should be left intact/unchanged.
quantileF must have signature: (label, qval) => number
*/
if (lowerQuantile < 0) lowerQuantile = 0;
if (upperQuantile > 1) upperQuantile = 1;
if (lowerQuantile === 0 && upperQuantile === 1) return df;
const keys = df.colIndex.keys();
return df.mapColumns((col, colIdx) => {
const colLabel = keys[colIdx];
if (!clipPredicate(df, colIdx, colLabel)) return col;
const colMin = quantileF(colLabel, lowerQuantile);
const colMax = quantileF(colLabel, upperQuantile);
const newCol = clip(col.slice(), colMin, colMax, value);
return newCol;
});
}
/*
Create World with contents eq entire universe. Commonly used to initialize World.
If clipQuantiles
*/
export function createWorldFromEntireUniverse(universe) {
const world = templateWorld();
/*
public interface follows
*/
/* Schema related */
world.schema = universe.schema;
world.nObs = universe.nObs;
world.nVar = universe.nVar;
world.clipQuantiles = { min: 0, max: 1 };
/* annotation dataframes */
world.obsAnnotations = universe.obsAnnotations;
world.varAnnotations = universe.varAnnotations;
/* dataframes: annotations and layout */
world.obsAnnotations = universe.obsAnnotations.clone();
world.varAnnotations = universe.varAnnotations.clone();
world.obsLayout = universe.obsLayout.clone();
/* layout and display characteristics dataframe */
world.obsLayout = universe.obsLayout;
/*
Var data columns - subset of all
*/
/* Var dataframe - contains a subset of all var columns */
world.varData = universe.varData.clone();
/* save unclipped copies of potentially clipped dataframes */
world.unclipped = {
obsAnnotations: world.obsAnnotations.clone(),
varData: world.varData.clone()
};
return world;
}
export function createWorldFromCurrentSelection(universe, world, crossfilter) {
const newWorld = templateWorld();
/*
clip dataframes based on quantiles.
/* these don't change as only OBS are selected in our current implementation */
newWorld.nVar = universe.nVar;
newWorld.schema = universe.schema;
newWorld.varAnnotations = universe.varAnnotations;
This is an in-place operation on the world object provided as an argument.
The values in world.unclipped are clipped and assigned to world.obsAnnotations
and world.varData.
*/
function setClippedDataframes(world) {
const { schema } = world;
const isContinuousObsAnnotation = (df, idx, label) =>
deduceDimensionType(schema.annotations.obsByName[label], label) !== "enum";
const obsQuantile = (label, q) =>
world.unclipped.obsAnnotations.col(label).summarize().percentiles[100 * q];
world.obsAnnotations = clipDataframe(
world.unclipped.obsAnnotations,
world.clipQuantiles.min,
world.clipQuantiles.max,
obsQuantile,
isContinuousObsAnnotation
);
/* now subset/cut obs */
const varDataQuantile = (label, q) =>
world.unclipped.varData.col(label).summarize().percentiles[100 * q];
world.varData = clipDataframe(
world.unclipped.varData,
world.clipQuantiles.min,
world.clipQuantiles.max,
varDataQuantile,
() => true
);
}
/*
Subset the current world based upon the current selection, maintaining any existing
clip. Returns new world. Parameters:
* unvierse
* world - the current world
* crossfilter - the selection state
*/
export function createWorldBySelection(universe, world, crossfilter) {
const newWorld = { ...world, obsLayout: null, unclipped: {}, varData: null };
/* subset unclipped dataframes based upon current selection */
const mask = crossfilter.allSelectedMask();
newWorld.obsAnnotations = world.obsAnnotations.isubsetMask(mask);
newWorld.obsLayout = world.obsLayout.isubsetMask(mask);
newWorld.nObs = newWorld.obsAnnotations.dims[0];
/*
Var data columns - subset of all
*/
if (world.varData.isEmpty()) {
newWorld.varData = world.varData.clone();
newWorld.unclipped.obsAnnotations = world.unclipped.obsAnnotations.isubsetMask(
mask
);
if (world.unclipped.varData.isEmpty()) {
newWorld.unclipped.varData = world.unclipped.varData.clone();
} else {
newWorld.varData = world.varData.isubsetMask(mask);
newWorld.unclipped.varData = world.unclipped.varData.isubsetMask(mask);
}
/* subsetting changings dimension size */
newWorld.nObs = newWorld.unclipped.obsAnnotations.dims[0];
/* and now clip */
setClippedDataframes(newWorld);
return newWorld;
}
/*
Change clip quantiles on the current world, returning a new world.
Parameters:
* universe
* world - current world
* clipQuantiles - new clip
*/
export function createWorldWithNewClip(
universe,
world,
crossfilter,
clipQuantiles
) {
const newWorld = { ...world, obsAnnotation: null, varData: null };
newWorld.clipQuantiles = clipQuantiles;
newWorld.obsLayout = world.obsLayout.clone();
newWorld.unclipped = {
obsAnnotations: world.unclipped.obsAnnotations.clone(),
varData: world.unclipped.varData.clone()
};
/* and now clip */
setClippedDataframes(newWorld);
return newWorld;
}
@@ -166,7 +289,10 @@ export function createObsDimensions(crossfilter, world) {
}
export function worldEqUniverse(world, universe) {
return world.obsAnnotations === universe.obsAnnotations;
return (
world.obsAnnotations === universe.obsAnnotations ||
world.obsAnnotations.rowIndex === universe.obsAnnotations.rowIndex
);
}
export function getSelectedByIndex(crossfilter) {

View File

@@ -0,0 +1,29 @@
/*
Various type and schema related helper functions.
*/
/*
Utility function to test for a typed array
*/
export function isTypedArray(x) {
return (
ArrayBuffer.isView(x) &&
Object.prototype.toString.call(x) !== "[object DataView]"
);
}
/*
Test for float typed array, ie, Float32TypedArray or Float64TypedArray
*/
export function isFpTypedArray(x) {
let constructor;
const isFloatArray =
x &&
({ constructor } = x) &&
(constructor === Float32Array || constructor === Float64Array);
return isFloatArray;
}
export function isArrayOrTypedArray(x) {
return Array.isArray(x) || isTypedArray(x);
}

View File

@@ -2,13 +2,13 @@ import { polygonContains } from "d3";
import PositiveIntervals from "./positiveIntervals";
import BitArray from "./bitArray";
import { sort } from "./sort";
import {
makeSortIndex,
sortArray,
lowerBound,
lowerBoundIndirect,
upperBoundIndirect
} from "./util";
} from "./sort";
import { makeSortIndex } from "./util";
class NotImplementedError extends Error {
constructor(...params) {
@@ -61,6 +61,10 @@ export default class ImmutableTypedCrossfilter {
return Object.keys(this.dimensions);
}
hasDimension(name) {
return !!this.dimensions[name];
}
addDimension(name, type, ...rest) {
/*
Add a new dimension to this crossfilter, of type DimensionType.
@@ -284,15 +288,15 @@ class _ImmutableBaseDimension {
this.name = name;
}
/* eslint-disable class-methods-use-this */
select(spec) {
const { mode } = spec;
if (mode === undefined) {
throw new Error("select spec does not contain 'mode'");
}
throw new Error(`select mode ${mode} not implemented`);
throw new Error(
`select mode ${mode} not implemented by dimension ${this.name}`
);
}
/* eslint-enable class-methods-use-this */
}
class ImmutableScalarDimension extends _ImmutableBaseDimension {
@@ -414,7 +418,7 @@ class ImmutableEnumDimension extends ImmutableScalarDimension {
for (let i = 0; i < len; i += 1) {
s.add(mapf(i, data));
}
const enumIndex = sort(Array.from(s));
const enumIndex = sortArray(Array.from(s));
this.enumIndex = enumIndex;
// create dimension value array

View File

@@ -1,5 +1,29 @@
const SmallArray = 32;
import { isTypedArray, isFpTypedArray } from "../typeHelpers";
/* eslint no-bitwise: "off" */
/*
** fast sort and search, with separate code paths for floats (NaN ordering),
** indirect and direct search/sort.
*/
/*
Comparators for float sort. -Infinity < finite < Infinity < NaN
*/
function lt(a, b) {
if (Number.isNaN(b)) return !Number.isNaN(a);
return a < b;
}
function gt(a, b) {
if (Number.isNaN(a)) return !Number.isNaN(b);
return a > b;
}
/*
insertion sort, used for small arrays (controlled by SMALL_ARRAY constant)
*/
const SMALL_ARRAY = 32;
function insertionsort(a, lo, hi) {
for (let i = lo + 1; i < hi + 1; i += 1) {
const x = a[i];
@@ -12,6 +36,18 @@ function insertionsort(a, lo, hi) {
return a;
}
function insertionsortFloats(a, lo, hi) {
for (let i = lo + 1; i < hi + 1; i += 1) {
const x = a[i];
let j;
for (j = i; j > lo && gt(a[j - 1], x); j -= 1) {
a[j] = a[j - 1];
}
a[j] = x;
}
return a;
}
function insertionsortIndirect(a, s, lo, hi) {
for (let i = lo + 1; i < hi + 1; i += 1) {
const x = a[i];
@@ -25,8 +61,24 @@ function insertionsortIndirect(a, s, lo, hi) {
return a;
}
function insertionsortFloatsIndirect(a, s, lo, hi) {
for (let i = lo + 1; i < hi + 1; i += 1) {
const x = a[i];
const t = s[x];
let j;
for (j = i; j > lo && gt(s[a[j - 1]], t); j -= 1) {
a[j] = a[j - 1];
}
a[j] = x;
}
return a;
}
/*
Quicksort - used for larger arrays
*/
function quicksort(a, lo, hi) {
if (hi - lo < SmallArray) {
if (hi - lo < SMALL_ARRAY) {
return insertionsort(a, lo, hi);
}
if (lo < hi) {
@@ -55,8 +107,38 @@ function quicksort(a, lo, hi) {
return a;
}
function quicksortFloats(a, lo, hi) {
if (hi - lo < SMALL_ARRAY) {
return insertionsortFloats(a, lo, hi);
}
if (lo < hi) {
// partition
const mid = Math.floor((lo + hi) / 2);
const p = a[mid];
let i = lo - 1;
let j = hi + 1;
while (i < j) {
do {
i += 1;
} while (lt(a[i], p));
do {
j -= 1;
} while (gt(a[j], p));
if (i < j) {
const tmp = a[i];
a[i] = a[j];
a[j] = tmp;
}
}
// sort
quicksortFloats(a, lo, j);
quicksortFloats(a, j + 1, hi);
}
return a;
}
function quicksortIndirect(a, s, lo, hi) {
if (hi - lo < SmallArray) {
if (hi - lo < SMALL_ARRAY) {
return insertionsortIndirect(a, s, lo, hi);
}
if (lo < hi) {
@@ -86,15 +168,248 @@ function quicksortIndirect(a, s, lo, hi) {
return a;
}
// Convenience wrappers
export function sort(arr, comparator = undefined) {
if (comparator !== undefined) {
// XXX for now
return arr.sort(arr, comparator);
function quicksortFloatsIndirect(a, s, lo, hi) {
if (hi - lo < SMALL_ARRAY) {
return insertionsortFloatsIndirect(a, s, lo, hi);
}
return quicksort(arr, 0, arr.length - 1);
if (lo < hi) {
// partition
const mid = Math.floor((lo + hi) / 2);
const p = a[mid];
const t = s[p];
let i = lo - 1;
let j = hi + 1;
while (i < j) {
do {
i += 1;
} while (lt(s[a[i]], t));
do {
j -= 1;
} while (gt(s[a[j]], t));
if (i < j) {
const tmp = a[i];
a[i] = a[j];
a[j] = tmp;
}
}
// sort
quicksortFloatsIndirect(a, s, lo, j);
quicksortFloatsIndirect(a, s, j + 1, hi);
}
return a;
}
/*
Convenience wrappers, handling optimization paths and default
handlers for NaN comparisons. Sorts in place.
*/
export function sortArray(arr) {
if (Array.isArray(arr)) {
return quicksort(arr, 0, arr.length - 1);
}
if (isTypedArray(arr)) {
if (isFpTypedArray(arr)) {
return quicksortFloats(arr, 0, arr.length - 1);
}
return quicksort(arr, 0, arr.length - 1);
}
/* else unsupported */
throw new Error("sortArray received unsupported object type");
}
export function sortIndex(index, source) {
if (isFpTypedArray(source))
return quicksortFloatsIndirect(index, source, 0, index.length - 1);
return quicksortIndirect(index, source, 0, index.length - 1);
}
// Search for `value` in the sorted array `arr`, in the range [first, last).
// Return the first (left most) index where arr[index] >= value.
//
// In other words, return array index I where:
// arr[i] < value for all tarr[lo:I]
// arr[i] >= value for all tarr[I:last]
//
// The same semantics/behavior as:
// C++: lower_bound()
// Python: bisect.bisect_left()
//
function lowerBoundNonFloat(valueArray, value, first, last) {
let lfirst = first;
let llast = last;
// this is just a binary search
while (lfirst < llast) {
const middle = (lfirst + llast) >>> 1;
if (valueArray[middle] < value) {
lfirst = middle + 1;
} else {
llast = middle;
}
}
return lfirst;
}
// lowerBound, but with NaN handling
//
// If the underlying array is a Float32Array or Float64Array, will enforce
// the ordering -Infinity < finite < Infinity < NaN.
//
function lowerBoundFloat(valueArray, value, first, last) {
let lfirst = first;
let llast = last;
// this is just a binary search
while (lfirst < llast) {
const middle = (lfirst + llast) >>> 1;
if (lt(valueArray[middle], value)) {
lfirst = middle + 1;
} else {
llast = middle;
}
}
return lfirst;
}
export function lowerBound(valueArray, value, first, last) {
if (isFpTypedArray(valueArray)) {
return lowerBoundFloat(valueArray, value, first, last);
}
return lowerBoundNonFloat(valueArray, value, first, last);
}
// Inlined performance optimization - used to indirect through a sort map.
//
function lowerBoundNonFloatIndirect(
valueArray,
indexArray,
value,
first,
last
) {
let lfirst = first;
let llast = last;
// this is just a binary search
while (lfirst < llast) {
const middle = (lfirst + llast) >>> 1;
if (valueArray[indexArray[middle]] < value) {
lfirst = middle + 1;
} else {
llast = middle;
}
}
return lfirst;
}
function lowerBoundFloatIndirect(valueArray, indexArray, value, first, last) {
let lfirst = first;
let llast = last;
// this is just a binary search
while (lfirst < llast) {
const middle = (lfirst + llast) >>> 1;
if (lt(valueArray[indexArray[middle]], value)) {
lfirst = middle + 1;
} else {
llast = middle;
}
}
return lfirst;
}
export function lowerBoundIndirect(valueArray, indexArray, value, first, last) {
if (isFpTypedArray(valueArray)) {
return lowerBoundFloatIndirect(valueArray, indexArray, value, first, last);
}
return lowerBoundNonFloatIndirect(valueArray, indexArray, value, first, last);
}
// Search for `value in the sorted array `arr`, in the range [first, last).
// Return the first value where arr[index] > value.
//
// In other words, return array index I, where:
// arr[i] <= value for all tarr[lo:I]
// arr[i] > value for all tarr[I:last]
//
// The same semantics/behavior as:
// C++: upper_bound()
// Python: bisect.bisect_right()
//
function upperBoundNonFloat(valueArray, value, first, last) {
let lfirst = first;
let llast = last;
// this is just a binary search
while (lfirst < llast) {
const middle = (lfirst + llast) >>> 1;
if (valueArray[middle] > value) {
llast = middle;
} else {
lfirst = middle + 1;
}
}
return lfirst;
}
function upperBoundFloat(valueArray, value, first, last) {
let lfirst = first;
let llast = last;
// this is just a binary search
while (lfirst < llast) {
const middle = (lfirst + llast) >>> 1;
if (gt(valueArray[middle], value)) {
llast = middle;
} else {
lfirst = middle + 1;
}
}
return lfirst;
}
export function upperBound(valueArray, value, first, last) {
if (isFpTypedArray(valueArray)) {
return upperBoundFloat(valueArray, value, first, last);
}
return upperBoundNonFloat(valueArray, value, first, last);
}
// Inline performance optimization
//
function upperBoundNonFloatIndirect(
valueArray,
indexArray,
value,
first,
last
) {
let lfirst = first;
let llast = last;
// this is just a binary search
while (lfirst < llast) {
const middle = (lfirst + llast) >>> 1;
if (valueArray[indexArray[middle]] > value) {
llast = middle;
} else {
lfirst = middle + 1;
}
}
return lfirst;
}
function upperBoundFloatIndirect(valueArray, indexArray, value, first, last) {
let lfirst = first;
let llast = last;
// this is just a binary search
while (lfirst < llast) {
const middle = (lfirst + llast) >>> 1;
if (gt(valueArray[indexArray[middle]], value)) {
llast = middle;
} else {
lfirst = middle + 1;
}
}
return lfirst;
}
export function upperBoundIndirect(valueArray, indexArray, value, first, last) {
if (isFpTypedArray(valueArray)) {
return upperBoundFloatIndirect(valueArray, indexArray, value, first, last);
}
return upperBoundNonFloatIndirect(valueArray, indexArray, value, first, last);
}

View File

@@ -1,5 +1,4 @@
// jshint esversion: 6
/* eslint no-bitwise: "off" */
import { sortIndex } from "./sort";
@@ -36,93 +35,3 @@ export function makeSortIndex(src) {
sortIndex(index, src);
return index;
}
// Search for `value` in the sorted array `arr`, in the range [first, last).
// Return the first (left most) index where arr[index] >= value.
//
// In other words, return array index I where:
// arr[i] < value for all tarr[lo:I]
// arr[i] >= value for all tarr[I:last]
//
// The same semantics/behavior as:
// C++: lower_bound()
// Python: bisect.bisect_left()
//
// XXX: it is likely that there would be minimal performance hit from creating
// a factory version of lowerBound that takes an accessor (rather than having
// a special-cased version for lining the indirection).
//
export function lowerBound(valueArray, value, first, last) {
let lfirst = first;
let llast = last;
// this is just a binary search
while (lfirst < llast) {
const middle = (lfirst + llast) >>> 1;
if (valueArray[middle] < value) {
lfirst = middle + 1;
} else {
llast = middle;
}
}
return lfirst;
}
// Inlined performance optimization - used to indirect through a sort map.
//
export function lowerBoundIndirect(valueArray, indexArray, value, first, last) {
let lfirst = first;
let llast = last;
// this is just a binary search
while (lfirst < llast) {
const middle = (lfirst + llast) >>> 1;
if (valueArray[indexArray[middle]] < value) {
lfirst = middle + 1;
} else {
llast = middle;
}
}
return lfirst;
}
// Search for `value in the sorted array `arr`, in the range [first, last).
// Return the first value where arr[index] > value.
//
// In other words, return array index I, where:
// arr[i] <= value for all tarr[lo:I]
// arr[i] > value for all tarr[I:last]
//
// The same semantics/behavior as:
// C++: upper_bound()
// Python: bisect.bisect_right()
//
export function upperBound(valueArray, value, first, last) {
let lfirst = first;
let llast = last;
// this is just a binary search
while (lfirst < llast) {
const middle = (lfirst + llast) >>> 1;
if (valueArray[middle] > value) {
llast = middle;
} else {
lfirst = middle + 1;
}
}
return lfirst;
}
// Inline performance optimization
//
export function upperBoundIndirect(valueArray, indexArray, value, first, last) {
let lfirst = first;
let llast = last;
// this is just a binary search
while (lfirst < llast) {
const middle = (lfirst + llast) >>> 1;
if (valueArray[indexArray[middle]] > value) {
llast = middle;
} else {
lfirst = middle + 1;
}
}
return lfirst;
}