Public
Edited
Jan 30, 2024
3 forks
Importers
7 stars
Golden Tonal Palette Generation
Golden Tonal Palette Analysis
Insert cell
Insert cell
// default is observablehq.com primary color: #596ab0
viewof color = Inputs.color({
label: "Input color",
value: inputColor || "#0f1432"
})
Insert cell
Insert cell
Insert cell
Insert cell
plotContrast(palette)
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
palette.reduce((a, c) => a + `--red-${c.meta.shade}: ${c.toString()};\n`, "\n")
Insert cell
palette.reduce((a, c) => a + `${c.meta.shade}: "${c.toString()}",\n`, "\n")
Insert cell
'["' + palette.map((m) => m.toString()).join('",\n"') + '"]'
Insert cell
Insert cell
isWhiteText = (color) => {
return contrast(color, "white") > contrast(color, "black");
}
Insert cell
Insert cell
Insert cell
palette = generate(color)
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
plotContrast = (palette, props) => {
const tickFormat = props?.tickFormat || ((d) => "");
const res = 512;
const data = new Array(res).fill(0).map((d, i) => i);
return Plot.plot({
height: 320,
width: res + 59,
...props,
x: {
// domain: palette.map(props?.x || ((p) => p.toString()))
tickFormat: (d) => ""
},
marks: [
Plot.areaY(data, {
x: (d) => d,
y: (d) => 21,
z: 0,
fill: (d) => {
const t = d / res;
const s = interpolateShades(palette, t);
return s;
}
}),
...palette.map((p, i) => {
return Plot.lineY(data, {
x: (d) => d,
y: (d) => {
const t = d / res;
const s = interpolateShades(palette, t);
const con = contrast(s, p.toString());
return con;
},
stroke: p.toString()
});
}),
Plot.lineY(data, {
x: (d) => d,
y: (d) => {
const t = d / res;
const s = interpolateShades(palette, t);
const conW = contrast(s, "white");
const conB = contrast(s, "black");
return Math.max(conW, conB);
},
stroke: (d) => {
const t = d / res;
const s = interpolateShades(palette, t);
const conW = contrast(s, "white");
const conB = contrast(s, "black");
return conW > conB ? "white" : "black";
}
}),
Plot.ruleY([7, 4.5, 3], { stroke: "red" }),
...(props?.marks || [])
]
});
}
Insert cell
ramp((t) => interpolateShades(palette, t))
Insert cell
interpolateShades = (colorShades, factor) => {
function mixColors(t, n, e, r, i) {
const o = t * t;
const a = o * t;
return (
((1 - 3 * t + 3 * o - a) * n +
(4 - 6 * o + 3 * a) * e +
(1 + 3 * t + 3 * o - 3 * a) * r +
a * i) /
6
);
}

function getColor(e) {
const n = colorShades.length - 1;
const r = Math.floor(Math.max(Math.min(e, 1), 0) * n);
const [rr, gg, bb] = ["R", "G", "B"].map((component) => {
function get(x) {
let c = colorShades[x];
if (!c) return 0;
return c[component] || 0;
}
for (let x = 0; x < n + 1; x++) {
if (x == r) {
const i = get(x);
const o = get(x + 1);
const a = r > 0 ? get(x - 1) : 2 * i - o;
const u = r < n - 1 ? get(x + 2) || 0 : 2 * o - i;
const fact = e * n - Math.floor(e * n);
return mixColors(fact, a, i, o, u);
}
}
return 0;
});
return new ColorRGB(
Math.min(1, rr),
Math.min(1, gg),
Math.min(1, bb)
).toString();
}
return getColor(1 - Math.max(0.001, factor));
}
Insert cell
ramp((t) => interpolateShadesD3(palette, t))
Insert cell
interpolateShadesD3 = (colorShades, factor) => {
const shadeCount = colorShades.length - 1;
// left id is the index of the color at
const factorInv = 1 - factor;
if (factorInv === 1) return colorShades[shadeCount];
const leftIdx = Math.floor(factorInv * shadeCount);
const rightIdx = leftIdx + 1;
const leftShade = colorShades[leftIdx].toString();
const rightShade = colorShades[rightIdx].toString();
const t =
(factorInv - leftIdx / shadeCount) /
(rightIdx / shadeCount - leftIdx / shadeCount);
return d3.interpolateLab(leftShade, rightShade)(t);
}
Insert cell
import {ramp} from "@mbostock/color-ramp"
Insert cell
Insert cell
Insert cell
goldenPalettes = FileAttachment("golden-palette.json").json().then(a => a.map(el => el.map(inEl => new ColorLAB(...inEl))))
Insert cell
lightnessTable = [
2.048875457, 5.124792061, 8.751659557, 12.07628774, 13.91449542, 15.92738893,
15.46585818, 15.09779227, 15.13738673, 15.09818372,
];
Insert cell
Plot.plot({
y: { grid: true },
marks: [Plot.lineY(lightnessTable)]
})
Insert cell
chromaTable = [
1.762442714, 4.213532634, 7.395827458, 11.07174158, 13.89634504, 16.37591477,
16.27071136, 16.54160806, 17.35916727, 19.88410864,
];
Insert cell
Plot.plot({
y: { grid: true },
marks: [Plot.lineY(chromaTable)]
})
Insert cell
// precision
O = Math.pow(2, -16);
Insert cell
assertRange = (a, b, c) => {
if (isNaN(a) || 0 > a || a > b)
throw new RangeError(a + " for " + c + " is not between 0 and " + b);
}
Insert cell
function toHexColor(a) {
const b = 1 > a.alpha ? toHexString(Math.round(255 * a.alpha)) : "";
return (
"#" +
(
toHexString(Math.round(255 * a.R)) +
toHexString(Math.round(255 * a.G)) +
toHexString(Math.round(255 * a.B)) +
b
).toLowerCase()
);
}
Insert cell
function toHexString(a) {
const b = a.toString(16);
return 2 <= b.length ? b : "0" + b;
}
Insert cell
class Color {}
Insert cell
/**
* CIELAB, CIE-L*ab, L*a*b color model
*/
class ColorLAB extends Color {
constructor(L, A, B, alpha = 1) {
super();
alpha = undefined === alpha ? 1 : alpha;
this.L = L; // lightness
this.A = A; // A Green ↔ Red
this.B = B; // B Blue ↔ Yellow
this.alpha = alpha;
assertRange(L, Number.MAX_VALUE, "lightness");
assertRange(alpha, 1, "alpha");
}

toHCL() {
return new ColorHCL(
((180 * Math.atan2(this.B, this.A)) / Math.PI + 360) % 360,
Math.sqrt(Math.pow(this.A, 2) + Math.pow(this.B, 2)),
this.L,
this.alpha
);
}

equals(a) {
return (
1e-4 > Math.abs(this.L - a.L) &&
1e-4 > Math.abs(this.A - a.A) &&
1e-4 > Math.abs(this.B - a.B) &&
Math.abs(this.alpha - a.alpha) < O
);
}
}
Insert cell
function rgb2lab(a) {
function transform(a) {
return 0.04045 >= a ? a / 12.92 : Math.pow((a + 0.055) / 1.055, 2.4);
}
function getRatio(a) {
let b = 6 / 29,
c = 1 / (3 * Math.pow(b, 2));
return a > Math.pow(b, 3) ? Math.pow(a, 1 / 3) : c * a + 4 / 29;
}

let b = transform(a.R),
c = transform(a.G),
d = transform(a.B),
e = 0.2126729 * b + 0.7151522 * c + 0.072175 * d;
return new ColorLAB(
116 * getRatio(e) - 16,
500 *
(getRatio((0.4124564 * b + 0.3575761 * c + 0.1804375 * d) / 0.95047) -
getRatio(e)),
200 *
(getRatio(e) -
getRatio((0.0193339 * b + 0.119192 * c + 0.9503041 * d) / 1.08883)),
a.alpha
);
}
Insert cell
function lab2hcl(a) {
return new ColorHCL(
((180 * Math.atan2(a.B, a.A)) / Math.PI + 360) % 360,
Math.sqrt(Math.pow(a.A, 2) + Math.pow(a.B, 2)),
a.L,
a.alpha
);
}
Insert cell
/**
* Red, Green, Blue color model
*/
class ColorRGB extends Color {
/**
* Red
*/
/**
* Green
*/
/**
* Blue
*/

constructor(a, b, c, d = 1) {
super();
d = undefined === d ? 1 : d;
this.R = a;
this.G = b;
this.B = c;
this.alpha = d;
this.meta = {};
assertRange(a, 1, "red");
assertRange(b, 1, "green");
assertRange(c, 1, "blue");
assertRange(d, 1, "alpha");
}

setMeta(m) {
this.meta = m;
}

toLAB() {
return rgb2lab(this);
}

equals(a) {
return (
Math.abs(this.R - a.R) < O &&
Math.abs(this.G - a.G) < O &&
Math.abs(this.B - a.B) < O &&
Math.abs(this.alpha - a.alpha) < O
);
}

static fromString(s) {
const m = s.match(
/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})?$/i
);
if (m) {
const a = m[4] ? parseInt(m[4], 16) / 255 : 1;
return new ColorRGB(
parseInt(m[1], 16) / 255,
parseInt(m[2], 16) / 255,
parseInt(m[3], 16) / 255,
a
);
}
return ColorRGB.black;
}

