From caaee7e9bf0ed76b4a3728d51c89b65821024f56 Mon Sep 17 00:00:00 2001 From: Bruce Martin Date: Tue, 12 Mar 2019 10:48:35 -0700 Subject: [PATCH] do not reset color-by when subsetting world (#636) * do not reset colors when subsetting to world * revert diffexp state change --- .../components/brushableHistogram/index.js | 1 - client/src/components/categorical/value.js | 2 +- .../src/components/continuous/continuous.js | 2 +- .../src/components/continuousLegend/index.js | 2 +- client/src/components/graph/graph.js | 8 +- .../src/components/scatterplot/scatterplot.js | 4 +- client/src/middleware/updateCellColors.js | 150 ------------------ client/src/reducers/controls.js | 89 ++++++----- client/src/reducers/index.js | 6 +- client/src/util/stateManager/colorHelpers.js | 122 ++++++++++++++ client/src/util/stateManager/index.js | 3 +- 11 files changed, 178 insertions(+), 211 deletions(-) delete mode 100644 client/src/middleware/updateCellColors.js create mode 100644 client/src/util/stateManager/colorHelpers.js diff --git a/client/src/components/brushableHistogram/index.js b/client/src/components/brushableHistogram/index.js index 54794ada..f4f7853a 100644 --- a/client/src/components/brushableHistogram/index.js +++ b/client/src/components/brushableHistogram/index.js @@ -21,7 +21,6 @@ import finiteExtent from "../../util/finiteExtent"; crossfilter: state.controls.crossfilter, differential: state.differential, colorAccessor: state.controls.colorAccessor, - colorScale: state.controls.colorScale, obsAnnotations: _.get(state.controls.world, "obsAnnotations", null) })) class HistogramBrush extends React.Component { diff --git a/client/src/components/categorical/value.js b/client/src/components/categorical/value.js index fc3677c8..fc379c03 100644 --- a/client/src/components/categorical/value.js +++ b/client/src/components/categorical/value.js @@ -8,7 +8,7 @@ import * as globals from "../../globals"; @connect(state => ({ categoricalSelectionState: state.controls.categoricalSelectionState, - colorScale: state.controls.colorScale, + colorScale: state.controls.colors.scale, colorAccessor: state.controls.colorAccessor, schema: _.get(state.controls.world, "schema", null), world: state.controls.world diff --git a/client/src/components/continuous/continuous.js b/client/src/components/continuous/continuous.js index 7ec7e464..7d994889 100644 --- a/client/src/components/continuous/continuous.js +++ b/client/src/components/continuous/continuous.js @@ -11,7 +11,7 @@ import HistogramBrush from "../brushableHistogram"; @connect(state => ({ obsAnnotations: _.get(state.controls.world, "obsAnnotations", null), colorAccessor: state.controls.colorAccessor, - colorScale: state.controls.colorScale, + colorScale: state.controls.colors.scale, selectionUpdate: _.get(state.controls, "crossfilter.updateTime", null), schema: _.get(state.controls.world, "schema", null) })) diff --git a/client/src/components/continuousLegend/index.js b/client/src/components/continuousLegend/index.js index 669a5c22..415df0a3 100644 --- a/client/src/components/continuousLegend/index.js +++ b/client/src/components/continuousLegend/index.js @@ -99,7 +99,7 @@ const continuous = (selectorId, colorscale, colorAccessor) => { @connect(state => ({ colorAccessor: state.controls.colorAccessor, - colorScale: state.controls.colorScale, + colorScale: state.controls.colors.scale, responsive: state.responsive })) class ContinuousLegend extends React.Component { diff --git a/client/src/components/graph/graph.js b/client/src/components/graph/graph.js index 0fec15ca..b8c16e31 100644 --- a/client/src/components/graph/graph.js +++ b/client/src/components/graph/graph.js @@ -30,7 +30,7 @@ import { World } from "../../util/stateManager"; universe: state.controls.universe, crossfilter: state.controls.crossfilter, responsive: state.responsive, - colorRGB: _.get(state.controls, "colorRGB", null), + colorRGB: _.get(state.controls, "colors.rgb", null), opacityForDeselectedCells: state.controls.opacityForDeselectedCells, selectionUpdate: _.get(state.controls, "crossfilter.updateTime", null), resettingInterface: state.controls.resettingInterface, @@ -159,11 +159,7 @@ class Graph extends React.Component { } // Colors for each point - a cached value that only changes when - // the cell metadata changes (done by updateCellColors middleware). - // NOTE: this is a slightly pessimistic assumption, as the metadata - // 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. + // the cell metadata changes. if (!renderCache.colors || colorRGB !== prevProps.colorRGB) { const rgb = colorRGB; if (!renderCache.colors) { diff --git a/client/src/components/scatterplot/scatterplot.js b/client/src/components/scatterplot/scatterplot.js index 779f78ea..2c7aedc3 100644 --- a/client/src/components/scatterplot/scatterplot.js +++ b/client/src/components/scatterplot/scatterplot.js @@ -44,9 +44,9 @@ import finiteExtent from "../../util/finiteExtent"; return { world, - colorRGB: state.controls.colorRGB, + colorRGB: _.get(state.controls, "colors.rgb", null), + colorScale: _.get(state.controls, "colors.scale", null), colorAccessor: state.controls.colorAccessor, - colorScale: state.controls.colorScale, // Accessors are var/gene names (strings) scatterplotXXaccessor, diff --git a/client/src/middleware/updateCellColors.js b/client/src/middleware/updateCellColors.js deleted file mode 100644 index 3edecdc3..00000000 --- a/client/src/middleware/updateCellColors.js +++ /dev/null @@ -1,150 +0,0 @@ -// jshint esversion: 6 -import _ from "lodash"; -import * as d3 from "d3"; -import { interpolateRainbow, interpolateCool } from "d3-scale-chromatic"; -import * as globals from "../globals"; -import parseRGB from "../util/parseRGB"; -import finiteExtent from "../util/finiteExtent"; - -/* - 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 updateCellColorsMiddleware = store => next => 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 === "color by expression" || - action.type === "color by continuous metadata" || - action.type === "color by categorical metadata"; - - const obsAnnotations = _.get(s.controls, "world.obsAnnotations", null); - if (!filterJustChanged || !obsAnnotations) { - return next( - action - ); /* if the cells haven't loaded or the action wasn't a color change, bail */ - } - - let colorScale; - const colorsByRGB = new Array(obsAnnotations.length); - - /* - in plain language... - (a) once the cells have loaded. - (b) each time a user changes a color control we need to update cellsMetadata colors - This is available to all the draw functions as controls.colorRGB[index] - */ - - if (action.type === "color by categorical metadata") { - const { categories } = _.filter(s.controls.world.schema.annotations.obs, { - name: action.colorAccessor - })[0]; - - colorScale = d3 - .scaleSequential(interpolateRainbow) - .domain([0, categories.length]); - - /* pre-create colors - much faster than doing it for each obs */ - const colors = _.transform(categories, (acc, cat, idx) => { - acc[cat] = parseRGB(colorScale(idx)); - }); - - const key = action.colorAccessor; - const col = obsAnnotations.col(key).asArray(); - for (let i = 0, len = obsAnnotations.length; i < len; i += 1) { - const cat = col[i]; - colorsByRGB[i] = colors[cat]; - } - } - - if (action.type === "color by continuous metadata") { - const colorBins = 100; - const { min, max } = action.rangeForColorAccessor; - colorScale = d3 - .scaleQuantile() - .domain([min, max]) - .range(_.range(colorBins - 1, -1, -1)); - - /* pre-create colors - much faster than doing it for each obs */ - const colors = new Array(colorBins); - for (let i = 0; i < colorBins; i += 1) { - colors[i] = parseRGB(interpolateCool(i / colorBins)); - } - - const key = action.colorAccessor; - const nonFiniteColor = parseRGB(globals.nonFiniteCellColor); - const col = obsAnnotations.col(key).asArray(); - for (let i = 0, len = obsAnnotations.length; i < len; i += 1) { - const val = col[i]; - if (Number.isFinite(val)) { - const c = colorScale(val); - colorsByRGB[i] = colors[c]; - } else { - colorsByRGB[i] = nonFiniteColor; - } - } - } - - if (action.type === "color by expression") { - const { gene, data } = action; - const expression = data[gene]; // Float32Array - const colorBins = 100; - const [min, max] = finiteExtent(expression); - colorScale = d3 - .scaleQuantile() - .domain([min, max]) - .range(_.range(colorBins - 1, -1, -1)); - - /* pre-create colors - much faster than doing it for each obs */ - const colors = new Array(colorBins); - for (let i = 0; i < colorBins; i += 1) { - colors[i] = parseRGB(interpolateCool(i / colorBins)); - } - const nonFiniteColor = parseRGB(globals.nonFiniteCellColor); - - for (let i = 0, len = expression.length; i < len; i += 1) { - const e = expression[i]; - if (Number.isFinite(e)) { - const c = colorScale(e); - colorsByRGB[i] = colors[c]; - } else { - colorsByRGB[i] = nonFiniteColor; - } - } - } - - /* - append the result of all the filters to the action the user just triggered - */ - const modifiedAction = Object.assign({}, action, { - colors: { rgb: colorsByRGB }, - colorScale - }); - - return next(modifiedAction); -}; - -export default updateCellColorsMiddleware; diff --git a/client/src/reducers/controls.js b/client/src/reducers/controls.js index 8c3f716e..570505f1 100644 --- a/client/src/reducers/controls.js +++ b/client/src/reducers/controls.js @@ -2,8 +2,12 @@ import _ from "lodash"; -import { World, WorldUtil, ControlsHelper } from "../util/stateManager"; -import parseRGB from "../util/parseRGB"; +import { + World, + WorldUtil, + ControlsHelpers, + createColors +} from "../util/stateManager"; import Crossfilter from "../util/typedCrossfilter"; import * as globals from "../globals"; import { @@ -29,7 +33,6 @@ const Controls = ( // all of the data + selection state world: null, - colorRGB: null, categoricalSelectionState: null, crossfilter: null, dimensionMap: null, @@ -37,8 +40,11 @@ const Controls = ( userDefinedGenesLoading: false, diffexpGenes: [], + // graph color-by + colorMode: null, colorAccessor: null, - colorScale: null, + colors: {}, + resettingInterface: false, opacityForDeselectedCells: 0.2, @@ -83,10 +89,9 @@ const Controls = ( /* first light - create world & other data-driven defaults */ const { universe } = action; const world = World.createWorldFromEntireUniverse(universe); - const colorRGB = new Array(universe.nObs).fill( - parseRGB(globals.defaultCellColor) - ); - const categoricalSelectionState = ControlsHelper.createCategoricalSelectionState( + const colorMode = null; + const colors = createColors(world, colorMode); + const categoricalSelectionState = ControlsHelpers.createCategoricalSelectionState( state, world ); @@ -101,28 +106,23 @@ const Controls = ( universe, fullUniverseCache: { world, crossfilter, dimensionMap }, world, - colorRGB, categoricalSelectionState, crossfilter, dimensionMap, + colorMode, colorAccessor: null, + colors, resettingInterface: false }; } case "reset World to eq Universe": { - const { - userDefinedGenes, - diffexpGenes, - universe, - fullUniverseCache - } = state; + const { userDefinedGenes, diffexpGenes, fullUniverseCache } = state; const { world, crossfilter } = fullUniverseCache; // reset all crossfilter dimensions _.forEach(fullUniverseCache.dimensionMap, dim => dim.filterAll()); - const colorRGB = new Array(universe.nObs).fill( - parseRGB(globals.defaultCellColor) - ); - const categoricalSelectionState = ControlsHelper.createCategoricalSelectionState( + const colorMode = null; + const colors = createColors(world, colorMode); + const categoricalSelectionState = ControlsHelpers.createCategoricalSelectionState( state, world ); @@ -135,7 +135,7 @@ const Controls = ( }); const dimensionMap = { ...fullUniverseCache.dimensionMap, - ...ControlsHelper.createGenesDimMap( + ...ControlsHelpers.createGenesDimMap( userDefinedGenes, diffexpGenes, world, @@ -147,16 +147,22 @@ const Controls = ( return { ...state, world, - colorRGB, categoricalSelectionState, crossfilter, dimensionMap, + colorMode, colorAccessor: null, + colors, resettingInterface: false }; } case "set World to current selection": { - const { userDefinedGenes, diffexpGenes } = state; + const { + userDefinedGenes, + diffexpGenes, + colorMode, + colorAccessor + } = state; /* Set viewable world to be the currently selected data */ const world = World.createWorldFromCurrentSelection( @@ -164,17 +170,15 @@ const Controls = ( action.world, action.crossfilter ); - const colorRGB = new Array(world.nObs).fill( - parseRGB(globals.defaultCellColor) - ); - const categoricalSelectionState = ControlsHelper.createCategoricalSelectionState( + const colors = createColors(world, colorMode, colorAccessor); + const categoricalSelectionState = ControlsHelpers.createCategoricalSelectionState( state, world ); const crossfilter = Crossfilter(world.obsAnnotations); const dimensionMap = { ...World.createObsDimensionMap(crossfilter, world), - ...ControlsHelper.createGenesDimMap( + ...ControlsHelpers.createGenesDimMap( userDefinedGenes, diffexpGenes, world, @@ -188,11 +192,10 @@ const Controls = ( loading: false, error: null, world, - colorRGB, + colors, categoricalSelectionState, crossfilter, - dimensionMap, - colorAccessor: null + dimensionMap }; } case "expression load success": { @@ -239,11 +242,11 @@ const Controls = ( Object.keys(action.expressionData) ) ); - universeVarData = ControlsHelper.pruneVarDataCache( + universeVarData = ControlsHelpers.pruneVarDataCache( universeVarData, allTheGenesWeNeed ); - worldVarData = ControlsHelper.pruneVarDataCache( + worldVarData = ControlsHelpers.pruneVarDataCache( worldVarData, allTheGenesWeNeed ); @@ -372,13 +375,11 @@ const Controls = ( } case "reset colorscale": { const { world } = state; - const colorRGB = new Array(world.nObs).fill( - parseRGB(globals.defaultCellColor) - ); return { ...state, - colorRGB, - colorAccessor: null + colorMode: null, + colorAccessor: null, + colors: createColors(world) }; } case "expression load error": @@ -476,7 +477,7 @@ const Controls = ( // update the filter to match all selected options const cat = newCategoricalSelectionState[action.metadataField]; state.dimensionMap[obsAnnoDimensionName(action.metadataField)].filterEnum( - ControlsHelper.selectedValuesForCategory(cat) + ControlsHelpers.selectedValuesForCategory(cat) ); return { @@ -500,7 +501,7 @@ const Controls = ( // update the filter to match all selected options const cat = newCategoricalSelectionState[action.metadataField]; state.dimensionMap[obsAnnoDimensionName(action.metadataField)].filterEnum( - ControlsHelper.selectedValuesForCategory(cat) + ControlsHelpers.selectedValuesForCategory(cat) ); return { @@ -552,19 +553,21 @@ const Controls = ( *******************************/ case "color by categorical metadata": case "color by continuous metadata": { + const { world } = state; return { ...state, - colorRGB: action.colors.rgb, + colorMode: action.type, colorAccessor: action.colorAccessor, - colorScale: action.colorScale + colors: createColors(world, action.type, action.colorAccessor) }; } case "color by expression": { + const { world } = state; return { ...state, - colorRGB: action.colors.rgb, + colorMode: action.type, colorAccessor: action.gene, - colorScale: action.colorScale + colors: createColors(world, action.type, action.gene) }; } diff --git a/client/src/reducers/index.js b/client/src/reducers/index.js index b13b9784..8c61d76f 100644 --- a/client/src/reducers/index.js +++ b/client/src/reducers/index.js @@ -2,7 +2,6 @@ import { combineReducers, createStore, applyMiddleware } from "redux"; import thunk from "redux-thunk"; import { composeWithDevTools } from "redux-devtools-extension"; -import updateCellColors from "../middleware/updateCellColors"; import config from "./config"; import differential from "./differential"; @@ -16,9 +15,6 @@ const Reducer = combineReducers({ differential }); -const store = createStore( - Reducer, - composeWithDevTools(applyMiddleware(thunk, updateCellColors)) -); +const store = createStore(Reducer, composeWithDevTools(applyMiddleware(thunk))); export default store; diff --git a/client/src/util/stateManager/colorHelpers.js b/client/src/util/stateManager/colorHelpers.js new file mode 100644 index 00000000..ced86459 --- /dev/null +++ b/client/src/util/stateManager/colorHelpers.js @@ -0,0 +1,122 @@ +/* +Helper functions for the embedded graph colors +*/ +import _ from "lodash"; +import * as d3 from "d3"; +import { interpolateRainbow, interpolateCool } from "d3-scale-chromatic"; +import * as globals from "../../globals"; +import parseRGB from "../parseRGB"; +import finiteExtent from "../finiteExtent"; + +/* +create new colors state object. Paramters: + - world - current world object + - mode - color-by mode. One of: null, "color by expression", + "color by continuous metadata", "color by categorical metadata" + - +*/ +function createColors(world, colorMode = null, colorAccessor = null) { + switch (colorMode) { + case "color by categorical metadata": { + return createColorsByCategoricalMetadata(world, colorAccessor); + } + case "color by continuous metadata": { + return createColorsByContinuousMetadata(world, colorAccessor); + } + case "color by expression": { + return createColorsByExpression(world, colorAccessor); + } + default: { + const defaultCellColor = parseRGB(globals.defaultCellColor); + return { + rgb: new Array(world.nObs).fill(defaultCellColor), + scale: undefined + }; + } + } +} + +function createColorsByCategoricalMetadata(world, accessor) { + const { categories } = _.filter(world.schema.annotations.obs, { + name: accessor + })[0]; + + const scale = d3 + .scaleSequential(interpolateRainbow) + .domain([0, categories.length]); + + /* pre-create colors - much faster than doing it for each obs */ + const colors = categories.reduce((acc, cat, idx) => { + acc[cat] = parseRGB(scale(idx)); + return acc; + }, {}); + + const rgb = new Array(world.nObs); + const data = world.obsAnnotations.col(accessor).asArray(); + for (let i = 0, len = world.obsAnnotations.length; i < len; i += 1) { + const cat = data[i]; + rgb[i] = colors[cat]; + } + return { rgb, scale }; +} + +function createColorsByContinuousMetadata(world, accessor) { + const colorBins = 100; + const col = world.obsAnnotations.col(accessor); + const { min, max } = col.summarize(); + const scale = d3 + .scaleQuantile() + .domain([min, max]) + .range(_.range(colorBins - 1, -1, -1)); + + /* pre-create colors - much faster than doing it for each obs */ + const colors = new Array(colorBins); + for (let i = 0; i < colorBins; i += 1) { + colors[i] = parseRGB(interpolateCool(i / colorBins)); + } + + const nonFiniteColor = parseRGB(globals.nonFiniteCellColor); + const rgb = new Array(world.nObs); + const data = col.asArray(); + for (let i = 0, len = world.obsAnnotations.length; i < len; i += 1) { + const val = data[i]; + if (Number.isFinite(val)) { + const c = scale(val); + rgb[i] = colors[c]; + } else { + rgb[i] = nonFiniteColor; + } + } + return { rgb, scale }; +} + +function createColorsByExpression(world, accessor) { + const expression = world.varData.col(accessor).asArray(); + const colorBins = 100; + const [min, max] = finiteExtent(expression); + const scale = d3 + .scaleQuantile() + .domain([min, max]) + .range(_.range(colorBins - 1, -1, -1)); + + /* pre-create colors - much faster than doing it for each obs */ + const colors = new Array(colorBins); + for (let i = 0; i < colorBins; i += 1) { + colors[i] = parseRGB(interpolateCool(i / colorBins)); + } + const nonFiniteColor = parseRGB(globals.nonFiniteCellColor); + + const rgb = new Array(world.nObs); + for (let i = 0, len = expression.length; i < len; i += 1) { + const e = expression[i]; + if (Number.isFinite(e)) { + const c = scale(e); + rgb[i] = colors[c]; + } else { + rgb[i] = nonFiniteColor; + } + } + return { rgb, scale }; +} + +export default createColors; diff --git a/client/src/util/stateManager/index.js b/client/src/util/stateManager/index.js index 8008d5f5..151d48de 100644 --- a/client/src/util/stateManager/index.js +++ b/client/src/util/stateManager/index.js @@ -14,7 +14,8 @@ This is all VERY tightly integrated with reducers and actions, and exists to support those concepts. */ +export { default as createColors } from "./colorHelpers"; export * as Universe from "./universe"; export * as World from "./world"; export * as WorldUtil from "./worldUtil"; -export * as ControlsHelper from "./controlsHelpers"; +export * as ControlsHelpers from "./controlsHelpers";