feat(color): support analogous colors to prevent color conflict (#19325)

* feat(color): support analogous colors

* fix test

* fix range

* add some comment
This commit is contained in:
Stephen Liu 2022-04-01 23:42:30 +08:00 committed by GitHub
parent 08aca83f6c
commit 90c9daea08
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 50 additions and 36 deletions

View File

@ -23,6 +23,7 @@ import { ExtensibleFunction } from '../models';
import { ColorsLookup } from './types'; import { ColorsLookup } from './types';
import stringifyAndTrim from './stringifyAndTrim'; import stringifyAndTrim from './stringifyAndTrim';
import getSharedLabelColor from './SharedLabelColorSingleton'; import getSharedLabelColor from './SharedLabelColorSingleton';
import { getAnalogousColors } from './utils';
// Use type augmentation to correct the fact that // Use type augmentation to correct the fact that
// an instance of CategoricalScale is also a function // an instance of CategoricalScale is also a function
@ -31,6 +32,8 @@ interface CategoricalColorScale {
} }
class CategoricalColorScale extends ExtensibleFunction { class CategoricalColorScale extends ExtensibleFunction {
originColors: string[];
colors: string[]; colors: string[];
scale: ScaleOrdinal<{ toString(): string }, string>; scale: ScaleOrdinal<{ toString(): string }, string>;
@ -39,6 +42,8 @@ class CategoricalColorScale extends ExtensibleFunction {
forcedColors: ColorsLookup; forcedColors: ColorsLookup;
multiple: number;
/** /**
* Constructor * Constructor
* @param {*} colors an array of colors * @param {*} colors an array of colors
@ -48,11 +53,13 @@ class CategoricalColorScale extends ExtensibleFunction {
constructor(colors: string[], parentForcedColors?: ColorsLookup) { constructor(colors: string[], parentForcedColors?: ColorsLookup) {
super((value: string, sliceId?: number) => this.getColor(value, sliceId)); super((value: string, sliceId?: number) => this.getColor(value, sliceId));
this.originColors = colors;
this.colors = colors; this.colors = colors;
this.scale = scaleOrdinal<{ toString(): string }, string>(); this.scale = scaleOrdinal<{ toString(): string }, string>();
this.scale.range(colors); this.scale.range(colors);
this.parentForcedColors = parentForcedColors; this.parentForcedColors = parentForcedColors;
this.forcedColors = {}; this.forcedColors = {};
this.multiple = 0;
} }
getColor(value?: string, sliceId?: number) { getColor(value?: string, sliceId?: number) {
@ -72,6 +79,15 @@ class CategoricalColorScale extends ExtensibleFunction {
return forcedColor; return forcedColor;
} }
const multiple = Math.floor(
this.domain().length / this.originColors.length,
);
if (multiple > this.multiple) {
this.multiple = multiple;
const newRange = getAnalogousColors(this.originColors, multiple);
this.range(this.originColors.concat(newRange));
}
const color = this.scale(cleanedValue); const color = this.scale(cleanedValue);
sharedLabelColor.addSlice(cleanedValue, color, sliceId); sharedLabelColor.addSlice(cleanedValue, color, sliceId);

View File

@ -17,9 +17,9 @@
* under the License. * under the License.
*/ */
import tinycolor from 'tinycolor2';
import { CategoricalColorNamespace } from '.'; import { CategoricalColorNamespace } from '.';
import makeSingleton from '../utils/makeSingleton'; import makeSingleton from '../utils/makeSingleton';
import { getAnalogousColors } from './utils';
export class SharedLabelColor { export class SharedLabelColor {
sliceLabelColorMap: Record<number, Record<string, string | undefined>>; sliceLabelColorMap: Record<number, Record<string, string | undefined>>;
@ -39,27 +39,16 @@ export class SharedLabelColor {
CategoricalColorNamespace.getNamespace(colorNamespace); CategoricalColorNamespace.getNamespace(colorNamespace);
const colors = categoricalNamespace.getScale(colorScheme).range(); const colors = categoricalNamespace.getScale(colorScheme).range();
const sharedLabels = this.getSharedLabels(); const sharedLabels = this.getSharedLabels();
const generatedColors: tinycolor.Instance[] = []; let generatedColors: string[] = [];
let sharedLabelMap; let sharedLabelMap;
if (sharedLabels.length) { if (sharedLabels.length) {
const multiple = Math.ceil(sharedLabels.length / colors.length); const multiple = Math.ceil(sharedLabels.length / colors.length);
const ext = 5; generatedColors = getAnalogousColors(colors, multiple);
const analogousColors = colors.map(color => {
const result = tinycolor(color).analogous(multiple + ext);
return result.slice(ext);
});
// [[A, AA, AAA], [B, BB, BBB]] => [A, B, AA, BB, AAA, BBB]
while (analogousColors[analogousColors.length - 1]?.length) {
analogousColors.forEach(colors =>
generatedColors.push(colors.shift() as tinycolor.Instance),
);
}
sharedLabelMap = sharedLabels.reduce( sharedLabelMap = sharedLabels.reduce(
(res, label, index) => ({ (res, label, index) => ({
...res, ...res,
[label.toString()]: generatedColors[index]?.toHexString(), [label.toString()]: generatedColors[index],
}), }),
{}, {},
); );

View File

@ -16,6 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import tinycolor from 'tinycolor2';
const rgbRegex = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/; const rgbRegex = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/;
export function getContrastingColor(color: string, thresholds = 186) { export function getContrastingColor(color: string, thresholds = 186) {
@ -51,3 +52,24 @@ export function getContrastingColor(color: string, thresholds = 186) {
return r * 0.299 + g * 0.587 + b * 0.114 > thresholds ? '#000' : '#FFF'; return r * 0.299 + g * 0.587 + b * 0.114 > thresholds ? '#000' : '#FFF';
} }
export function getAnalogousColors(colors: string[], results: number) {
const generatedColors: string[] = [];
// This is to solve the problem that the first three values generated by tinycolor.analogous
// may have the same or very close colors.
const ext = 3;
const analogousColors = colors.map(color => {
const result = tinycolor(color).analogous(results + ext);
return result.slice(ext);
});
// [[A, AA, AAA], [B, BB, BBB]] => [A, B, AA, BB, AAA, BBB]
while (analogousColors[analogousColors.length - 1]?.length) {
analogousColors.forEach(colors => {
const color = colors.shift() as tinycolor.Instance;
generatedColors.push(color.toHexString());
});
}
return generatedColors;
}

View File

@ -62,28 +62,15 @@ describe('CategoricalColorScale', () => {
expect(c2).not.toBe(c3); expect(c2).not.toBe(c3);
expect(c3).not.toBe(c1); expect(c3).not.toBe(c1);
}); });
it('recycles colors when number of items exceed available colors', () => { it('get analogous colors when number of items exceed available colors', () => {
const colorSet: { [key: string]: number } = {};
const scale = new CategoricalColorScale(['blue', 'red', 'green']); const scale = new CategoricalColorScale(['blue', 'red', 'green']);
const colors = [ scale.getColor('pig');
scale.getColor('pig'), scale.getColor('horse');
scale.getColor('horse'), scale.getColor('cat');
scale.getColor('cat'), scale.getColor('cow');
scale.getColor('cow'), scale.getColor('donkey');
scale.getColor('donkey'), scale.getColor('goat');
scale.getColor('goat'), expect(scale.range()).toHaveLength(6);
];
colors.forEach(color => {
if (colorSet[color]) {
colorSet[color] += 1;
} else {
colorSet[color] = 1;
}
});
expect(Object.keys(colorSet)).toHaveLength(3);
['blue', 'red', 'green'].forEach(color => {
expect(colorSet[color]).toBe(2);
});
}); });
}); });
describe('.setColor(value, forcedColor)', () => { describe('.setColor(value, forcedColor)', () => {