mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-23 06:38:12 +08:00
248 lines
8.9 KiB
JavaScript
248 lines
8.9 KiB
JavaScript
import uri from "urijs";
|
|
import * as globals from "../globals";
|
|
|
|
/*
|
|
https://medium.com/@jacobp100/you-arent-using-redux-middleware-enough-94ffe991e6
|
|
storeInstance => functionToCallWithAnActionThatWillSendItToTheNextMiddleware => actionThatDispatchWasCalledWith => valueToUseAsTheReturnValueOfTheDispatchCall
|
|
*/
|
|
|
|
/*
|
|
What this file does:
|
|
|
|
1. fire a filter action anywhere in the app
|
|
2. ** this middleware checks to see the state of all the currently selected filters, including the new one
|
|
3. ** create updated selection from a copy of all the cells presently on the client (this may be a subset of 'all')
|
|
4. ** append that new selection to the action so that it magically appears in the reducer just because the action was fired
|
|
|
|
This is nice because we keep a lot of filtering business logic centralized (what it means in practice to be selected)
|
|
*/
|
|
|
|
const updateCellSelectionMiddleware = (store) => {
|
|
return (next) => {
|
|
return (action) => {
|
|
const s = store.getState();
|
|
|
|
/* this is a hardcoded map of the things we need to keep an eye on and update global cell selection in response to */
|
|
const filterJustChanged =
|
|
action.type === "continuous selection using parallel coords brushing" ||
|
|
action.type === "continuous metadata histogram brush" ||
|
|
action.type === "graph brush selection change" ||
|
|
action.type === "graph brush deselect" ||
|
|
action.type === "categorical metadata filter deselect" ||
|
|
action.type === "categorical metadata filter select" ||
|
|
action.type === "categorical metadata filter none of these" ||
|
|
action.type === "categorical metadata filter all of these"
|
|
;
|
|
|
|
if (
|
|
!filterJustChanged ||
|
|
!s.controls.allCellsOnClient
|
|
/* graphMap is set at the same time as allCells, so we assume it exists */
|
|
) {
|
|
return next(action); /* if the cells haven't loaded or the action wasn't a filter, bail */
|
|
}
|
|
|
|
|
|
/*
|
|
- make a FRESH copy of all of the cells
|
|
- metadata has cellname, and that's all we ever need (is a key to graphMap)
|
|
*/
|
|
let newSelection = s.controls.currentCellSelection.slice(0);
|
|
_.each(newSelection, (cell) => { cell["__selected__"] = true } );
|
|
/*
|
|
in plain language...
|
|
|
|
(a) once the cells have loaded.
|
|
(b) each time a user changes ANY control we need to update currentCellSelection
|
|
there are two states:
|
|
|
|
1. control state we already know about (state.foo)
|
|
2. control states that override states we already know about (action.foo applied instead of state.foo)
|
|
|
|
*/
|
|
|
|
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
|
|
|
|
_.each(newSelection, (cell, i) => {
|
|
|
|
if (!s.controls.graphMap[cell["CellName"]]) {
|
|
newSelection[i]["__selected__"] = false; /* make a toggle in future */
|
|
return
|
|
}
|
|
|
|
const coords = s.controls.graphMap[cell["CellName"]]; // [0.08005009151334168, 0.6907652173913044]
|
|
|
|
const pointIsInsideBrushBounds = (
|
|
globals.graphXScale(coords[0]) >= graphBrushSelection.northwestX &&
|
|
globals.graphXScale(coords[0]) <= graphBrushSelection.southeastX &&
|
|
globals.graphYScale(coords[1]) >= graphBrushSelection.northwestY &&
|
|
globals.graphYScale(coords[1]) <= graphBrushSelection.southeastY
|
|
);
|
|
|
|
if (!pointIsInsideBrushBounds) {
|
|
newSelection[i]["__selected__"] = false;
|
|
}
|
|
|
|
})
|
|
}
|
|
if (
|
|
action.type === "continuous selection using parallel coords brushing" && s.controls.continuousSelection ||
|
|
s.controls.continuousSelection
|
|
) {
|
|
|
|
_.each(newSelection, (cell, i) => {
|
|
|
|
const cellExtentsAreWithinContinuousSelectionBounds = s.controls.continuousSelection.every((active) => {
|
|
// test if point is within extents for each active brush
|
|
return active.dimension.type.within(
|
|
cell[active.dimension.key],
|
|
active.extent,
|
|
active.dimension
|
|
);
|
|
})
|
|
|
|
if (!cellExtentsAreWithinContinuousSelectionBounds) {
|
|
newSelection[i]["__selected__"] = false;
|
|
}
|
|
})
|
|
}
|
|
|
|
/*
|
|
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) => {
|
|
_.each(newSelection, (cell, i) => {
|
|
if (""+cell[d.category] === ""+d.option) { /* nums and strings to strings -__- */
|
|
newSelection[i]["__selected__"] = false;
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
let modifiedAction = Object.assign({}, action, {
|
|
newSelection,
|
|
newCategoricalAsBooleansMap,
|
|
newContinuousUserDefinedRanges,
|
|
}) /* append the result of all the filters to the action the user just triggered */
|
|
|
|
return next(modifiedAction);
|
|
}
|
|
}
|
|
};
|
|
|
|
export default updateCellSelectionMiddleware;
|