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