diff --git a/web/src/lib/core/palette.test.ts b/web/src/lib/core/palette.test.ts new file mode 100644 index 0000000..a9d97d6 --- /dev/null +++ b/web/src/lib/core/palette.test.ts @@ -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); + }); +}); diff --git a/web/src/lib/core/palette.ts b/web/src/lib/core/palette.ts new file mode 100644 index 0000000..e83e171 --- /dev/null +++ b/web/src/lib/core/palette.ts @@ -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; + } + } +} diff --git a/web/src/lib/i18n/ru.ts b/web/src/lib/i18n/ru.ts index 0672823..f24e1b7 100644 --- a/web/src/lib/i18n/ru.ts +++ b/web/src/lib/i18n/ru.ts @@ -436,6 +436,109 @@ export const ru: Dict = { }, options: { direction: { horizontal: 'По горизонтали', vertical: 'По вертикали' } } }, + 'color-wheel-png': { + title: 'Цветовой круг PNG', + description: + 'Круг HSL: оттенок по окружности, насыщенность от центра к краю, выбранная светлота.', + params: { width: 'Размер', lightness: 'Светлота, %' } + }, + 'complementary-png': { + title: 'Комплементарная палитра PNG', + description: 'Два противоположных цвета круга — базовый и его дополнение.', + params: { + baseColor: 'Базовый цвет', + width: 'Ширина', + layout: 'Раскладка' + }, + options: { layout: { grid: 'Сетка', strip: 'Полоса' } } + }, + 'triadic-png': { + title: 'Триадная палитра PNG', + description: 'Три цвета через 120° друг от друга на цветовом круге.', + params: { baseColor: 'Базовый цвет', width: 'Ширина', layout: 'Раскладка' }, + options: { layout: { grid: 'Сетка', strip: 'Полоса' } } + }, + 'tetradic-png': { + title: 'Тетрадная палитра PNG', + description: 'Четыре цвета — две комплементарные пары, шаг 90° по кругу.', + params: { baseColor: 'Базовый цвет', width: 'Ширина', layout: 'Раскладка' }, + options: { layout: { grid: 'Сетка', strip: 'Полоса' } } + }, + 'analogous-png': { + title: 'Аналоговая палитра PNG', + description: 'Соседние оттенки вокруг базового — спокойная родственная гамма.', + params: { + baseColor: 'Базовый цвет', + width: 'Ширина', + layout: 'Раскладка', + spread: 'Разброс оттенка, °', + count: 'Сколько цветов' + }, + options: { layout: { grid: 'Сетка', strip: 'Полоса' } } + }, + 'monochromatic-png': { + title: 'Монохромная палитра PNG', + description: + 'Тоны одного оттенка: меняется светлота в выбранном диапазоне, тон и насыщенность фиксированы.', + params: { + baseColor: 'Базовый цвет', + width: 'Ширина', + layout: 'Раскладка', + count: 'Сколько цветов', + range: 'Диапазон светлоты, %' + }, + options: { layout: { grid: 'Сетка', strip: 'Полоса' } } + }, + 'shades-png': { + title: 'Градация оттенка PNG', + description: 'Ступени базового цвета от исходного к более тёмному.', + params: { + baseColor: 'Базовый цвет', + width: 'Ширина', + layout: 'Раскладка', + count: 'Сколько цветов', + depth: 'Глубина затемнения, %' + }, + options: { layout: { grid: 'Сетка', strip: 'Полоса' } } + }, + 'mix-colors-png': { + title: 'Смешать цвета PNG', + description: + 'Усредняет несколько hex-цветов в один свотч. Введите значения через запятую; неверные токены пропускаются.', + params: { colors: 'Цвета (hex через запятую)', width: 'Ширина' } + }, + 'blend-two-png': { + title: 'Перелив двух цветов PNG', + description: 'Непрерывный горизонтальный градиент между двумя цветами.', + params: { colorA: 'Цвет A', colorB: 'Цвет B', width: 'Ширина' } + }, + 'step-colors-png': { + title: 'Ступени между цветами PNG', + description: 'Дискретный набор равномерно распределённых ступеней между двумя цветами.', + params: { + colorA: 'Цвет A', + colorB: 'Цвет B', + steps: 'Сколько ступеней', + width: 'Ширина', + layout: 'Раскладка' + }, + options: { layout: { grid: 'Сетка', strip: 'Полоса' } } + }, + 'sort-colors-png': { + title: 'Отсортировать цвета PNG', + description: + 'Рисует ваш hex-список свотчами, отсортированными по тону, яркости или насыщенности. Неверные токены пропускаются.', + params: { + colors: 'Цвета (hex через запятую)', + order: 'Сортировка', + width: 'Ширина', + layout: 'Раскладка' + }, + options: { + order: { hue: 'Оттенок', luma: 'Яркость', sat: 'Насыщенность' }, + layout: { grid: 'Сетка', strip: 'Полоса' } + } + }, 'png-is-grayscale': { title: 'Проверить: PNG монохромный?', description: 'Сообщает, состоит ли изображение только из оттенков серого.', @@ -488,8 +591,7 @@ export const ru: Dict = { } }, 'add-text-png': { - title: 'Надпись на PNG', - description: + title: 'Надпись на PNG', description: 'Рисует текст на изображении: шрифт, размер, цвет, жирность, позиция на сетке 3×3 и опциональная подложка.', params: { text: 'Текст', diff --git a/web/src/lib/registry.ts b/web/src/lib/registry.ts index b2cd574..e6068b5 100644 --- a/web/src/lib/registry.ts +++ b/web/src/lib/registry.ts @@ -60,7 +60,23 @@ import { } from './core/domText'; import { getOverlay } from './tools/overlay-store.svelte'; import { formatStamp } from './core/datefmt'; -import type { Position9 } from './core/textdraw'; +import type { Position9 } from './core/textdraw'; +import { + analogousSet, + complementarySet, + monochromaticSet, + parseHexList, + renderBlend, + renderSwatches, + renderWheel, + shadeSet, + sortPalette, + stepColors, + mixColors, + tetradicSet, + triadicSet, + type SortKey +} from './core/palette'; import { hexToPixels, pixelsToHex } from './core/text'; import { clonePixelImage, type PixelImage } from './core/types'; @@ -155,6 +171,31 @@ function bool(params: Record, id: string): boolean { throw new ToolError('errors.paramBool', { id }); } return v; +} + +function paletteParams(baseDefault: string) { + return [ + { id: 'baseColor', label: 'Base color', type: 'color' as const, default: baseDefault }, + { + id: 'width', + label: 'Width', + type: 'slider' as const, + min: 128, + max: 1024, + step: 16, + default: 512 + }, + { + id: 'layout', + label: 'Layout', + type: 'select' as const, + default: 'grid', + options: [ + { value: 'grid', label: 'Grid' }, + { value: 'strip', label: 'Strip' } + ] + } + ]; } function decodeToPng(id: string, title: string, description: string): ToolEntry { @@ -868,17 +909,198 @@ export const TOOLS: ToolEntry[] = [ ] } ], - generate: (p) => - gradientImage( - Math.trunc(num(p, 'width')), - Math.trunc(num(p, 'height')), - hexToRgba(str(p, 'fromColor')), - hexToRgba(str(p, 'toColor')), - str(p, 'direction') === 'vertical' ? 'vertical' : 'horizontal' - ) - }, + generate: (p) => + gradientImage( + Math.trunc(num(p, 'width')), + Math.trunc(num(p, 'height')), + hexToRgba(str(p, 'fromColor')), + hexToRgba(str(p, 'toColor')), + str(p, 'direction') === 'vertical' ? 'vertical' : 'horizontal' + ) + }, { - id: 'add-text-png', + id: 'color-wheel-png', + title: 'Color Wheel PNG', + description: + 'Generates an HSL color wheel: hue around the circle, saturation from center to edge, chosen lightness.', + category: 'generate', + sourceMode: 'none', + params: [ + { id: 'width', label: 'Size', type: 'slider', min: 128, max: 1024, step: 16, default: 512 }, + { id: 'lightness', label: 'Lightness, %', type: 'slider', min: 0, max: 100, step: 1, default: 50 } + ], + generate: (p) => renderWheel(Math.trunc(num(p, 'width')), num(p, 'lightness')) + }, + { + id: 'complementary-png', + title: 'Complementary Palette PNG', + description: 'Two opposite colors on the color wheel — the base and its complement.', + category: 'generate', + sourceMode: 'none', + params: paletteParams('#2563eb'), + generate: (p) => + renderSwatches(complementarySet(str(p, 'baseColor')), num(p, 'width'), str(p, 'layout') as 'strip' | 'grid') + }, + { + id: 'triadic-png', + title: 'Triadic Palette PNG', + description: 'Three colors evenly spaced 120° apart on the color wheel.', + category: 'generate', + sourceMode: 'none', + params: paletteParams('#ff0000'), + generate: (p) => + renderSwatches(triadicSet(str(p, 'baseColor')), num(p, 'width'), str(p, 'layout') as 'strip' | 'grid') + }, + { + id: 'tetradic-png', + title: 'Tetradic Palette PNG', + description: 'Four colors in two complementary pairs, 90° apart on the wheel.', + category: 'generate', + sourceMode: 'none', + params: paletteParams('#8000ff'), + generate: (p) => + renderSwatches(tetradicSet(str(p, 'baseColor')), num(p, 'width'), str(p, 'layout') as 'strip' | 'grid') + }, + { + id: 'analogous-png', + title: 'Analogous Palette PNG', + description: 'Neighboring hues around the base color — calm, related color scheme.', + category: 'generate', + sourceMode: 'none', + params: [ + ...paletteParams('#22c55e'), + { id: 'spread', label: 'Hue spread, °', type: 'slider', min: 10, max: 90, step: 5, default: 30 }, + { id: 'count', label: 'Colors', type: 'slider', min: 3, max: 9, step: 1, default: 5 } + ], + generate: (p) => + renderSwatches( + analogousSet(str(p, 'baseColor'), num(p, 'spread'), num(p, 'count')), + num(p, 'width'), + str(p, 'layout') as 'strip' | 'grid' + ) + }, + { + id: 'monochromatic-png', + title: 'Monochromatic Palette PNG', + description: 'Tones of a single hue: lightness varies within the chosen range, hue and saturation stay fixed.', + category: 'generate', + sourceMode: 'none', + params: [ + ...paletteParams('#0ea5e9'), + { id: 'count', label: 'Colors', type: 'slider', min: 2, max: 9, step: 1, default: 5 }, + { id: 'range', label: 'Lightness range, %', type: 'slider', min: 10, max: 90, step: 5, default: 40 } + ], + generate: (p) => + renderSwatches( + monochromaticSet(str(p, 'baseColor'), num(p, 'count'), num(p, 'range')), + num(p, 'width'), + str(p, 'layout') as 'strip' | 'grid' + ) + }, + { + id: 'shades-png', + title: 'Shade Ramp PNG', + description: 'A ramp of the base color getting darker step by step.', + category: 'generate', + sourceMode: 'none', + params: [ + ...paletteParams('#f59e0b'), + { id: 'count', label: 'Colors', type: 'slider', min: 2, max: 9, step: 1, default: 5 }, + { id: 'depth', label: 'Darkening depth, %', type: 'slider', min: 10, max: 90, step: 5, default: 50 } + ], + generate: (p) => + renderSwatches( + shadeSet(str(p, 'baseColor'), num(p, 'count'), num(p, 'depth')), + num(p, 'width'), + str(p, 'layout') as 'strip' | 'grid' + ) + }, + { + id: 'mix-colors-png', + title: 'Mix Colors PNG', + description: + 'Averages several hex colors into one swatch. Enter comma-separated #hex values; invalid tokens are skipped.', + category: 'generate', + sourceMode: 'none', + params: [ + { + id: 'colors', + label: 'Colors (comma-separated hex)', + type: 'text', + default: '#ff0000,#00ff00,#0000ff' + }, + { id: 'width', label: 'Width', type: 'slider', min: 128, max: 1024, step: 16, default: 512 } + ], + generate: (p) => renderSwatches([mixColors(parseHexList(str(p, 'colors')))], num(p, 'width'), 'strip') + }, + { + id: 'blend-two-png', + title: 'Blend Two Colors PNG', + description: 'A continuous horizontal gradient between two colors.', + category: 'generate', + sourceMode: 'none', + params: [ + { id: 'colorA', label: 'Color A', type: 'color', default: '#000000' }, + { id: 'colorB', label: 'Color B', type: 'color', default: '#ffffff' }, + { id: 'width', label: 'Width', type: 'slider', min: 128, max: 1024, step: 16, default: 512 } + ], + generate: (p) => renderBlend(str(p, 'colorA'), str(p, 'colorB'), num(p, 'width')) + }, + { + id: 'step-colors-png', + title: 'Color Steps PNG', + description: 'A discrete set of evenly spaced steps between two colors.', + category: 'generate', + sourceMode: 'none', + params: [ + { id: 'colorA', label: 'Color A', type: 'color', default: '#000000' }, + { id: 'colorB', label: 'Color B', type: 'color', default: '#ffffff' }, + { id: 'steps', label: 'Steps', type: 'slider', min: 2, max: 12, step: 1, default: 6 }, + ...paletteParams('#808080').filter((q) => q.id !== 'baseColor') + ], + generate: (p) => + renderSwatches( + stepColors(str(p, 'colorA'), str(p, 'colorB'), num(p, 'steps')), + num(p, 'width'), + str(p, 'layout') as 'strip' | 'grid' + ) + }, + { + id: 'sort-colors-png', + title: 'Sort Colors PNG', + description: + 'Renders your hex list as swatches sorted by hue, brightness or saturation. Invalid tokens are skipped.', + category: 'generate', + sourceMode: 'none', + params: [ + { + id: 'colors', + label: 'Colors (comma-separated hex)', + type: 'text', + default: '#ff0000,#ff8800,#ffff00,#00cc44,#0066ff,#8800ff' + }, + { + id: 'order', + label: 'Sort by', + type: 'select', + default: 'hue', + options: [ + { value: 'hue', label: 'Hue' }, + { value: 'luma', label: 'Brightness' }, + { value: 'sat', label: 'Saturation' } + ] + }, + ...paletteParams('#ffffff').filter((q) => q.id !== 'baseColor') + ], + generate: (p) => + renderSwatches( + sortPalette(parseHexList(str(p, 'colors')), str(p, 'order') as SortKey), + num(p, 'width'), + str(p, 'layout') as 'strip' | 'grid' + ) + }, + { + id: 'add-text-png', title: 'Add text to PNG', description: 'Draws a text label on the image: font, size, color, bold, position on a 3×3 grid and an optional backing plate.', diff --git a/web/src/lib/tools/tool-icons.ts b/web/src/lib/tools/tool-icons.ts index dcdd78e..11f576e 100644 --- a/web/src/lib/tools/tool-icons.ts +++ b/web/src/lib/tools/tool-icons.ts @@ -101,6 +101,17 @@ export const TOOL_ICONS: Record = { 'date-stamp-png': CalendarDays, 'watermark-tile-png': Stamp, 'watermark-image-png': FileImage, + 'color-wheel-png': Rainbow, + 'complementary-png': Contrast, + 'triadic-png': Hash, + 'tetradic-png': Grid3x3, + 'analogous-png': Blend, + 'monochromatic-png': Droplets, + 'shades-png': Sun, + 'mix-colors-png': PaintBucket, + 'blend-two-png': Layers, + 'step-colors-png': Scaling, + 'sort-colors-png': SearchCheck, 'find-contour-png': Scan, 'make-thicker-png': ZoomIn, 'make-thinner-png': ZoomOut,