Skip to content

Commit

Permalink
feat(color): support better color interpolation for sequential schemes (
Browse files Browse the repository at this point in the history
apache#547)

* feat: update functions

* test: add unit tests

* fix: unit tests

* fix: address comments and use piecewise
  • Loading branch information
kristw authored and zhaoyongjie committed Nov 24, 2021
1 parent 43d7d60 commit fa4404f
Show file tree
Hide file tree
Showing 3 changed files with 99 additions and 54 deletions.
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
Original file line number Diff line number Diff line change
@@ -1,15 +1,7 @@
import { scaleLinear } from 'd3-scale';
import { interpolateHcl, interpolateNumber, piecewise, quantize } from 'd3-interpolate';
import ColorScheme, { ColorSchemeConfig } from './ColorScheme';

function range(count: number) {
const values: number[] = [];
for (let i = 0; i < count; i += 1) {
values.push(i);
}

return values;
}

export interface SequentialSchemeConfig extends ColorSchemeConfig {
isDiverging?: boolean;
}
Expand All @@ -23,24 +15,43 @@ 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));

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) {
const scale = scaleLinear<string>().interpolate(interpolateHcl).clamp(true);

return modifyRange || domain.length === this.colors.length
? scale.domain(domain).range(this.getColors(domain.length))
: scale
.domain(quantize(piecewise(interpolateNumber, domain), this.colors.length))
.range(this.colors);
}

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: (t: number) => string = piecewise(interpolateHcl, this.colors);
const adjustExtent = scaleLinear().range(extent).clamp(true);

return quantize<string>(t => piecewiseScale(adjustExtent(t)), numColors);
}
}
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)']);
});
});
});
});

0 comments on commit fa4404f

Please sign in to comment.