Improve color ranges script (#752)

This commit is contained in:
Lea Verou
2025-02-13 18:15:47 -05:00
committed by GitHub
parent 99ad0abdd3
commit 4bfebf3249
2 changed files with 225 additions and 69 deletions

View File

@@ -1,98 +1,207 @@
// Run via node ranges.js to analyze all palettes
// or node ranges.js <paletteId> 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*((?<op>[-+±])\s*(?<offset>\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);
}

View File

@@ -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,
};