From 1fa95f66e86ed4853341b92aa3a735b9ff46795c Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Fri, 28 Feb 2025 14:34:20 -0500 Subject: [PATCH] Evaluate palette lightness relative to hue, better capping of consecutive hue shifts --- docs/assets/scripts/tweak/data.js | 50 ++++++++++-- docs/assets/scripts/tweak/util.js | 34 ++++++++ docs/docs/palettes/edit/generatePalette.js | 94 ++++++++++++++++++++++ docs/docs/palettes/edit/generateScale.js | 49 +++++++---- docs/docs/palettes/edit/tweak.js | 81 +------------------ 5 files changed, 209 insertions(+), 99 deletions(-) create mode 100644 docs/docs/palettes/edit/generatePalette.js diff --git a/docs/assets/scripts/tweak/data.js b/docs/assets/scripts/tweak/data.js index 00d33cc88..d60374420 100644 --- a/docs/assets/scripts/tweak/data.js +++ b/docs/assets/scripts/tweak/data.js @@ -65,6 +65,23 @@ for (let range of [HUE_RANGES, L_RANGES]) { } } +/** + * Most common tint per hue. + * Largely the statistical mode, but also informed by the average and median. + */ +export const HUE_TOP_TINT = { + red: 50, + orange: 70, + yellow: 80, + green: 80, + cyan: 70, + blue: 50, + indigo: 40, + purple: 50, + pink: 50, + gray: 40, +}; + export const moreHue = { red: 'Redder', orange: 'More orange', // https://www.reddit.com/r/grammar/comments/u9n0uo/is_it_oranger_or_more_orange/ @@ -76,17 +93,30 @@ export const moreHue = { purple: 'Purpler', pink: 'Pinker', }; - +/* +┌─────────┬──────┬─────┬────────┬───────┬────────┬───────┐ +│ (index) │ min │ max │ median │ avg │ stddev │ count │ +├─────────┼──────┼─────┼────────┼───────┼────────┼───────┤ +│ red │ -1.9 │ 4.2 │ 0.12 │ 0.37 │ 1.3 │ 88 │ +│ yellow │ -14 │ 2.9 │ -1.5 │ -3.1 │ 4.1 │ 88 │ +│ green │ -4.5 │ 7.8 │ 0.22 │ 0.48 │ 1.6 │ 88 │ +│ cyan │ -2.8 │ 10 │ 0.67 │ 0.99 │ 2.5 │ 88 │ +│ blue │ -4.1 │ 7.9 │ 0.94 │ 1.3 │ 2.3 │ 88 │ +│ indigo │ -3.8 │ 3 │ -0.18 │ -0.1 │ 1.2 │ 88 │ +│ purple │ -4.4 │ 2.9 │ -0.29 │ -0.61 │ 1.3 │ 88 │ +│ pink │ -4.8 │ 6.9 │ 0.2 │ 0.23 │ 2 │ 88 │ +└─────────┴──────┴─────┴────────┴───────┴────────┴───────┘ +*/ export const HUE_SHIFTS = [ // Reds - { range: [0, 25], peak: [10, 25], shift: { dark: 15, light: -18 }, maxConsecutive: 2 }, + { range: [0, 25], peak: [10, 25], shift: { dark: 15, light: -18 }, maxConsecutive: { dark: 4, light: -2 } }, // Yellows - { range: [30, 112], peak: [70, 100], shift: { dark: -48, light: 16 }, maxConsecutive: 13 }, + { range: [30, 112], peak: [70, 100], shift: { dark: -48, light: 16 }, maxConsecutive: { dark: -20, light: 4 } }, // Greens - { range: [140, 160], peak: [145, 155], shift: { dark: 15, light: -5 }, maxConsecutive: 2 }, + { range: [140, 160], peak: [145, 155], shift: { dark: 15, light: -5 }, maxConsecutive: { dark: 7, light: -5 } }, // Blues - { range: [240, 265], peak: [245, 260], shift: { dark: -3, light: -15 }, maxConsecutive: 7 }, + { range: [240, 265], peak: [245, 260], shift: { dark: -3, light: -15 }, maxConsecutive: { dark: -3, light: -4 } }, ]; export const MAX_CHROMA_BOUNDS = { min: 0.08, max: 0.3 }; @@ -105,3 +135,13 @@ export const maxGrayChroma = { purple: 0.3, pink: 0.25, }; + +/** Default accent tint if all chromas are 0, but also the tint accent colors will be nudged towards (see chromaTolerance) */ +export const DEFAULT_ACCENT = 60; + +/** Min and max allowed tints */ +export const MIN_ACCENT = 40; +export const MAX_ACCENT = 90; + +/** Chroma tolerance: Chroma will need to differ more than this to gravitate away from defaultAccent */ +export const CHROMA_TOLERANCE = 0.000001; diff --git a/docs/assets/scripts/tweak/util.js b/docs/assets/scripts/tweak/util.js index 61084c88e..3aafb502f 100644 --- a/docs/assets/scripts/tweak/util.js +++ b/docs/assets/scripts/tweak/util.js @@ -252,3 +252,37 @@ export function progressAngle(angle, range) { [angle, ...range] = normalizeAngles([angle, ...range]); return progress(angle, range, { unclamped: true }); } + +/** + * Round a number to the nearest multiple of `roundTo` or to the closest number in an array of numbers + * @param {number} value + * @param {number | number[]} roundTo + * @returns + */ +export function roundTo(value, roundTo = 1) { + if (Array.isArray(roundTo)) { + let closest = roundTo[0]; + let closestDistance = Math.abs(value - closest); + + for (let candidate of roundTo) { + let distance = Math.abs(value - candidate); + + if (distance < closestDistance) { + closest = candidate; + closestDistance = distance; + } + } + + return closest; + } + + let decimals = roundTo.toString().split('.')[1]?.length ?? 0; + let ret = Math.round(value / roundTo) * roundTo; + + if (decimals > 0) { + // Eliminate IEEE 754 floating point errors + ret = +ret.toFixed(decimals); + } + + return ret; +} diff --git a/docs/docs/palettes/edit/generatePalette.js b/docs/docs/palettes/edit/generatePalette.js new file mode 100644 index 000000000..b9d64cbf1 --- /dev/null +++ b/docs/docs/palettes/edit/generatePalette.js @@ -0,0 +1,94 @@ +// TODO move these to local imports +import Color from 'https://colorjs.io/dist/color.js'; +import { generateGrays, generateScale, getCoreTint, placeColor } from './generateScale.js'; +import { HUE_RANGES, HUE_TOP_TINT, L_RANGES, tints } from '/assets/scripts/tweak/data.js'; +import { clampAngle, interpolate, progressAngle, roundTo, subtractAngles } from '/assets/scripts/tweak/util.js'; + +export default function generatePalette(seedHues, { huesAfter: allHuesAfter, ...options } = {}) { + let ret = {}; + + // Generate scales from seed hues + let firstSeedHue; + + let coreLevels = {}; + let coreLOffsets = {}; + + for (let hue in seedHues) { + let seedColors = seedHues[hue]; + + if (!seedColors) { + continue; + } + + firstSeedHue ??= hue; + + let coreLevel = (coreLevels[hue] = getCoreTint(seedColors)); + let coreColor = seedColors[coreLevel]; + coreLOffsets[hue] = coreColor.get('oklch.l') - L_RANGES[coreLevel].mid; + + ret[hue] = generateScale(seedColors); + } + + if (!firstSeedHue) { + // No valid seed colors, abort mission + return null; + } + + // Fill in remaining hues + let hueBefore = firstSeedHue; + + for (let hue of allHuesAfter[firstSeedHue]) { + if (hue in ret) { + continue; + } + + // Shift if too close to pinned hues + let huesAfter = allHuesAfter[hue]; + let pinnedHuesAfter = huesAfter.filter(hue => hue in ret); + let pinnedHue = [pinnedHuesAfter.at(-1), pinnedHuesAfter[0]]; + let pinnedHueOffsets = pinnedHue.map(hue => coreLevels[hue] - HUE_TOP_TINT[hue]); + + let hueProgress = + pinnedHuesAfter.length === 1 + ? 0 + : progressAngle( + HUE_RANGES[hue].mid, + pinnedHue.map(hue => HUE_RANGES[hue].mid), + ); + + let pinnedScale = pinnedHue.map(hue => ret[hue]); + let h = HUE_RANGES[hue].mid; + + let hBefore = ret[hueBefore][ret[hueBefore].maxChromaTint].get('oklch.h'); + let hDelta = subtractAngles(h, hBefore); + + if (hDelta < 40) { + h = hBefore + 40; + } + + h = clampAngle(HUE_RANGES[hue].min, h, HUE_RANGES[hue].max); + + let c = interpolate( + hueProgress, + pinnedScale.map(scale => scale.maxChroma), + ); + + let coreLevelOffset = interpolate(hueProgress, pinnedHueOffsets); + let coreLevel = (coreLevels[hue] = roundTo(HUE_TOP_TINT[hue] + coreLevelOffset, 10)); + let lOffset = (coreLOffsets[hue] = interpolate( + hueProgress, + pinnedHue.map(hue => coreLOffsets[hue]), + )); + + let l = L_RANGES[coreLevel].mid + lOffset; + + let coreColor = new Color('oklch', [l, c, h]).toGamut('p3'); + + ret[hue] = generateScale(coreColor); + hueBefore = hue; + } + + ret.gray = generateGrays(ret, options); + + return ret; +} diff --git a/docs/docs/palettes/edit/generateScale.js b/docs/docs/palettes/edit/generateScale.js index 8913fad53..8614ed048 100644 --- a/docs/docs/palettes/edit/generateScale.js +++ b/docs/docs/palettes/edit/generateScale.js @@ -1,4 +1,13 @@ -import { HUE_RANGES, HUE_SHIFTS, L_RANGES, tints } from '/assets/scripts/tweak/data.js'; +import { + CHROMA_TOLERANCE, + DEFAULT_ACCENT, + HUE_RANGES, + HUE_SHIFTS, + L_RANGES, + MAX_ACCENT, + MIN_ACCENT, + tints, +} from '/assets/scripts/tweak/data.js'; import { clamp, getRange, mapRange } from '/assets/scripts/tweak/util.js'; const chromaScaleLightest = { @@ -17,20 +26,8 @@ export function generateScale(seedColors) { } // Find core color - let coreColor, maxChroma, coreLevel; - - for (let level in seedColors) { - let color = seedColors[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 coreLevel = getCoreTint(seedColors); + let coreColor = seedColors[coreLevel]; let distance = coreColor.get('oklch.l') - L_RANGES[coreLevel].mid; let coreChroma = coreColor.get('oklch.c'); @@ -194,9 +191,27 @@ export function getHueShift(color, fromTint, toTint) { let shift = hueShift.shift[type]; let ret = shift * intensity; - let maxShift = Math.sign(shift) * hueShift.maxConsecutive * tintDistance; - console.log(ret, clamp(undefined, ret, maxShift)); + let maxConsecutive = hueShift.maxConsecutive[type] ?? hueShift.maxConsecutive; + let maxShift = Math.sign(shift) * maxConsecutive * tintDistance; + ret = clamp(undefined, ret, maxShift); return ret; } + +export function getCoreTint(scale) { + let ret = DEFAULT_ACCENT; + let maxChroma = 0; + + for (let tint in scale) { + let color = scale[tint]; + let chroma = color.get('oklch.c'); + + if (chroma > maxChroma + CHROMA_TOLERANCE && tint >= MIN_ACCENT && tint <= MAX_ACCENT) { + ret = tint; + maxChroma = chroma; + } + } + + return ret; +} diff --git a/docs/docs/palettes/edit/tweak.js b/docs/docs/palettes/edit/tweak.js index b35d5a7cc..411110a38 100644 --- a/docs/docs/palettes/edit/tweak.js +++ b/docs/docs/palettes/edit/tweak.js @@ -2,6 +2,7 @@ import Color from 'https://colorjs.io/dist/color.js'; import { createApp, nextTick } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js'; import CoreColorInput from './core-color-input.js'; +import generatePalette from './generatePalette.js'; import { generateGrays, generateScale, placeColor } from './generateScale.js'; import Prism from '/assets/scripts/prism.js'; import { Permalink } from '/assets/scripts/tweak.js'; @@ -18,15 +19,7 @@ import { tints, urls, } from '/assets/scripts/tweak/data.js'; -import { - camelCase, - capitalize, - clampAngle, - interpolate, - interpolateAngles, - progressAngle, - subtractAngles, -} from '/assets/scripts/tweak/util.js'; +import { camelCase, capitalize, subtractAngles } from '/assets/scripts/tweak/util.js'; await Promise.all(['wa-slider'].map(tag => customElements.whenDefined(tag))); @@ -189,74 +182,8 @@ let paletteAppSpec = { return this.originalColors; } - let ret = {}; - - // Generate scales from seed hues - let firstSeedHue; - for (let hue in this.seedHues) { - let seedColors = this.seedHues[hue]; - - if (seedColors) { - firstSeedHue ??= hue; - ret[hue] = generateScale(seedColors); - } - } - - if (!firstSeedHue) { - // No valid seed colors, abort mission - return this.originalColors; - } - - // Fill in remaining hues - let hueBefore = firstSeedHue; - - for (let hue of this.huesAfter[firstSeedHue]) { - if (hue in ret) { - continue; - } - - // Shift if too close to pinned hues - let huesAfter = this.huesAfter[hue]; - let pinnedHuesAfter = huesAfter.filter(hue => hue in ret); - let pinnedHue = [pinnedHuesAfter.at(-1), pinnedHuesAfter[0]]; - let hueProgress = - pinnedHuesAfter.length === 1 - ? 0 - : progressAngle( - HUE_RANGES[hue].mid, - pinnedHue.map(hue => HUE_RANGES[hue].mid), - ); - - let pinnedScale = pinnedHue.map(hue => ret[hue]); - let h = HUE_RANGES[hue].mid; - - let hBefore = ret[hueBefore][ret[hueBefore].maxChromaTint].get('oklch.h'); - let hDelta = subtractAngles(h, hBefore); - - if (hDelta < 40) { - h = hBefore + 40; - } - - h = clampAngle(HUE_RANGES[hue].min, h, HUE_RANGES[hue].max); - - let c = interpolate( - hueProgress, - pinnedScale.map(scale => scale.maxChroma), - ); - let l = interpolate( - hueProgress, - pinnedScale.map(scale => L_RANGES[scale.maxChromaTint].mid), - ); - - let coreColor = new Color('oklch', [l, c, h]).toGamut('p3'); - - ret[hue] = generateScale(coreColor); - hueBefore = hue; - } - - ret.gray = generateGrays(ret, this); - - return ret; + let { huesAfter, grayChroma, grayColor } = this; + return generatePalette(this.seedHues, { huesAfter, grayChroma, grayColor }) ?? this.originalColors; }, colors() {