Skip to content

Commit

Permalink
feat: generate-color-scales (#508)
Browse files Browse the repository at this point in the history
  • Loading branch information
T-Wizard authored Sep 28, 2020
1 parent 36c708f commit 6ca871e
Show file tree
Hide file tree
Showing 5 changed files with 255 additions and 3 deletions.
59 changes: 59 additions & 0 deletions apis/theme/src/__tests__/theme-scale-generator.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import scaleGenerator from '../theme-scale-generator';

describe('Theme scale generator', () => {
const input = ['#ffffff', '#000000'];
const base8 = ['#ffffff', '#d4d4d4', '#aaaaaa', '#7f7f7f', '#545454', '#2a2a2a', '#000000'];

beforeEach(() => {});

it('Should generate a pyramid', () => {
const scales = [{ type: 'class', scale: input }];
scaleGenerator(scales);
expect(scales).to.have.length(1);
const { scale, type } = scales[0];

expect(type).to.equals('class-pyramid');
expect(scale.length).to.equal(8);
expect(scale[scale.length - 1].length).to.equal(7);
});

it('Should generate a correct base of colors', () => {
const scales = [{ type: 'class', scale: input }];
scaleGenerator(scales);
const { scale } = scales[0];

const colors = scale[scale.length - 1];
expect(colors).to.deep.equal(base8);
});

it('Should work correctly on a scale from the sense theme', () => {
const senseDivergentScale = [
'#ae1c3e',
'#d24d3e',
'#ed875e',
'#f9bd7e',
'#ffe3aa',
'#e6f5fe',
'#b4ddf7',
'#77b7e5',
'#3a89c9',
'#3d52a1',
];
const scales = [{ type: 'class', scale: senseDivergentScale }];
scaleGenerator(scales);
const { scale } = scales[0];
expect(scale).to.deep.equal([
null,
['#e6f5fe'],
['#ed875e', '#3a89c9'],
['#d24d3e', '#ffe3aa', '#3a89c9'],
['#d24d3e', '#f9bd7e', '#b4ddf7', '#3a89c9'],
['#d24d3e', '#f9bd7e', '#e6f5fe', '#b4ddf7', '#3a89c9'],
['#d24d3e', '#ed875e', '#ffe3aa', '#e6f5fe', '#77b7e5', '#3d52a1'],
['#ae1c3e', '#ed875e', '#f9bd7e', '#e6f5fe', '#b4ddf7', '#77b7e5', '#3d52a1'],
['#ae1c3e', '#d24d3e', '#f9bd7e', '#ffe3aa', '#e6f5fe', '#b4ddf7', '#3a89c9', '#3d52a1'],
['#ae1c3e', '#d24d3e', '#ed875e', '#f9bd7e', '#e6f5fe', '#b4ddf7', '#77b7e5', '#3a89c9', '#3d52a1'],
['#ae1c3e', '#d24d3e', '#ed875e', '#f9bd7e', '#ffe3aa', '#e6f5fe', '#b4ddf7', '#77b7e5', '#3a89c9', '#3d52a1'],
]);
});
});
91 changes: 91 additions & 0 deletions apis/theme/src/color-scale.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { color, rgb } from 'd3-color';

/**
* Gets this mapping between the scaled value and the color parts
* @param {Number} scaledValue - A value between 0 and 1 representing a value in the data scaled between the max and min boundaries of the data. Values are clamped to 0 and 1.
* @param {Number} numEdges - Number of parts that makes up this scale
*/
function limitFunction(scaledValue, numParts) {
/*
* Color-Scale doesn't calculate exact color blends based of the scaled value. It instead shifts the value inwards to achieve
* a better color representation at the edges. Primarily this is done to allow setting custom limits to where each color begins
* and ends. If a color begins and ends at 1, it should not be visible. The simplest way to achive this is to remove 1 and 0
* from the possible numbers that can be used. Colors that are not equal to 1 or 0 should not be affected.
*/

// The following is done to keep the scaled value above 0 and below 1. This shifts values that hits an exact boundary upwards.
// eslint-disable-next-line no-param-reassign
scaledValue = Math.min(Math.max(scaledValue, 0.000000000001), 0.999999999999);
return numParts - scaledValue * numParts;
}

function getLevel(scale, level) {
return Math.min(level || scale.startLevel, scale.colorParts.length - 1);
}

function blend(c1, c2, t) {
const r = Math.floor(c1.r + (c2.r - c1.r) * t);
const g = Math.floor(c1.g + (c2.g - c1.g) * t);
const b = Math.floor(c1.b + (c2.b - c1.b) * t);
const a = Math.floor(c1.opacity + (c2.opacity - c1.opacity) * t);
return rgb(r, g, b, a);
}

class ColorScale {
constructor(nanColor) {
this.colorParts = [];
this.startLevel = 0;
this.max = 1;
this.min = 0;
this.nanColor = color(nanColor);
}

/**
* Adds a part to this color scale. The input colors span one part of the gradient, colors between them are interpolated. Input two equal colors for a solid scale part.
* @param {String|Number} color1 - First color to be used, in formats defined by Color
* @param {String|Number} color2 - Second color to be used, in formats defined by Color
* @param {Number} level - Which level of the color pyramid to add this part to.
*/
addColorPart(color1, color2, level) {
// eslint-disable-next-line no-param-reassign
level = level || 0;
this.startLevel = Math.max(level, this.startLevel);
if (!this.colorParts[level]) {
this.colorParts[level] = [];
}
this.colorParts[level].push([color(color1), color(color2)]);
}

/**
* Gets the color which represents the input value
* @param {Number} scaledValue - A value between 0 and 1 representing a value in the data scaled between the max and min boundaries of the data. Values are clamped to 0 and 1.
*/
getColor(value, level) {
const scaledValue = value - this.min;
if (Number.isNaN(+value) || Number.isNaN(+scaledValue)) {
return this.nanColor;
}
// eslint-disable-next-line no-param-reassign
level = getLevel(this, level);
const k = limitFunction(scaledValue, this.colorParts[level].length);
let f = Math.floor(k);
f = f === k ? f - 1 : f; // To fulfill equal or greater than: 329-<330
const part = this.colorParts[level][f];
const c1 = part[0];
const c2 = part[1];

// For absolute edges we return the colors at the limit
if (value === this.min) {
return c2;
}
if (value === this.max) {
return c1;
}

const t = k - f;
const uc = blend(c1, c2, t);
return uc;
}
}

export default ColorScale;
4 changes: 2 additions & 2 deletions apis/theme/src/palette-resolver.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/**
* @interface Theme~ScalePalette
* @property {string} key
* @property {'gradient'|'class'} type
* @property {string[]} colors
* @property {'gradient'|'class-pyramid'} type
* @property {string[]|Array<Array<string>>} colors
*/

/**
Expand Down
7 changes: 6 additions & 1 deletion apis/theme/src/style-resolver.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import extend from 'extend';
import generateScales from './theme-scale-generator';

/**
* Creates the follwing array of paths
Expand Down Expand Up @@ -133,9 +134,13 @@ function resolveVariables(objTree, variables) {

styleResolver.resolveRawTheme = (raw) => {
// TODO - validate format
// TODO - generate class-pyramid
const c = extend(true, {}, raw);
resolveVariables(c, c._variables); // eslint-disable-line

// generate class-pyramid
if (c.scales) {
generateScales(c.scales, c.dataColors && c.dataColors.nullColor);
}

return c;
};
97 changes: 97 additions & 0 deletions apis/theme/src/theme-scale-generator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { color } from 'd3-color';
import ColorScale from './color-scale';

/* Calculates a value that expands from 0.5 out to 0 and 1
* Ex for size 8:
* current -> percent
* 0 -> 0.0625 4 -> 0.3125
* 1 -> 0.125 5 -> 0.375
* 2 -> 0.1875 6 -> 0.4375
* 3 -> 0.25 7 -> 0.5
*/

function getScaleValue(value, current, size) {
const percent = 0.25 + ((current + 1) / size) * 0.25;
const min = 0.5 - percent;
const max = 0.5 + percent;
const span = max - min;
return min + (value / 1) * span;
}

function setupColorScale(colors, nanColor, gradient) {
const newColors = [];
const cs = new ColorScale(nanColor);
newColors.push(colors[0]);

if (!gradient) {
newColors.push(colors[0]);
}
let i = 1;
for (; i < colors.length - 1; i++) {
newColors.push(colors[i]);
newColors.push(colors[i]);
}

newColors.push(colors[i]);
if (!gradient) {
newColors.push(colors[i]);
}

for (let j = 0; j < newColors.length; j += 2) {
cs.addColorPart(newColors[j], newColors[j + 1]);
}

return cs;
}

function generateLevel(scale, current, size) {
const level = [];
for (let j = 0; j < current + 1; j++) {
let c;
switch (current) {
case 0:
c = scale.getColor(0.5);
break;
default: {
const scaled = getScaleValue((1 / current) * j, current, size);

c = scale.getColor(scaled);
break;
}
}
level.push(color(c).formatHex());
}
return level;
}

/**
* Generates a pyramid of colors from a minimum of 2 colors
*
* @internal
* @param {Array} colors an array of colors to generate from
* @param {Int} size the size of the base of the pyramid
* @returns {Array} A 2 dimensional array containing the levels of the color pyramid
*/
function createPyramidFromColors(colors, size, nanColor) {
const gradientScale = setupColorScale(colors, nanColor, true);
const baseLevel = generateLevel(gradientScale, size - 1, size);
const scale = setupColorScale(baseLevel, nanColor, false);
const pyramid = [null];
for (let i = 0; i < size; i++) {
pyramid.push(generateLevel(scale, i, size));
}
return pyramid;
}

export default function generateOrdinalScales(scalesDef, nanColor = '#d2d2d2') {
scalesDef.forEach((def) => {
if (def.type === 'class') {
// generate pyramid
const pyramid = createPyramidFromColors(def.scale, Math.max(def.scale.length, 7), nanColor);
// eslint-disable-next-line no-param-reassign
def.scale = pyramid;
// eslint-disable-next-line no-param-reassign
def.type = 'class-pyramid';
}
});
}

0 comments on commit 6ca871e

Please sign in to comment.