static fromInt(colorInt) {
return ColorRGB.fromString(
"#" + ("000000" + colorInt.toString(16)).slice(-6)
);
}

toInt() {
const transparent = 0x00000000;
function f(v) {
return Math.floor(v * 255);
}
return (
transparent |
(f(this.a) << 32) |
(f(this.R) << 16) |
(f(this.G) << 8) |
f(this.B)
);
}

mix(other, t) {
return new ColorRGB(
this.R * (1 - t) + other.R * t,
this.G * (1 - t) + other.G * t,
this.B * (1 - t) + other.B * t,
this.alpha * (1 - t) + other.alpha * t
);
}

toString() {
return toHexColor(this);
}

toTuple() {
return [
Math.round(255 * this.R),
Math.round(255 * this.G),
Math.round(255 * this.B)
];
}

/** Euclidean distance in LAB color space (delta E) */
distance(other) {
const lab = rgb2lab(this);
const lab2 = rgb2lab(other);

const dE = Math.sqrt(
Math.pow(lab.L - lab2.L, 2) +
Math.pow(lab.A - lab2.A, 2) +
Math.pow(lab.B - lab2.B, 2)
);
return dE;
}
}
Insert cell
/**
* Hue, Chroma, Lightness color model
*/
class ColorHCL extends Color {
/**
* Lightness
*/
/**
* Chroma
*/
/**
* Hue
*/

constructor(hue, chroma, lightness, alpha = 1) {
super();
alpha = undefined === alpha ? 1 : alpha;
this.L = lightness; // lightness
this.C = chroma; // chroma
this.H = hue;
this.alpha = alpha;
assertRange(lightness, Number.MAX_VALUE, "lightness");
assertRange(chroma, Number.MAX_VALUE, "chroma");
assertRange(hue, 360, "hue");
assertRange(alpha, 1, "alpha");
}

equals(a) {
return (
1e-4 > Math.abs(this.L - a.L) &&
1e-4 > Math.abs(this.C - a.C) &&
1e-4 > Math.abs(this.H - a.H) &&
Math.abs(this.alpha - a.alpha) < O
);
}
}
Insert cell
generate = {
function getPalette(rgbaColor) {
const labColor = rgb2lab(rgbaColor);
const { palette, mainColorIndex } = generateTonalPalette(labColor);
let mainLAB = palette[mainColorIndex],
mainHCL = lab2hcl(palette[mainColorIndex]),
currentHCL = lab2hcl(labColor),
chromaThreshold = 30 > lab2hcl(palette[5]).C,
dL = mainHCL.L - currentHCL.L,
dC = mainHCL.C - currentHCL.C,
dH = mainHCL.H - currentHCL.H,
refL = lightnessTable[mainColorIndex],
refC = chromaTable[mainColorIndex],
A = 100;
return palette.map(function (inputColor, i) {
if (inputColor === mainLAB)
return (A = Math.max(currentHCL.L - 1.7, 0)), rgbaColor;
const colorHCL = lab2hcl(inputColor);
let newL = colorHCL.L - (lightnessTable[i] / refL) * dL;
newL = Math.min(newL, A);
const color = new ColorHCL(
(colorHCL.H - dH + 360) % 360,
Math.max(
0,
chromaThreshold
? colorHCL.C - dC
: colorHCL.C - dC * Math.min(chromaTable[i] / refC, 1.25)
),
clipRange(newL, 0, 100)
);
A = Math.max(color.L - 1.7, 0);
const hue = (color.H * Math.PI) / 180;
const outputColor = new ColorLAB(
color.L,
color.C * Math.cos(hue),
color.C * Math.sin(hue),
color.alpha
);
let diffL = (color.L + 16) / 116;
function getRatio(a) {
let b = 6 / 29,
c = 3 * Math.pow(b, 2);
return a > b ? Math.pow(a, 3) : c * (a - 4 / 29);
}
const scalar = 0.95047 * getRatio(diffL + outputColor.A / 500);
newL = 1 * getRatio(diffL);
diffL = 1.08883 * getRatio(diffL - outputColor.B / 200);
function transform(a) {
return 0.0031308 >= a
? 12.92 * a
: 1.055 * Math.pow(a, 1 / 2.4) - 0.055;
}
return new ColorRGB(
clipRange(
transform(
3.2404542 * scalar + -1.5371385 * newL + -0.4985314 * diffL
),
0,
1
),
clipRange(
transform(-0.969266 * scalar + 1.8760108 * newL + 0.041556 * diffL),
0,
1
),
clipRange(
transform(0.0556434 * scalar + -0.2040259 * newL + 1.0572252 * diffL),
0,
1
),
color.alpha
);
});
}

function generateTonalPalette(color) {
if (!goldenPalettes.length || !goldenPalettes[0].length)
throw Error("Invalid golden palettes");

let palette = goldenPalettes[0];
let mainColorIndex = -1;
for (let c = Infinity, f = 0; f < goldenPalettes.length; f++) {
for (let h = 0; h < goldenPalettes[f].length && 0 < c; h++) {
let colorCurrent = goldenPalettes[f][h],
avgL = (colorCurrent.L + color.L) / 2,
currentLabDist = Math.sqrt(
Math.pow(colorCurrent.A, 2) + Math.pow(colorCurrent.B, 2)
),
targetLabDist = Math.sqrt(
Math.pow(color.A, 2) + Math.pow(color.B, 2)
),
labDist = (currentLabDist + targetLabDist) / 2;
labDist =
0.5 *
(1 -
Math.sqrt(
Math.pow(labDist, 7) / (Math.pow(labDist, 7) + Math.pow(25, 7))
));
let currentRefA = colorCurrent.A * (1 + labDist),
targetRefA = color.A * (1 + labDist),
currentRefDist = Math.sqrt(
Math.pow(currentRefA, 2) + Math.pow(colorCurrent.B, 2)
),
targetRefDist = Math.sqrt(
Math.pow(targetRefA, 2) + Math.pow(color.B, 2)
);
labDist = targetRefDist - currentRefDist;
let refDist = (currentRefDist + targetRefDist) / 2;
function transform(a, b) {
if (1e-4 > Math.abs(a) && 1e-4 > Math.abs(b)) return 0;
a = (180 * Math.atan2(a, b)) / Math.PI;
return 0 <= a ? a : a + 360;
}
currentRefA = transform(colorCurrent.B, currentRefA);
targetRefA = transform(color.B, targetRefA);
currentRefDist =
2 *
Math.sqrt(currentRefDist * targetRefDist) *
Math.sin(
(((1e-4 > Math.abs(currentLabDist) || 1e-4 > Math.abs(targetLabDist)
? 0
: 180 >= Math.abs(targetRefA - currentRefA)
? targetRefA - currentRefA
: targetRefA <= currentRefA
? targetRefA - currentRefA + 360
: targetRefA - currentRefA - 360) /
2) *
Math.PI) /
180
);
currentLabDist =
1e-4 > Math.abs(currentLabDist) || 1e-4 > Math.abs(targetLabDist)
? 0
: 180 >= Math.abs(targetRefA - currentRefA)
? (currentRefA + targetRefA) / 2
: 360 > currentRefA + targetRefA
? (currentRefA + targetRefA + 360) / 2
: (currentRefA + targetRefA - 360) / 2;
targetLabDist = 1 + 0.045 * refDist;
targetRefDist =
1 +
0.015 *
refDist *
(1 -
0.17 * Math.cos(((currentLabDist - 30) * Math.PI) / 180) +
0.24 * Math.cos((2 * currentLabDist * Math.PI) / 180) +
0.32 * Math.cos(((3 * currentLabDist + 6) * Math.PI) / 180) -
0.2 * Math.cos(((4 * currentLabDist - 63) * Math.PI) / 180));
const result = Math.sqrt(
Math.pow(
(color.L - colorCurrent.L) /
(1 +
(0.015 * Math.pow(avgL - 50, 2)) /
Math.sqrt(20 + Math.pow(avgL - 50, 2))),
2
) +
Math.pow(labDist / (1 * targetLabDist), 2) +
Math.pow(currentRefDist / (1 * targetRefDist), 2) +
(labDist / (1 * targetLabDist)) *
Math.sqrt(
Math.pow(refDist, 7) / (Math.pow(refDist, 7) + Math.pow(25, 7))
) *
Math.sin(
(60 *
Math.exp(-Math.pow((currentLabDist - 275) / 25, 2)) *
Math.PI) /
180
) *
-2 *
(currentRefDist / (1 * targetRefDist))
);
result < c &&
((c = result), (palette = goldenPalettes[f]), (mainColorIndex = h));
}
}
return { palette, mainColorIndex };
}

function parseHexColor(hex) {
if (!/^#[a-fA-F0-9]{3,8}$/.test(hex))
throw Error("Invalid hex color string: " + hex);
hex = hex.substr(1);
let arr;
if (3 === hex.length || 4 === hex.length)
arr = /^(.)(.)(.)(.)?$/
.exec(hex)
?.slice(1, 5)
.map(function (e) {
return e ? e + e : "ff";
});
else if (6 === hex.length || 8 === hex.length)
(arr = /^(..)(..)(..)(..)?$/.exec(hex)?.slice(1, 5)),
undefined === arr[3] && (arr[3] = "ff");
else throw Error("Invalid hex color string: " + hex);
const r = parseHexString(arr[0]) / 255;
const g = parseHexString(arr[1]) / 255;
const b = parseHexString(arr[2]) / 255;
const a = parseHexString(arr[3]) / 255;
return new ColorRGB(r, g, b, a);
}

function parseHexString(a) {
if (!/^[a-fA-F0-9]+$/.test(a)) throw Error("Invalid hex string: " + a);
return parseInt(a, 16);
}

function clipRange(a, b, c) {
return Math.min(Math.max(a, b), c);
}

/**
* Generates a palette for any color input.
* Hue, chroma, and lightness are adjusted by an algorithm that creates palettes that are usable and aesthetically pleasing.
* The shades include lighter and darker options to separate surfaces and provide colors that meet accessibility standards.
*
* @param hex Input color in hex #aabbcc
* @returns Pallette shades
*/
function generate(hex) {
const inputColorRgba = parseHexColor(hex);
const palette = getPalette(inputColorRgba);
const shades = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900];
return palette.map((color, i) => {
color.setMeta({
shade: shades[i]
});
return color;
});
}
return generate;
}
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more