Interpolate subsequent hues

This commit is contained in:
Lea Verou
2025-02-26 11:00:36 -05:00
parent d04e3d860e
commit 6693cafe8e
6 changed files with 126 additions and 53 deletions

View File

@@ -167,6 +167,7 @@
'--color-base': colorsMinusCurrentTweak[hue][tint],
colorScheme: tint > 60 ? 'light' : 'dark'
}">
<wa-icon name="thumbtack" v-if="seedHues[hue]?.[tint]"></wa-icon>
<wa-copy-button :value="`--wa-color-${ hue }-${ tint }`" :copy-label="`--wa-color-${ hue }-${ tint }`"></wa-copy-button>
</div>
</td>

View File

@@ -43,7 +43,7 @@ export const hueRanges = {
pink: { min: 320, max: 375 }, // 55
};
export const lRanges = {
export const L_RANGES = {
'05': { min: 0.18, max: 0.2 },
10: { min: 0.23, max: 0.25 },
20: { min: 0.31, max: 0.35 },
@@ -57,6 +57,10 @@ export const lRanges = {
95: { min: 0.95, max: 0.97 },
};
for (let lightness in L_RANGES) {
L_RANGES[lightness].mid = (L_RANGES[lightness].min + L_RANGES[lightness].max) / 2;
}
export const moreHue = {
red: 'Redder',
orange: 'More orange', // https://www.reddit.com/r/grammar/comments/u9n0uo/is_it_oranger_or_more_orange/

View File

@@ -76,6 +76,11 @@ export function camelCase(str) {
}
export function capitalize(str) {
if (!str) {
return str;
}
str = str + '';
return str[0].toUpperCase() + str.slice(1);
}

View File

@@ -6,7 +6,7 @@ order: 99
---
<link href="{{ page.url }}../edit/custom.css" rel="stylesheet">
<p>Create your own color palette from scratch.</p>
<p>Create your own color palette from scratch, from one or more seed colors.</p>
<h2>Seed color(s)</h2>
@@ -14,7 +14,7 @@ order: 99
<template v-for="color, i in seedColors">
<core-color-input v-model="seedColors[i]" @delete="seedColors.splice(i, 1)"></core-color-input>
</template>
<wa-button class="add-button" appearance="outlined" @click="seedColors.push(seedColorSamples.shift())">
<wa-button class="add-button" appearance="outlined" @click="seedColors.push(seedColorSamples.shift() || '')">
<wa-icon slot="prefix" name="plus" variant="regular"></wa-icon>
Add color
</wa-button>

View File

@@ -1,5 +1,5 @@
import Color from 'https://colorjs.io/dist/color.js';
import { hueRanges, lRanges } from '/assets/scripts/tweak/data.js';
import { hueRanges, L_RANGES } from '/assets/scripts/tweak/data.js';
import { capitalize, findClosestRange } from '/assets/scripts/tweak/util.js';
export default {
@@ -22,11 +22,13 @@ export default {
},
closestHue() {
if (!this.color) return '';
return findClosestRange(hueRanges, this.color.get('oklch.h'), { type: 'angle' })?.key;
},
closestLevel() {
return findClosestRange(lRanges, this.color.get('oklch.l'))?.key;
if (!this.color) return '';
return findClosestRange(L_RANGES, this.color.get('oklch.l'))?.key;
},
},
methods: {
@@ -40,7 +42,7 @@ export default {
<div slot="image" :style="{'--color': modelValue, colorScheme: closestLevel <= 60 ? 'dark' : 'light'}">
<wa-icon-button name="trash" label="Delete" variant="regular" class="delete-button" @click="$emit('delete')"></wa-icon-button>
</div>
<div class="name">{{ capitalize(closestHue) }} {{ closestLevel }}</div>
<div class="name">{{ capitalize(closestHue) || 'New color' }} {{ closestLevel }}</div>
<wa-input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)"></wa-input>
</wa-card>
`,

View File

@@ -10,7 +10,7 @@ import {
HUE_SHIFTS,
hueRanges,
hues,
lRanges,
L_RANGES,
MAX_CHROMA_BOUNDS,
maxGrayChroma,
moreHue,
@@ -23,6 +23,7 @@ import {
capitalize,
clamp,
findClosestRange,
interpolate,
mapRange,
progress,
subtractAngles,
@@ -67,7 +68,15 @@ let paletteAppSpec = {
return {
uid: undefined,
seedColors: [],
seedColorSamples: ['oklch(77% 0.19 70)', 'rgb(95, 59, 255)', '#f06', 'yellowgreen', 'oklch(82% 0.185 195)'],
seedColorSamples: [
'oklch(77% 0.19 70)',
'rgb(95, 59, 255)',
'#f06',
'yellowgreen',
'oklch(82% 0.185 195)',
'oklch(30% 0.18 175)',
'oklch(30% 0.18 150)',
],
paletteId,
paletteTitle: palette.title,
originalColors: palette.colors,
@@ -147,7 +156,7 @@ let paletteAppSpec = {
for (let color of this.seedColorObjects) {
let hue = findClosestRange(hueRanges, color.get('oklch.h'), { type: 'angle' }).key;
let level = findClosestRange(lRanges, color.get('oklch.l')).key;
let level = findClosestRange(L_RANGES, color.get('oklch.l')).key;
ret[hue] ??= {};
ret[hue][level] = color;
}
@@ -186,77 +195,129 @@ let paletteAppSpec = {
// Generate scales from seed hues
for (let hue in this.seedHues) {
let [coreLevel, coreColor] = Object.entries(this.seedHues[hue])[0];
// Find core color
let coreColor, maxChroma, coreLevel;
let distance = coreColor.get('oklch.l') - (lRanges[coreLevel].max + lRanges[coreLevel].min) / 2;
for (let level in this.seedHues[hue]) {
let color = this.seedHues[hue][level];
let chroma = color.get('oklch.c');
if (!(chroma < maxChroma)) {
// not < will also kick in when they are empty
coreColor = color;
maxChroma = chroma;
coreLevel = level;
}
}
let distance = coreColor.get('oklch.l') - L_RANGES[coreLevel].mid;
let coreChroma = coreColor.get('oklch.c');
ret[hue] ??= {
ret[hue] = {
maxChromaTint: coreLevel,
maxChromaTintRaw: coreLevel,
maxChroma: coreChroma,
maxChromaRaw: coreChroma,
};
let scale = getLightestChromaScale(hue, coreLevel, coreChroma);
let chroma95 = clamp(0, coreChroma * scale, 0.1);
// Find if any hue shift applies to this hue (we assume defined hue shifts are mutually exclusive)
// Find if any hue shift applies to this hue (we assume defined hue shift ranges are mutually exclusive)
let hueShift = { dark: 0, light: 0, intensity: 0 };
let autoHueShift = HUE_SHIFTS.find(
({ range }) =>
subtractAngles(range[0], coreColor.get('oklch.h')) <= 0 &&
subtractAngles(coreColor.get('oklch.h'), range[1]) <= 0,
);
if (autoHueShift) {
hueShift = { ...autoHueShift.shift };
let hueRange = [autoHueShift.range[0], ...autoHueShift.peak, autoHueShift.range[1]];
hueShift.intensity = mapRange(coreColor.get('oklch.h'), hueRange, [0, 1, 1, 0]);
}
// First, add pinned colors
for (let tint in this.seedHues[hue]) {
ret[hue][tint] = this.seedHues[hue][tint];
}
// Now generate the rest, starting from the edges
if (!('95' in ret[hue])) {
let color = coreColor.clone().to('oklch');
let scale = getLightestChromaScale(hue, coreLevel, coreChroma);
color.set({
l: L_RANGES[95].mid + distance,
c: clamp(0, coreChroma * scale, 0.1),
h: h => h + hueShift.light * hueShift.intensity,
});
ret[hue][95] = color;
}
if (!('05' in ret[hue])) {
let color = coreColor.clone().to('oklch');
color.set({
l: L_RANGES['05'].mid + distance,
// TODO c
h: h => h + hueShift.dark * hueShift.intensity,
});
ret[hue]['05'] = color;
}
let pinnedLevels = Object.keys(this.seedHues[hue]).sort((a, b) => a - b);
let levelBefore = '05';
for (let tint of tints) {
if (tint in this.seedHues[hue]) {
ret[hue][tint] = this.seedHues[hue][tint];
} else {
let color = coreColor.clone().to('oklch');
if (tint in ret[hue]) {
// Pinned or already generated
levelBefore = tint;
continue;
}
// Lightness
let mid = (lRanges[tint].max + lRanges[tint].min) / 2;
color.set('l', mid + distance);
// Generated color
// First, find closest pinned colors before and after
let levelAfter = pinnedLevels.findLast(level => level < tint) ?? '95';
let colorBefore = ret[hue][levelBefore];
let colorAfter = ret[hue][levelAfter];
// Calculate auto hue shift
let deltaLevel = tint - coreLevel;
let edgeLevel = deltaLevel < 0 ? '05' : '95';
let color = coreColor.clone().to('oklch');
if (autoHueShift && tint !== '05') {
// No hue shift for darkest tint
let intensity = 1;
// Lightness
color.set('l', L_RANGES[tint].mid + distance);
if (coreColor.get('oklch.h') < autoHueShift.peak[0]) {
intensity = progress(coreColor.get('oklch.h'), autoHueShift.range[0], autoHueShift.peak[0]);
} else if (coreColor.get('oklch.h') > autoHueShift.peak[1]) {
intensity = progress(coreColor.get('oklch.h'), autoHueShift.peak[1], autoHueShift.range[1]);
}
// Interpolate hue linearly and chroma with a power curve
color.set({
l: L_RANGES[tint].mid + distance,
h: mapRange(tint, {
from: [levelBefore, levelAfter],
to: [colorBefore.get('oklch.h'), colorAfter.get('oklch.h')],
}),
});
let maxShift = deltaLevel < 0 ? autoHueShift.shift.dark : autoHueShift.shift.light;
let p = progress(tint, coreLevel, Math.max(edgeLevel, 10));
let shift = maxShift * intensity * p ** 0.75;
color.set('oklch.h', coreColor.get('oklch.h') + shift);
}
// Chroma
if (tint > coreLevel) {
// Lighter, reduce chroma
let chroma = mapRange(tint, {
from: [coreLevel, 95],
to: [coreChroma, chroma95],
if (tint > coreLevel) {
color.set(
'c',
mapRange(tint, {
from: [levelBefore, levelAfter],
to: [colorBefore.get('oklch.c'), colorAfter.get('oklch.c')],
progression: p => p ** this.factor,
});
color.set('c', chroma);
}
}),
);
}
color = color.toGamut('p3');
ret[hue][tint] = color;
}
ret[hue][tint] = color;
for (let tint in ret[hue]) {
if (!(tint in this.seedHues) && ret[hue][tint].toGamut) {
ret[hue][tint] = ret[hue][tint].toGamut('p3');
}
}
}
// Get rest of hues from default palette
// TODO generate from existing colors
// Fill in remaining hues
// TODO generate from seed hues
for (let hue of hues) {
if (hue in ret) {
continue;