From 4bfebf3249e1c34ac7556b780c548fd80218e5d9 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Thu, 13 Feb 2025 18:15:47 -0500 Subject: [PATCH] Improve color ranges script (#752) --- src/styles/color/scripts/ranges.js | 247 +++++++++++++++++++++-------- src/styles/color/scripts/util.js | 47 ++++++ 2 files changed, 225 insertions(+), 69 deletions(-) diff --git a/src/styles/color/scripts/ranges.js b/src/styles/color/scripts/ranges.js index 507be4adc..d0ba46308 100644 --- a/src/styles/color/scripts/ranges.js +++ b/src/styles/color/scripts/ranges.js @@ -1,98 +1,207 @@ // Run via node ranges.js to analyze all palettes -// or node ranges.js to analyze a single palette +// Add palette ids, hue names, or tints to filter the analysis (e.g. node ranges.js red anodized -gray) import palettes from './palettes-analyzed.js'; -import { toPrecision } from './util.js'; - -let paletteId = process.argv[2]; +import { aggregates, normalizeAngles, toPrecision } from './util.js'; /** * Each "test" consists of the following params to analyze: - * - component: The color component to analyze (h, c, l) - * - label: The label to display in the console - * - by: The grouping to analyze by (tint, hue) - * - levels: The number of tints from the core color to include in the analysis. - * Examples: undefined for all tints, 0 for the core color only, 10 for the core color and ±10 from it. + * - component: The color component to analyze (h, c, l). If `getValue()` is provided, this is ignored. + * - getValue: A function to extract the value to analyze from a color, for more complex analysis than just getting a component + * - by: The grouping to analyze by (1-2 of 'tint', 'hue', 'palette'). If `getKey()` is provided, this is ignored + * - getKey: A function to generate a key for each group. If not provided, it is generated based on the 'by' param + * - caption: The caption to display in the console. If not provided, a default label is generated from test params. + * - filter: Restrict to specific hues/tints/palettes or exclude them + * - stats: The stats to calculate for each group (min, max, mid, extent, avg, median, count) */ let tests = [ - { component: 'h', label: 'Hue', by: 'hue', levels: paletteId ? undefined : 10 }, - { component: 'c', label: 'Chroma', by: 'tint' }, - { component: 'l', label: 'L', by: 'tint' }, + { component: 'h', by: 'hue', filter: ['core ± 10', '-gray'] }, + { component: 'h', by: 'hue', filter: ['core', '-gray'] }, + { component: 'h', by: 'palette', filter: ['core', 'gray'] }, + { component: 'c', by: 'tint', filter: '-gray' }, + { component: 'c', by: 'palette', filter: 'core', stats: ['max', 'avg', 'median', 'count'] }, + { component: 'l', by: 'tint' }, + { + caption: 'Hue change between consecutive tints', + getValue(color, { palette, tint, hue }) { + let nextTint = getNextTint(tint); + let nextColor = palettes[palette][hue][nextTint]; + return color.h - nextColor.h; + }, + getKey({ tint }) { + return `${tint} → ${getNextTint(tint)}`; + }, + filter: '-95', + }, ]; -if (!paletteId) { - tests.push({ component: 'h', label: 'Core Hue', by: 'hue', levels: 0 }); +const COMPONENT_NAMES = { l: 'lightness', c: 'chroma', h: 'hue' }; +const CORE_TINT_MICROSYNTAX = /^core\s*((?[-+±])\s*(?\d+))?$/; + +const all = { + tints: ['95', '90', '80', '70', '60', '50', '40', '30', '20', '10', '05'], + hues: ['red', 'yellow', 'green', 'cyan', 'blue', 'indigo', 'purple', 'pink', 'gray'], + palettes: Object.keys(palettes), +}; + +let args = process.argv.slice(2); +let used = getSubset(all, args); + +const getNextTint = tint => Number(tint) + (tint == 5 || tint == 90 ? 5 : 10); + +for (let key in used) { + if (used[key].length < all[key].length) { + // Subset of values + console.log(`Analyzing only ${key}:`, used[key].join(', ')); + } } -const tints = ['95', '90', '80', '70', '60', '50', '40', '30', '20', '10', '05']; -const hues = ['red', 'yellow', 'green', 'cyan', 'blue', 'indigo', 'purple', 'pink', 'gray']; +/** + * Apply a list of args (hues, tints, palette ids) to add or exclude against the corresponding arrays + */ +function getSubset(all, args) { + args = Array.isArray(args) ? args : [args]; -function analyzePalette(scales, results, { component, levels, by = 'tint' }) { - for (let hue in scales) { - let colors = scales[hue]; - let key = colors.maxChromaTint; - let resultsByHue = by === 'hue' ? results[hue] : results; + let used = { + tints: [], + hues: [], + palettes: [], + }; - for (let tint of tints) { - let color = colors[tint]; - let value = color[component]; - let resultsByTint = by === 'tint' ? resultsByHue[tint] : resultsByHue; + if (args.length > 0) { + for (let arg of args) { + let isNegative = arg.startsWith('-'); + arg = isNegative ? arg.slice(1) : arg; + let key = Object.entries(all).find(([key, values]) => values.includes(arg))?.[0]; - if (levels === undefined || Math.abs(tint - key) <= levels) { - if (resultsByTint.min > value) resultsByTint.min = value; - if (resultsByTint.max < value) resultsByTint.max = value; + if (!key && CORE_TINT_MICROSYNTAX.test(arg)) { + key = 'tints'; } - } - } -} -function analyze(options = {}) { - let results = {}; - let keys = options.by === 'hue' ? hues : tints; - - for (let key of keys) { - results[key] = { min: Infinity, max: -Infinity }; - } - - if (paletteId) { - analyzePalette(palettes[paletteId], results, options); - } else { - for (let paletteId in palettes) { - analyzePalette(palettes[paletteId], results, options); - } - } - - // Add extent & mid, make numbers easier to read - for (let key of keys) { - let info = results[key]; - if (options.component === 'h') { - // Fixup hues crossing 0 - if (Math.abs(info.max - info.min) > 180) { - info.min += 360; - - if (info.min > info.max) { - [info.min, info.max] = [info.max, info.min]; + if (key) { + if (isNegative) { + let array = used[key].length === 0 ? all[key] : used[key]; + used[key] = array.filter(value => value !== arg); + } else { + used[key].push(arg); } } } + } - info.extent = info.max - info.min; - info.mid = (info.min + info.max) / 2; - - for (let prop in info) { - info[prop] = toPrecision(info[prop]); + // If no filters, use all values + for (let key in used) { + if (used[key].length === 0) { + used[key] = all[key].slice(); } } - let label = `${options.label || options.component} ranges`; - console.log(label + (options.levels !== undefined ? ` (±${options.levels} from core tint)` : '') + ':'); - console.table(results); + return used; } -if (paletteId) { - // Analyze a single palette - console.log(`Analyzing palette '${paletteId}'`); +function runTest(test = {}) { + let { + component, + getValue = color => color[component], + by = 'tint', + getKey = getDefaultKey(by), + filter, + caption = getDefaultCaption(test), + stats = ['min', 'max', 'median', 'count'], + silent, + } = test; + let results = {}; + let localUsed = filter ? getSubset(used, filter) : used; + + for (let palette of localUsed.palettes) { + for (let hue of localUsed.hues) { + let coreTint = palettes[palette][hue].maxChromaTint; + // Resolve any core tint microsyntax + let tints = localUsed.tints.flatMap(tint => { + if (CORE_TINT_MICROSYNTAX.test(tint)) { + let { op, offset } = CORE_TINT_MICROSYNTAX.exec(tint).groups; + + if (!offset) { + return coreTint; + } + + return used.tints.filter(t => { + let distance = t - coreTint; + return Math.abs(distance) <= offset && !((op === '-' && distance > 0) || (op === '+' && distance < 0)); + }); + } + + return tint; + }); + + for (let tint of tints) { + let key = getKey({ hue, tint, palette }); + let color = palettes[palette][hue][tint]; + let value = getValue(color, { hue, tint, palette }, localUsed); + + results[key] ??= []; + results[key].push(value); + } + } + } + + // Process results + for (let key in results) { + let values = results[key]; + + if (component === 'h') { + values = normalizeAngles(values); + } + + results[key] = stats.reduce((acc, stat) => { + acc[stat] = toPrecision(aggregates[stat](values, acc)); + return acc; + }, {}); + } + + if (!silent) { + console.log(caption); + console.table(results); + console.log('\n'); + } + + return results; } for (let test of tests) { - analyze(test); + runTest(test); +} + +function getDefaultKey(by) { + by = Array.isArray(by) ? by : [by]; + + return variables => + by + .map((variableName, i) => { + if (variableName === 'tint' && i === 0) { + // Drop leading zeros because they throw off row order + return String(variables.tint).replace(/^0+/, ''); + } + return variables[variableName]; + }) + .join('-'); +} + +function getDefaultCaption({ component, by, filter }) { + let ret = COMPONENT_NAMES[component]; + + by = Array.isArray(by) ? by : [by]; + ret += ` by ${by.join(' ')}`; + + if (filter) { + filter = Array.isArray(filter) ? filter.join(', ') : filter; + ret += ` (${filter})`; + } + + ret = ret.replace('hue by hue', 'hue by color name'); + + return capitalize(ret); +} + +function capitalize(str) { + return str[0].toUpperCase() + str.slice(1); } diff --git a/src/styles/color/scripts/util.js b/src/styles/color/scripts/util.js index 723d083e1..8a2837306 100644 --- a/src/styles/color/scripts/util.js +++ b/src/styles/color/scripts/util.js @@ -57,3 +57,50 @@ export function hueToChalk(hue) { export function toPrecision(value, precision = 2) { return +Number(value).toPrecision(precision); } + +export function normalizeAngles(angles) { + // First, normalize + angles = angles.map(h => ((h % 360) + 360) % 360); + + // Remove top and bottom 25% and find average + let averageHue = + angles + .toSorted((a, b) => a - b) + .slice(angles.length / 4, -angles.length / 4) + .reduce((a, b) => a + b, 0) / angles.length; + + for (let i = 0; i < angles.length; i++) { + let h = angles[i]; + let prevHue = angles[i - 1]; + let delta = h - prevHue; + + if (Math.abs(delta) > 180) { + let equivalent = [h + 360, h - 360]; + // Offset hue to minimize difference in the direction that brings it closer to the average + let delta = h - averageHue; + + if (Math.abs(equivalent[0] - prevHue) <= Math.abs(equivalent[1] - prevHue)) { + angles[i] = equivalent[0]; + } else { + angles[i] = equivalent[1]; + } + } + } + + return angles; +} + +export const aggregates = { + min: values => Math.min(...values), + max: values => Math.max(...values), + avg: values => values.reduce((a, b) => a + b, 0) / values.length, + count: values => values.length, + values: values => values, + median: values => { + let sorted = values.slice().sort((a, b) => a - b); + let mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; + }, + extent: (values, { min, max }) => max - min, + mid: (values, { min, max }) => (max + min) / 2, +};