Merge pull request #71 from chanzuckerberg/bkmartinjr-staterefactor

introduce new selection state machinery
This commit is contained in:
Colin Megill
2018-05-29 15:04:21 -04:00
committed by GitHub
17 changed files with 1304 additions and 385 deletions
+8 -1
View File
@@ -65,6 +65,12 @@ const regraph = () => {
}; };
}; };
const resetGraph = () => {
return (dispatch, getState) => {
dispatch({ type: "reset graph" });
};
};
const initialize = () => { const initialize = () => {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch({ type: "initialize started" }); dispatch({ type: "initialize started" });
@@ -89,7 +95,7 @@ const initialize = () => {
// //
function cleanupExpressionResponse(data) { function cleanupExpressionResponse(data) {
const s = store.getState(); const s = store.getState();
const metadata = s.controls.currentCellSelectionMap; const metadata = s.controls.allCellsMetadataMap;
let errorFound = false; let errorFound = false;
data.data.cells = _.filter(data.data.cells, cell => { data.data.cells = _.filter(data.data.cells, cell => {
if (!errorFound && !metadata[cell.cellname]) { if (!errorFound && !metadata[cell.cellname]) {
@@ -218,6 +224,7 @@ export default {
initialize, initialize,
requestCells, requestCells,
regraph, regraph,
resetGraph,
requestGeneExpressionCounts, requestGeneExpressionCounts,
requestGeneExpressionCountsPOST, requestGeneExpressionCountsPOST,
requestSingleGeneExpressionCountsForColoringPOST, requestSingleGeneExpressionCountsForColoringPOST,
-1
View File
@@ -38,7 +38,6 @@ import { margin, width, height, createDimensions } from "./util";
colorAccessor: state.controls.colorAccessor, colorAccessor: state.controls.colorAccessor,
colorScale: state.controls.colorScale, colorScale: state.controls.colorScale,
graphBrushSelection: state.controls.graphBrushSelection, graphBrushSelection: state.controls.graphBrushSelection,
currentCellSelection: state.controls.currentCellSelection,
axesHaveBeenDrawn: state.controls.axesHaveBeenDrawn axesHaveBeenDrawn: state.controls.axesHaveBeenDrawn
}; };
}) })
+2 -2
View File
@@ -26,7 +26,7 @@ import { connect } from "react-redux";
return { return {
colorAccessor: state.controls.colorAccessor, colorAccessor: state.controls.colorAccessor,
colorScale: state.controls.colorScale, colorScale: state.controls.colorScale,
currentCellSelection: state.controls.currentCellSelection cellsMetadata: state.controls.cellsMetadata
}; };
}) })
class HistogramBrush extends React.Component { class HistogramBrush extends React.Component {
@@ -52,7 +52,7 @@ class HistogramBrush extends React.Component {
calcHistogramCache(nextProps) { calcHistogramCache(nextProps) {
// recalculate expensive stuff // recalculate expensive stuff
const allValuesForContinuousFieldAsArray = _.map( const allValuesForContinuousFieldAsArray = _.map(
nextProps.currentCellSelection, nextProps.cellsMetadata,
nextProps.metadataField nextProps.metadataField
); );
+3 -3
View File
@@ -36,7 +36,7 @@ import { margin, width, height, createDimensions } from "./util";
colorAccessor: state.controls.colorAccessor, colorAccessor: state.controls.colorAccessor,
colorScale: state.controls.colorScale, colorScale: state.controls.colorScale,
graphBrushSelection: state.controls.graphBrushSelection, graphBrushSelection: state.controls.graphBrushSelection,
currentCellSelection: state.controls.currentCellSelection, cellsMetadata: state.controls.cellsMetadata,
axesHaveBeenDrawn: state.controls.axesHaveBeenDrawn axesHaveBeenDrawn: state.controls.axesHaveBeenDrawn
}; };
}) })
@@ -96,7 +96,7 @@ class Parallel extends React.Component {
/* https://stackoverflow.com/questions/23123138/perform-debounce-in-react-js */ /* https://stackoverflow.com/questions/23123138/perform-debounce-in-react-js */
if ( if (
nextProps.ranges && nextProps.ranges &&
nextProps.currentCellSelection && nextProps.cellsMetadata &&
nextProps.axesHaveBeenDrawn nextProps.axesHaveBeenDrawn
) { ) {
if (this.state._drawLinesCanvas) { if (this.state._drawLinesCanvas) {
@@ -106,7 +106,7 @@ class Parallel extends React.Component {
this.state.ctx.clearRect(0, 0, width, height); this.state.ctx.clearRect(0, 0, width, height);
const _drawLinesCanvas = drawLinesCanvas( const _drawLinesCanvas = drawLinesCanvas(
nextProps.currentCellSelection, nextProps.cellsMetadata,
this.state.dimensions, this.state.dimensions,
this.state.xscale, this.state.xscale,
this.state.ctx, this.state.ctx,
+1 -7
View File
@@ -9,13 +9,7 @@ import actions from "../../actions";
@connect() @connect()
class CellSetButton extends React.Component { class CellSetButton extends React.Component {
set() { set() {
const set = []; const set = _.map(this.props.crossfilter.cells.allFiltered(), "CellName");
_.each(this.props.currentCellSelection, cell => {
if (cell["__selected__"]) {
set.push(cell.CellName);
}
});
this.props.dispatch({ this.props.dispatch({
type: type:
@@ -8,8 +8,8 @@ import CellSetButton from "./cellSetButtons";
@connect(state => { @connect(state => {
return { return {
currentCellSelection: state.controls.currentCellSelection, differential: state.differential,
differential: state.differential crossfilter: state.controls.crossfilter
}; };
}) })
class Expression extends React.Component { class Expression extends React.Component {
@@ -43,7 +43,9 @@ class Expression extends React.Component {
<div style={{ marginBottom: 15, width: 300 }}> <div style={{ marginBottom: 15, width: 300 }}>
There are currently There are currently
{" " + {" " +
_.filter(this.props.currentCellSelection, "__selected__").length + (this.props.crossfilter
? this.props.crossfilter.cells.countFiltered()
: 0) +
" "} " "}
cells selected, click a cell set button to store them. cells selected, click a cell set button to store them.
</div> </div>
+88 -54
View File
@@ -22,31 +22,11 @@ import FaSave from "react-icons/lib/fa/download";
/* https://bl.ocks.org/mbostock/9078690 - quadtree for onClick / hover selections */ /* https://bl.ocks.org/mbostock/9078690 - quadtree for onClick / hover selections */
@connect(state => { @connect(state => {
const vertices =
state.cells.cells && state.cells.cells.data.graph
? state.cells.cells.data.graph
: null;
const ranges =
state.cells.cells && state.cells.cells.data.ranges
? state.cells.cells.data.ranges
: null;
const metadata =
state.cells.cells && state.cells.cells.data.metadata
? state.cells.cells.data.metadata
: null;
return { return {
ranges, cellsMetadata: state.controls.cellsMetadata,
vertices,
metadata,
colorAccessor: state.controls.colorAccessor,
colorScale: state.controls.colorScale,
continuousSelection: state.controls.continuousSelection,
graphVec: state.controls.graphVec,
currentCellSelection: state.controls.currentCellSelection,
graphBrushSelection: state.controls.graphBrushSelection,
opacityForDeselectedCells: state.controls.opacityForDeselectedCells, opacityForDeselectedCells: state.controls.opacityForDeselectedCells,
responsive: state.responsive responsive: state.responsive,
crossfilter: state.controls.crossfilter
}; };
}) })
class Graph extends React.Component { class Graph extends React.Component {
@@ -55,6 +35,10 @@ class Graph extends React.Component {
this.count = 0; this.count = 0;
this.inverse = mat4.identity([]); this.inverse = mat4.identity([]);
this.graphPaddingTop = 100; this.graphPaddingTop = 100;
this.renderCache = {
positions: null,
colors: null
};
this.state = { this.state = {
drawn: false, drawn: false,
svg: null, svg: null,
@@ -104,42 +88,74 @@ class Graph extends React.Component {
} }
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
if (this.state.regl && nextProps.vertices) { if (this.state.regl && nextProps.crossfilter) {
/* update regl */ /* update the regl state */
const vertices = nextProps.currentCellSelection; const crossfilter = nextProps.crossfilter.cells;
const vertexCount = vertices.length; const cells = crossfilter.all();
const positions = new Float32Array(2 * vertexCount); const cellCount = cells.length;
const colors = new Float32Array(3 * vertexCount);
const sizes = new Float32Array(vertexCount);
// d3.scaleLinear().domain([0,1]).range([-1,1]) // X/Y positions for each point - a cached value that only
const glScaleX = scaleLinear([0, 1], [-1, 1]); // changes if we have loaded entirely new cell data
// d3.scaleLinear().domain([0,1]).range([1,-1]) //
const glScaleY = scaleLinear([0, 1], [1, -1]); if (
!this.renderCache.positions ||
this.props.crossfilter.cells != nextProps.crossfilter.cells
) {
if (!this.renderCache.positions)
this.renderCache.positions = new Float32Array(2 * cellCount);
/* // d3.scaleLinear().domain([0,1]).range([-1,1])
Construct Vectors const glScaleX = scaleLinear([0, 1], [-1, 1]);
*/ // d3.scaleLinear().domain([0,1]).range([1,-1])
const graphVec = nextProps.graphVec; const glScaleY = scaleLinear([0, 1], [1, -1]);
for (var i = 0; i < vertexCount; i++) {
const cell = vertices[i];
const cellIdx = cell.__cellIndex__;
const x = glScaleX(graphVec[2 * cellIdx]);
const y = glScaleY(graphVec[2 * cellIdx + 1]);
positions[2 * i] = x;
positions[2 * i + 1] = y;
colors.set(cell.__colorRGB__, 3 * i); for (
let i = 0, positions = this.renderCache.positions;
sizes[i] = cell.__selected__ i < cellCount;
? 4 i++
: 0.2; /* make this a function of the number of total cells, including regraph */ ) {
positions[2 * i] = glScaleX(cells[i].__x__);
positions[2 * i + 1] = glScaleY(cells[i].__y__);
}
this.state.pointBuffer({
data: this.renderCache.positions,
dimension: 2
});
} }
this.state.pointBuffer({ data: positions, dimension: 2 }); // Colors for each point - a cached value that only changes when
this.state.colorBuffer({ data: colors, dimension: 3 }); // the cell metadata changes (done by updateCellColors middleware).
this.state.sizeBuffer({ data: sizes, dimension: 1 }); // NOTE: this is a slightly pessimistic assumption, as the metadata
this.count = vertexCount; // could have changed for some other reason, but for now color is
// the only metadata that changes client-side. If this is problematic,
// we could add some sort of color-specific indicator to the app state.
if (
!this.renderCache.colors ||
this.props.cellsMetadata != nextProps.cellsMetadata
) {
if (!this.renderCache.colors)
this.renderCache.colors = new Float32Array(3 * cellCount);
for (let i = 0, colors = this.renderCache.colors; i < cellCount; i++) {
colors.set(cells[i].__colorRGB__, 3 * i);
}
this.state.colorBuffer({ data: this.renderCache.colors, dimension: 3 });
}
// Sizes for each point - this is presumed to change each time the
// component receives new props. Almost always a true assumption, as
// most property upates are due to changes driving a crossfilter
// selection set change.
//
if (
!this.renderCache.sizes ||
this.props.crossfilter.cells != nextProps.crossfilter.cells
) {
this.renderCache.sizes = new Float32Array(cellCount);
}
crossfilter.fillByIsFiltered(this.renderCache.sizes, 4, 0.2);
this.state.sizeBuffer({ data: this.renderCache.sizes, dimension: 1 });
this.count = cellCount;
} }
if ( if (
@@ -228,6 +244,24 @@ class Graph extends React.Component {
alignItems: "baseline" alignItems: "baseline"
}} }}
> >
<button
onClick={() => {
this.props.dispatch(actions.resetGraph());
}}
style={{
fontSize: 14,
fontWeight: 700,
color: "white",
padding: "10px 20px",
marginRight: 10,
borderRadius: 2,
backgroundColor: globals.brightBlue,
border: "none",
cursor: "pointer"
}}
>
reset graph
</button>
<button <button
onClick={() => { onClick={() => {
this.props.dispatch(actions.regraph()); this.props.dispatch(actions.regraph());
+10 -13
View File
@@ -39,11 +39,10 @@ import { margin, width, height, createDimensions } from "./util";
initializeRanges, initializeRanges,
colorAccessor: state.controls.colorAccessor, colorAccessor: state.controls.colorAccessor,
colorScale: state.controls.colorScale, colorScale: state.controls.colorScale,
currentCellSelection: state.controls.currentCellSelection,
currentCellSelectionMap: state.controls.currentCellSelectionMap,
scatterplotXXaccessor: state.controls.scatterplotXXaccessor, scatterplotXXaccessor: state.controls.scatterplotXXaccessor,
scatterplotYYaccessor: state.controls.scatterplotYYaccessor, scatterplotYYaccessor: state.controls.scatterplotYYaccessor,
opacityForDeselectedCells: state.controls.opacityForDeselectedCells, opacityForDeselectedCells: state.controls.opacityForDeselectedCells,
crossfilter: state.controls.crossfilter,
differential: state.differential, differential: state.differential,
expression: state.expression expression: state.expression
}; };
@@ -124,7 +123,6 @@ class Scatterplot extends React.Component {
this.state.pointBuffer && this.state.pointBuffer &&
this.state.colorBuffer && this.state.colorBuffer &&
this.state.sizeBuffer && this.state.sizeBuffer &&
this.props.currentCellSelection &&
this.props.expression.data && this.props.expression.data &&
this.props.expression.data.genes && this.props.expression.data.genes &&
this.props.scatterplotXXaccessor && this.props.scatterplotXXaccessor &&
@@ -132,8 +130,7 @@ class Scatterplot extends React.Component {
this.state.xScale && this.state.xScale &&
this.state.yScale this.state.yScale
) { ) {
const currentCellSelectionMap = this.props.currentCellSelectionMap; const crossfilter = this.props.crossfilter.cells;
const data = this.props.expression.data; const data = this.props.expression.data;
const cells = data.cells; const cells = data.cells;
const genes = data.genes; const genes = data.genes;
@@ -158,9 +155,8 @@ class Scatterplot extends React.Component {
/* /*
Construct Vectors Construct Vectors
*/ */
for (var i = 0; i < cellCount; i++) { for (let i = 0; i < cellCount; i++) {
const cell = cells[i]; const cell = cells[i];
const cellMetadata = currentCellSelectionMap[cell.cellname];
positions[2 * i] = glScaleX( positions[2 * i] = glScaleX(
this.state.xScale(cell.e[geneXXaccessorIndex]) this.state.xScale(cell.e[geneXXaccessorIndex])
@@ -168,14 +164,15 @@ class Scatterplot extends React.Component {
positions[2 * i + 1] = glScaleY( positions[2 * i + 1] = glScaleY(
this.state.yScale(cell.e[geneYYaccessorIndex]) this.state.yScale(cell.e[geneYYaccessorIndex])
); );
colors.set(cellMetadata.__colorRGB__, 3 * i);
sizes[i] = cellMetadata.__selected__
? 4
: 0.2; /* make this a function of the number of total cells, including regraph */
} }
for (let i = 0; i < cellCount; i++) {
const metadata = this.props.metadata[i];
colors.set(metadata.__colorRGB__, 3 * i);
}
crossfilter.fillByIsFiltered(sizes, 4, 0.2);
this.state.pointBuffer({ data: positions, dimension: 2 }); this.state.pointBuffer({ data: positions, dimension: 2 });
this.state.colorBuffer({ data: colors, dimension: 3 }); this.state.colorBuffer({ data: colors, dimension: 3 });
this.state.sizeBuffer({ data: sizes, dimension: 1 }); this.state.sizeBuffer({ data: sizes, dimension: 1 });
+14 -16
View File
@@ -20,7 +20,7 @@ import { parseRGB } from "../util/parseRGB";
This is nice because we keep a lot of filtering business logic centralized (what it means in practice to be selected) This is nice because we keep a lot of filtering business logic centralized (what it means in practice to be selected)
*/ */
const updateCellSelectionMiddleware = store => { const updateCellColorsMiddleware = store => {
return next => { return next => {
return action => { return action => {
const s = store.getState(); const s = store.getState();
@@ -31,22 +31,20 @@ const updateCellSelectionMiddleware = store => {
action.type === "color by continuous metadata" || action.type === "color by continuous metadata" ||
action.type === "color by categorical metadata"; action.type === "color by categorical metadata";
if (!filterJustChanged || !s.controls.allCellsOnClient) { if (!filterJustChanged || !s.controls.cellsMetadata) {
return next( return next(
action action
); /* if the cells haven't loaded or the action wasn't a color change, bail */ ); /* if the cells haven't loaded or the action wasn't a color change, bail */
} }
let currentSelectionWithUpdatedColors = s.controls.currentCellSelection.slice( let cellsMetadataWithUpdatedColors = s.controls.cellsMetadata.slice(0);
0
);
let colorScale; let colorScale;
/* /*
in plain language... in plain language...
(a) once the cells have loaded. (a) once the cells have loaded.
(b) each time a user changes a color control we need to update currentCellSelection colors (b) each time a user changes a color control we need to update cellsMetadata colors
This is available to all the draw functions as cell["__color__"] and cell["__colorRGB__"] This is available to all the draw functions as cell["__color__"] and cell["__colorRGB__"]
*/ */
@@ -54,8 +52,8 @@ const updateCellSelectionMiddleware = store => {
if (action.type === "color by categorical metadata") { if (action.type === "color by categorical metadata") {
colorScale = d3.scaleOrdinal().range(globals.ordinalColors); colorScale = d3.scaleOrdinal().range(globals.ordinalColors);
for (let i = 0; i < currentSelectionWithUpdatedColors.length; i++) { for (let i = 0; i < cellsMetadataWithUpdatedColors.length; i++) {
const cell = currentSelectionWithUpdatedColors[i]; const cell = cellsMetadataWithUpdatedColors[i];
let c = colorScale(cell[action.colorAccessor]); let c = colorScale(cell[action.colorAccessor]);
cell.__color__ = c; cell.__color__ = c;
cell.__colorRGB__ = parseRGB(c); cell.__colorRGB__ = parseRGB(c);
@@ -68,10 +66,10 @@ const updateCellSelectionMiddleware = store => {
.domain([0, action.rangeMaxForColorAccessor]) .domain([0, action.rangeMaxForColorAccessor])
.range([1, 0]); .range([1, 0]);
_.each(currentSelectionWithUpdatedColors, (cell, i) => { _.each(cellsMetadataWithUpdatedColors, (cell, i) => {
let c = d3.interpolateViridis(colorScale(cell[action.colorAccessor])); let c = d3.interpolateViridis(colorScale(cell[action.colorAccessor]));
currentSelectionWithUpdatedColors[i]["__color__"] = c; cellsMetadataWithUpdatedColors[i]["__color__"] = c;
currentSelectionWithUpdatedColors[i]["__colorRGB__"] = parseRGB(c); cellsMetadataWithUpdatedColors[i]["__colorRGB__"] = parseRGB(c);
}); });
} }
@@ -113,12 +111,12 @@ const updateCellSelectionMiddleware = store => {
0 0
]); /* invert viridis... probably pass this scale through to others */ ]); /* invert viridis... probably pass this scale through to others */
_.each(currentSelectionWithUpdatedColors, (cell, i) => { _.each(cellsMetadataWithUpdatedColors, (cell, i) => {
let c = d3.interpolateViridis( let c = d3.interpolateViridis(
colorScale(expressionMap[cell.CellName][indexOfGene]) colorScale(expressionMap[cell.CellName][indexOfGene])
); );
currentSelectionWithUpdatedColors[i]["__color__"] = c; cellsMetadataWithUpdatedColors[i]["__color__"] = c;
currentSelectionWithUpdatedColors[i]["__colorRGB__"] = parseRGB(c); cellsMetadataWithUpdatedColors[i]["__colorRGB__"] = parseRGB(c);
}); });
} }
@@ -126,7 +124,7 @@ const updateCellSelectionMiddleware = store => {
append the result of all the filters to the action the user just triggered append the result of all the filters to the action the user just triggered
*/ */
let modifiedAction = Object.assign({}, action, { let modifiedAction = Object.assign({}, action, {
currentSelectionWithUpdatedColors, cellsMetadataWithUpdatedColors,
colorScale colorScale
}); });
@@ -135,4 +133,4 @@ const updateCellSelectionMiddleware = store => {
}; };
}; };
export default updateCellSelectionMiddleware; export default updateCellColorsMiddleware;
+11 -167
View File
@@ -2,6 +2,13 @@
import uri from "urijs"; import uri from "urijs";
import * as globals from "../globals"; import * as globals from "../globals";
/*
XXX: this file should be obsolete. We just need to complete the refactoring
of parallel.js and it can be removed entirely.
It is currently not in use - the middleware constructor does not include include it
*/
/* /*
https://medium.com/@jacobp100/you-arent-using-redux-middleware-enough-94ffe991e6 https://medium.com/@jacobp100/you-arent-using-redux-middleware-enough-94ffe991e6
storeInstance => functionToCallWithAnActionThatWillSendItToTheNextMiddleware => actionThatDispatchWasCalledWith => valueToUseAsTheReturnValueOfTheDispatchCall storeInstance => functionToCallWithAnActionThatWillSendItToTheNextMiddleware => actionThatDispatchWasCalledWith => valueToUseAsTheReturnValueOfTheDispatchCall
@@ -34,11 +41,7 @@ const updateCellSelectionMiddleware = store => {
action.type === "categorical metadata filter none of these" || action.type === "categorical metadata filter none of these" ||
action.type === "categorical metadata filter all of these"; action.type === "categorical metadata filter all of these";
if ( if (!filterJustChanged || !s.controls.cellsMetadata) {
!filterJustChanged ||
!s.controls.allCellsOnClient
/* graph is set at the same time as allCells, so we assume it exists */
) {
return next( return next(
action action
); /* if the cells haven't loaded or the action wasn't a filter, bail */ ); /* if the cells haven't loaded or the action wasn't a filter, bail */
@@ -48,7 +51,7 @@ const updateCellSelectionMiddleware = store => {
- make a FRESH copy of all of the cells - make a FRESH copy of all of the cells
- metadata has cellname and index, and that's all we ever need to reference cell info - metadata has cellname and index, and that's all we ever need to reference cell info
*/ */
let newSelection = s.controls.currentCellSelection.slice(0); let newSelection = s.controls.cellsMetadata.slice(0);
// _.forEach(newSelection, cell => (cell.__selected__ = true)); // _.forEach(newSelection, cell => (cell.__selected__ = true));
for (let i = 0; i < newSelection.length; i++) { for (let i = 0; i < newSelection.length; i++) {
newSelection[i].__selected__ = true; newSelection[i].__selected__ = true;
@@ -58,7 +61,7 @@ const updateCellSelectionMiddleware = store => {
in plain language... in plain language...
(a) once the cells have loaded. (a) once the cells have loaded.
(b) each time a user changes ANY control we need to update currentCellSelection (b) each time a user changes ANY control we need to update cellsMetadata
there are two states: there are two states:
1. control state we already know about (state.foo) 1. control state we already know about (state.foo)
@@ -66,34 +69,6 @@ const updateCellSelectionMiddleware = store => {
*/ */
if (
/* is there a 2d graph brush selection ? */
action.type === "graph brush selection change" ||
s.controls.graphBrushSelection
) {
const graphBrushSelection /* it exists, so is it new or old */ =
action.type === "graph brush selection change"
? action.brushCoords
: s.controls.graphBrushSelection;
const graphVec = s.controls.graphVec;
for (let i = 0; i < newSelection.length; i++) {
const cell = newSelection[i];
const cellId = cell.__cellIndex__;
const x = graphVec[2 * cellId];
const y = graphVec[2 * cellId + 1];
const pointIsInsideBrushBounds =
x >= graphBrushSelection.northwest[0] &&
x <= graphBrushSelection.southeast[0] &&
y <= graphBrushSelection.northwest[1] &&
y >= graphBrushSelection.southeast[1];
if (!pointIsInsideBrushBounds) {
cell.__selected__ = false;
}
}
}
if ( if (
(action.type === (action.type ===
"continuous selection using parallel coords brushing" && "continuous selection using parallel coords brushing" &&
@@ -118,139 +93,8 @@ const updateCellSelectionMiddleware = store => {
}); });
} }
/*
Continuous histograms ___---^^^^--[------__]__---___
Create newContinuousUserDefinedRanges
Filter based on them
*/
let newContinuousUserDefinedRanges =
s.controls.continuousUserDefinedRanges;
/* check if this is the action and take care of that metadata field */
if (action.type === "continuous metadata histogram brush") {
/*
was this a deselect? if so it will be null
was it a select? set the new range [20, 50]
we overload this because it is less if statements thru the whole system
but it's invisible here, thus comment.
*/
newContinuousUserDefinedRanges[action.selection] = action.range;
}
let activeContinuousHistogramFilters = [];
_.each(newContinuousUserDefinedRanges, (value, key, i) => {
if (value !== null) {
activeContinuousHistogramFilters.push(key);
}
});
/* see if there are others from previous... */
if (activeContinuousHistogramFilters.length > 0) {
_.each(activeContinuousHistogramFilters, key => {
_.each(newSelection, (cell, i) => {
if (
+cell[key] < newContinuousUserDefinedRanges[key][0] ||
+cell[key] > newContinuousUserDefinedRanges[key][1]
) {
newSelection[i]["__selected__"] = false;
}
});
});
}
/*
1. figure out if the users have unchecked boxes
2. put them in an array
3. filter on them
*/
let newCategoricalAsBooleansMap = s.controls.categoricalAsBooleansMap;
/*
...spread for merge: https://github.com/reactjs/redux/issues/432
we do the update here instead of the reducer because we need it for the reactive computation
*/
if (action.type === "categorical metadata filter select") {
newCategoricalAsBooleansMap = {
...s.controls.categoricalAsBooleansMap,
[action.metadataField]: {
...s.controls.categoricalAsBooleansMap[action.metadataField],
[action.value]: true
}
};
} else if (action.type === "categorical metadata filter deselect") {
newCategoricalAsBooleansMap = {
...s.controls.categoricalAsBooleansMap,
[action.metadataField]: {
...s.controls.categoricalAsBooleansMap[action.metadataField],
[action.value]: false
}
};
} else if (action.type === "categorical metadata filter none of these") {
const metadataFieldWithAllOfTheseValueSelected = {};
/* set EVERYTHING to false in this intermediate object */
_.each(
s.controls.categoricalAsBooleansMap[action.metadataField],
(isActive, option) => {
metadataFieldWithAllOfTheseValueSelected[option] = false;
}
);
newCategoricalAsBooleansMap = {
...s.controls.categoricalAsBooleansMap,
[action.metadataField]: metadataFieldWithAllOfTheseValueSelected
};
} else if (action.type === "categorical metadata filter all of these") {
const metadataFieldWithAllOfTheseValueSelected = {};
/* set EVERYTHING to true in this intermediate object */
_.each(
s.controls.categoricalAsBooleansMap[action.metadataField],
(isActive, option) => {
metadataFieldWithAllOfTheseValueSelected[option] = true;
}
);
newCategoricalAsBooleansMap = {
...s.controls.categoricalAsBooleansMap,
[action.metadataField]: metadataFieldWithAllOfTheseValueSelected
};
}
const inactiveCategories = [];
_.each(newCategoricalAsBooleansMap, (options, category) => {
_.each(options, (isActive, option) => {
if (!isActive) {
inactiveCategories.push({ category, option });
}
});
});
if (inactiveCategories.length > 0) {
_.each(inactiveCategories, d => {
if (
s.controls.categoricalAsCellsMap[d.category] &&
s.controls.categoricalAsCellsMap[d.category][d.option]
) {
_.forEach(
s.controls.categoricalAsCellsMap[d.category][d.option],
c => {
c.__selected__ = false;
}
);
}
});
}
let modifiedAction = Object.assign({}, action, { let modifiedAction = Object.assign({}, action, {
newSelection, newSelection
newCategoricalAsBooleansMap,
newContinuousUserDefinedRanges
}); /* append the result of all the filters to the action the user just triggered */ }); /* append the result of all the filters to the action the user just triggered */
return next(modifiedAction); return next(modifiedAction);
+259 -116
View File
@@ -1,16 +1,114 @@
// jshint esversion: 6 // jshint esversion: 6
import _ from "lodash"; import _ from "lodash";
import { parseRGB } from "../util/parseRGB"; import { parseRGB } from "../util/parseRGB";
import { createSchemaByDataSniffing } from "../util/schema";
var crossfilter = require("../util/typedCrossfilter");
// Deduce the correct crossfilter dimension type from a metadata
// schema description.
//
function deduceDimensionType(attributes, fieldName) {
let dimensionType;
if (attributes.type === "string") {
dimensionType = "enum";
} else if (attributes.type === "int") {
dimensionType = Int32Array;
} else if (attributes.type === "float") {
dimensionType = Float32Array;
} else {
console.error(
`Warning - REST API returned unknown metadata schema (${
attributes.type
}) for field ${fieldName}.`
);
// skip it - we don't know what to do with this type
}
return dimensionType;
}
// Create view state from /cells data response. Used both during a data
// load and during a graph reset.
//
function createViewState(schema, data) {
const cellsMetadata = data.metadata.slice(0);
/*
construct a copy of the ranges object that only has categorical
replace all counts with bool flags
ie., everything starts out checked
we mutate this map in the actions below
*/
const categoricalAsBooleansMap = {};
_.each(data.ranges, (value, key) => {
if (
key !== "CellName" &&
value.options /* it's categorical, it has options instead of ranges */
) {
const optionsAsBooleans = {};
_.each(value.options, (_value, _key) => {
optionsAsBooleans[_key] = true;
});
categoricalAsBooleansMap[key] = optionsAsBooleans;
}
});
const graph = data.graph;
_.each(cellsMetadata, (cell, idx) => {
cell.__cellIndex__ = idx;
cell.__color__ =
"rgba(0,0,0,1)"; /* initial color for all cells in all charts */
cell.__colorRGB__ = parseRGB(cell.__color__);
cell.__x__ = graph[idx][1];
cell.__y__ = graph[idx][2];
});
// Build the selection crossfilter.
//
let cellsCrossfilter = crossfilter(cellsMetadata);
let cellsDimensionsMap = {};
cellsDimensionsMap.x = cellsCrossfilter.dimension(r => r.__x__, Float32Array);
cellsDimensionsMap.y = cellsCrossfilter.dimension(r => r.__y__, Float32Array);
// Now walk the schema and make an appropriate dimension for each
// metadata field. This is a simplistic mapping, and could be
// optmized to use smaller scalars (to save memory) or larger
// floating point where precision is needed.
//
_.forEach(schema, (attributes, key) => {
if (key !== "CellName") {
const dimensionType = deduceDimensionType(attributes, key);
if (dimensionType) {
cellsDimensionsMap[key] = cellsCrossfilter.dimension(
r => r[key],
dimensionType
);
}
}
});
return {
cellsMetadata,
crossfilter: {
cells: cellsCrossfilter,
dimensionMap: cellsDimensionsMap
},
categoricalAsBooleansMap
};
}
const Controls = ( const Controls = (
state = { state = {
/* Universe - all cells known to us. Set once, during initial load */
_ranges: null /* this comes from initialize, this is universe */, _ranges: null /* this comes from initialize, this is universe */,
allGeneNames: null, allGeneNames: null,
allCellsOnClient: null /* this comes from cells endpoint, this is world */, allCells: null /* this comes from cells endpoint, this is universe */,
currentCellSelection: null /* this comes from user actions, all draw components use this, it is created by middleware */, allCellsMetadata: null /* this comes from cells endpoint, and is just the metadata for universe */,
graphVec: null,
/* View / World - all cells currently being displayed. May be a subset of Universe. */
cellsMetadata: null,
crossfilter: null /* the current user selection state */,
categoricalAsBooleansMap: null, categoricalAsBooleansMap: null,
categoricalAsCellsMap: null,
colorAccessor: null, colorAccessor: null,
colorScale: null, colorScale: null,
opacityForDeselectedCells: 0.2, opacityForDeselectedCells: 0.2,
@@ -28,118 +126,107 @@ const Controls = (
/********************************** /**********************************
Keep a copy of 'universe' Keep a copy of 'universe'
***********************************/ ***********************************/
case "initialize success": case "initialize success": {
if (!action.data.data.schema) {
console.error("Warning - REST API omitted schema description.");
}
return Object.assign({}, state, { return Object.assign({}, state, {
_ranges: action.data.data.ranges, _ranges: action.data.data.ranges,
allGeneNames: action.data.data.genes allGeneNames: action.data.data.genes,
schema: action.data.data.schema
}); });
case "request cells success": }
// Store the graph in a linear array for fast access. Index into case "request cells success": {
// the array by "cell index", which is stored as metadata field // If we don't have a schema (bad server!), fake it by inferring
// __cellIndex__. // important fields from the ranges element.
// //
// Code below relies on the REST API guarantee that the graph and if (!state.schema) {
// metadata are returned as arrays with the same order and length. state.schema = createSchemaByDataSniffing(action.data.data.ranges);
// }
const graphVec = new Float32Array(2 * action.data.data.graph.length);
_.each(action.data.data.graph, (g, i) => {
graphVec[2 * i] = g[1];
graphVec[2 * i + 1] = g[2];
});
const currentCellSelection = action.data.data.metadata.slice(0);
const currentCellSelectionMap = _.keyBy(currentCellSelection, "CellName");
/*
construct a copy of the ranges object that only has categorical
replace all counts with bool flags
ie., everything starts out checked
we mutate this map in the actions below
*/
const categoricalAsBooleansMap = {},
categoricalAsCellsMap = {};
const continuousUserDefinedRanges = {};
_.each(action.data.data.ranges, (value, key) => {
if (
key !== "CellName" &&
value.options /* it's categorical, it has options instead of ranges */
) {
const optionsAsBooleans = {},
optionsAsCells = {};
_.each(value.options, (_value, _key) => {
optionsAsBooleans[_key] = true;
optionsAsCells[_key] = [];
});
categoricalAsBooleansMap[key] = optionsAsBooleans;
categoricalAsCellsMap[key] = optionsAsCells;
} else if (key !== "CellName" && value.range) {
continuousUserDefinedRanges[key] = null;
}
});
_.each(currentCellSelection, (cell, idx) => {
cell.__cellIndex__ = idx;
cell.__selected__ = true;
cell.__color__ =
"rgba(0,0,0,1)"; /* initial color for all cells in all charts */
cell.__colorRGB__ = parseRGB(cell.__color__);
// Add each cell to its categorical metadata set.
_.forEach(cell, (_value, key) => {
if (
categoricalAsCellsMap[key] &&
categoricalAsCellsMap[key][_value]
) {
const s = categoricalAsCellsMap[key][_value];
if (s) s.push(cell);
}
});
});
/* Set viewable world to the provided cell data */
const viewState = createViewState(state.schema, action.data.data);
return Object.assign({}, state, { return Object.assign({}, state, {
allCellsOnClient: action.data.data, /* Universe - initialize once */
currentCellSelection, allCells: state.allCells ? state.allCells : action.data,
currentCellSelectionMap, allCellsMetadata: state.allCellsMetadata
graphVec, ? state.allCellsMetadata
categoricalAsBooleansMap, : viewState.cellsMetadata,
categoricalAsCellsMap, allCellsMetadataMap: state.allCellsMetadataMap
continuousUserDefinedRanges, ? state.allCellsMetadataMap
: _.keyBy(viewState.cellsMetadata, "CellName"),
/* World */
...viewState,
graphBrushSelection: null /* if we are getting new cells from the server, the layout (probably? definitely?) just changed, so this is now irrelevant, and we WILL need to call a function to reset state of this kind when cells success happens */ graphBrushSelection: null /* if we are getting new cells from the server, the layout (probably? definitely?) just changed, so this is now irrelevant, and we WILL need to call a function to reset state of this kind when cells success happens */
}); });
}
/* * * * * * * * * * * * * * * * * * /* * * * * * * * * * * * * * * * * *
User events User events
* * * * * * * * * * * * * * * * * */ * * * * * * * * * * * * * * * * * */
case "parallel coordinates axes have been drawn": case "reset graph": {
/* Reset viewable world to the entire Universe */
const viewState = createViewState(state.schema, state.allCells.data);
return Object.assign({}, state, {
...viewState
});
}
case "parallel coordinates axes have been drawn": {
return Object.assign({}, state, { return Object.assign({}, state, {
axesHaveBeenDrawn: true axesHaveBeenDrawn: true
}); });
}
case "continuous selection using parallel coords brushing": { case "continuous selection using parallel coords brushing": {
return Object.assign({}, state, { return Object.assign({}, state, {
continuousSelection: action.data, continuousSelection: action.data,
currentCellSelection: crossfilter: {
action.newSelection /* this comes from middleware */ ...state.crossfilter
}
}); });
} }
case "graph brush selection change": case "graph brush selection change": {
state.crossfilter.dimensionMap.x.filterRange([
action.brushCoords.northwest[0],
action.brushCoords.southeast[0]
]);
state.crossfilter.dimensionMap.y.filterRange([
action.brushCoords.southeast[1],
action.brushCoords.northwest[1]
]);
return Object.assign({}, state, { return Object.assign({}, state, {
graphBrushSelection: graphBrushSelection: action.brushCoords,
action.brushCoords /* this has already been applied in middleware but store it for next time */, crossfilter: {
currentCellSelection: ...state.crossfilter
action.newSelection /* this comes from middleware */ }
}); });
case "graph brush deselect": }
case "graph brush deselect": {
state.crossfilter.dimensionMap.x.filterAll();
state.crossfilter.dimensionMap.y.filterAll();
return Object.assign({}, state, { return Object.assign({}, state, {
graphBrushSelection: null, graphBrushSelection: null,
currentCellSelection: crossfilter: {
action.newSelection /* this comes from middleware */ ...state.crossfilter
}
}); });
case "continuous metadata histogram brush": }
case "continuous metadata histogram brush": {
// action.selection: metadata name being selected
// action.range: filter range, or null if deselected
if (!action.range) {
state.crossfilter.dimensionMap[action.selection].filterAll();
} else {
state.crossfilter.dimensionMap[action.selection].filterRange(
action.range
);
}
return Object.assign({}, state, { return Object.assign({}, state, {
newContinuousUserDefinedRanges: crossfilter: {
action.newContinuousUserDefinedRanges /* this has already been applied in middleware but store it for next time */, ...state.crossfilter
currentCellSelection: }
action.newSelection /* this comes from middleware */
}); });
}
case "change opacity deselected cells in 2d graph background": case "change opacity deselected cells in 2d graph background":
return Object.assign({}, state, { return Object.assign({}, state, {
opacityForDeselectedCells: action.data opacityForDeselectedCells: action.data
@@ -147,57 +234,113 @@ const Controls = (
/******************************* /*******************************
Categorical metadata Categorical metadata
*******************************/ *******************************/
case "categorical metadata filter select": case "categorical metadata filter select": {
const newCategoricalAsBooleansMap = {
...state.categoricalAsBooleansMap,
[action.metadataField]: {
...state.categoricalAsBooleansMap[action.metadataField],
[action.value]: true
}
};
// update the filter for the one category that changed state
state.crossfilter.dimensionMap[action.metadataField].filterEnum(
_.filter(
_.map(
newCategoricalAsBooleansMap[action.metadataField],
(val, key) => (val ? key : false)
)
)
);
return Object.assign({}, state, { return Object.assign({}, state, {
categoricalAsBooleansMap: categoricalAsBooleansMap: newCategoricalAsBooleansMap,
action.newCategoricalAsBooleansMap /* this comes from middleware */, crossfilter: {
currentCellSelection: ...state.crossfilter
action.newSelection /* this comes from middleware */ }
}); });
case "categorical metadata filter deselect": }
case "categorical metadata filter deselect": {
const newCategoricalAsBooleansMap = {
...state.categoricalAsBooleansMap,
[action.metadataField]: {
...state.categoricalAsBooleansMap[action.metadataField],
[action.value]: false
}
};
// update the filter for the one category that changed state
state.crossfilter.dimensionMap[action.metadataField].filterEnum(
_.filter(
_.map(
newCategoricalAsBooleansMap[action.metadataField],
(val, key) => (val ? key : false)
)
)
);
return Object.assign({}, state, { return Object.assign({}, state, {
categoricalAsBooleansMap: categoricalAsBooleansMap: newCategoricalAsBooleansMap,
action.newCategoricalAsBooleansMap /* this comes from middleware */, crossfilter: {
currentCellSelection: ...state.crossfilter
action.newSelection /* this comes from middleware */ }
}); });
case "categorical metadata filter none of these": }
case "categorical metadata filter none of these": {
const newCategoricalAsBooleansMap = {
...state.categoricalAsBooleansMap
};
_.forEach(
newCategoricalAsBooleansMap[action.metadataField],
(v, k, c) => {
c[k] = false;
}
);
state.crossfilter.dimensionMap[action.metadataField].filterNone();
return Object.assign({}, state, { return Object.assign({}, state, {
categoricalAsBooleansMap: categoricalAsBooleansMap: newCategoricalAsBooleansMap,
action.newCategoricalAsBooleansMap /* this comes from middleware */, crossfilter: {
currentCellSelection: ...state.crossfilter
action.newSelection /* this comes from middleware */ }
}); });
case "categorical metadata filter all of these": }
case "categorical metadata filter all of these": {
const newCategoricalAsBooleansMap = {
...state.categoricalAsBooleansMap
};
_.forEach(
newCategoricalAsBooleansMap[action.metadataField],
(v, k, c) => {
c[k] = true;
}
);
state.crossfilter.dimensionMap[action.metadataField].filterAll();
return Object.assign({}, state, { return Object.assign({}, state, {
categoricalAsBooleansMap: categoricalAsBooleansMap: newCategoricalAsBooleansMap,
action.newCategoricalAsBooleansMap /* this comes from middleware */, crossfilter: {
currentCellSelection: ...state.crossfilter
action.newSelection /* this comes from middleware */ }
}); });
}
/******************************* /*******************************
Color Scale Color Scale
*******************************/ *******************************/
case "color by continuous metadata": case "color by continuous metadata":
return Object.assign({}, state, { return Object.assign({}, state, {
colorAccessor: action.colorAccessor, colorAccessor: action.colorAccessor,
currentCellSelection: cellsMetadata:
action.currentSelectionWithUpdatedColors /* this comes from middleware */, action.cellsMetadataWithUpdatedColors /* this comes from middleware */,
colorScale: action.colorScale colorScale: action.colorScale
}); });
case "color by expression": case "color by expression":
return Object.assign({}, state, { return Object.assign({}, state, {
colorAccessor: action.gene, colorAccessor: action.gene,
currentCellSelection: cellsMetadata:
action.currentSelectionWithUpdatedColors /* this comes from middleware */, action.cellsMetadataWithUpdatedColors /* this comes from middleware */,
colorScale: action.colorScale colorScale: action.colorScale
}); });
case "color by categorical metadata": case "color by categorical metadata":
return Object.assign({}, state, { return Object.assign({}, state, {
colorAccessor: colorAccessor:
action.colorAccessor /* pass the scale through additionally, and it's a legend! */, action.colorAccessor /* pass the scale through additionally, and it's a legend! */,
currentCellSelection: cellsMetadata:
action.currentSelectionWithUpdatedColors /* this comes from middleware */, action.cellsMetadataWithUpdatedColors /* this comes from middleware */,
colorScale: action.colorScale colorScale: action.colorScale
}); });
case "store current cell selection as differential set 1": case "store current cell selection as differential set 1":
+2 -2
View File
@@ -1,7 +1,7 @@
// jshint esversion: 6 // jshint esversion: 6
import { combineReducers, createStore, applyMiddleware } from "redux"; import { combineReducers, createStore, applyMiddleware } from "redux";
import updateURLMiddleware from "../middleware/updateURLMiddleware"; import updateURLMiddleware from "../middleware/updateURLMiddleware";
import updateCellSelectionMiddleware from "../middleware/updateCellSelectionMiddleware"; // import updateCellSelectionMiddleware from "../middleware/updateCellSelectionMiddleware";
import updateCellColors from "../middleware/updateCellColors"; import updateCellColors from "../middleware/updateCellColors";
import thunk from "redux-thunk"; import thunk from "redux-thunk";
@@ -28,7 +28,7 @@ let store = createStore(
applyMiddleware( applyMiddleware(
thunk, thunk,
updateURLMiddleware, updateURLMiddleware,
updateCellSelectionMiddleware, // updateCellSelectionMiddleware,
updateCellColors updateCellColors
) )
); );
+41
View File
@@ -0,0 +1,41 @@
// jshint esversion: 6
// In the case where the REST server does not implement data schema
// declaration, we attempt to deduce it by sniffing the data.
//
export function createSchemaByDataSniffing(ranges) {
let schema = {};
_.forEach(ranges, (value, key) => {
schema[key] = {
displayname: key,
variabletype: value.options ? "categorical" : "continuous"
};
// Metadata field type is inferred by sniffing the data. This has some risks.
// Caveats:
// * Values have been converted to native JS objects by the JSON parser.
// * Lots of assumptions about he REST API behaving properly (eg, min/max
// are the same type, etc).
let type;
if (schema[key].variabletype === "continuous" && value.range) {
// Use min/max as a proxy for all data.
const min = value.range.min;
const max = value.range.max;
type =
typeof min !== "number" || typeof max !== "number"
? "string"
: Number.isSafeInteger(min) && Number.isSafeInteger(max)
? "int"
: "float";
} else {
// use an option value as a proxy for all data
const aVal = value.options[0];
type =
typeof aVal !== "number"
? "string"
: Number.isSafeInteger(aVal) ? "int" : "float";
}
schema[key].type = type;
});
return schema;
}
+228
View File
@@ -0,0 +1,228 @@
"use strict";
// jshint esversion: 6
// BitArray is a 2D bitarray with size [length, nBitWidth].
// Each bit is referred to as a `dimension`. Dimensions may be
// dynamically allocated and deallocated. The overall length
// of the BitArray is fixed at creation time (for simplicity).
//
// Organization of the bitarray is dimension-major. As dimensions
// are added, the underlying store is grown 32 bits at a time.
// NOTE: currently does not deallocate / shrink.
//
// Primary operations on the BitArray are:
// - set & clear dimension
// - test dimension
// - various performance or convenience operations to optimize bulk ops
//
// The underlying data structure uses TypedArrays for performance.
//
class BitArray {
constructor(length) {
// Initially allocate a 32 bit wide array. allocDimension() will expand
// as necessary.
//
// Int32Array is (counterintuitively) used to accomadate JS numeric casting
// (to/from primitive number type).
//
// Fixed for the life of this object.
this.length = length;
// Bitarray width. width is always greater than 32*dimensionCount.
this.width = 1; // underlying number of 32 bit arrays
this.dimensionCount = 0; // num allocated dimensions
this.bitmask = new Int32Array(this.width); // dimension allocation mask
this.bitarray = new Int32Array(this.width * this.length);
}
// Return the number of records that are selected, ie, have a one bit in
// all allocated dimensions.
//
get selectionCount() {
return this.countAllOnes();
}
// Count all records that have a 'one' bit in allocated dimensions.
//
countAllOnes() {
let count = 0;
for (let i = 0; i < this.width; i++) {
const bitmask = this.bitmask[i];
for (let j = i * this.length, len = j + this.length; j < len; j++) {
if (this.bitarray[i * this.length + j] === bitmask) count++;
}
}
return count;
}
// count trailing zeros - hard to do fast in JS!
// https://en.wikipedia.org/wiki/Find_first_set#CTZ
static ctz(v) {
let c = 32;
v &= -v; // isolate lowest non-zero bit
if (v) c--;
if (v & 0x0000ffff) c -= 16;
if (v & 0x00ff00ff) c -= 8;
if (v & 0x0f0f0f0f) c -= 4;
if (v & 0x33333333) c -= 2;
if (v & 0x55555555) c -= 1;
return c;
}
// find a free dimension. Return undefined if none
_findFreeDimension() {
let dim;
for (let col = 0; col < this.width; col++) {
const bitmask = this.bitmask[col];
const lowestZeroBit = ~this.bitmask[col] & -~this.bitmask[col];
if (lowestZeroBit) {
this.bitmask[col] |= lowestZeroBit;
dim = 32 * col + BitArray.ctz(lowestZeroBit);
}
}
return dim;
}
// allocate and return the dimension ID (bit position)
//
allocDimension() {
let dim = this._findFreeDimension();
// if we did not find free dimension, expand the bitarray.
if (dim === undefined) {
this.width++;
const biggerBitArray = new Int32Array(this.width * this.length);
biggerBitArray.set(this.bitarray);
this.bitarray = biggerBitArray;
const biggerBitmask = new Int32Array(this.width);
biggerBitmask.set(this.bitmask);
this.bitmask = biggerBitmask;
dim = this._findFreeDimension();
}
this.dimensionCount++;
return dim;
}
// free a dimension for later use. MUST deselect the dimension, as other
// code assume the column will be zero valued.
//
freeDimension(dim) {
// all selection tests assume unallocated dimensions are zero valued.
this.deselectAll(dim);
const col = dim >>> 5;
this.bitmask[col] &= ~(1 << (dim % 32));
this.dimensionCount--;
}
// return true if this index is selected in ALL dimensions.
//
isSelected(index) {
const width = this.width;
const length = this.length;
const bitarray = this.bitarray;
for (let w = 0; w < width; w++) {
const bitmask = this.bitmask[w];
if (!bitmask || bitarray[w * length + index] !== bitmask) return false;
}
return true;
}
// select index on dimension
//
selectOne(dim, index) {
const col = dim >>> 5;
const before = this.bitarray[col * this.length + index];
const after = before | (1 << (dim % 32));
this.bitarray[col] = after;
}
// deselect index on dimension
//
deselectOne(dim, index) {
const col = dim >>> 5;
const before = this.bitarray[col * this.length + index];
const after = before & ~(1 << (dim % 32));
this.bitarray[col] = after;
}
// select all indices on dimension.
//
selectAll(dim) {
let col = dim >> 5;
const bitmask = this.bitmask[col];
const bitarray = this.bitarray;
const one = 1 << (dim % 32);
for (let i = col * this.length, len = i + this.length; i < len; i++) {
bitarray[i] |= one;
}
}
// deselect all indices on dimension
//
deselectAll(dim) {
let col = dim >> 5;
const bitmask = this.bitmask[col];
const bitarray = this.bitarray;
const zero = ~(1 << (dim % 32));
for (let i = col * this.length, len = i + this.length; i < len; i++) {
bitarray[i] &= zero;
}
}
// select range of indices on a dimension, indirect through a sort map.
// Indirect functions are used to map between sort and natural order.
//
selectIndirectFromRange(dim, indirect, range) {
const col = dim >>> 5;
const first = range[0];
const last = range[1];
const bitarray = this.bitarray;
const one = 1 << (dim % 32);
const offset = col * this.length;
for (let i = first; i < last; i++) {
bitarray[offset + indirect[i]] |= one;
}
}
// deselect range of indices on a dimension, indirect through a sort map.
//
deselectIndirectFromRange(dim, indirect, range) {
const col = dim >>> 5;
const first = range[0];
const last = range[1];
const bitarray = this.bitarray;
const zero = ~(1 << (dim % 32));
const offset = col * this.length;
for (let i = first; i < last; i++) {
bitarray[offset + indirect[i]] &= zero;
}
}
// Fill the array with selected|deselected value based upon the
// current selection state.
//
fillBySelection(result, selectedValue, deselectedValue) {
// special case (width === 1) for performance
if (this.width === 1) {
const bitmask = this.bitmask[0];
const bitarray = this.bitarray;
for (let i = 0, len = this.length; i < len; i++) {
result[i] = bitarray[i] === bitmask ? selectedValue : deselectedValue;
}
} else {
for (let i = 0, len = this.length; i < len; i++) {
result[i] = this.isSelected(i) ? selectedValue : deselectedValue;
}
}
return result;
}
}
module.exports = BitArray;
+394
View File
@@ -0,0 +1,394 @@
"use strict";
// jshint esversion: 6
/*
Typedarray Crossfilter - a re-implementation of a subset of crossfilter, with
time/space optimizations predicated upon the following assumptions:
- dimensions are uniformly typed, and all values must be of that type
- dimension values must be a primitive type (int, float, string). Arrays
or other complex types not supported.
- dimension creation requires call-provided type declaration
- no support for adding/removing data to an existing crossfilter. If you
want to do that, you have to create the new crossfilter, using the new
data, from scratch.
The actual backing store for a dimension is a TypedArray, enabling significant
performance improvements over the original crossfilter.
There are also a handful of new methods, primarily to take advantage of the
performance (eg, crossfilter.fillBySelection)
Helpful documents (this module tries to follow the original API as much
as is feasable):
https://github.com/square/crossfilter/
http://square.github.io/crossfilter/
There is also a newer, community supported fork of crossfilter, with a
more complex API. In a few cases, elements of that API were incorporated.
https://github.com/square/crossfilter/
*/
var PositiveIntervals = require("./positiveIntervals");
var BitArray = require("./bitArray");
var Util = require("./util");
class TypedCrossfilter {
constructor(data) {
this.data = data;
// filters: array of { id, dimension }
this.filters = [];
this.selection = new BitArray(data.length);
}
size() {
return this.data.length;
}
all() {
return this.data;
}
dimension(value, valueArrayType) {
const id = this.selection.allocDimension();
let dim;
if (valueArrayType === "enum") {
dim = new EnumDimension(value, this, id);
} else {
dim = new ScalarDimension(value, valueArrayType, this, id);
}
this.filters.push({ id, dim });
dim.filterAll();
return dim;
}
_freeDimension(id) {
this.selection.freeDimension(id);
this.filters = this.filters.filter(f => f.id != id);
}
// return array of all records that are selected/filtered
// by all dimensions.
allFiltered() {
const selection = this.selection;
const res = [];
for (let i = 0, len = this.data.length; i < len; i++) {
if (selection.isSelected(i)) {
res.push(this.data[i]);
}
}
return res;
}
countFiltered() {
return this.selection.selectionCount;
}
isElementFiltered(i) {
return this.selection.isSelected(i);
}
// fill array with one of two values, based upon selection state
fillByIsFiltered(array, selectedValue, deselectedValue) {
return this.selection.fillBySelection(
array,
selectedValue,
deselectedValue
);
}
}
// Base dimension type - value must be a scalar type (eg, int, float),
// and value array must be a TypedArray.
//
class ScalarDimension {
constructor(value, valueArrayType, crossfilter, id) {
this.crossfilter = crossfilter;
this.id = id;
// current selection filter, expressed as PostiveIntervals.
this.currentFilter = [];
// Create value array
const array = this._createValueArray(
value,
new valueArrayType(this.crossfilter.data.length)
);
this.value = array;
// create sort index
this.index = Util.fillRange(new Uint32Array(this.crossfilter.data.length));
this.index.sort((a, b) => array[a] - array[b]);
}
_createValueArray(value, array) {
// create dimension value array
const data = this.crossfilter.data;
const len = data.length;
for (let i = 0; i < len; i++) {
array[i] = value(data[i]);
}
return array;
}
dispose() {
this.crossfilter._freeDimension(this.id);
}
id() {
return this.id;
}
_updateFilters(newFilter) {
newFilter = PositiveIntervals.canonicalize(newFilter);
// special case optimization - select all/none can bypass
// more complex work and just clobber everything.
//
if (newFilter.length === 0) {
this.crossfilter.selection.deselectAll(this.id);
} else if (
newFilter.length === 1 &&
newFilter[0][0] === 0 &&
newFilter[0][1] == this.index.length
) {
this.crossfilter.selection.selectAll(this.id);
} else {
const adds = PositiveIntervals.difference(newFilter, this.currentFilter);
const dels = PositiveIntervals.difference(this.currentFilter, newFilter);
dels.forEach(interval =>
this.crossfilter.selection.deselectIndirectFromRange(
this.id,
this.index,
interval
)
);
adds.forEach(interval =>
this.crossfilter.selection.selectIndirectFromRange(
this.id,
this.index,
interval
)
);
}
this.currentFilter = newFilter;
}
// filter by value - exact match
filterExact(value) {
const newFilter = [
Util.lowerBoundIndirect(
this.value,
this.index,
value,
0,
this.value.length
),
Util.upperBoundIndirect(
this.value,
this.index,
value,
0,
this.value.length
)
];
if (newFilter[0] <= newFilter[1]) {
this._updateFilters([newFilter]);
} else {
this._updateFilters([]);
}
return this;
}
// filter by a set of values, eg. enum.
filterEnum(values) {
const newFilter = [];
for (let v = 0, len = values.length; v < len; v++) {
const intv = [
Util.lowerBoundIndirect(
this.value,
this.index,
values[v],
0,
this.value.length
),
Util.upperBoundIndirect(
this.value,
this.index,
values[v],
0,
this.value.length
)
];
if (intv[0] <= intv[1]) newFilter.push(intv);
}
this._updateFilters(newFilter);
return this;
}
// filter by value range [lo, hi)
// lo: inclusive, hi: exclusive
filterRange(range) {
const newFilter = [];
const intv = [
Util.lowerBoundIndirect(
this.value,
this.index,
range[0],
0,
this.value.length
),
Util.upperBoundIndirect(
this.value,
this.index,
range[1],
0,
this.value.length
)
];
if (intv[0] < intv[1]) newFilter.push(intv);
this._updateFilters(newFilter);
return this;
}
// select all - equivalent of selecting all in this dimension
filterAll() {
this._updateFilters([[0, this.value.length]]);
return this;
}
// select none
filterNone() {
this._updateFilters([]);
}
// return top k records, starting with offset, in descending order.
// Order is this dimension's sort order
top(k, offset = 0) {
const data = this.crossfilter.data;
const selection = this.crossfilter.selection;
const index = this.index;
const len = index.length;
const ret = [];
let i = 0;
let skip = 0;
let found = 0;
// skip up to offset records
for (i = len - 1; 0 <= i && skip < offset; i--) {
if (selection.isSelected(index[i])) {
skip++;
}
}
// grab up to k records
for (; 0 <= i && found < k; i--) {
if (selection.isSelected(index[i])) {
ret.push(data[index[i]]);
found++;
}
}
return ret;
}
// return bottom k records, starting with offset, in ascending order.
// Order is this dimension's sort order
bottom(k, offset = 0) {
const data = this.crossfilter.data;
const selection = this.crossfilter.selection;
const index = this.index;
const len = index.length;
const ret = [];
let skip = 0;
let found = 0;
let i = 0;
// skip up to offset records
for (i = 0; i < len && skip < offset; i++) {
if (selection.isSelected(index[i])) {
skip++;
}
}
// grab up to k records
for (; i < len && found < k; i++) {
if (selection.isSelected(index[i])) {
ret.push(data[index[i]]);
found++;
}
}
return ret;
}
}
// Ordered enumeration - supports any sortable enumerable type, eg,
// strings, which can be mapped into an fixed numeric range [0..n).
//
class EnumDimension extends ScalarDimension {
constructor(value, crossfilter, id) {
super(value, Uint32Array, crossfilter, id);
}
_createValueArray(value, array) {
const data = this.crossfilter.data;
const len = data.length;
// create enumeration table - mapping between the value
// and the enum.
const s = new Set();
for (let i = 0; i < len; i++) {
s.add(value(data[i]));
}
this.enumIndex = Array.from(s);
this.enumIndex.sort();
// create dimension value array
const enumLen = this.enumIndex.length;
for (let i = 0; i < len; i++) {
const v = value(data[i]);
const e = Util.lowerBound(this.enumIndex, v, 0, enumLen);
array[i] = e;
}
return array;
}
filterExact(value) {
return super.filterExact(
Util.lowerBound(this.enumIndex, value, 0, this.enumIndex.length)
);
}
filterEnum(values) {
return super.filterEnum(
values.map(v =>
Util.lowerBound(this.enumIndex, v, 0, this.enumIndex.length)
)
);
}
filterRange(range) {
return super.filterEnum(
range.map(v =>
Util.lowerBound(this.enumIndex, v, 0, this.enumIndex.length)
)
);
}
}
// Wrapper for backwards compat with crossfilter.
//
function crossfilter(data) {
return new TypedCrossfilter(data);
}
crossfilter.PositiveIntervals = PositiveIntervals;
crossfilter.BitArray = BitArray;
crossfilter.TypedCrossfilter = TypedCrossfilter;
crossfilter.ScalarDimension = ScalarDimension;
crossfilter.EnumDimension = EnumDimension;
module.exports = crossfilter;
@@ -0,0 +1,132 @@
"use strict";
// jshint esversion: 6
// Interval operations - very simple version of interval set relationship
// operators. An interval is a multi-interval list of [min, max),
// where min and max are mandatory. Constraints:
// * min <= max, min >= 0
// * empty interval groups are OK, ie, []
// * Legal intervals: [], [ [0, 1], ... ]
// * Not legal: [ [] ]
//
// All intervals are represented by simple JS arrays/numbers.
//
// Code assumes intervals have a low cardinality; many operations are done
// with a brute force scan. Little attempt to reduce GC pressure.
//
class PositiveIntervals {
// Canonicalize - ensure that:
// 1. no overlapping intervals
// 2. sorted in order of interval min.
//
static canonicalize(A) {
if (A.length <= 1) return A;
let copy = A.slice();
copy.sort((a, b) => a[0] - b[0]);
const res = [];
res.push(copy[0]);
for (let i = 1, len = copy.length; i < len; i++) {
if (copy[i][0] > res[res.length - 1][1]) {
// non-overlapping, add to result
res.push(copy[i]);
} else if (copy[i][1] > res[res.length - 1][1]) {
// merge this into previous
res[res.length - 1][1] = copy[i][1];
}
}
return res;
}
// Return interval with values belonging to both A and B. Essentially
// a set union operation.
//
static union(A, B) {
return PositiveIntervals.canonicalize([...A, ...B]);
}
static _flatten(A, B) {
let points = []; /* point, A, start */
for (let a = 0; a < A.length; a++) {
points.push([A[a][0], true, true]);
points.push([A[a][1], true, false]);
}
for (let b = 0; b < B.length; b++) {
points.push([B[b][0], false, true]);
points.push([B[b][1], false, false]);
}
// Sort order: point, then start
points.sort((a, b) => (a[0] !== b[0] ? a[0] - b[0] : a[2] ? 1 : -1));
return points;
}
// A - B, ie, the interval with all values in A that are not in B. Essentially
// a set difference operation.
//
static difference(A, B) {
// Corner cases
if (A.length === 0 || B.length === 0) {
return PositiveIntervals.canonicalize(A);
}
A = PositiveIntervals.canonicalize(A);
B = PositiveIntervals.canonicalize(B);
const points = PositiveIntervals._flatten(A, B);
const res = [];
let aDepth = 0;
let depth = 0;
let intervalStart;
let prevPoint;
for (let i = 0; i < points.length; i++) {
const p = points[i];
const before = depth;
const delta = p[2] ? 1 : -1;
depth += delta;
if (p[1]) aDepth += delta;
if (i === points.length - 1 || p[0] !== points[i + 1][0]) {
if (aDepth === 1 && depth === 1) {
intervalStart = p[0];
} else if (intervalStart !== undefined) {
res.push([intervalStart, p[0]]);
intervalStart = undefined;
}
}
prevPoint = p[0];
}
// guaranteed to be in canonical form
return res;
}
// Return interval with values belonging to A or B. Essentially a set
// intersection.
//
static intersection(A, B) {
if (A.length === 0 || B.length === 0) {
return [];
}
A = PositiveIntervals.canonicalize(A);
B = PositiveIntervals.canonicalize(B);
const points = PositiveIntervals._flatten(A, B);
const res = [];
let depth = 0;
let intervalStart;
for (let i = 0; i < points.length; i++) {
const p = points[i];
const before = depth;
depth += p[2] ? 1 : -1;
if (depth === 2) {
intervalStart = p[0];
} else if (intervalStart !== undefined) {
res.push([intervalStart, p[0]]);
intervalStart = undefined;
}
}
// guaranteed to be in canonical form
return res;
}
}
module.exports = PositiveIntervals;
+106
View File
@@ -0,0 +1,106 @@
"use strict";
// jshint esversion: 6
/*
Utility functions, private to this module.
*/
// fill an array or typedarray with a sequential range of numbers,
// starting with `start`
//
function fillRange(arr, start = 0) {
for (let i = 0, len = arr.length; i < len; i++) {
arr[i] = i + start;
}
return arr;
}
// 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).
//
function lowerBound(valueArray, value, first, last) {
// this is just a binary search
while (first < last) {
const middle = (first + last) >>> 1;
if (valueArray[middle] < value) {
first = middle + 1;
} else {
last = middle;
}
}
return first;
}
// Inlined performance optimization - used to indirect through a sort map.
//
function lowerBoundIndirect(valueArray, indexArray, value, first, last) {
// this is just a binary search
while (first < last) {
const middle = (first + last) >>> 1;
if (valueArray[indexArray[middle]] < value) {
first = middle + 1;
} else {
last = middle;
}
}
return first;
}
// 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 upperBound(valueArray, value, first, last) {
// this is just a binary search
while (first < last) {
const middle = (first + last) >>> 1;
if (valueArray[middle] > value) {
last = middle;
} else {
first = middle + 1;
}
}
return first;
}
// Inline performance optimization
//
function upperBoundIndirect(valueArray, indexArray, value, first, last) {
// this is just a binary search
while (first < last) {
const middle = (first + last) >>> 1;
if (valueArray[indexArray[middle]] > value) {
last = middle;
} else {
first = middle + 1;
}
}
return first;
}
module.exports = {
fillRange,
lowerBound,
lowerBoundIndirect,
upperBound,
upperBoundIndirect
};