Last active
December 31, 2019 03:51
-
-
Save ericyd/d685a9ee0cadb7422d8b839febb6ba6b to your computer and use it in GitHub Desktop.
Convert a number from an arbitrary scale to a color value
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
///////////////////////// | |
// | |
// 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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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