Fix indexing bug in user-specified colors (#2051)

* repaint category value when color changes

* bug fix incorrect indexing of user colors

* add test for bug 2007

* lint
This commit is contained in:
Bruce Martin
2021-02-08 18:00:03 -08:00
committed by GitHub
parent e6281baa39
commit 036b5f8c0f
3 changed files with 236 additions and 31 deletions

View File

@@ -0,0 +1,198 @@
/* eslint-disable no-bitwise -- unsigned right shift better than Math.round */
/*
test color helpers
*/
import {
createColorTable,
loadUserColorConfig,
} from "../../../src/util/stateManager/colorHelpers";
import * as Dataframe from "../../../src/util/dataframe";
describe("categorical color helpers", () => {
/*
Primary test constraint for categorical colors is that they are ordered/identified
by schema order, NOT by value. Ie,
scale(schemaIndex) should match rgb[obsOffset]
*/
const schema = indexSchema({
annotations: {
obs: {
columns: [
{
name: "name_0",
type: "string",
writable: false,
},
{
name: "continuousColumn",
type: "float32",
writable: false,
},
{
categories: [
"CD4 T cells",
"CD14+ Monocytes",
"B cells",
"CD8 T cells",
"NK cells",
"FCGR3A+ Monocytes",
"Dendritic cells",
"Megakaryocytes",
],
name: "categoricalColumn",
type: "categorical",
writable: false,
},
],
index: "name_0",
},
var: {
columns: [
{
name: "name_0",
type: "string",
writable: false,
},
],
index: "name_0",
},
},
dataframe: {
nObs: 2638,
nVar: 1838,
type: "float32",
},
layout: {},
});
const catColCategories = schema.annotations.obs.columns[2].categories;
const obsDataframe = new Dataframe.Dataframe(
[schema.dataframe.nObs, 2],
[
new Float32Array(schema.dataframe.nObs).map(() => Math.random()),
new Array(schema.dataframe.nObs)
.fill("")
.map(
() =>
catColCategories[(Math.random() * catColCategories.length) >>> 0]
),
],
null,
new Dataframe.KeyIndex(["continuousColumn", "categoricalColumn"])
);
test("default category order", () => {
const ct = createColorTable(
"color by categorical metadata",
"categoricalColumn",
obsDataframe,
schema
);
expect(ct).toBeDefined();
const data = obsDataframe.col("categoricalColumn").asArray();
const cats = schema.annotations.obsByName.categoricalColumn.categories;
for (let i = 0; i < schema.dataframe.nObs; i += 1) {
expect(makeScale(ct.rgb[i])).toEqual(ct.scale(cats.indexOf(data[i])));
}
});
test("shuffle category order", () => {
const schemaClone = indexSchema(JSON.parse(JSON.stringify(schema)));
shuffle(schemaClone.annotations.obsByName.categoricalColumn.categories);
const ct = createColorTable(
"color by categorical metadata",
"categoricalColumn",
obsDataframe,
schemaClone
);
expect(ct).toBeDefined();
const data = obsDataframe.col("categoricalColumn").asArray();
const cats = schemaClone.annotations.obsByName.categoricalColumn.categories;
for (let i = 0; i < schemaClone.dataframe.nObs; i += 1) {
expect(makeScale(ct.rgb[i])).toEqual(ct.scale(cats.indexOf(data[i])));
}
});
test("user defined color order", () => {
const cats = schema.annotations.obsByName.categoricalColumn.categories;
const shuffleCats = shuffle(
Array.from(schema.annotations.obsByName.categoricalColumn.categories)
);
const userDefinedColorTable = {
categoricalColumn: shuffleCats.reduce((acc, label) => {
acc[label] = randRGBColor();
return acc;
}, {}),
};
const userColors = loadUserColorConfig(userDefinedColorTable);
expect(userColors).toBeDefined();
const ct = createColorTable(
"color by categorical metadata",
"categoricalColumn",
obsDataframe,
schema,
userColors
);
expect(ct).toBeDefined();
const data = obsDataframe.col("categoricalColumn").asArray();
for (let i = 0; i < schema.dataframe.nObs; i += 1) {
expect(makeScale(ct.rgb[i])).toEqual(
ct.scale(cats.indexOf(data[i])).toString()
);
}
});
});
/*
TODO:
1. mix up category order in schema to make sure it works with varied order
2. user defined colors
*/
function indexSchema(schema) {
schema.annotations.obsByName = Object.fromEntries(
schema.annotations?.obs?.columns?.map((v) => [v.name, v]) ?? []
);
schema.annotations.varByName = Object.fromEntries(
schema.annotations?.var?.columns?.map((v) => [v.name, v]) ?? []
);
schema.layout.obsByName = Object.fromEntries(
schema.layout?.obs?.map((v) => [v.name, v]) ?? []
);
schema.layout.varByName = Object.fromEntries(
schema.layout?.var?.map((v) => [v.name, v]) ?? []
);
return schema;
}
function makeScale(rgb) {
// make a scale string from a rgb float triple
return `rgb(${(rgb[0] * 255) >>> 0}, ${(rgb[1] * 255) >>> 0}, ${
(rgb[2] * 256) >>> 0
})`;
}
function shuffle(array) {
for (let i = array.length - 1; i > 0; i -= 1) {
const j = (Math.random() * (i + 1)) >>> 0;
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
function randHexColor() {
const hex = ((Math.random() * 255) >>> 0).toString(16);
return `0${hex}`.slice(-2);
}
function randRGBColor() {
return `#${randHexColor()}${randHexColor()}${randHexColor()}`;
}
/* eslint-enable no-bitwise -- unsigned right shift better than Math.round */

View File

@@ -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
);
};

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);