Redux refactor (#1571)

* refactor categorical controls state

* lint

* fix race condition in tests

* fix typo

* add missing update on subset

* remove obsolete code

* update jest and puppeteer major version; update all minors

* update when label changes

* remove lint from tests; increase timeouts in e2e tests

* initial refactoring to new async annomatrix

* refine error handling

* fix bad merge

* add continuous legend

* lint

* fix memoization in color table creators

* partial implementation of user defined annotations

* add new annotations action creator file

* first pass at user annotations

* additional user annotation bug fixes

* user annotation auto-save

* unit test cleanup

* lint

* refactor into multiple files

* cleanup

* add column GC

* fix several bugs in user annotations

* remove debug code

* no anonymous functions

* undo redo cleanup

* file cleanup

* scatterplot

* performance

* cleanup

* remove old code

* render in parallel with load

* fix race condition

* simply graph rendering

* render throttle DRY

* fix category label order

* fix typo in e2e test setup

* re-fix the e2e test setup

* be more tolerant of races

* anno matrix unit tests

* temp disable reembedding

* pilot port continuous histo to react-async

* name change

* lint

* fix repaint bug

* typo fix

* update snap to match new ids

* world/universe name cleanup

* move annoMatrix to src dir

* use private underscore naming convention

* fix corner case in all selected

* name cleanup

* add layout control

* init edge case

* lint

* port scatterplot

* fix label indexing bug and improve tests

* port category to react-async

* fix user annotation labelling while subset

* select all of prev layout on layout switch

* fix race with crossfilter update

* prettier lint

* fix misleading comment

* fix url composition in loader

* first pass at crossfilter tests

* lint

* lint

* fix typo

* improved error handling for network errors

* fix memoization bug

* add memo

* refactor for performnce

* add missing single-value handling in select exact parser

* small bugs discovered by tests

* lint

* additional crossfilter unit tests

* remove extraneous comment

* add support for automatic category determination

* lint

* fix render bug in category

* take advantage of schema categories guarantee

* lint

* do not clear history when resetting

* enhanced annomatrix gc

* lint

* finish renaming to follow conventions; fix clone race bug

* lint

* add priority based loading to improve initial data load UX

* crossfilter cache perf

* perf tuning

* remove timers

* documentation

* PR review changes

* PR review changes

* more PR review edits

* improve clarity of comment

* more PR review fixes

* port centroidLabels to use react-async

* remove dead code

* pr review updates

* oops, remove logging
This commit is contained in:
Bruce Martin
2020-07-14 13:53:33 -07:00
committed by GitHub
parent f69d141336
commit 1269e188be
95 changed files with 18051 additions and 5382 deletions
@@ -2,16 +2,13 @@
Helper functions for user-editable annotations state management.
See also reducers/annotations.js
*/
import { unassignedCategoryLabel } from "../../globals";
import * as SchemaHelpers from "./schemaHelpers";
import { obsAnnoDimensionName } from "../nameCreators";
/*
There are a number of state constraints assumed throughout the
application:
- all obs annotations are in {world|universe}.obsAnnotations,
- all obs annotations are in annoMatrix,
regardless of whether or not they are user editable.
- the {world|universe}.schema is always up to date and matches
- the annoMatrix.schema is always up to date and matches
the data
- the schema flag `writable` correctly indicates whether
the annotation is editable/mutable.
@@ -31,105 +28,11 @@ export function isContinuousAnnotation(schema, name) {
}
function _isUserAnnotation(schema, name) {
return schema.annotations.obsByName[name]?.writable;
return schema.annotations.obsByName[name]?.writable || false;
}
export function isUserAnnotation(worldOrUniverse, name) {
return _isUserAnnotation(worldOrUniverse.schema, name);
}
export function removeObsAnnoSchema(schema, name) {
/*
remove named annotation from obs annotation schema
*/
/* only remove if it exists and is a user annotation */
if (!_isUserAnnotation(schema, name))
throw new Error("removing non-user-defined schema");
return SchemaHelpers.removeObsAnnoColumn(schema, name);
}
export function addObsAnnoSchema(schema, name, colSchema) {
/*
add a categorical type to the obs annotation schema
*/
/* collision detection */
if (schema.annotations.obs.columns.some((v) => v.name === name))
throw Error("annotations may not contain duplicate category names");
if (name !== colSchema.name) throw Error("column schema does not match");
return SchemaHelpers.addObsAnnoColumn(schema, name, colSchema);
}
export function dupObsAnnoSchema(schema, sourceName, dupName, defaultSchema) {
/*
duplicate the obs annotation `sourceName` schema, but with the name `dupName`
*/
const colSchema = {
...schema.annotations.obsByName[sourceName],
...defaultSchema,
name: dupName,
};
/* existance check */
if (!colSchema) throw Error("source annotation does not exist");
/* collision detection */
if (schema.annotations.obs.columns.some((v) => v.name === dupName))
throw Error("annotations may not contain duplicate category names");
return SchemaHelpers.addObsAnnoColumn(schema, dupName, colSchema);
}
export function removeObsAnnoCategory(schema, name, category) {
/* don't allow deletion of unassigned category on writable annotations */
if (!_isUserAnnotation(schema, name))
throw new Error("unable to modify read-only schema");
if (category === unassignedCategoryLabel)
throw new Error("may not remove unassigned category label");
return SchemaHelpers.removeObsAnnoCategory(schema, name, category);
}
export function addObsAnnoCategory(schema, name, category) {
if (!_isUserAnnotation(schema, name))
throw new Error("unable to modify read-only schema");
return SchemaHelpers.addObsAnnoCategory(schema, name, category);
}
export function setLabelByValue(df, colName, fromLabel, toLabel) {
/*
in the dataframe column `colName`, set any value of `fromLabel` to `toLabel`
*/
const keys = df.colIndex.labels();
const ndf = df.mapColumns((col, colIdx) => {
if (colName !== keys[colIdx]) return col;
/* clone data and return it. */
const newCol = col.slice();
for (let i = 0, l = newCol.length; i < l; i += 1) {
if (newCol[i] === fromLabel) newCol[i] = toLabel;
}
return newCol;
});
return ndf;
}
export function setLabelByMask(df, colName, mask, label) {
/*
in the dataframe column `colName`, set the masked rows to 'label'
*/
const keys = df.colIndex.labels();
const ndf = df.mapColumns((col, colIdx) => {
if (colName !== keys[colIdx]) return col;
/* clone data and return it. */
const newCol = col.slice();
for (let i = 0, l = newCol.length; i < l; i += 1) {
if (mask[i]) newCol[i] = label;
}
return newCol;
});
return ndf;
export function isUserAnnotation(annoMatrix, name) {
return _isUserAnnotation(annoMatrix.schema, name);
}
export function allHaveLabelByMask(df, colName, label, mask) {
@@ -148,41 +51,6 @@ export function allHaveLabelByMask(df, colName, label, mask) {
return true;
}
export function worldToUniverseMask(worldMask, worldObsAnnotations, nObs) {
/*
given world seleciton mask, return a selection mask for entire universe
that has same selection state.
*/
const mask = new Uint8Array(nObs);
const { rowIndex } = worldObsAnnotations;
for (let i = 0, l = worldMask.length; i < l; i += 1) {
if (worldMask[i]) {
const label = rowIndex.getLabel(i);
mask[label] = 1;
}
}
return mask;
}
export function createWritableAnnotationDimensions(world, crossfilter) {
const { obsAnnotations, schema } = world;
const writableAnnotations = schema.annotations.obs.columns
.filter((s) => s.writable)
.map((s) => s.name);
crossfilter = writableAnnotations.reduce((xflt, anno) => {
const dimName = obsAnnoDimensionName(anno);
if (xflt.hasDimension(dimName)) xflt = xflt.delDimension(dimName);
return xflt.addDimension(
dimName,
"enum",
obsAnnotations.col(anno).asArray()
);
}, crossfilter);
return crossfilter;
}
const legalCharacters = /^(\w|[ .()-])+$/;
export function annotationNameIsErroneous(name) {
/*
+85 -89
View File
@@ -3,46 +3,93 @@ Helper functions for the embedded graph colors
*/
import * as d3 from "d3";
import { interpolateRainbow, interpolateCool } from "d3-scale-chromatic";
import memoize from "memoize-one";
import * as globals from "../../globals";
import parseRGB from "../parseRGB";
import finiteExtent from "../finiteExtent";
import { range } from "../range";
/*
create new colors state object. Paramters:
- world - current world object
- colorMode - color-by mode. One of {null, "color by expression", "color by continuous metadata",
"color by categorical metadata"}
- colorAccessor - the obs annotations used for color-by
given a color mode & accessor, generate an annoMatrix query that will
fulfill it
*/
export function createColors(
world,
colorMode = null,
colorAccessor = null,
export function createColorQuery(colorMode, colorByAccessor, schema) {
if (!colorMode || !colorByAccessor || !schema) return null;
switch (colorMode) {
case "color by categorical metadata":
case "color by continuous metadata": {
return ["obs", colorByAccessor];
}
case "color by expression": {
const varIndex = schema?.annotations?.var?.index;
if (!varIndex) return null;
return [
"X",
{
field: "var",
column: varIndex,
value: colorByAccessor,
},
];
}
default: {
return null;
}
}
}
function _defaultColors(nObs) {
const defaultCellColor = parseRGB(globals.defaultCellColor);
return {
rgb: new Array(nObs).fill(defaultCellColor),
scale: undefined,
};
}
const defaultColors = memoize(_defaultColors);
/*
create colors scale and RGB array and return as object. Parameters:
* colorMode - categorical, etc.
* colorByAccessor - the annotation label name
* colorByDataframe - the actual color-by data
* schema - the entire schema
* userColors - optional user color table
Returns:
{
scale: color scale
rgb: cell to color mapping
}
*/
function _createColorTable(
colorMode,
colorByAccessor,
colorByData,
schema,
userColors = null
) {
switch (colorMode) {
case "color by categorical metadata": {
if (userColors && colorAccessor in userColors) {
return createUserColors(world, colorAccessor, userColors);
const data = colorByData.col(colorByAccessor).asArray();
if (userColors && colorByAccessor in userColors) {
return createUserColors(data, colorByAccessor, userColors);
}
return createColorsByCategoricalMetadata(world, colorAccessor);
return createColorsByCategoricalMetadata(data, colorByAccessor, schema);
}
case "color by continuous metadata": {
return createColorsByContinuousMetadata(world, colorAccessor);
const col = colorByData.col(colorByAccessor);
const { min, max } = col.summarize();
return createColorsByContinuousMetadata(col.asArray(), min, max);
}
case "color by expression": {
return createColorsByExpression(world, colorAccessor);
const col = colorByData.icol(0);
const { min, max } = col.summarize();
return createColorsByContinuousMetadata(col.asArray(), min, max);
}
default: {
const defaultCellColor = parseRGB(globals.defaultCellColor);
return {
rgb: new Array(world.nObs).fill(defaultCellColor),
scale: undefined,
};
return defaultColors(schema.dataframe.nObs);
}
}
}
export const createColorTable = memoize(_createColorTable);
export function loadUserColorConfig(userColors) {
const convertedUserColors = {};
@@ -62,14 +109,15 @@ export function loadUserColorConfig(userColors) {
return convertedUserColors;
}
function createUserColors(world, colorAccessor, userColors) {
function _createUserColors(data, colorAccessor, userColors) {
const { colors, scale } = userColors[colorAccessor];
const rgb = createRgbArray(world, colors, colorAccessor);
const rgb = createRgbArray(data, colors);
return { rgb, scale };
}
const createUserColors = memoize(_createUserColors);
function createColorsByCategoricalMetadata(world, colorAccessor) {
const { categories } = world.schema.annotations.obsByName[colorAccessor];
function _createColorsByCategoricalMetadata(data, colorAccessor, schema) {
const { categories } = schema.annotations.obsByName[colorAccessor];
const scale = d3
.scaleSequential(interpolateRainbow)
@@ -81,25 +129,24 @@ function createColorsByCategoricalMetadata(world, colorAccessor) {
return acc;
}, {});
const rgb = createRgbArray(world, colors, colorAccessor);
const rgb = createRgbArray(data, colors);
return { rgb, scale };
}
const createColorsByCategoricalMetadata = memoize(
_createColorsByCategoricalMetadata
);
export function createRgbArray(world, colors, colorAccessor) {
const rgb = new Array(world.nObs);
const df = world.obsAnnotations;
const data = df.col(colorAccessor).asArray();
for (let i = 0, len = df.length; i < len; i += 1) {
function createRgbArray(data, colors) {
const rgb = new Array(data.length);
for (let i = 0, len = data.length; i < len; i += 1) {
const label = data[i];
rgb[i] = colors[label];
}
return rgb;
}
function createColorsByContinuousMetadata(world, accessor) {
function _createColorsByContinuousMetadata(data, min, max) {
const colorBins = 100;
const col = world.obsAnnotations.col(accessor);
const { min, max } = col.summarize();
const scale = d3
.scaleQuantile()
.domain([min, max])
@@ -112,9 +159,8 @@ function createColorsByContinuousMetadata(world, accessor) {
}
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 rgb = new Array(data.length);
for (let i = 0, len = data.length; i < len; i += 1) {
const val = data[i];
if (Number.isFinite(val)) {
const c = scale(val);
@@ -125,56 +171,6 @@ function createColorsByContinuousMetadata(world, accessor) {
}
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 const resetColors = (world) => {
const { rgb, scale } = createColors(world);
return {
colorMode: null,
colorAccessor: null,
rgb,
scale,
};
};
export const checkIfColorByDiffexpAndResetColors = (
prevControls,
state,
prevWorld
) => {
if (prevControls.diffexpGenes.includes(state.colorAccessor)) {
return {
...state,
...resetColors(prevWorld),
};
}
return null;
};
export const createColorsByContinuousMetadata = memoize(
_createColorsByContinuousMetadata
);
@@ -7,10 +7,6 @@ import _ from "lodash";
import * as globals from "../../globals";
import { rangeFill as fillRange } from "../range";
import fromEntries from "../fromEntries";
import {
userDefinedDimensionName,
diffexpDimensionName,
} from "../nameCreators";
import { isCategoricalAnnotation } from "./annotationsHelpers";
/*
@@ -40,7 +36,7 @@ Remember that option values can be ANY js type, except undefined/null.
}
*/
function topNCategories(colSchema, summary, N) {
/* return top N by occurrences in the data, preserving original category order */
/* return top N by occurrences in the data */
const { categories } = colSchema;
const counts = categories.map((cat) => summary.categoryCounts.get(cat) ?? 0);
@@ -81,19 +77,16 @@ export function selectableCategoryNames(schema, names) {
return names.filter((name) => isSelectableCategoryName(schema, name));
}
export function createCategorySummary(world, name) {
export function createCategorySummaryFromDfCol(dfCol, colSchema) {
const N = globals.maxCategoricalOptionsToDisplay;
const { obsAnnotations, schema } = world;
const colSchema = schema.annotations.obsByName[name];
const { writable: isUserAnno } = colSchema;
/*
Summarize the annotation data currently in world. Must return categoryValues
in sorted order, and must include all category values even if they are not
actively used in the current world.
Summarize the annotation data currently in dataframe column. Must return
categoryValues in sorted order, and must include all category values even
if they are not actively used in the current annoMatrix view.
*/
const summary = obsAnnotations.col(name).summarizeCategorical();
const summary = dfCol.summarizeCategorical();
const [categoryValues, categoryValueCounts] = topNCategories(
colSchema,
summary,
@@ -117,38 +110,6 @@ export function createCategoricalSelection(names) {
return fromEntries(names.map((name) => [name, new Map()]));
}
/*
build a crossfilter dimensions for all gene expression related dimensions.
*/
export function createGeneDimensions(
userDefinedGenes,
diffexpGenes,
world,
crossfilter
) {
crossfilter = userDefinedGenes.reduce(
(xflt, gene) =>
xflt.addDimension(
userDefinedDimensionName(gene),
"scalar",
world.varData.col(gene).asArray(),
Float32Array
),
crossfilter
);
crossfilter = diffexpGenes.reduce(
(xflt, gene) =>
xflt.addDimension(
diffexpDimensionName(gene),
"scalar",
world.varData.col(gene).asArray(),
Float32Array
),
crossfilter
);
return crossfilter;
}
export function pruneVarDataCache(varData, needed) {
/*
Remove any unneeded columns from the varData dataframe. Will only
-12
View File
@@ -1,22 +1,10 @@
// jshint esversion: 6
/*
Model manager providing an abstraction for the use of the reducer code.
This module provides several buckets of functionality:
- schema and config driven tranformation of the wire protocol
into a format that is easy for the UI code to use.
- manage the universe/world abstraction:
+ universe: all of the server-provided, read-only data
+ world: subset of universe
- lazy access and caching of dataframe contents as needed
This is all VERY tightly integrated with reducers and actions, and
exists to support those concepts.
*/
export * as ColorHelpers from "./colorHelpers";
export * as Universe from "./universe";
export * as World from "./world";
export * as ControlsHelpers from "./controlsHelpers";
export * as AnnotationsHelpers from "./annotationsHelpers";
export * as SchemaHelpers from "./schemaHelpers";
@@ -13,22 +13,21 @@ import catLabelSort from "../catLabelSort";
System wide schema assumptions:
- schema and data wil be consistent (eg, for user-created annotations)
- schema will be internally self-consistent (eg, index matches columns)
- world & universe schema are same - only data is subset
*/
export function indexEntireSchema(schema) {
/* Index schema for ease of use */
schema.annotations.obsByName = fromEntries(
schema.annotations.obs.columns.map((v) => [v.name, v])
schema.annotations?.obs?.columns?.map((v) => [v.name, v]) ?? []
);
schema.annotations.varByName = fromEntries(
schema.annotations.var.columns.map((v) => [v.name, v])
schema.annotations?.var?.columns?.map((v) => [v.name, v]) ?? []
);
schema.layout.obsByName = fromEntries(
schema.layout.obs.map((v) => [v.name, v])
schema.layout?.obs?.map((v) => [v.name, v]) ?? []
);
schema.layout.varByName = fromEntries(
schema.layout.var.map((v) => [v.name, v])
schema.layout?.var?.map((v) => [v.name, v]) ?? []
);
return schema;
-156
View File
@@ -1,156 +0,0 @@
import { unassignedCategoryLabel } from "../../globals";
import { decodeMatrixFBS } from "./matrix";
import * as Dataframe from "../dataframe";
import { isFpTypedArray } from "../typeHelpers";
import { indexEntireSchema } from "./schemaHelpers";
import catLabelSort from "../catLabelSort";
/*
Private helper function - create and return a template Universe
*/
function templateUniverse() {
/* default universe template */
return {
nObs: 0,
nVar: 0,
schema: {},
/*
annotations
*/
obsAnnotations: Dataframe.Dataframe.empty(),
varAnnotations: Dataframe.Dataframe.empty(),
/*
layout
*/
obsLayout: Dataframe.Dataframe.empty(),
/*
Var data columns - subset of all
*/
varData: Dataframe.Dataframe.empty(null, new Dataframe.KeyIndex()),
};
}
/*
This module implements functions that support storage of "Universe",
aka all of the var/obs data and annotations.
These functions are used exclusively by the actions and reducers to
build an internal POJO for use by the rendering components.
*/
export function createUniverseFromResponse(configResponse, schemaResponse) {
/*
build & return universe from a REST 0.2 /config, /schema and /annotations/obs response
*/
const { schema } = schemaResponse;
const universe = templateUniverse();
/* schema related */
universe.schema = schema;
universe.nObs = schema.dataframe.nObs;
universe.nVar = schema.dataframe.nVar;
/* add defaults, as we can't assume back-end will fully populate schema */
if (!schema.layout.var) schema.layout.var = [];
if (!schema.layout.obs) schema.layout.obs = [];
indexEntireSchema(universe.schema);
normalizeEntireSchema(universe.schema);
return universe;
}
function normalizeSchemaCategory(colSchema, col = undefined) {
const { type, writable } = colSchema;
if (type === "string" || type === "boolean" || type === "categorical") {
let categories = [
...new Set([
...(colSchema.categories ?? []),
...(col?.summarize?.().categories ?? []),
]),
];
if (writable && categories.indexOf(unassignedCategoryLabel) === -1) {
categories = categories.concat(unassignedCategoryLabel);
}
colSchema.categories = categories;
} else if (writable) {
throw new Error(
"Writable continuous obs annotations are not supported - failed to load"
);
}
if (colSchema.categories) {
colSchema.categories = catLabelSort(writable, colSchema.categories);
}
}
function normalizeEntireSchema(schema) {
// currently only needed for obsAnnotations
schema.annotations.obs.columns.forEach((colSchema) =>
normalizeSchemaCategory(colSchema)
);
}
export function addObsAnnotations(universe, df) {
const obsAnnotations = universe.obsAnnotations.withColsFromAll(df);
if (universe.nObs !== obsAnnotations.length) {
throw new Error("Universe dimensionality mismatch - failed to load");
}
// for all of the new data, reconcile with schema and sort categories.
const dfs = Array.isArray(df) ? df : [df];
const keys = dfs.map((d) => d.colIndex.labels()).flat();
const { schema } = universe;
keys.forEach((k) => {
const colSchema = schema.annotations.obsByName[k];
const col = obsAnnotations.col(k);
normalizeSchemaCategory(colSchema, col);
});
return { obsAnnotations, schema };
}
export function addVarAnnotations(universe, df) {
const varAnnotations = universe.varAnnotations.withColsFromAll(df);
if (universe.nVar !== varAnnotations.length) {
throw new Error("Universe dimensionality mismatch - failed to load");
}
return { varAnnotations };
}
export function addObsLayout(universe, df) {
const obsLayout = universe.obsLayout.withColsFromAll(df);
if (universe.nObs !== obsLayout.length) {
throw new Error("Universe dimensionality mismatch - failed to load");
}
return { obsLayout };
}
export function convertDataFBStoObject(universe, arrayBuffer) {
/*
/data/var returns a flatbuffer (FBS) as described by cellxgene/fbs/matrix.fbs
This routine converts the binary wire encoding into a JS object:
{
gene: Float32Array,
...
}
*/
const fbs = decodeMatrixFBS(arrayBuffer);
const { colIdx, columns } = fbs;
const result = {};
if (!columns.every(isFpTypedArray)) {
// We have strong assumptions that all var data is float
throw new Error("Unexpected non-floating point response from server.");
}
const varIndexName = universe.schema.annotations.var.index;
for (let c = 0; c < colIdx.length; c += 1) {
const varName = universe.varAnnotations.at(colIdx[c], varIndexName);
result[varName] = columns[c];
}
return result;
}
-335
View File
@@ -1,335 +0,0 @@
import clip from "../clip";
import { layoutDimensionName, obsAnnoDimensionName } from "../nameCreators";
import * as Dataframe from "../dataframe";
import { isContinuousAnnotation } from "./annotationsHelpers";
/*
World is a subset of universe. Most code should use world, and should
(generally) not use Universe. World contains any per-obs or per-var data
that must be consistent acorss the app when we view/manipulate subsets
of Universe.
Private API indicated by leading underscore in key name (eg, _foo). Anything else
is public.
Notable keys in the world object:
* nObs, nVar: dimensions
* schema: data schema from the server
* clipQuantiles: the quantiles used to clip all data in world.
* obsAnnotations:
Dataframe containing obs annotations. Columns are indexed by annotation
name (eg, 'tissue type'), and rows are indexed by the REST API obsIndex
(ie, the offset into the underlying server-side dataframe).
This indexing means that you can access data by _either_ the server's
obxIndex, or the offset into the client-side column array . Be careful
to know which you want and are using.
* obsLayout:
A dataframe containing the X/Y layout for all obs. Columns are named
'X' and 'Y', and rows are indexed in the same way as obsAnnotation.
* varData: a cache of expression columns, stored in a Dataframe. Cache
managed by controls reducer.
* unclipped: will contain unclipped variants of all potentiall clipped
dataframes (obsAnnotations, varData).
*/
function templateWorld() {
const obsAnnotations = Dataframe.Dataframe.empty();
const varAnnotations = Dataframe.Dataframe.empty();
const obsLayout = Dataframe.Dataframe.empty();
const varData = Dataframe.Dataframe.empty(null, new Dataframe.KeyIndex());
return {
/* schema/version related */
schema: null,
nObs: 0,
nVar: 0,
clipQuantiles: { min: 0, max: 1 },
/* annotations */
obsAnnotations,
varAnnotations,
/* layout of graph. Dataframe. */
obsLayout,
/* Var data columns - subset of all data (may be empty) */
varData,
/* unclipped dataframes - subset, but not value clipped */
unclipped: {
obsAnnotations,
varData,
},
};
}
function clipDataframe(
df,
lowerQuantile,
upperQuantile,
quantileF,
clipPredicate = () => true,
value = Number.NaN
) {
/*
For all columns in the dataframe, clip all values above or below specified
quantiles to `value` if clipPredicate returns True for that column (if it
returns false, skip the column entirely).
Returns a clipped copy - does not mutate original.
clipPredicate must have signature: (dataframe, colIndex, colLabel) => boolean
True signifies that the column should be clipped; false indicates that the
column should be left intact/unchanged.
quantileF must have signature: (label, qval) => number
*/
if (lowerQuantile < 0) lowerQuantile = 0;
if (upperQuantile > 1) upperQuantile = 1;
if (lowerQuantile === 0 && upperQuantile === 1) return df;
const keys = df.colIndex.labels();
return df.mapColumns((col, colIdx) => {
const colLabel = keys[colIdx];
if (!clipPredicate(df, colIdx, colLabel)) return col;
const colMin = quantileF(colLabel, lowerQuantile);
const colMax = quantileF(colLabel, upperQuantile);
const newCol = clip(col.slice(), colMin, colMax, value);
return newCol;
});
}
/*
Create World with contents eq entire universe. Commonly used to initialize World.
*/
export function createWorldFromEntireUniverse(universe) {
const world = templateWorld();
/* Schema related */
world.schema = universe.schema;
world.nObs = universe.nObs;
world.nVar = universe.nVar;
world.clipQuantiles = { min: 0, max: 1 };
/* dataframes: annotations and layout */
world.obsAnnotations = universe.obsAnnotations.clone();
world.varAnnotations = universe.varAnnotations.clone();
world.obsLayout = universe.obsLayout.clone();
/* Var dataframe - contains a subset of all var columns */
world.varData = universe.varData.clone();
/* save unclipped copies of potentially clipped dataframes */
world.unclipped = {
obsAnnotations: world.obsAnnotations.clone(),
varData: world.varData.clone(),
};
return world;
}
/*
clip dataframes based on quantiles.
This is an in-place operation on the world object provided as an argument.
The values in world.unclipped are clipped and assigned to world.obsAnnotations
and world.varData.
*/
function setClippedDataframes(world) {
const { schema } = world;
const isContinuousObsAnnotation = (df, idx, label) =>
isContinuousAnnotation(schema, label);
const obsQuantile = (label, q) =>
world.unclipped.obsAnnotations.col(label).summarize().percentiles[100 * q];
world.obsAnnotations = clipDataframe(
world.unclipped.obsAnnotations,
world.clipQuantiles.min,
world.clipQuantiles.max,
obsQuantile,
isContinuousObsAnnotation
);
const varDataQuantile = (label, q) =>
world.unclipped.varData.col(label).summarize().percentiles[100 * q];
world.varData = clipDataframe(
world.unclipped.varData,
world.clipQuantiles.min,
world.clipQuantiles.max,
varDataQuantile,
() => true
);
}
/*
Subset the current world based upon the current selection, maintaining any existing
clip. Returns new world. Parameters:
* universe
* world - the current world
* crossfilter - the selection state
*/
export function createWorldBySelection(universe, world, crossfilter) {
const newWorld = { ...world, obsLayout: null, unclipped: {}, varData: null };
/* subset unclipped dataframes based upon current selection */
const mask = crossfilter.allSelectedMask();
newWorld.obsLayout = world.obsLayout.isubsetMask(mask);
newWorld.unclipped.obsAnnotations = world.unclipped.obsAnnotations.isubsetMask(
mask
);
if (world.unclipped.varData.isEmpty()) {
newWorld.unclipped.varData = world.unclipped.varData.clone();
} else {
newWorld.unclipped.varData = world.unclipped.varData.isubsetMask(mask);
}
/* subsetting changings dimension size */
newWorld.nObs = newWorld.unclipped.obsAnnotations.dims[0];
/* and now clip */
setClippedDataframes(newWorld);
return newWorld;
}
/*
Change clip quantiles on the current world, returning a new world.
Parameters:
* universe
* world - current world
* clipQuantiles - new clip
*/
export function createWorldWithNewClip(
universe,
world,
crossfilter,
clipQuantiles
) {
const newWorld = { ...world, obsAnnotation: null, varData: null };
newWorld.clipQuantiles = clipQuantiles;
newWorld.obsLayout = world.obsLayout.clone();
newWorld.unclipped = {
obsAnnotations: world.unclipped.obsAnnotations.clone(),
varData: world.unclipped.varData.clone(),
};
/* and now clip */
setClippedDataframes(newWorld);
return newWorld;
}
/*
Deduce the correct crossfilter dimension type from a metadata
schema description.
*/
function deduceDimensionType(attributes, fieldName) {
let dimensionType;
const { type } = attributes;
if (type === "string" || type === "categorical" || type === "boolean") {
dimensionType = "enum";
} else if (type === "int32") {
dimensionType = Int32Array;
} else if (type === "float32") {
dimensionType = Float32Array;
} else {
/*
Currently not supporting boolean and categorical types.
*/
console.error(
`Warning - REST API returned unknown metadata schema (${type}) for field ${fieldName}.`
);
// skip it - we don't know what to do with this type
}
return dimensionType;
}
function addObsDimension(crossfilter, world, anno) {
/*
add single dimension to the crosfilter
*/
const { obsAnnotations } = world;
if (obsAnnotations.hasCol(anno.name)) {
const dimType = deduceDimensionType(anno, anno.name);
const colData = obsAnnotations.col(anno.name).asArray();
const name = obsAnnoDimensionName(anno.name);
if (dimType === "enum") {
return crossfilter.addDimension(name, "enum", colData);
}
if (dimType) {
return crossfilter.addDimension(name, "scalar", colData, dimType);
}
}
return crossfilter;
}
export function addObsDimensions(crossfilter, world) {
/*
Add to crossfilter any dimension present in world.obsAnnotations
but not yet in the crossfilter
*/
const schema = world.schema.annotations.obsByName;
const dimsWeNeed = world.obsAnnotations.colIndex.labels();
crossfilter = dimsWeNeed.reduce((xfltr, name) => {
const dimName = obsAnnoDimensionName(name);
if (xfltr.hasDimension(dimName)) return xfltr;
return addObsDimension(xfltr, world, schema[name]);
}, crossfilter);
return crossfilter;
}
export function createObsDimensions(crossfilter, world, XYdimNames) {
/*
create and return a crossfilter with a dimension for every obs annotation
for which we have a supported type, *except* for the index column, indicated
by schema.annotations.obs.index.
*/
const { schema, obsLayout } = world;
const indexName = schema.annotations.obs.index;
const annoList = schema.annotations.obs.columns.filter(
(anno) => anno.name !== indexName
);
crossfilter = annoList.reduce((xfltr, anno) => {
return addObsDimension(xfltr, world, anno);
}, crossfilter);
return crossfilter.addDimension(
layoutDimensionName("XY"),
"spatial",
obsLayout.col(XYdimNames[0]).asArray(),
obsLayout.col(XYdimNames[1]).asArray()
);
}
export function worldEqUniverse(world, universe) {
return (
world.obsAnnotations === universe.obsAnnotations ||
world.obsAnnotations.rowIndex === universe.obsAnnotations.rowIndex
);
}
export function getSelectedByIndex(crossfilter) {
/*
return array of obsIndex, containing all selected obs/cells.
*/
const selected = crossfilter.allSelectedMask(); // array of bool-ish
const keys = crossfilter.data.rowIndex.labels(); // row keys, aka universe rowIndex
const set = new Int32Array(selected.length);
let numElems = 0;
for (let i = 0, l = selected.length; i < l; i += 1) {
if (selected[i]) {
set[numElems] = keys[i];
numElems += 1;
}
}
return new Int32Array(set.buffer, 0, numElems);
}