// Палитра цветов-меток проекта (0.63): ~100 оттенков, выведенных из 8 базовых
// цветов кита. Порядок стабилен — по нему строится сетка выбора, поэтому цвета
// генерируем детерминированно, а не держим списком из сотни hex-литералов.
// В БД уходит HEX #rrggbb (бэкенд проверяет только формат, не палитру).
/** Базовые цвета кита (gnexus-ui-kit: accent/info/success/warning/danger…). */
export const BASE_COLORS = [
'#7aa2f7', // синий
'#7dcfff', // голубой
'#2ac3de', // циан
'#bb9af7', // сиреневый
'#9ece6a', // зелёный
'#e0af68', // песочный
'#f7768e', // розово-красный
'#ff9e64', // оранжевый
] as const
// Ступени светлоты (в процентах, абсолютные — колонки сетки сопоставимы между
// рядами) и множители насыщенности. Светлота держится в 36..84%, иначе оттенок
// нечитаем на тёмной подложке кита.
const LIGHT_LEVELS = [78, 70, 62, 54, 46, 38] as const
const SAT_STEPS = [1, 0.72] as const
function clamp(value: number, low: number, high: number): number {
return Math.min(high, Math.max(low, value))
}
/** #rrggbb → [h, s, l] (h 0..360, s/l 0..100). */
function hexToHsl(hex: string): [number, number, number] {
const r = parseInt(hex.slice(1, 3), 16) / 255
const g = parseInt(hex.slice(3, 5), 16) / 255
const b = parseInt(hex.slice(5, 7), 16) / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const l = (max + min) / 2
const d = max - min
if (d === 0) return [0, 0, l * 100]
const s = d / (1 - Math.abs(2 * l - 1))
let h: number
if (max === r) h = ((g - b) / d) % 6
else if (max === g) h = (b - r) / d + 2
else h = (r - g) / d + 4
return [(((h * 60) % 360) + 360) % 360, s * 100, l * 100]
}
/** [h, s, l] → #rrggbb в нижнем регистре (как нормализует бэкенд). */
function hslToHex(h: number, s: number, l: number): string {
const sat = clamp(s, 0, 100) / 100
const light = clamp(l, 36, 84) / 100
const c = (1 - Math.abs(2 * light - 1)) * sat
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
const m = light - c / 2
const [r, g, b] =
h < 60 ? [c, x, 0]
: h < 120 ? [x, c, 0]
: h < 180 ? [0, c, x]
: h < 240 ? [0, x, c]
: h < 300 ? [x, 0, c]
: [c, 0, x]
const channel = (v: number) => Math.round((v + m) * 255).toString(16).padStart(2, '0')
return `#${channel(r)}${channel(g)}${channel(b)}`
}
// Грубые «корзины» по каналам: соседние ступени дают почти одинаковые цвета
// (у насыщенных базовых насыщенность упирается в предел) — такие дубликаты
// в сетку не берём, ряды остаются различимыми глазом.
function bucket(hex: string): string {
return [1, 3, 5].map((i) => Math.round(parseInt(hex.slice(i, i + 2), 16) / 14)).join('-')
}
/** Оттенки одного базового цвета — строка сетки выбора (сам базовый первым). */
function shadeRow(base: string): string[] {
// светлоту базового не берём: ступени заданы абсолютно, чтобы колонки рядов совпадали
const [h, s] = hexToHsl(base)
const out = [base]
const seen = new Set([bucket(base)])
for (const level of LIGHT_LEVELS) {
for (const k of SAT_STEPS) {
// насыщенность не ниже 30% — иначе оттенок выглядит серым
const hex = hslToHex(h, Math.max(s * k, 30), level)
const key = bucket(hex)
if (seen.has(key)) continue
seen.add(key)
out.push(hex)
}
}
return out
}
export interface PaletteGroup {
base: string
shades: string[]
}
/** Сетка палитры: 8 рядов по базовым цветам кита, ~100 оттенков всего. */
export const PALETTE_GROUPS: PaletteGroup[] = BASE_COLORS.map((base) => ({
base,
shades: shadeRow(base),
}))
/** Плоский список всех оттенков (проверка «цвет из палитры»). */
export const PALETTE: string[] = PALETTE_GROUPS.flatMap((group) => group.shades)
/** Цвет пришёл из палитры? (произвольный #rrggbb тоже валиден — показываем отдельно) */
export function isKnownColor(color: string | null): boolean {
return !!color && PALETTE.includes(color.toLowerCase())
}