Skip to content
This repository has been archived by the owner on Dec 10, 2021. It is now read-only.

feat(color): support better color interpolation for sequential schemes #547

Merged
merged 4 commits into from
May 28, 2020
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/superset-ui-color/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
},
"dependencies": {
"@types/d3-scale": "^2.1.1",
"d3-scale": "^3.0.0"
"@types/d3-interpolate": "^1.3.1",
"d3-scale": "^3.0.0",
"d3-interpolate": "^1.4.0"
},
"peerDependencies": {
"@superset-ui/core": "^0.13.0"
Expand Down
76 changes: 63 additions & 13 deletions packages/superset-ui-color/src/SequentialScheme.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { scaleLinear } from 'd3-scale';
import { interpolateHcl } from 'd3-interpolate';
import ColorScheme, { ColorSchemeConfig } from './ColorScheme';

function range(count: number) {
Expand All @@ -10,6 +11,11 @@ function range(count: number) {
return values;
}

function rangeZeroToOne(steps: number) {
const denominator = steps - 1;
return range(steps).map(i => i / denominator);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can replace range with [...new Array(steps).keys()]

}

export interface SequentialSchemeConfig extends ColorSchemeConfig {
isDiverging?: boolean;
}
Expand All @@ -23,24 +29,68 @@ export default class SequentialScheme extends ColorScheme {
this.isDiverging = isDiverging;
}

createLinearScale(extent: number[] = [0, 1]) {
// Create matching domain
// because D3 continuous scale uses piecewise mapping
// between domain and range.
const valueScale = scaleLinear().range(extent);
const denominator = this.colors.length - 1;
const domain = range(this.colors.length).map(i => valueScale(i / denominator));
/**
* Create a linear scale using the colors in this scheme as a range
* and interpolate domain [0, 1]
* to match the number of elements in the range
* e.g.
* If the range is ['red', 'white', 'blue']
* the domain of this scale will be
* [0, 0.5, 1]
*/
private createZeroToOnePiecewiseScale() {
return scaleLinear<string>()
.domain(rangeZeroToOne(this.colors.length))
.range(this.colors)
.interpolate(interpolateHcl)
.clamp(true);
}

return scaleLinear<string>().domain(domain).range(this.colors).clamp(true);
/**
* Return a linear scale with a new domain interpolated from the input domain
* to match the number of elements in the color scheme
* because D3 continuous scale uses piecewise mapping between domain and range.
* This is a common use-case when the domain is [min, max]
* and the palette has more than two colors.
*
* @param domain domain of the scale
* @param modifyRange Set this to true if you don't want to modify the domain and
* want to interpolate range to have the same number of elements with domain instead.
*/
createLinearScale(domain: number[] = [0, 1], modifyRange = false) {
if (modifyRange || domain.length === this.colors.length) {
return scaleLinear<string>()
.interpolate(interpolateHcl)
.domain(domain)
.range(this.getColors(domain.length));
}

const piecewiseScale = this.createZeroToOnePiecewiseScale();
const adjustDomain = scaleLinear()
.domain(rangeZeroToOne(domain.length))
.range(domain)
.clamp(true);
const newDomain = piecewiseScale.domain().map(d => adjustDomain(d));

return piecewiseScale.domain(newDomain);
}

getColors(numColors: number = this.colors.length): string[] {
if (numColors === this.colors.length) {
/**
* Get colors from this scheme
* @param numColors number of colors to return.
* Will interpolate the current scheme to match the number of colors requested
* @param extent The extent of the color range to use.
* For example [0.2, 1] will rescale the color scheme
* such that color values in the range [0, 0.2) are excluded from the scheme.
*/
getColors(numColors: number = this.colors.length, extent: number[] = [0, 1]): string[] {
if (numColors === this.colors.length && extent[0] === 0 && extent[1] === 1) {
return this.colors;
}
const colorScale = this.createLinearScale();
const denominator = numColors - 1;

return range(numColors).map(i => colorScale(i / denominator));
const piecewiseScale = this.createZeroToOnePiecewiseScale();
const adjustExtent = scaleLinear().range(extent).clamp(true);

return rangeZeroToOne(numColors).map(x => piecewiseScale(adjustExtent(x)));
}
}
92 changes: 62 additions & 30 deletions packages/superset-ui-color/test/SequentialScheme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,41 +17,73 @@ describe('SequentialScheme', () => {
expect(scheme2).toBeInstanceOf(SequentialScheme);
});
});
describe('.createLinearScale(extent)', () => {
it('returns a linear scale for the given extent', () => {
describe('.createLinearScale(domain, modifyRange)', () => {
it('returns a piecewise scale', () => {
const scale = scheme.createLinearScale([10, 100]);
expect(scale(1)).toEqual('rgb(255, 255, 255)');
expect(scale(10)).toEqual('rgb(255, 255, 255)');
expect(scale(55)).toEqual('rgb(128, 128, 128)');
expect(scale(100)).toEqual('rgb(0, 0, 0)');
expect(scale(1000)).toEqual('rgb(0, 0, 0)');
expect(scale.domain()).toHaveLength(scale.range().length);
const scale2 = scheme.createLinearScale([0, 10, 100]);
expect(scale2.domain()).toHaveLength(scale2.range().length);
});
it('uses [0, 1] as extent if not specified', () => {
const scale = scheme.createLinearScale();
expect(scale(-1)).toEqual('rgb(255, 255, 255)');
expect(scale(0)).toEqual('rgb(255, 255, 255)');
expect(scale(0.5)).toEqual('rgb(128, 128, 128)');
expect(scale(1)).toEqual('rgb(0, 0, 0)');
expect(scale(2)).toEqual('rgb(0, 0, 0)');
describe('domain', () => {
it('returns a linear scale for the given domain', () => {
const scale = scheme.createLinearScale([10, 100]);
expect(scale(1)).toEqual('rgb(255, 255, 255)');
expect(scale(10)).toEqual('rgb(255, 255, 255)');
expect(scale(55)).toEqual('rgb(119, 119, 119)');
expect(scale(100)).toEqual('rgb(0, 0, 0)');
expect(scale(1000)).toEqual('rgb(0, 0, 0)');
});
it('uses [0, 1] as domain if not specified', () => {
const scale = scheme.createLinearScale();
expect(scale(-1)).toEqual('rgb(255, 255, 255)');
expect(scale(0)).toEqual('rgb(255, 255, 255)');
expect(scale(0.5)).toEqual('rgb(119, 119, 119)');
expect(scale(1)).toEqual('rgb(0, 0, 0)');
expect(scale(2)).toEqual('rgb(0, 0, 0)');
});
});
describe('modifyRange', () => {
const scheme3 = new SequentialScheme({
id: 'test-scheme3',
colors: ['#fee087', '#fa5c2e', '#800026'],
});
it('modifies domain by default', () => {
const scale = scheme3.createLinearScale([0, 100]);
expect(scale.domain()).toEqual([0, 50, 100]);
expect(scale.range()).toEqual(['#fee087', '#fa5c2e', '#800026']);
});
it('modifies range instead of domain if set to true', () => {
const scale = scheme3.createLinearScale([0, 100], true);
expect(scale.domain()).toEqual([0, 100]);
expect(scale.range()).toEqual(['rgb(254, 224, 135)', 'rgb(128, 0, 38)']);
});
});
});
describe('.getColors(numColors)', () => {
it('returns the original colors if numColors is not specified', () => {
expect(scheme.getColors()).toEqual(['#fff', '#000']);
describe('.getColors(numColors, extent)', () => {
describe('numColors', () => {
it('returns the original colors if numColors is not specified', () => {
expect(scheme.getColors()).toEqual(['#fff', '#000']);
});
it('returns the exact number of colors if numColors is specified', () => {
expect(scheme.getColors(2)).toEqual(['#fff', '#000']);
expect(scheme.getColors(3)).toEqual([
'rgb(255, 255, 255)',
'rgb(119, 119, 119)',
'rgb(0, 0, 0)',
]);
expect(scheme.getColors(4)).toEqual([
'rgb(255, 255, 255)',
'rgb(162, 162, 162)',
'rgb(78, 78, 78)',
'rgb(0, 0, 0)',
]);
});
});
it('returns the exact number of colors if numColors is specified', () => {
expect(scheme.getColors(2)).toEqual(['#fff', '#000']);
expect(scheme.getColors(3)).toEqual([
'rgb(255, 255, 255)',
'rgb(128, 128, 128)',
'rgb(0, 0, 0)',
]);
expect(scheme.getColors(4)).toEqual([
'rgb(255, 255, 255)',
'rgb(170, 170, 170)',
'rgb(85, 85, 85)',
'rgb(0, 0, 0)',
]);
describe('extent', () => {
it('adjust the range if extent is specified', () => {
expect(scheme.getColors(2, [0, 0.5])).toEqual(['rgb(255, 255, 255)', 'rgb(119, 119, 119)']);
expect(scheme.getColors(2, [0.5, 1])).toEqual(['rgb(119, 119, 119)', 'rgb(0, 0, 0)']);
});
});
});
});