Skip to content

Instantly share code, notes, and snippets.

@ericyd
Last active December 31, 2019 03:51
Show Gist options
  • Save ericyd/d685a9ee0cadb7422d8b839febb6ba6b to your computer and use it in GitHub Desktop.
Save ericyd/d685a9ee0cadb7422d8b839febb6ba6b to your computer and use it in GitHub Desktop.
Convert a number from an arbitrary scale to a color value
/////////////////////////
//
// Examples
//
// Live examples:
// https://observablehq.com/@ericyd/rgb-factory
//
/////////////////////////
console.log(rgbFactory()(0)); // { r: 212, g: 90.00000000000004, b: 90 }
console.log(rgbFactory()(100)); // { r: 212, g: 90.0000000000002, b: 90 }
console.log(rgbFactory()(52)); // { r: 90, g: 198.2769015471564, b: 212 }
console.log(rgbFactory({ nMax: 1 })(0.52)); // { r: 90, g: 198.27690154715663, b: 212 }
console.log(rgbToString(rgbFactory()(0))); // rgb(212,90.00000000000004,90)
console.log(rgbToString(rgbFactory()(52), true)); // rgb(90,198.28,212)
console.log(rgbToString(rgbFactory()(52), true, 4)); // rgb(90,198.2769,212)
console.log(rgbToHex(rgbFactory()(0))); // #d45a5a
console.log(rgbToHex(rgbFactory({ fixEdges: true })(0))); // #000000
// Make color swatches
const body = document.querySelector('body')
let parent = document.createElement('div')
const parentStyle = `
display: flex;
`
const childStyle = hex => (`
width: 10px;
height: 100px;
display: block;
background-color: ${hex};
`)
parent.setAttribute('style', parentStyle)
let rgb = rgbFactory()
for (let i = 0; i < 100; i++) {
const hex = rgbToHex(rgb(i))
const child = document.createElement('div')
child.setAttribute('style', childStyle(hex))
parent.appendChild(child)
}
body.appendChild(parent)
parent = document.createElement('div')
parent.setAttribute('style', parentStyle)
rgb = rgbFactory({saturationMax: 255, saturationMin: 0})
for (let i = 0; i < 100; i++) {
const hex = rgbToHex(rgb(i))
const child = document.createElement('div')
child.setAttribute('style', childStyle(hex))
parent.appendChild(child)
}
body.appendChild(parent)
// RGB Factory
// ======================
//
// There are six phases that describe the RGB progression of hues.
// All changes occur from a set "min" to a set "max" based on the desired saturation
//
// 1. Blue increases
// 2. Red decreases
// 3. Green increases
// 4. Blue decreases
// 5. Red increases
// 6. Green decreases
//
// These phases can be approximated with a clipped sine function
// offset to different sections of its period.
//
// Thus, we can transform single numbers on an arbitrary scale to a point
// in this cycle, and thereby convert a number to a color.
/////////////////////////
//
// Interfaces and types
//
/////////////////////////
type RGBColor = {
r: number;
g: number;
b: number;
};
type RGBFactoryOptions = {
nMin?: number;
nMax?: number;
// number between [0, 255]
saturationMin?: number;
// number between [0, 255]
saturationMax?: number;
// return black @ nMin and white @ nMax
fixEdges?: boolean;
};
interface INumberFunction {
(n: number): number;
}
interface INumberFactory {
(n: number): INumberFunction;
}
/////////////////////////
//
// Color factory
//
/////////////////////////
/**
* Validate RGBFactoryOptions
*/
const validateOptions = ({
nMin = 0,
nMax = 100,
saturationMin = 90,
saturationMax = 212
}: RGBFactoryOptions): string | null => {
if (nMin > nMax) return `nMax (${nMax}) must be larger than nMin (${nMin})`;
if (saturationMax > 255)
return `saturationMax (${saturationMax}) must be less than or equal to 255`;
if (0 > saturationMin)
return `saturationMin (${saturationMin}) must be greater than or equal to 0`;
if (saturationMin > saturationMax)
return `saturationMax (${saturationMax}) must be larger than saturationMin (${saturationMin})`;
return null;
};
/**
* Factory function to convert arbitrary numeric value to an rgb hue.
* The offset math is a bit opaque, but it makes the sine waves line up correctly.
*/
function rgbFactory({
nMin = 0,
nMax = 100,
saturationMin = 90,
saturationMax = 212,
fixEdges = false
}: RGBFactoryOptions = {}): (n: number) => RGBColor {
const error = validateOptions({ nMin, nMax, saturationMin, saturationMax });
if (error) throw new Error(error);
// since there are 6 "phases" of the color cycle,
// we need to create a scale for that.
// Half phases (1/12th) are needed for proper offset
const n6th = (nMax - nMin) / 6;
const n12th = n6th / 2;
const range = saturationMax - saturationMin;
// transformers convert the number to a channel value
// where a channel is one of red, green, or blue
const clip: INumberFunction = x =>
x < saturationMin ? saturationMin : x > saturationMax ? saturationMax : x;
// range adjustment is slightly non-standard because sine functions can be negative
const rangeAdjust: INumberFunction = x =>
x * range + saturationMin + range / 2;
// calculate the points position on a sine curve, where
// `x` is the input number that is being transformed
// `offset` is the phase offset of the point in the sine curve
// `nMax` is the wave amplitude
// `2*PI` is the frequency in radians
// Ref: https://en.wikipedia.org/wiki/Sine_wave
const position = (x: number, offset: number): number =>
Math.sin((2 * Math.PI / nMax) * (x + offset));
// returns a function that describes a channel of color
const channel: INumberFactory = offset => x =>
clip(rangeAdjust(position(x, offset)));
// define each channel
const r: INumberFunction = channel(n6th * 2 - n12th);
const g: INumberFunction = channel(n6th * 6 - n12th);
const b: INumberFunction = channel(n6th * 4 - n12th);
return function(n: number): RGBColor {
if (n < nMin || n > nMax)
throw new Error(`n must satisfy ${nMin} <= n <= ${nMax}`);
if (fixEdges) {
if (n === nMax) return { r: 255, g: 255, b: 255 };
if (n === nMin) return { r: 0, g: 0, b: 0 };
}
return { r: r(n), g: g(n), b: b(n) };
};
}
/////////////////////////
//
// Formatting
//
/////////////////////////
/**
* factory to create rounding functions with arbitrary precision
*/
const roundN: INumberFactory = (decimals: number) => {
return val => {
if (isNaN(val)) return val;
try {
return Math.round(val * Math.pow(10, decimals)) / Math.pow(10, decimals);
} catch (e) {
return val;
}
};
};
/**
* returns rgb string: rgb(r,g,b)
*/
const rgbToString = (
{ r, g, b }: RGBColor,
round = false,
decimals = 2
): string => {
if (round) {
const rounder = roundN(decimals);
return `rgb(${rounder(r)},${rounder(g)},${rounder(b)})`;
}
return `rgb(${r},${g},${b})`;
};
/**
* returns hex representation of number
*/
const toHex = (value: number): string => {
if (value > 255 || value < 0) {
throw new Error(`Please use an 8-bit value. ${value} is outside [0,255]`);
}
const hex = Math.round(value).toString(16);
if (hex.length === 1) return `0${hex}`;
return hex;
};
/**
* returns hex string: #HHHHHH
*/
const rgbToHex = ({ r, g, b }: RGBColor): string =>
["#", toHex(r), toHex(g), toHex(b)].join("");
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment