Merge branch 'main' into colinmegill/geneset-prototype

This commit is contained in:
Colin Megill
2021-03-05 16:14:49 -08:00
185 changed files with 16613 additions and 253 deletions
+42 -29
View File
@@ -390,41 +390,51 @@ export const saveObsAnnotationsAction = () => async (dispatch, getState) => {
export const saveGenesetsAction = () => async (dispatch, getState) => {
const state = getState();
const { config, genesets, annotations } = state;
// bail if gene sets not available, or in readonly mode.
const { config } = state;
const genesetsAreAvailable =
config?.parameters?.["annotations_genesets"] ?? false;
const genesetsReadonly =
config?.parameters?.["annotations_genesets_readonly"] ?? true;
if (!genesetsAreAvailable || genesetsReadonly) {
// our non-save was completed!
dispatch({
return dispatch({
type: "autosave: genesets complete",
lastSavedGenesets: genesets,
lastSavedGenesets: state.genesets,
});
}
/*
JSON data structure is an array of arrays, where the first
element is gene set name, remainder are the genes. Eg,
const { lastTid, genesets: lastGenesets } = state.genesets;
{
"genesets": [
[ "gs1", ["TNFRSF4","SUMO3","BRWD1"]],
[ "gs2", ["DSCR3", "BRWD1", "BACE2", "SIK1", "C21orf33", "ICOSLG", "SUMO3"]]
]
/* Create the JSON OTA data structure */
const tid = (lastTid ?? 0) + 1;
const genesets = [];
for (const [name, gs] of lastGenesets) {
const genes = [];
for (const g of gs.genes.values()) {
genes.push({
gene_symbol: g.geneSymbol,
gene_description: g.geneDescription,
});
}
Order of gene sets and genes is significant
*/
const gsArr = [];
for (const [gsName, gsGenes] of genesets.genesets) {
gsArr.push([gsName, Array.from(gsGenes)]);
genesets.push({
geneset_name: name,
geneset_description: gs.genesetDescription,
genes,
});
}
const ota = {
tid,
genesets,
};
/* Save to server */
try {
const { dataCollectionNameIsReadOnly, dataCollectionName } = annotations;
const {
dataCollectionNameIsReadOnly,
dataCollectionName,
} = state.annotations;
const queryString =
!dataCollectionNameIsReadOnly && !!dataCollectionName
? `?annotation-collection-name=${encodeURIComponent(
@@ -440,26 +450,29 @@ export const saveGenesetsAction = () => async (dispatch, getState) => {
Accept: "application/json",
"Content-Type": "application/json",
}),
body: JSON.stringify({
genesets: gsArr,
}),
body: JSON.stringify(ota),
credentials: "include",
}
);
if (res.ok) {
dispatch({
type: "autosave: genesets complete",
lastSavedGenesets: genesets,
});
} else {
dispatch({
if (!res.ok) {
return dispatch({
type: "autosave: genesets error",
message: `HTTP error ${res.status} - ${res.statusText}`,
res,
});
}
return Promise.all([
dispatch({
type: "autosave: genesets complete",
lastSavedGenesets: genesets,
}),
dispatch({
type: "geneset: set tid",
tid,
}),
]);
} catch (error) {
dispatch({
return dispatch({
type: "autosave: genesets error",
message: error.toString(),
error,
+7 -3
View File
@@ -53,18 +53,22 @@ async function userInfoFetch(dispatch) {
}
async function genesetsFetch(dispatch, config) {
/* request genesets ONLY if the backend supports the feature */
const defaultResponse = {
genesets: [],
tid: 0,
};
if (config?.parameters?.["annotations_genesets"] ?? false) {
fetchJson("genesets").then((response) => {
const genesets = response?.genesets ?? {};
dispatch({
type: "geneset: initial load",
init: genesets,
data: response ?? defaultResponse,
});
});
} else {
dispatch({
type: "geneset: initial load",
init: [],
data: defaultResponse,
});
}
}
+14 -10
View File
@@ -11,7 +11,7 @@ import FilenameDialog from "./filenameDialog";
error: state.autosave?.error,
writableCategoriesEnabled: state.config?.parameters?.annotations ?? false,
writableGenesetsEnabled: !(
state.config?.parameters?.annotations_genesets_readonly ?? true
state.config?.parameters?.["annotations_genesets_readonly"] ?? true
),
annoMatrix: state.annoMatrix,
genesets: state.genesets,
@@ -27,11 +27,11 @@ class Autosave extends React.Component {
}
componentDidMount() {
const { writableCategoriesEnabled } = this.props;
const { writableCategoriesEnabled, writableGenesetsEnabled } = this.props;
let { timer } = this.state;
if (timer) clearInterval(timer);
if (writableCategoriesEnabled) {
if (writableCategoriesEnabled || writableGenesetsEnabled) {
timer = setInterval(this.tick, 2500);
} else {
timer = null;
@@ -58,11 +58,6 @@ class Autosave extends React.Component {
}
};
saveInProgress() {
const { obsAnnotationSaveInProgress, genesetSaveInProgress } = this.props;
return obsAnnotationSaveInProgress || genesetSaveInProgress;
}
needToSaveObsAnnotations = () => {
/* return true if we need to save obs cell labels, false if we don't */
const { annoMatrix, lastSavedAnnoMatrix } = this.props;
@@ -79,6 +74,11 @@ class Autosave extends React.Component {
return this.needToSaveGenesets() || this.needToSaveObsAnnotations();
}
saveInProgress() {
const { obsAnnotationSaveInProgress, genesetSaveInProgress } = this.props;
return obsAnnotationSaveInProgress || genesetSaveInProgress;
}
statusMessage() {
const { error } = this.props;
if (error) {
@@ -88,10 +88,14 @@ class Autosave extends React.Component {
}
render() {
const { writableCategoriesEnabled, lastSavedAnnoMatrix } = this.props;
const {
writableCategoriesEnabled,
writableGenesetsEnabled,
lastSavedAnnoMatrix,
} = this.props;
const initialDataLoadComplete = lastSavedAnnoMatrix;
if (!writableCategoriesEnabled) return null;
if (!writableCategoriesEnabled && !writableGenesetsEnabled) return null;
return (
<div
@@ -174,7 +174,7 @@ class CategoryValue extends React.Component {
Checks to see if at least one of the following changed:
* world state
* the color accessor (what is currently being colored by)
* if this catagorical value's selection status has changed
* if this categorical value's selection status has changed
* the crossfilter (ie, global selection state)
If and only if true, update the component
@@ -201,6 +201,13 @@ class CategoryValue extends React.Component {
const newCount = newCategorySummary.categoryValueCounts[newCategoryIndex];
const countChanged = count !== newCount;
// If the user edits an annotation that is currently colored-by, colors may be re-assigned.
// This test is conservative - it may cause re-rendering of entire category (all labels)
// if any one changes, but only for the currently colored-by category.
const colorMightHaveChanged =
nextProps.colorAccessor === nextProps.metadataField &&
props.categorySummary !== nextProps.categorySummary;
return (
labelChanged ||
valueSelectionChange ||
@@ -208,7 +215,8 @@ class CategoryValue extends React.Component {
annotationsChange ||
editingLabel ||
dilationChange ||
countChanged
countChanged ||
colorMightHaveChanged
);
};
+16 -6
View File
@@ -14,7 +14,7 @@ export default function drawPointsRegl(regl) {
uniform float nPoints;
uniform float minViewportDimension;
varying vec4 fragColor;
varying lowp vec4 fragColor;
const float zBottom = 0.99;
const float zMiddle = 0.;
@@ -27,23 +27,23 @@ export default function drawPointsRegl(regl) {
${glPointSize}
void main() {
bool isNaN, isSelected, isHighlight;
getFlags(flag, isNaN, isSelected, isHighlight);
bool isBackground, isSelected, isHighlight;
getFlags(flag, isBackground, isSelected, isHighlight);
float size = pointSize(nPoints, minViewportDimension, isSelected, isHighlight);
gl_PointSize = size * pow(distance, 0.5);
float z = isNaN ? zBottom : (isHighlight ? zTop : zMiddle);
float z = isBackground ? zBottom : (isHighlight ? zTop : zMiddle);
vec3 xy = projView * vec3(position, 1.);
gl_Position = vec4(xy.xy, z, 1.);
float alpha = isNaN ? 0.9 : 1.0;
float alpha = isBackground ? 0.9 : 1.0;
fragColor = vec4(color, alpha);
}`,
frag: `
precision mediump float;
varying vec4 fragColor;
varying lowp vec4 fragColor;
void main() {
if (length(gl_PointCoord.xy - 0.5) > 0.5) {
discard;
@@ -67,5 +67,15 @@ export default function drawPointsRegl(regl) {
count: regl.prop("count"),
primitive: "points",
blend: {
enable: true,
func: {
srcRGB: "src alpha",
srcAlpha: 1,
dstRGB: 0,
dstAlpha: "zero",
},
},
});
}
+9 -6
View File
@@ -22,6 +22,12 @@ import CentroidLabels from "./overlays/centroidLabels";
import actions from "../../actions";
import renderThrottle from "../../util/renderThrottle";
import {
flagBackground,
flagSelected,
flagHighlight,
} from "../../util/glHelpers";
/*
Simple 2D transforms control all point painting. There are three:
* model - convert from underlying per-point coordinate to a layout.
@@ -62,10 +68,6 @@ function createModelTF() {
return m;
}
const flagSelected = 1;
const flagNaN = 2;
const flagHighlight = 4;
@connect((state) => ({
annoMatrix: state.annoMatrix,
crossfilter: state.obsCrossfilter,
@@ -159,8 +161,9 @@ class Graph extends React.Component {
const flags = new Float32Array(nObs);
if (colorByData) {
for (let i = 0, len = flags.length; i < len; i += 1) {
if (!Number.isFinite(colorByData[i])) {
flags[i] = flagNaN;
const val = colorByData[i];
if (typeof val === "number" && !Number.isFinite(val)) {
flags[i] = flagBackground;
}
}
}
@@ -13,7 +13,7 @@ export default function drawPointsRegl(regl) {
uniform float nPoints;
uniform float minViewportDimension;
varying vec4 fragColor;
varying lowp vec4 fragColor;
const float zBottom = 0.99;
const float zMiddle = 0.;
@@ -26,22 +26,22 @@ export default function drawPointsRegl(regl) {
${glPointSize}
void main() {
bool isNaN, isSelected, isHighlight;
getFlags(flag, isNaN, isSelected, isHighlight);
bool isBackground, isSelected, isHighlight;
getFlags(flag, isBackground, isSelected, isHighlight);
gl_PointSize = pointSize(nPoints, minViewportDimension, isSelected, isHighlight);
float z = isNaN ? zBottom : (isHighlight ? zTop : zMiddle);
float z = isBackground ? zBottom : (isHighlight ? zTop : zMiddle);
vec3 xy = projection * vec3(position, 1.);
gl_Position = vec4(xy.xy, z, 1.);
float alpha = isNaN ? 0.9 : 1.0;
float alpha = isBackground ? 0.9 : 1.0;
fragColor = vec4(color, alpha);
}`,
frag: `
precision mediump float;
varying vec4 fragColor;
varying lowp vec4 fragColor;
void main() {
if (length(gl_PointCoord.xy - 0.5) > 0.5) {
discard;
@@ -64,5 +64,15 @@ export default function drawPointsRegl(regl) {
count: regl.prop("count"),
primitive: "points",
blend: {
enable: true,
func: {
srcRGB: "src alpha",
srcAlpha: 1,
dstRGB: 0,
dstAlpha: "zero",
},
},
});
}
@@ -16,10 +16,11 @@ import {
createColorQuery,
} from "../../util/stateManager/colorHelpers";
import renderThrottle from "../../util/renderThrottle";
const flagSelected = 1;
const flagNaN = 2;
const flagHighlight = 4;
import {
flagBackground,
flagSelected,
flagHighlight,
} from "../../util/glHelpers";
function createProjectionTF(viewportWidth, viewportHeight) {
/*
@@ -135,8 +136,9 @@ class Scatterplot extends React.PureComponent {
const flags = new Float32Array(nObs);
if (colorByData) {
for (let i = 0, len = flags.length; i < len; i += 1) {
if (!Number.isFinite(colorByData[i])) {
flags[i] = flagNaN;
const val = colorByData[i];
if (typeof val === "number" && !Number.isFinite(val)) {
flags[i] = flagBackground;
}
}
}
+266 -85
View File
@@ -2,14 +2,30 @@
* Gene set state. Geneset UI state is in a different reducer.
*
* geneset reducer state is a Map object, where:
* key: the gene set name, a string.
* val: the genes in the gene set, which is a Set of string
* key: the geneset name, a string.
* val: the geneset defined as an object ("geneset object")
*
* Ie, Map<string, Set<string>>
* A geneset object is:
* {
* genesetName: <string> # same as the map key
* genesetDescription: <string>
* genes: Map<<string>, {
* geneSymbol: <string>, # same as the map key
* geneDescription: <string>
* }>
* }
*
* Geneset and genes Map order is significant, and will be preserved across
* CRUD operations on either.
*
* This reducer does light error checking, but not as much as the backend
* routes. Do not rely on it to enforce geneset integrity - eg, no duplicate
* genes in a geneset.
*/
const GeneSets = (
state = {
initialized: false,
lastTid: undefined,
genesets: new Map(),
},
action
@@ -19,39 +35,73 @@ const GeneSets = (
* Initial, load-time bootstrap.
* {
* type: "geneset: initial load"
* init: Array<Tuple<string, Array<string>>>
* data: JSON response
* }
*/
case "geneset: initial load": {
const { init } = action;
const { data } = action;
if (
!data ||
typeof data.tid !== "number" ||
!Array.isArray(data.genesets)
)
throw new Error("missing or malformed JSON response");
const lastTid = data.tid;
const genesetsData = data.genesets;
const genesets = new Map();
for (const gs of init) {
const genes = new Set();
for (const gene of gs[1]) {
genes.add(gene);
for (const gsData of genesetsData) {
const genes = new Map();
for (const gene of gsData.genes) {
genes.set(gene.gene_symbol, {
geneSymbol: gene.gene_symbol,
geneDescription: gene?.["gene_description"] ?? "",
});
}
genesets.set(gs[0], genes);
const gs = {
genesetName: gsData.geneset_name,
genesetDescription: gsData?.["geneset_description"] ?? "",
genes,
};
genesets.set(gsData.geneset_name, gs);
}
return {
genesets,
initialized: true,
lastTid,
genesets,
};
}
/**
* Creates a new & empty geneset with the given name and description.
* {
* type: "geneset: create",
* name: string, // gene set name
* genes: Set<string> || Array<string> || undefined
* genesetName: string, // gene set name
* genesetDescription: string, // geneset description
* }
*
*/
case "geneset: create": {
const { name, genes } = action;
if (state.genesets.has(name))
const { genesetName, genesetDescription } = action;
if (
typeof genesetName !== "string" ||
!genesetName ||
genesetDescription === undefined
)
throw new Error("geneset: create -- name or description unspecified.");
if (state.genesets.has(genesetName))
throw new Error("geneset: create -- name already defined.");
const genesets = new Map(state.genesets); // clone
genesets.set(name, new Set(genes || []));
genesets.set(genesetName, {
genesetName,
genesetDescription,
genes: new Map(),
});
return {
...state,
genesets,
@@ -59,18 +109,19 @@ const GeneSets = (
}
/**
* Deletes the named geneset, if it exists. Throws if it does not.
* {
* type: "geneset: delete",
* name: string
* genesetName: string
* }
*/
case "geneset: delete": {
const { name } = action;
if (!state.genesets.has(name))
throw new Error("geneset: delete -- name does not exist.");
const { genesetName } = action;
if (!state.genesets.has(genesetName))
throw new Error("geneset: delete -- geneset name does not exist.");
const genesets = new Map(state.genesets); // clone
genesets.delete(name);
genesets.delete(genesetName);
return {
...state,
genesets,
@@ -78,81 +129,142 @@ const GeneSets = (
}
/**
* Update the named geneset with a new name and description. Preserves the existing
* order of the geneset, even when the genesetName changes.
* {
* type: "geneset: add genes"
* name: string, // gene set name
* genes: Set<string> || Array<string> // genes to add
* type: "geneset: update",
* genesetName: string, current name of geneset to be updated
* update: {
* genesetName: string, new name
* genesetDescription: string, new description
* }
* }
*/
case "geneset: add genes": {
const { name, genes } = action;
if (!state.genesets.has(name))
throw new Error("geneset: add genes -- name does not exist.");
const genesets = new Map(state.genesets); // clone
const newGenes = new Set(state.genesets.get(name)); // clone
for (const gene of genes) {
newGenes.add(gene);
}
genesets.set(name, newGenes);
return {
...state,
genesets,
};
}
/**
* {
* type: "geneset: del genes"
* name: string, // gene set name
* genes: Set<string> || Array<string> // genes to delete
* }
*/
case "geneset: del genes": {
const { name, genes } = action;
if (!state.genesets.has(name))
throw new Error("geneset: add genes -- name does not exist.");
const genesets = new Map(state.genesets); // clone
const newGenes = new Set(state.genesets.get(name)); // clone
for (const gene of genes) {
newGenes.delete(gene);
}
genesets.set(name, newGenes);
return {
...state,
genesets,
};
}
/**
* Rename the gene set, preserving its order in the geneset collection.
*
* {
* type: "geneset: rename",
* name: string, // gene set current (previous) name
* newName: string, // the new gene set name
* }
* For example, if you want to update JUST the description:
* dispatch({
* action: "geneset: update",
* genesetName: "foo",
* update: { genesetName: "foo", genesetDescription: "a new description"}
* })
*/
case "geneset: rename": {
const { name, newName } = action;
if (!state.genesets.has(name))
throw new Error("geneset: rename -- name does not exist.");
case "geneset: update": {
const { genesetName, update } = action;
if (
typeof newName !== "string" ||
!newName.length ||
state.genesets.has(newName)
typeof genesetName !== "string" ||
!genesetName ||
!state.genesets.has(genesetName)
)
throw new Error(
"geneset: rename -- new name must be unique, non-null string."
"geneset: update -- geneset name unspecified or does not exist."
);
if (state.genesets.has(update.genesetName))
throw new Error("geneset: update -- update specified existing name.");
const prevGs = state.genesets.get(genesetName);
const newGs = {
...update,
genes: prevGs.genes,
}; // clone
// clone the map, preserving current insert order, but mapping name->newName.
const genesets = new Map();
for (const [gsName, gsGenes] of state.genesets) {
if (gsName === name) genesets.set(newName, gsGenes);
else genesets.set(gsName, gsGenes);
for (const [name, gs] of state.genesets) {
if (name === genesetName) genesets.set(newGs.genesetName, newGs);
else genesets.set(name, gs);
}
return {
...state,
genesets,
};
}
/**
* Adds genes to the geneset. They are appended to the END of the geneset, in the
* order provided. Duplicates or genes already in the geneset, will be ignored.
* {
* type: "geneset: add genes"
* genesetName: <string>, // gene set name
* genes: Array<{
* geneSymbol: <string>,
* geneDescription: <string>
* }>
* }
*
* Example:
* dispatch({
* type: "add genes",
* genesetName: "foo",
* genes: [ { geneSymbol: "FOXP", geneDescription: "test" }]
* });
*/
case "geneset: add genes": {
const { genesetName, genes } = action;
if (!state.genesets.has(genesetName))
throw new Error("geneset: add genes -- geneset name does not exist.");
// clone
const genesets = new Map(state.genesets);
const gs = {
...genesets.get(genesetName),
genes: new Map(genesets.get(genesetName).genes),
};
genesets.set(genesetName, gs);
// add
const newGenes = gs.genes;
for (const gene of genes) {
const { geneSymbol } = gene;
const geneDescription = gene?.geneDescription ?? "";
// ignore genes already present
if (!newGenes.has(geneSymbol))
newGenes.set(geneSymbol, {
geneSymbol,
geneDescription,
});
}
return {
...state,
genesets,
};
}
/**
* Delete genes from the named geneset. Will throw if the genesetName does
* not exist. Will ignore geneSymbols that do not exist.
* {
* type: "geneset: delete genes",
* genesetName: <string>, // the geneset from which to delete genes
* geneSymbols: [<string>, ...], // the gene symbols to delete.
* }
*
* Example:
* dispatch({
* type: "geneset: delete genes",
* genesetName: "a geneset name",
* geneSymbols: ["F5"]
* })
*/
case "geneset: delete genes": {
const { genesetName, geneSymbols } = action;
if (!state.genesets.has(genesetName))
throw new Error(
"geneset: delete genes -- geneset name does not exist."
);
// clone
const genesets = new Map(state.genesets);
const gs = {
...genesets.get(genesetName),
genes: new Map(genesets.get(genesetName).genes),
};
genesets.set(genesetName, gs);
// delete
const { genes } = gs;
for (const geneSymbol of geneSymbols) {
genes.delete(geneSymbol);
}
return {
...state,
@@ -160,6 +272,75 @@ const GeneSets = (
};
}
/**
* Set/update the description of the gene. NOTE that this does not allow the name
* of the gene to change - only "geneset: add" and "geneset: delete" can change
* the genes in a geneset. Use this to update a gene description AFTER you add it
* to the geneset.
* {
* type: "geneset: set gene description",
* genesetName: <string>, // the geneset to update
* update: {
* geneSymbol: <string>, // the gene to update, MUST exist already in the geneset
* geneDescription: <string>
* }
* }
*
* Example:
* dispatch({
* type: "geneset: set gene description",
* genesetName: "my fav geneset",
* update: {
* geneSymbol: "F5",
* geneDescription: "tada, moar description"
* }
* })
*/
case "geneset: set gene description": {
const { genesetName, update } = action;
if (!state.genesets.has(genesetName))
throw new Error(
"geneset: set gene description -- geneset name does not exist."
);
// clone
const genesets = new Map(state.genesets);
const gs = {
...genesets.get(genesetName),
genes: new Map(genesets.get(genesetName).genes),
};
genesets.set(genesetName, gs);
const { geneSymbol, geneDescription } = update;
const gene = gs.genes.get(geneSymbol);
if (!gene)
throw new Error("geneset: set gene description -- no such gene");
gs.genes.set(geneSymbol, {
geneSymbol,
geneDescription,
});
return {
...state,
genesets,
};
}
/**
* Used by autosave to update the server synchronization TID
*/
case "geneset: set tid": {
const { tid } = action;
if (!Number.isInteger(tid) || tid < 0)
throw new Error("TID must be a positive integer number");
if (state.lastTid !== undefined && tid < state.lastTid)
throw new Error("TID may not be decremented.");
return {
...state,
lastTid: tid,
};
}
default:
return state;
}
+2 -1
View File
@@ -4,7 +4,7 @@ import thunk from "redux-thunk";
import cascadeReducers from "./cascade";
import undoable from "./undoable";
import config from "./config";
import userInfo from "./userInfo";
import userInfo from "./userinfo";
import annoMatrix from "./annoMatrix";
import obsCrossfilter from "./obsCrossfilter";
import categoricalSelection from "./categoricalSelection";
@@ -59,6 +59,7 @@ const Reducer = undoable(
"differential",
"layoutChoice",
"centroidLabels",
"genesets",
"annotations",
],
undoableConfig
+10 -4
View File
@@ -8,18 +8,24 @@ PointFlags:
We want a bitmask-like flag structure, but due to webgl limitations
must emulate it with floats.
Supported flags are:
selected: the point is currently selected
highlight: the point is currently highlighted
background: the point is background information
*/
// for JS
export const flagSelected = 1;
export const flagNaN = 2;
export const flagBackground = 2;
export const flagHighlight = 4;
// for GLSL
export const glPointFlags = `
const float flagSelected = 1.;
const float flagNaN = 2.;
const float flagBackground = 2.;
const float flagHighlight = 4.;
bool isLowBitSet(float f) {
@@ -32,12 +38,12 @@ export const glPointFlags = `
}
void getFlags(in float flag,
out bool isNaN,
out bool isBackground,
out bool isSelected,
out bool isHighlight) {
isSelected = isLowBitSet(flag);
flag = shiftRightOne(flag);
isNaN = isLowBitSet(flag);
isBackground = isLowBitSet(flag);
flag = shiftRightOne(flag);
isHighlight = isLowBitSet(flag);
}
+28 -29
View File
@@ -55,8 +55,8 @@ create colors scale and RGB array and return as object. Parameters:
* userColors - optional user color table
Returns:
{
scale: color scale
rgb: cell to color mapping
scale: function, mapping label index to color scale
rgb: cell label to color mapping
}
*/
function _createColorTable(
@@ -70,7 +70,7 @@ function _createColorTable(
case "color by categorical metadata": {
const data = colorByData.col(colorByAccessor).asArray();
if (userColors && colorByAccessor in userColors) {
return createUserColors(data, colorByAccessor, userColors);
return createUserColors(data, colorByAccessor, schema, userColors);
}
return createColorsByCategoricalMetadata(data, colorByAccessor, schema);
}
@@ -91,42 +91,41 @@ function _createColorTable(
}
export const createColorTable = memoize(_createColorTable);
/**
* Create two category label-indexed objects:
* - colors: maps label to RGB triplet for that label (used by graph, etc)
* - scale: function which given label returns d3 color scale for label
* Order doesn't matter - everything is keyed by label value.
*/
export function loadUserColorConfig(userColors) {
const convertedUserColors = {};
Object.keys(userColors).forEach((category) => {
// We cannot iterate over keys without sorting
// because we handle categorical values in alphabetical order __ignoring case__
// while Object.keys() _usually_ is ordered alphabetically where all upper characters are less than lowercase (A, B, C, a, b, c)
const [colors, scaleMap] = Object.keys(userColors[category])
.sort((a, b) => {
a = a.toLowerCase();
b = b.toLowerCase();
if (a === b) return 0;
if (a > b) return 1;
return -1;
})
.reduce(
(acc, label) => {
const color = parseRGB(userColors[category][label]);
acc[0][label] = color;
acc[1][label] = d3.rgb(
255 * color[0],
255 * color[1],
255 * color[2]
);
return acc;
},
[{}, {}]
);
const [colors, scaleMap] = Object.keys(userColors[category]).reduce(
(acc, label) => {
const color = parseRGB(userColors[category][label]);
acc[0][label] = color;
acc[1][label] = d3.rgb(255 * color[0], 255 * color[1], 255 * color[2]);
return acc;
},
[{}, {}]
);
const scale = (label) => scaleMap[label];
convertedUserColors[category] = { colors, scale };
});
return convertedUserColors;
}
function _createUserColors(data, colorAccessor, userColors) {
const { colors, scale } = userColors[colorAccessor];
function _createUserColors(data, colorAccessor, schema, userColors) {
const { colors, scale: scaleByLabel } = userColors[colorAccessor];
const rgb = createRgbArray(data, colors);
// color scale function param is INDEX (offset) into schema categories. It is NOT label value.
// See createColorsByCategoricalMetadata() for another example.
const { categories } = schema.annotations.obsByName[colorAccessor];
const categoryMap = new Map();
categories.forEach((label, idx) => categoryMap.set(idx, label));
const scale = (idx) => scaleByLabel(categoryMap.get(idx));
return { rgb, scale };
}
const createUserColors = memoize(_createUserColors);