feat: add pallette tools

This commit is contained in:
2026-08-26 07:38:54 +05:00
parent 11fc160997
commit db02cbd165
5 changed files with 750 additions and 13 deletions
+138
View File
@@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest';
import {
analogousSet,
complementarySet,
hexToRgb,
hslToRgb,
monochromaticSet,
mixColors,
normalizeHex,
parseHexList,
renderBlend,
renderSwatches,
renderWheel,
rgbToHsl,
rgbToHex,
shadeSet,
sortPalette,
triadicSet,
tetradicSet
} from './palette';
describe('конвертация rgb↔hsl', () => {
it('красный → hsl(0,100%,50%) и обратно', () => {
const hsl = rgbToHsl(hexToRgb('#ff0000'));
expect(hsl.h).toBeCloseTo(0, 0);
expect(hsl.s).toBeCloseTo(1, 5);
expect(hsl.l).toBeCloseTo(0.5, 5);
expect(rgbToHex(hslToRgb({ h: 0, s: 1, l: 0.5 }))).toBe('#ff0000');
});
it('серые не имеют оттенка', () => {
expect(rgbToHsl(hexToRgb('#808080')).s).toBe(0);
});
it('круговой переход 360° возвращает исходный цвет', () => {
const base = '#3b82f6';
expect(rgbToHex(hslToRgb({ ...rgbToHsl(hexToRgb(base)), h: 720 + 30 }))).toBe(
rgbToHex(hslToRgb({ ...rgbToHsl(hexToRgb(base)), h: 30 }))
);
});
});
describe('гармонии', () => {
it('complementary — пара с сдвигом 180°', () => {
const [a, b] = complementarySet('#ff0000');
expect(a).toBe('#ff0000');
const ha = rgbToHsl(hexToRgb(a)).h;
const hb = rgbToHsl(hexToRgb(b)).h;
expect(Math.abs((hb - ha + 360) % 360)).toBeCloseTo(180, 0);
});
it('triadic — три цвета через 120°', () => {
const set = triadicSet('#ff0000');
expect(set).toHaveLength(3);
const hues = set.map((h) => rgbToHsl(hexToRgb(h)).h);
expect(hues[1] - hues[0]).toBeCloseTo(120, 0);
expect(hues[2] - hues[0]).toBeCloseTo(240, 0);
});
it('tetradic — четыре цвета через 90°', () => {
const set = tetradicSet('#00ff00');
expect(set).toHaveLength(4);
});
it('analogous симметричен вокруг базы', () => {
const set = analogousSet('#0000ff', 30, 5);
expect(set).toHaveLength(5);
expect(set[2]).toBe(normalizeHex('#0000ff'));
});
it('monochromatic держит оттенок, меняет светлоту', () => {
const set = monochromaticSet('#ff8800', 5, 60);
expect(set).toHaveLength(5);
const hues = new Set(set.map((h) => Math.round(rgbToHsl(hexToRgb(h)).h)));
expect(hues.size).toBe(1);
const lights = set.map((h) => rgbToHsl(hexToRgb(h)).l);
expect(Math.min(...lights)).toBeLessThan(Math.max(...lights));
});
it('shades — тёмный край темнее базы', () => {
const set = shadeSet('#88cc44', 4, 70);
const lights = set.map((h) => rgbToHsl(hexToRgb(h)).l);
expect(lights[lights.length - 1]).toBeLessThan(lights[0]);
});
});
describe('parseHexList / mixColors / sortPalette', () => {
it('парсит список и отбрасывает мусор и токены без решётки', () => {
expect(parseHexList('#ff0000, #00FF00 ; 00ff00 zz')).toEqual(['#ff0000', '#00ff00']);
});
it('пустой валидный список бросает badHex', () => {
expect(() => parseHexList('нет цветов')).toThrow(/errors\.badHex/);
});
it('mixColors — среднее компонент', () => {
expect(mixColors(['#000000', '#ffffff'])).toBe('#808080');
});
it('sortPalette по luma ставит тёмные раньше', () => {
const sorted = sortPalette(['#ffffff', '#000000', '#808080'], 'luma');
expect(sorted[0]).toBe('#000000');
expect(sorted[2]).toBe('#ffffff');
});
});
describe('рендеры', () => {
it('renderSwatches strip: колонки по цветам', () => {
const img = renderSwatches(['#ff0000', '#00ff00'], 200, 'strip');
expect(img.width).toBe(200);
expect(img.data[(Math.floor(img.height / 2) * 200 + 10) * 4 + 3]).toBe(255);
expect(img.data[(Math.floor(img.height / 2) * 200 + 10) * 4]).toBeGreaterThan(200);
const right = (Math.floor(img.height / 2) * 200 + 150) * 4;
expect(img.data[right]).toBeLessThan(50);
expect(img.data[right + 1]).toBeGreaterThan(200);
});
it('renderSwatches grid: квадратные ячейки', () => {
const img = renderSwatches(['#111111', '#222222', '#333333'], 120, 'grid');
expect(img.width).toBe(120);
expect(img.height).toBeGreaterThan(0);
});
it('renderWheel: углы прозрачны, центр непрозрачен', () => {
const size = 101;
const img = renderWheel(size, 50);
expect(img.data[3]).toBe(0);
const c = ((Math.floor(size / 2) * size) + Math.floor(size / 2)) * 4;
expect(img.data[c + 3]).toBe(255);
});
it('renderBlend: левый край = a, правый = b', () => {
const img = renderBlend('#000000', '#ffffff', 100);
expect(img.data[0]).toBe(0);
const last = (99 * 4);
expect(img.data[last]).toBe(255);
});
});
+264
View File
@@ -0,0 +1,264 @@
import { ToolError } from './errors';
import type { PixelImage } from './types';
import { createPixelImage } from './types';
export type Rgb = { r: number; g: number; b: number };
export type Hsl = { h: number; s: number; l: number };
export function hexToRgb(hex: string): Rgb {
const m = /^#([0-9a-f]{6})$/i.exec(hex.trim());
if (!m) throw new ToolError('errors.badHex', { value: hex });
const d = m[1];
return {
r: parseInt(d.slice(0, 2), 16),
g: parseInt(d.slice(2, 4), 16),
b: parseInt(d.slice(4, 6), 16)
};
}
const byte = (v: number) =>
Math.round(Math.min(255, Math.max(0, v)))
.toString(16)
.padStart(2, '0');
export function rgbToHex({ r, g, b }: Rgb): string {
return `#${byte(r)}${byte(g)}${byte(b)}`;
}
/** h ∈ [0..360), s,l ∈ [0..1] */
export function rgbToHsl({ r, g, b }: Rgb): Hsl {
const rn = r / 255;
const gn = g / 255;
const bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const l = (max + min) / 2;
if (max === min) return { h: 0, s: 0, l };
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h: number;
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) * 60;
else if (max === gn) h = ((bn - rn) / d + 2) * 60;
else h = ((rn - gn) / d + 4) * 60;
return { h, s, l };
}
export function hslToRgb({ h, s, l }: Hsl): Rgb {
const hn = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((hn / 60) % 2) - 1));
const m = l - c / 2;
let r = 0;
let g = 0;
let b = 0;
if (hn < 60) [r, g, b] = [c, x, 0];
else if (hn < 120) [r, g, b] = [x, c, 0];
else if (hn < 180) [r, g, b] = [0, c, x];
else if (hn < 240) [r, g, b] = [0, x, c];
else if (hn < 300) [r, g, b] = [x, 0, c];
else [r, g, b] = [c, 0, x];
return {
r: Math.round((r + m) * 255),
g: Math.round((g + m) * 255),
b: Math.round((b + m) * 255)
};
}
export function shiftHue(hex: string, deltaDeg: number): string {
const hsl = rgbToHsl(hexToRgb(hex));
return rgbToHex(hslToRgb({ ...hsl, h: hsl.h + deltaDeg }));
}
function withLightness(hex: string, l: number): string {
return rgbToHex(hslToRgb({ ...rgbToHsl(hexToRgb(hex)), l }));
}
export function complementarySet(base: string): string[] {
return [normalizeHex(base), shiftHue(base, 180)];
}
export function triadicSet(base: string): string[] {
return [normalizeHex(base), shiftHue(base, 120), shiftHue(base, 240)];
}
export function tetradicSet(base: string): string[] {
return [normalizeHex(base), shiftHue(base, 90), shiftHue(base, 180), shiftHue(base, 270)];
}
export function analogousSet(base: string, spreadDeg: number, count: number): string[] {
const n = Math.max(3, Math.min(9, Math.round(count)));
const half = Math.floor(n / 2);
return Array.from({ length: n }, (_, i) => shiftHue(base, (i - half) * spreadDeg));
}
export function monochromaticSet(base: string, count: number, rangePercent: number): string[] {
const n = Math.max(2, Math.min(9, Math.round(count)));
const baseL = rgbToHsl(hexToRgb(normalizeHex(base))).l;
const halfSpan = Math.min(0.495, rangePercent / 200);
return Array.from({ length: n }, (_, i) => {
const t = n === 1 ? 0.5 : i / (n - 1);
const l = clamp01(baseL - halfSpan + t * halfSpan * 2);
return withLightness(normalizeHex(base), l);
});
}
export function shadeSet(base: string, count: number, depthPercent: number): string[] {
const n = Math.max(2, Math.min(9, Math.round(count)));
const baseL = rgbToHsl(hexToRgb(normalizeHex(base))).l;
const floorL = Math.max(0.03, baseL - depthPercent / 100);
return Array.from({ length: n }, (_, i) => {
const t = i / (n - 1);
return withLightness(normalizeHex(base), baseL - (baseL - floorL) * t);
});
}
export function parseHexList(text: string): string[] {
const list = text
.split(/[,\s;]+/)
.map((t) => t.trim())
.filter((t) => /^#[0-9a-f]{6}$/i.test(t))
.map((t) => normalizeHex(t));
if (list.length === 0) throw new ToolError('errors.badHex', { value: text });
return list;
}
export function normalizeHex(hex: string): string {
const m = /^#([0-9a-f]{6})$/i.exec(hex.trim());
if (!m) throw new ToolError('errors.badHex', { value: hex });
return `#${m[1].toLowerCase()}`;
}
export function mixColors(hexes: string[]): string {
const sum = hexes
.map(hexToRgb)
.reduce((acc, c) => ({ r: acc.r + c.r, g: acc.g + c.g, b: acc.b + c.b }), { r: 0, g: 0, b: 0 });
return rgbToHex({
r: sum.r / hexes.length,
g: sum.g / hexes.length,
b: sum.b / hexes.length
});
}
export function luma(hex: string): number {
const { r, g, b } = hexToRgb(hex);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
export type SortKey = 'hue' | 'luma' | 'sat';
export function sortPalette(hexes: string[], key: SortKey): string[] {
const scored = hexes.map((h) => {
if (key === 'luma') return { h, k: luma(h) };
const hsl = rgbToHsl(hexToRgb(h));
return { h, k: key === 'hue' ? hsl.h : hsl.s };
});
return scored.sort((a, b) => a.k - b.k || a.h.localeCompare(b.h)).map((s) => s.h);
}
function clamp01(v: number): number {
return Math.min(1, Math.max(0, v));
}
/** Горизонтальные равные колонки-свотчи (strip) или сетка ~квадратных ячеек (grid). */
export function renderSwatches(colors: string[], width: number, layout: 'strip' | 'grid'): PixelImage {
const n = colors.length;
if (layout === 'strip') {
const cellW = width / n;
const height = Math.max(24, Math.round(cellW));
const out = createPixelImage(width, height);
colors.forEach((hex, i) => {
const { r, g, b } = hexToRgb(hex);
fillRect(out, Math.floor(i * cellW), 0, Math.ceil(cellW), height, r, g, b);
});
return out;
}
const cols = Math.max(1, Math.ceil(Math.sqrt(n)));
const rows = Math.max(1, Math.ceil(n / cols));
const cell = Math.floor(width / cols);
const height = cell * rows;
const out = createPixelImage(width, height);
colors.forEach((hex, i) => {
const { r, g, b } = hexToRgb(hex);
fillRect(out, (i % cols) * cell, Math.floor(i / cols) * cell, cell, cell, r, g, b);
});
return out;
}
export function renderWheel(size: number, lightness: number): PixelImage {
const out = createPixelImage(size, size);
const c = (size - 1) / 2;
const radius = c;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const dx = x - c;
const dy = y - c;
const dist = Math.sqrt(dx * dx + dy * dy);
const di = (y * size + x) * 4;
if (dist > radius) continue;
const h = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
const s = dist / radius;
const { r, g, b } = hslToRgb({ h, s, l: lightness / 100 });
out.data[di] = r;
out.data[di + 1] = g;
out.data[di + 2] = b;
out.data[di + 3] = 255;
}
}
return out;
}
export function renderBlend(a: string, b: string, width: number): PixelImage {
const height = Math.max(24, Math.round(width / 4));
const out = createPixelImage(width, height);
const ca = hexToRgb(a);
const cb = hexToRgb(b);
for (let x = 0; x < width; x++) {
const t = width === 1 ? 0 : x / (width - 1);
fillRect(
out,
x,
0,
1,
height,
Math.round(ca.r + (cb.r - ca.r) * t),
Math.round(ca.g + (cb.g - ca.g) * t),
Math.round(ca.b + (cb.b - ca.b) * t)
);
}
return out;
}
export function stepColors(aHex: string, bHex: string, steps: number): string[] {
const n = Math.max(2, Math.min(12, Math.round(steps)));
const ca = hexToRgb(aHex);
const cb = hexToRgb(bHex);
return Array.from({ length: n }, (_, i) => {
const t = i / (n - 1);
return rgbToHex({
r: ca.r + (cb.r - ca.r) * t,
g: ca.g + (cb.g - ca.g) * t,
b: ca.b + (cb.b - ca.b) * t
});
});
}
function fillRect(
img: PixelImage,
x0: number,
y0: number,
w: number,
h: number,
r: number,
g: number,
b: number
): void {
for (let y = y0; y < Math.min(y0 + h, img.height); y++) {
for (let x = x0; x < Math.min(x0 + w, img.width); x++) {
const di = (y * img.width + x) * 4;
img.data[di] = r;
img.data[di + 1] = g;
img.data[di + 2] = b;
img.data[di + 3] = 255;
}
}
}