This commit is contained in:
Geido 2024-05-05 02:18:23 -03:00 committed by GitHub
commit 1d9c3e9e00
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 450 additions and 235 deletions

View File

@ -88,6 +88,7 @@ These features flags currently default to True and **will be removed in a future
[//]: # "PLEASE KEEP THE LIST SORTED ALPHABETICALLY"
- AVOID_COLORS_COLLISION
- DASHBOARD_CROSS_FILTERS
- ENABLE_JAVASCRIPT_CONTROLS
- KV_STORE

View File

@ -124,7 +124,7 @@ function selectColorScheme(color: string) {
)
.first()
.click();
cy.getBySel(color).click();
cy.getBySel(color).click({ force: true });
}
function applyChanges() {
@ -169,6 +169,7 @@ function writeMetadata(metadata: string) {
function openExplore(chartName: string) {
interceptExploreJson();
interceptGet();
cy.get(
`[data-test-chart-name='${chartName}'] [aria-label='More Options']`,
@ -210,7 +211,7 @@ describe('Dashboard edit', () => {
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.first()
.should('have.css', 'fill', 'rgb(69, 78, 124)');
.should('have.css', 'fill', 'rgb(31, 168, 201)');
});
it('should apply same color to same labels with color scheme set', () => {
@ -231,7 +232,7 @@ describe('Dashboard edit', () => {
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.first()
.should('have.css', 'fill', 'rgb(51, 61, 71)');
.should('have.css', 'fill', 'rgb(234, 11, 140)');
// open 2nd main tab
openTab(0, 1);
@ -240,7 +241,7 @@ describe('Dashboard edit', () => {
// label Anthony
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.eq(2)
.should('have.css', 'fill', 'rgb(51, 61, 71)');
.should('have.css', 'fill', 'rgb(234, 11, 140)');
});
it('should apply same color to same labels with no color scheme set', () => {
@ -261,7 +262,7 @@ describe('Dashboard edit', () => {
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.first()
.should('have.css', 'fill', 'rgb(69, 78, 124)');
.should('have.css', 'fill', 'rgb(31, 168, 201)');
// open 2nd main tab
openTab(0, 1);
@ -270,7 +271,7 @@ describe('Dashboard edit', () => {
// label Anthony
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.eq(2)
.should('have.css', 'fill', 'rgb(69, 78, 124)');
.should('have.css', 'fill', 'rgb(31, 168, 201)');
});
it('custom label colors should take the precedence in nested tabs', () => {
@ -384,17 +385,17 @@ describe('Dashboard edit', () => {
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.first()
.should('have.css', 'fill', 'rgb(69, 78, 124)');
.should('have.css', 'fill', 'rgb(31, 168, 201)');
cy.get(
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.eq(1)
.should('have.css', 'fill', 'rgb(224, 67, 85)');
.should('have.css', 'fill', 'rgb(69, 78, 124)');
cy.get(
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.eq(2)
.should('have.css', 'fill', 'rgb(163, 143, 121)');
.should('have.css', 'fill', 'rgb(90, 193, 137)');
openProperties();
cy.get('[aria-label="Select color scheme"]').should('have.value', '');
@ -423,17 +424,17 @@ describe('Dashboard edit', () => {
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.first()
.should('have.css', 'fill', 'rgb(69, 78, 124)');
.should('have.css', 'fill', 'rgb(31, 168, 201)');
cy.get(
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.eq(1)
.should('have.css', 'fill', 'rgb(224, 67, 85)');
.should('have.css', 'fill', 'rgb(69, 78, 124)');
cy.get(
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.eq(2)
.should('have.css', 'fill', 'rgb(163, 143, 121)');
.should('have.css', 'fill', 'rgb(90, 193, 137)');
});
it('should show the same colors in Explore', () => {
@ -459,12 +460,6 @@ describe('Dashboard edit', () => {
)
.first()
.should('have.css', 'fill', 'rgb(255, 0, 0)');
// label Christopher
cy.get(
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.eq(1)
.should('have.css', 'fill', 'rgb(172, 32, 119)');
openExplore('Top 10 California Names Timeseries');
@ -472,10 +467,6 @@ describe('Dashboard edit', () => {
cy.get('[data-test="chart-container"] .line .nv-legend-symbol')
.first()
.should('have.css', 'fill', 'rgb(255, 0, 0)');
// label Christopher
cy.get('[data-test="chart-container"] .line .nv-legend-symbol')
.eq(1)
.should('have.css', 'fill', 'rgb(172, 32, 119)');
});
it.skip('should change color scheme multiple times', () => {
@ -496,7 +487,7 @@ describe('Dashboard edit', () => {
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.first()
.should('have.css', 'fill', 'rgb(51, 61, 71)');
.should('have.css', 'fill', 'rgb(234, 11, 140)');
// open 2nd main tab
openTab(0, 1);
@ -505,7 +496,7 @@ describe('Dashboard edit', () => {
// label Anthony
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.eq(2)
.should('have.css', 'fill', 'rgb(51, 61, 71)');
.should('have.css', 'fill', 'rgb(234, 11, 140)');
editDashboard();
openProperties();
@ -516,7 +507,7 @@ describe('Dashboard edit', () => {
// label Anthony
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.eq(2)
.should('have.css', 'fill', 'rgb(244, 176, 42)');
.should('have.css', 'fill', 'rgb(41, 105, 107)');
// open main tab and nested tab
openTab(0, 0);
@ -527,7 +518,7 @@ describe('Dashboard edit', () => {
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.first()
.should('have.css', 'fill', 'rgb(244, 176, 42)');
.should('have.css', 'fill', 'rgb(41, 105, 107)');
});
it.skip('should apply the color scheme across main tabs', () => {
@ -542,7 +533,7 @@ describe('Dashboard edit', () => {
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.first()
.should('have.css', 'fill', 'rgb(51, 61, 71)');
.should('have.css', 'fill', 'rgb(234, 11, 140)');
});
it.skip('should apply the color scheme across main tabs for rendered charts', () => {
@ -558,7 +549,7 @@ describe('Dashboard edit', () => {
cy.get('[data-test-chart-name="Trends"] .line .nv-legend-symbol')
.first()
.should('have.css', 'fill', 'rgb(156, 52, 152)');
.should('have.css', 'fill', 'rgb(41, 105, 107)');
// change scheme now that charts are rendered across the main tabs
editDashboard();
@ -588,7 +579,7 @@ describe('Dashboard edit', () => {
'[data-test-chart-name="Top 10 California Names Timeseries"] .line .nv-legend-symbol',
)
.first()
.should('have.css', 'fill', 'rgb(51, 61, 71)');
.should('have.css', 'fill', 'rgb(234, 11, 140)');
// open another nested tab
openTab(2, 1);

View File

@ -97,8 +97,5 @@ describe('Visualization > Compare', () => {
cy.get(
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="supersetColors"]',
).should('exist');
cy.get('.compare .nv-legend .nv-legend-symbol')
.first()
.should('have.css', 'fill', 'rgb(31, 168, 201)');
});
});

View File

@ -87,8 +87,5 @@ describe('Visualization > Distribution bar chart', () => {
cy.get(
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="bnbColors"]',
).should('exist');
cy.get('.dist_bar .nv-legend .nv-legend-symbol')
.first()
.should('have.css', 'fill', 'rgb(41, 105, 107)');
});
});

View File

@ -17,7 +17,6 @@
* under the License.
*/
/* eslint-disable no-dupe-class-members */
import { scaleOrdinal, ScaleOrdinal } from 'd3-scale';
import { ExtensibleFunction } from '../models';
import { ColorsInitLookup, ColorsLookup } from './types';
@ -39,99 +38,172 @@ class CategoricalColorScale extends ExtensibleFunction {
scale: ScaleOrdinal<{ toString(): string }, string>;
parentForcedColors: ColorsLookup;
forcedColors: ColorsLookup;
sharedColorMapInstance: ReturnType<typeof getSharedLabelColor>;
sliceMap: Map<string, string>;
multiple: number;
/**
* Constructor
* @param {*} colors an array of colors
* @param {*} parentForcedColors optional parameter that comes from parent
* (usually CategoricalColorNamespace) and supersede this.forcedColors
* @param {*} forcedColors optional parameter that comes from parent
* (usually CategoricalColorNamespace)
*/
constructor(colors: string[], parentForcedColors: ColorsInitLookup = {}) {
constructor(colors: string[], forcedColors: ColorsInitLookup = {}) {
super((value: string, sliceId?: number) => this.getColor(value, sliceId));
// holds original color scheme colors
this.originColors = colors;
// holds the extended color range (includes analagous colors)
this.colors = colors;
// holds the values of this specific slice (label+color)
this.sliceMap = new Map();
// shared color map instance (when context is shared, i.e. dashboard)
this.sharedColorMapInstance = getSharedLabelColor();
// holds the multiple value for analogous colors range
this.multiple = 0;
this.scale = scaleOrdinal<{ toString(): string }, string>();
this.scale.range(colors);
// reserve fixed colors in parent map based on their index in the scale
Object.entries(parentForcedColors).forEach(([key, value]) => {
Object.entries(forcedColors).forEach(([key, value]) => {
if (typeof value === 'number') {
// eslint-disable-next-line no-param-reassign
parentForcedColors[key] = colors[value % colors.length];
forcedColors[key] = colors[value % colors.length];
}
});
// all indexes have been replaced by a fixed color
this.parentForcedColors = parentForcedColors as ColorsLookup;
this.forcedColors = {};
this.multiple = 0;
// forced colors from parent (usually CategoricalColorNamespace)
// currently used in dashboards to set custom label colors
this.forcedColors = forcedColors as ColorsLookup;
}
removeSharedLabelColorFromRange(
sharedColorMap: Map<string, string>,
cleanedValue: string,
) {
// make sure we don't overwrite the origin colors
const updatedRange = new Set(this.originColors);
// remove the color option from shared color
sharedColorMap.forEach((value: string, key: string) => {
if (key !== cleanedValue) {
updatedRange.delete(value);
}
});
// remove the color option from forced colors
Object.entries(this.parentForcedColors).forEach(([key, value]) => {
if (key !== cleanedValue) {
updatedRange.delete(value);
}
});
this.range(updatedRange.size > 0 ? [...updatedRange] : this.originColors);
/**
* Increments the color range with analogous colors
*/
incrementColorRange() {
const multiple = Math.floor(
this.domain().length / this.originColors.length,
);
// the domain has grown larger than the original range
// increments the range with analogous colors
if (multiple > this.multiple) {
this.multiple = multiple;
const newRange = getAnalogousColors(this.originColors, multiple);
const extendedColors = this.originColors.concat(newRange);
this.range(extendedColors);
this.colors = extendedColors;
}
}
getColor(value?: string, sliceId?: number) {
/**
* Get the color for a given value
* @param value
* @param sliceId
* @returns the color for the given value
*/
getColor(value?: string, sliceId?: number): string {
const cleanedValue = stringifyAndTrim(value);
const sharedLabelColor = getSharedLabelColor();
const sharedColorMap = sharedLabelColor.getColorMap();
const sharedColorMap = this.sharedColorMapInstance.getColorMap();
const sharedColor = sharedColorMap.get(cleanedValue);
// priority: forced color (i.e. custom label colors) > shared color > scale color
const forcedColor = this.forcedColors?.[cleanedValue] || sharedColor;
const isExistingLabel = this.sliceMap.has(cleanedValue);
let color = forcedColor || this.scale(cleanedValue);
// priority: parentForcedColors > forcedColors > labelColors
let color =
this.parentForcedColors?.[cleanedValue] ||
this.forcedColors?.[cleanedValue] ||
sharedColor;
if (isFeatureEnabled(FeatureFlag.UseAnalagousColors)) {
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));
// a forced color will always be used independently of the usage count
if (!forcedColor && !isExistingLabel) {
if (isFeatureEnabled(FeatureFlag.UseAnalagousColors)) {
this.incrementColorRange();
}
}
const newColor = this.scale(cleanedValue);
if (!color) {
color = newColor;
if (isFeatureEnabled(FeatureFlag.AvoidColorsCollision)) {
this.removeSharedLabelColorFromRange(sharedColorMap, cleanedValue);
color = this.scale(cleanedValue);
if (
// feature flag to be deprecated (will become standard behaviour)
isFeatureEnabled(FeatureFlag.AvoidColorsCollision) &&
this.isColorUsed(color)
) {
// fallback to least used color
color = this.getNextAvailableColor(color);
}
}
sharedLabelColor.addSlice(cleanedValue, color, sliceId);
// keep track of values in this slice
this.sliceMap.set(cleanedValue, color);
// store the value+color in the shared context
if (sliceId) {
this.sharedColorMapInstance.addSlice(cleanedValue, color, sliceId);
}
return color;
}
/**
* Enforce specific color for given value
*
* @param color
* @returns whether the color is used in this slice
*/
isColorUsed(color: string): boolean {
return this.getColorUsageCount(color) > 0;
}
/**
* Get the count of the color usage in this slice
* @param sliceId
* @param color
* @returns the count of the color usage in this slice
*/
getColorUsageCount(currentColor: string): number {
let count = 0;
this.sliceMap.forEach(color => {
if (color === currentColor) {
count += 1;
}
});
return count;
}
/**
* Lower chances of color collision by returning the least used color
* Checks across colors of current slice within SharedLabelColorSingleton
*
* @param currentColor
* @returns the least used color that is not the excluded color
*/
getNextAvailableColor(currentColor: string) {
const colorUsageArray = this.colors.map(color => ({
color,
count: this.getColorUsageCount(color),
}));
const currentColorCount = this.getColorUsageCount(currentColor);
const otherColors = colorUsageArray.filter(
colorEntry => colorEntry.color !== currentColor,
);
// all other colors are used as much or more than currentColor
const hasNoneAvailable = otherColors.every(
colorEntry => colorEntry.count >= currentColorCount,
);
// fallback to currentColor color
if (!otherColors.length || hasNoneAvailable) {
return currentColor;
}
// Finding the least used color
const leastUsedColor = otherColors.reduce((min, entry) =>
entry.count < min.count ? entry : min,
).color;
return leastUsedColor;
}
/**
* Enforce specific color for a given value at the scale level
* Overrides any existing color and forced color for the given value
*
* @param {*} value value
* @param {*} forcedColor forcedColor
*/
@ -153,7 +225,6 @@ class CategoricalColorScale extends ExtensibleFunction {
return {
...colorMap,
...this.forcedColors,
...this.parentForcedColors,
};
}
@ -163,12 +234,11 @@ class CategoricalColorScale extends ExtensibleFunction {
copy() {
const copy = new CategoricalColorScale(
this.scale.range(),
this.parentForcedColors,
this.forcedColors,
);
copy.forcedColors = { ...this.forcedColors };
copy.domain(this.domain());
copy.unknown(this.unknown());
return copy;
}

View File

@ -43,10 +43,10 @@ export class SharedLabelColor {
CategoricalColorNamespace.getNamespace(colorNamespace);
const newColorMap = new Map();
this.colorMap.clear();
this.sliceLabelMap.forEach(labels => {
this.sliceLabelMap.forEach((labels, sliceId) => {
const colorScale = categoricalNamespace.getScale(colorScheme);
labels.forEach(label => {
const newColor = colorScale(label);
const newColor = colorScale.getColor(label, sliceId);
newColorMap.set(label, newColor);
});
});
@ -57,12 +57,8 @@ export class SharedLabelColor {
return this.colorMap;
}
addSlice(label: string, color: string, sliceId?: number) {
if (
this.source !== SharedLabelColorSource.Dashboard ||
sliceId === undefined
)
return;
addSlice(label: string, color: string, sliceId: number) {
if (this.source !== SharedLabelColorSource.Dashboard) return;
const labels = this.sliceLabelMap.get(sliceId) || [];
if (!labels.includes(label)) {
labels.push(label);
@ -83,13 +79,6 @@ export class SharedLabelColor {
this.colorMap = newColorMap;
}
reset() {
const copyColorMap = new Map(this.colorMap);
copyColorMap.forEach((_, label) => {
this.colorMap.set(label, '');
});
}
clear() {
this.sliceLabelMap.clear();
this.colorMap.clear();

View File

@ -55,11 +55,12 @@ export function getContrastingColor(color: string, thresholds = 186) {
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 => {
// returns an array of tinycolor instances
const result = tinycolor(color).analogous(results + ext);
// remove the first three colors to avoid the same or very close colors
return result.slice(ext);
});

View File

@ -98,7 +98,7 @@ describe('CategoricalColorNamespace', () => {
namespace.setColor('dog', 'black');
const scale = namespace.getScale('testColors');
scale.setColor('dog', 'pink');
expect(scale.getColor('dog')).toBe('black');
expect(scale.getColor('dog')).toBe('pink');
expect(scale.getColor('boy')).not.toBe('black');
});
it('does not affect scales in other namespaces', () => {

View File

@ -18,50 +18,76 @@
*/
import { ScaleOrdinal } from 'd3-scale';
import {
CategoricalColorScale,
FeatureFlag,
getSharedLabelColor,
} from '@superset-ui/core';
import { CategoricalColorScale, FeatureFlag } from '@superset-ui/core';
describe('CategoricalColorScale', () => {
beforeEach(() => {
window.featureFlags = {};
});
it('exists', () => {
expect(CategoricalColorScale !== undefined).toBe(true);
});
describe('new CategoricalColorScale(colors, parentForcedColors)', () => {
it('can create new scale when parentForcedColors is not given', () => {
describe('new CategoricalColorScale(colors, forcedColors)', () => {
it('can create new scale when forcedColors is not given', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
expect(scale).toBeInstanceOf(CategoricalColorScale);
});
it('can create new scale when parentForcedColors is given', () => {
const parentForcedColors = {};
it('can create new scale when forcedColors is given', () => {
const forcedColors = {};
const scale = new CategoricalColorScale(
['blue', 'red', 'green'],
parentForcedColors,
forcedColors,
);
expect(scale).toBeInstanceOf(CategoricalColorScale);
expect(scale.parentForcedColors).toBe(parentForcedColors);
expect(scale.forcedColors).toBe(forcedColors);
});
it('can refer to colors based on their index', () => {
const parentForcedColors = { pig: 1, horse: 5 };
const forcedColors = { pig: 1, horse: 5 };
const scale = new CategoricalColorScale(
['blue', 'red', 'green'],
parentForcedColors,
forcedColors,
);
expect(scale.getColor('pig')).toEqual('red');
expect(parentForcedColors.pig).toEqual('red');
expect(forcedColors.pig).toEqual('red');
// can loop around the scale
expect(scale.getColor('horse')).toEqual('green');
expect(parentForcedColors.horse).toEqual('green');
expect(forcedColors.horse).toEqual('green');
});
});
describe('.getColor(value)', () => {
describe('.getColor(value, sliceId)', () => {
let scale: CategoricalColorScale;
let addSliceSpy: jest.SpyInstance<
void,
[label: string, color: string, sliceId: number]
>;
let getNextAvailableColorSpy: jest.SpyInstance<
string,
[currentColor: string]
>;
beforeEach(() => {
scale = new CategoricalColorScale(['blue', 'red', 'green']);
// Spy on the addSlice method of sharedColorMapInstance
addSliceSpy = jest.spyOn(scale.sharedColorMapInstance, 'addSlice');
getNextAvailableColorSpy = jest
.spyOn(scale, 'getNextAvailableColor')
.mockImplementation(color => color);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('returns same color for same value', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
const scale = new CategoricalColorScale(['blue', 'red', 'green'], {
pig: 'red',
horse: 'green',
});
const c1 = scale.getColor('pig');
const c2 = scale.getColor('horse');
const c3 = scale.getColor('pig');
@ -82,9 +108,6 @@ describe('CategoricalColorScale', () => {
expect(c3).not.toBe(c1);
});
it('recycles colors when number of items exceed available colors', () => {
window.featureFlags = {
[FeatureFlag.UseAnalagousColors]: false,
};
const colorSet: { [key: string]: number } = {};
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
const colors = [
@ -118,43 +141,68 @@ describe('CategoricalColorScale', () => {
scale.getColor('cow');
scale.getColor('donkey');
scale.getColor('goat');
expect(scale.range()).toHaveLength(6);
expect(scale.range()).toHaveLength(9);
});
it('adds the color and value to sliceMap and calls addSlice', () => {
const value = 'testValue';
const sliceId = 123;
it('should remove shared color from range if avoid colors collision enabled', () => {
expect(scale.sliceMap.has(value)).toBe(false);
scale.getColor(value, sliceId);
expect(scale.sliceMap.has(value)).toBe(true);
expect(scale.sliceMap.get(value)).toBeDefined();
expect(addSliceSpy).toHaveBeenCalledWith(
value,
expect.any(String),
sliceId,
);
const expectedColor = scale.sliceMap.get(value);
const returnedColor = scale.getColor(value, sliceId);
expect(returnedColor).toBe(expectedColor);
});
it('conditionally calls getNextAvailableColor', () => {
window.featureFlags = {
[FeatureFlag.AvoidColorsCollision]: true,
};
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
const color1 = scale.getColor('a', 1);
expect(scale.range()).toHaveLength(3);
const color2 = scale.getColor('a', 2);
expect(color1).toBe(color2);
scale.getColor('b', 2);
expect(scale.range()).toHaveLength(2);
scale.getColor('c', 2);
expect(scale.range()).toHaveLength(1);
scale.getColor('testValue1');
scale.getColor('testValue2');
scale.getColor('testValue1');
scale.getColor('testValue3');
scale.getColor('testValue4');
expect(getNextAvailableColorSpy).toHaveBeenCalledWith('blue');
getNextAvailableColorSpy.mockClear();
window.featureFlags = {
[FeatureFlag.AvoidColorsCollision]: false,
};
scale.getColor('testValue3');
expect(getNextAvailableColorSpy).not.toHaveBeenCalled();
});
window.featureFlags = {
[FeatureFlag.AvoidColorsCollision]: false,
};
});
describe('.setColor(value, forcedColor)', () => {
it('overrides default color', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
scale.setColor('pig', 'pink');
expect(scale.getColor('pig')).toBe('pink');
});
it('does not override parentForcedColors', () => {
it('does override forcedColors', () => {
const scale1 = new CategoricalColorScale(['blue', 'red', 'green']);
scale1.setColor('pig', 'black');
const scale2 = new CategoricalColorScale(
['blue', 'red', 'green'],
scale1.forcedColors,
);
const scale2 = new CategoricalColorScale(['blue', 'red', 'green']);
scale2.setColor('pig', 'pink');
expect(scale2.getColor('pig')).toBe('pink');
expect(scale1.getColor('pig')).toBe('black');
expect(scale2.getColor('pig')).toBe('black');
});
it('returns the scale', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
@ -163,7 +211,7 @@ describe('CategoricalColorScale', () => {
});
});
describe('.getColorMap()', () => {
it('returns correct mapping and parentForcedColors and forcedColors are specified', () => {
it('returns correct mapping using least used color', () => {
const scale1 = new CategoricalColorScale(['blue', 'red', 'green']);
scale1.setColor('cow', 'black');
const scale2 = new CategoricalColorScale(
@ -177,7 +225,7 @@ describe('CategoricalColorScale', () => {
expect(scale2.getColorMap()).toEqual({
cow: 'black',
pig: 'pink',
horse: 'green',
horse: 'blue', // least used color
});
});
});
@ -230,10 +278,114 @@ describe('CategoricalColorScale', () => {
});
describe('a CategoricalColorScale instance is also a color function itself', () => {
it('scale(value) returns color similar to calling scale.getColor(value)', () => {
it('scale(value) returns same color for same value', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
expect(scale.getColor('pig')).toBe(scale('pig'));
expect(scale.getColor('cat')).toBe(scale('cat'));
expect(scale.getColor('pig')).toBe('blue');
expect(scale('pig')).toBe('blue');
expect(scale.getColor('cat')).toBe('red');
expect(scale('cat')).toBe('red');
});
});
describe('.getNextAvailableColor(currentColor)', () => {
it('returns the current color if it is the least used or equally used among colors', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
scale.getColor('cat');
scale.getColor('dog');
// Since 'green' hasn't been used, it's considered the least used.
expect(scale.getNextAvailableColor('blue')).toBe('green');
});
it('handles cases where all colors are equally used and returns the current color', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
scale.getColor('cat'); // blue
scale.getColor('dog'); // red
scale.getColor('fish'); // green
// All colors used once, so the function should return the current color
expect(scale.getNextAvailableColor('red')).toBe('red');
});
it('returns the least used color accurately even when some colors are used more frequently', () => {
const scale = new CategoricalColorScale([
'blue',
'red',
'green',
'yellow',
]);
scale.getColor('cat'); // blue
scale.getColor('dog'); // red
scale.getColor('frog'); // green
scale.getColor('fish'); // yellow
scale.getColor('goat'); // blue
scale.getColor('horse'); // red
scale.getColor('pony'); // green
// Yellow is the least used color, so it should be returned.
expect(scale.getNextAvailableColor('blue')).toBe('yellow');
});
});
describe('.isColorUsed(color)', () => {
it('returns true if the color is already used, false otherwise', () => {
const scale = new CategoricalColorScale(['blue', 'red', 'green']);
// Initially, no color is used
expect(scale.isColorUsed('blue')).toBe(false);
expect(scale.isColorUsed('red')).toBe(false);
expect(scale.isColorUsed('green')).toBe(false);
scale.getColor('item1');
// Now, 'blue' is used, but 'red' and 'green' are not
expect(scale.isColorUsed('blue')).toBe(true);
expect(scale.isColorUsed('red')).toBe(false);
expect(scale.isColorUsed('green')).toBe(false);
// Simulate using the 'red' color
scale.getColor('item2'); // Assigns 'red' to 'item2'
// Now, 'blue' and 'red' are used
expect(scale.isColorUsed('blue')).toBe(true);
expect(scale.isColorUsed('red')).toBe(true);
expect(scale.isColorUsed('green')).toBe(false);
});
});
describe('.getColorUsageCount(color)', () => {
it('accurately counts the occurrences of a specific color', () => {
const scale = new CategoricalColorScale([
'blue',
'red',
'green',
'yellow',
]);
// No colors are used initially
expect(scale.getColorUsageCount('blue')).toBe(0);
expect(scale.getColorUsageCount('red')).toBe(0);
expect(scale.getColorUsageCount('green')).toBe(0);
expect(scale.getColorUsageCount('yellow')).toBe(0);
// Simulate using colors
scale.getColor('item1');
scale.getColor('item2');
scale.getColor('item1');
// Check the counts after using the colors
expect(scale.getColorUsageCount('blue')).toBe(1);
expect(scale.getColorUsageCount('red')).toBe(1);
expect(scale.getColorUsageCount('green')).toBe(0);
expect(scale.getColorUsageCount('yellow')).toBe(0);
// Simulate using colors more
scale.getColor('item3');
scale.getColor('item4');
scale.getColor('item3');
// Final counts
expect(scale.getColorUsageCount('blue')).toBe(1);
expect(scale.getColorUsageCount('red')).toBe(1);
expect(scale.getColorUsageCount('green')).toBe(1);
expect(scale.getColorUsageCount('yellow')).toBe(1);
});
});
@ -244,50 +396,4 @@ describe('CategoricalColorScale', () => {
expect(scale('pig')).toBe('blue');
});
});
describe('.removeSharedLabelColorFromRange(colorMap, cleanedValue)', () => {
it('should remove shared color from range', () => {
const scale = new CategoricalColorScale(['blue', 'green', 'red']);
expect(scale.range()).toEqual(['blue', 'green', 'red']);
const sharedLabelColor = getSharedLabelColor();
sharedLabelColor.clear();
const colorMap = sharedLabelColor.getColorMap();
sharedLabelColor.addSlice('cow', 'blue', 1);
scale.removeSharedLabelColorFromRange(colorMap, 'pig');
expect(scale.range()).toEqual(['green', 'red']);
scale.removeSharedLabelColorFromRange(colorMap, 'cow');
expect(scale.range()).toEqual(['blue', 'green', 'red']);
sharedLabelColor.clear();
});
it('recycles colors when all colors are in sharedLabelColor', () => {
const scale = new CategoricalColorScale(['blue', 'green', 'red']);
expect(scale.range()).toEqual(['blue', 'green', 'red']);
const sharedLabelColor = getSharedLabelColor();
const colorMap = sharedLabelColor.getColorMap();
sharedLabelColor.addSlice('cow', 'blue', 1);
sharedLabelColor.addSlice('pig', 'red', 1);
sharedLabelColor.addSlice('horse', 'green', 1);
scale.removeSharedLabelColorFromRange(colorMap, 'goat');
expect(scale.range()).toEqual(['blue', 'green', 'red']);
sharedLabelColor.clear();
});
it('should remove parentForcedColors from range', () => {
const parentForcedColors = { house: 'blue', cow: 'red' };
const scale = new CategoricalColorScale(
['blue', 'red', 'green'],
parentForcedColors,
);
const sharedLabelColor = getSharedLabelColor();
sharedLabelColor.clear();
const colorMap = sharedLabelColor.getColorMap();
scale.removeSharedLabelColorFromRange(colorMap, 'pig');
expect(scale.range()).toEqual(['green']);
scale.removeSharedLabelColorFromRange(colorMap, 'cow');
expect(scale.range()).toEqual(['red', 'green']);
sharedLabelColor.clear();
});
});
});

View File

@ -94,13 +94,7 @@ describe('SharedLabelColor', () => {
it('should do nothing when source is not dashboard', () => {
const sharedLabelColor = getSharedLabelColor();
sharedLabelColor.source = SharedLabelColorSource.Explore;
sharedLabelColor.addSlice('a', 'red');
expect(Object.fromEntries(sharedLabelColor.sliceLabelMap)).toEqual({});
});
it('should do nothing when sliceId is undefined', () => {
const sharedLabelColor = getSharedLabelColor();
sharedLabelColor.addSlice('a', 'red');
sharedLabelColor.addSlice('a', 'red', 1);
expect(Object.fromEntries(sharedLabelColor.sliceLabelMap)).toEqual({});
});
});
@ -142,8 +136,8 @@ describe('SharedLabelColor', () => {
const colorMap = sharedLabelColor.getColorMap();
expect(Object.fromEntries(colorMap)).toEqual({
a: 'yellow',
b: 'yellow',
c: 'green',
b: 'green',
c: 'yellow',
});
});
@ -193,9 +187,9 @@ describe('SharedLabelColor', () => {
const sharedLabelColor = getSharedLabelColor();
sharedLabelColor.addSlice('a', 'red', 1);
sharedLabelColor.addSlice('b', 'blue', 2);
sharedLabelColor.reset();
sharedLabelColor.clear();
const colorMap = sharedLabelColor.getColorMap();
expect(Object.fromEntries(colorMap)).toEqual({ a: '', b: '' });
expect(Object.fromEntries(colorMap)).toEqual({});
});
});
});

View File

@ -58,16 +58,15 @@ export function dashboardInfoChanged(newInfo: { metadata: any }) {
const categoricalNamespace = CategoricalColorNamespace.getNamespace(
metadata?.color_namespace,
);
// reset forced colors
categoricalNamespace.resetColors();
if (metadata?.shared_label_colors) {
updateColorSchema(metadata, metadata?.shared_label_colors);
}
const mergedLabelColors = {
...(metadata?.label_colors || {}),
...(metadata?.shared_label_colors || {}),
};
if (metadata?.label_colors) {
updateColorSchema(metadata, metadata?.label_colors);
}
updateColorSchema(metadata, mergedLabelColors);
return { type: DASHBOARD_INFO_UPDATED, newInfo };
}

View File

@ -372,13 +372,17 @@ class Header extends React.PureComponent {
? currentRefreshFrequency
: dashboardInfo.metadata?.refresh_frequency;
const currentColorScheme =
dashboardInfo?.metadata?.color_scheme || colorScheme;
const currentColorNamespace =
dashboardInfo?.metadata?.color_namespace || colorNamespace;
const currentColorScheme =
dashboardInfo?.metadata?.color_scheme || colorScheme;
const currentSharedLabelColors = Object.fromEntries(
getSharedLabelColor().getColorMap(),
);
// an empty color scheme should not bring shared label colors forward
const sharedLabelColors = currentColorScheme
? currentSharedLabelColors
: {};
const data = {
certified_by: dashboardInfo.certified_by,
@ -395,7 +399,7 @@ class Header extends React.PureComponent {
color_scheme: currentColorScheme,
positions,
refresh_frequency: refreshFrequency,
shared_label_colors: currentSharedLabelColors,
shared_label_colors: sharedLabelColors,
},
};

View File

@ -381,16 +381,21 @@ const PropertiesModal = ({
const sharedLabelColor = getSharedLabelColor();
const categoricalNamespace =
CategoricalColorNamespace.getNamespace(colorNamespace);
// reset forced colors
categoricalNamespace.resetColors();
if (currentColorScheme) {
// reset the shared label color map based on the current color scheme
sharedLabelColor.updateColorMap(colorNamespace, currentColorScheme);
// store the shared label color map and domain in the metadata
metadata.shared_label_colors = Object.fromEntries(
sharedLabelColor.getColorMap(),
);
metadata.color_scheme_domain =
categoricalSchemeRegistry.get(colorScheme)?.colors || [];
} else {
sharedLabelColor.reset();
sharedLabelColor.clear();
metadata.shared_label_colors = {};
metadata.color_scheme_domain = [];
}

View File

@ -18,11 +18,19 @@
*/
import React from 'react';
import PropTypes from 'prop-types';
import { styled, t } from '@superset-ui/core';
import {
getSharedLabelColor,
styled,
SupersetClient,
t,
} from '@superset-ui/core';
import { connect } from 'react-redux';
import { LineEditableTabs } from 'src/components/Tabs';
import { LOG_ACTIONS_SELECT_DASHBOARD_TAB } from 'src/logger/LogUtils';
import { AntdModal } from 'src/components';
import { isEqual } from 'lodash';
import jsonStringify from 'json-stringify-pretty-compact';
import { dashboardInfoChanged } from 'src/dashboard/actions/dashboardInfo';
import { Draggable } from '../dnd/DragDroppable';
import DragHandle from '../dnd/DragHandle';
import DashboardComponent from '../../containers/DashboardComponent';
@ -256,10 +264,51 @@ export class Tabs extends React.PureComponent {
}
};
// the initial color map is generated on save and catches all rendered charts
// charts hidden in nested tabs are not rendered, thus not included
// must update the label color map when switching tabs to include these charts
updateLabelColorsMap() {
const currentLabelColorsMap = Object.fromEntries(
getSharedLabelColor().getColorMap(),
);
const { metadata } = this.props.dashboardInfo;
const labelColorsMap = metadata?.shared_label_colors || {};
// color consistency is currently only supported when a color scheme is set
if (
metadata?.color_scheme &&
!isEqual(labelColorsMap, currentLabelColorsMap)
) {
const updatedMetadata = {
...metadata,
shared_label_colors: currentLabelColorsMap,
};
SupersetClient.put({
endpoint: `/api/v1/dashboard/${this.props.dashboardId}`,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
json_metadata: jsonStringify(updatedMetadata),
}),
})
.then(() =>
this.props.dispatch(
dashboardInfoChanged({
metadata: updatedMetadata,
}),
),
)
.catch(e => console.error(e));
}
}
handleClickTab(tabIndex) {
const { component } = this.props;
const { children: tabIds } = component;
// the charts need to render first
setTimeout(() => {
this.updateLabelColorsMap();
}, 500);
if (tabIndex !== this.state.tabIndex) {
const pathToTabIndex = getDirectPathToTabIndex(component, tabIndex);
const targetTabId = pathToTabIndex[pathToTabIndex.length - 1];

View File

@ -78,6 +78,7 @@ function mapStateToProps(
editMode: dashboardState.editMode,
filters: getActiveFilters(),
dashboardId: dashboardInfo.id,
dashboardInfo,
fullSizeChartId: dashboardState.fullSizeChartId,
};

View File

@ -190,6 +190,7 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
useEffect(() => {
const sharedLabelColor = getSharedLabelColor();
sharedLabelColor.source = SharedLabelColorSource.Dashboard;
return () => {
// clean up label color
const categoricalNamespace = CategoricalColorNamespace.getNamespace(

View File

@ -24,6 +24,7 @@ import { Tooltip } from 'src/components/Tooltip';
import {
CategoricalColorNamespace,
css,
getSharedLabelColor,
logging,
SupersetClient,
t,
@ -95,6 +96,16 @@ export const ExploreChartHeader = ({
const { dashboards } = metadata || {};
const dashboard =
dashboardId && dashboards && dashboards.find(d => d.id === dashboardId);
const categoricalNamespace = CategoricalColorNamespace.getNamespace();
const sharedLabelColor = getSharedLabelColor();
if (!dashboard) {
// clean up color namespace and shared color maps
// to avoid colors spill outside of dashboard context
categoricalNamespace.resetColors();
sharedLabelColor.clear();
return;
}
if (dashboard) {
try {
@ -106,21 +117,19 @@ export const ExploreChartHeader = ({
const result = response?.json?.result;
// setting the chart to use the dashboard custom label colors if any
const metadata = JSON.parse(result.json_metadata);
const sharedLabelColors = metadata.shared_label_colors || {};
const customLabelColors = metadata.label_colors || {};
const dashboardMetadata = JSON.parse(result.json_metadata);
const sharedLabelColors = dashboardMetadata.shared_label_colors || {};
const customLabelColors = dashboardMetadata.label_colors || {};
const mergedLabelColors = {
...sharedLabelColors,
...customLabelColors,
};
const categoricalNamespace = CategoricalColorNamespace.getNamespace();
Object.keys(mergedLabelColors).forEach(label => {
categoricalNamespace.setColor(
label,
mergedLabelColors[label],
metadata.color_scheme,
dashboardMetadata.color_scheme,
);
});
} catch (error) {
@ -130,7 +139,7 @@ export const ExploreChartHeader = ({
};
useEffect(() => {
if (dashboardId) updateCategoricalNamespace();
updateCategoricalNamespace();
}, []);
const openPropertiesModal = () => {

View File

@ -71,6 +71,7 @@ FEATURE_FLAGS = {
"SHARE_QUERIES_VIA_KV_STORE": True,
"ENABLE_TEMPLATE_PROCESSING": True,
"ALERT_REPORTS": True,
"AVOID_COLORS_COLLISION": True,
"DRILL_TO_DETAIL": True,
"DRILL_BY": True,
"HORIZONTAL_FILTER_BAR": True,