// ============================================================================= // Color harmony utilities — pure functions, no Angular dependencies // ============================================================================= // ── Types ──────────────────────────────────────────────────────────────────── export type HarmonyRule = 'complementary' | 'analogous' | 'triadic' | 'split-complementary' | 'none'; export interface HSL { h: number; // 0-360 s: number; // 0-100 l: number; // 0-100 } export interface ColorValidation { isHarmonious: boolean; suggestedHex?: string; deviation: number; // degrees from expected hue } // ── Hex ↔ HSL conversion ──────────────────────────────────────────────────── export function hexToHSL(hex: string): HSL { hex = hex.replace('#', ''); if (hex.length === 3) { hex = hex.split('').map(c => c + c).join(''); } const r = parseInt(hex.substring(0, 2), 16) / 255; const g = parseInt(hex.substring(2, 4), 16) / 255; const b = parseInt(hex.substring(4, 6), 16) / 255; const max = Math.max(r, g, b); const min = Math.min(r, g, b); const l = (max + min) / 2; if (max === min) { return { h: 0, s: 0, l: Math.round(l * 100) }; } const d = max - min; const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); let h = 0; if (max === r) { h = ((g - b) / d + (g < b ? 6 : 0)) * 60; } else if (max === g) { h = ((b - r) / d + 2) * 60; } else { h = ((r - g) / d + 4) * 60; } return { h: Math.round(h), s: Math.round(s * 100), l: Math.round(l * 100), }; } export function hslToHex(h: number, s: number, l: number): string { h = ((h % 360) + 360) % 360; s = Math.max(0, Math.min(100, s)) / 100; l = Math.max(0, Math.min(100, l)) / 100; const c = (1 - Math.abs(2 * l - 1)) * s; const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); const m = l - c / 2; let r = 0, g = 0, b = 0; if (h < 60) { r = c; g = x; b = 0; } else if (h < 120) { r = x; g = c; b = 0; } else if (h < 180) { r = 0; g = c; b = x; } else if (h < 240) { r = 0; g = x; b = c; } else if (h < 300) { r = x; g = 0; b = c; } else { r = c; g = 0; b = x; } const toHex = (v: number) => { const hex = Math.round((v + m) * 255).toString(16); return hex.length === 1 ? '0' + hex : hex; }; return `#${toHex(r)}${toHex(g)}${toHex(b)}`; } // ── Variant generation ────────────────────────────────────────────────────── /** Generate a lighter variant (+20% lightness). */ export function generateLight(hex: string): string { const { h, s, l } = hexToHSL(hex); return hslToHex(h, s, Math.min(95, l + 20)); } /** Generate a darker variant (-20% lightness). */ export function generateDark(hex: string): string { const { h, s, l } = hexToHSL(hex); return hslToHex(h, s, Math.max(5, l - 20)); } /** Generate a muted variant (-20% saturation). */ export function generateMuted(hex: string): string { const { h, s, l } = hexToHSL(hex); return hslToHex(h, Math.max(0, s - 20), l); } // ── Harmony detection ─────────────────────────────────────────────────────── /** * Detect the harmony rule between two hues. * Returns the best-fit rule or 'none' if the colors are too close or unrelated. */ export function detectHarmonyRule(primaryHex: string, accentHex: string): HarmonyRule { const p = hexToHSL(primaryHex); const a = hexToHSL(accentHex); // If either color is near-gray (low saturation), harmony doesn't apply if (p.s < 10 || a.s < 10) return 'none'; const diff = hueDistance(p.h, a.h); // Tolerance in degrees for each rule const rules: Array<{ rule: HarmonyRule; target: number; tolerance: number }> = [ { rule: 'complementary', target: 180, tolerance: 20 }, { rule: 'split-complementary', target: 150, tolerance: 15 }, { rule: 'triadic', target: 120, tolerance: 15 }, { rule: 'analogous', target: 30, tolerance: 15 }, ]; for (const { rule, target, tolerance } of rules) { if (Math.abs(diff - target) <= tolerance) { return rule; } } return 'none'; } /** Calculate the shortest distance between two hues (0-180). */ function hueDistance(h1: number, h2: number): number { const diff = Math.abs(h1 - h2); return Math.min(diff, 360 - diff); } // ── Validation ────────────────────────────────────────────────────────────── /** * Check if a color's hue is harmonious given the primary hue and harmony rule. * Neutrals (saturation < 10) are always considered harmonious. */ export function validateColor( colorHex: string, primaryHex: string, rule: HarmonyRule, tolerance: number = 15, ): ColorValidation { const color = hexToHSL(colorHex); const primary = hexToHSL(primaryHex); // Neutrals are always harmonious if (color.s < 10) { return { isHarmonious: true, deviation: 0 }; } // No rule = can't validate if (rule === 'none') { return { isHarmonious: true, deviation: 0 }; } const expectedHues = getExpectedHues(primary.h, rule); let minDeviation = 360; for (const expected of expectedHues) { const dev = hueDistance(color.h, expected); if (dev < minDeviation) minDeviation = dev; } const isHarmonious = minDeviation <= tolerance; return { isHarmonious, deviation: minDeviation, suggestedHex: isHarmonious ? undefined : suggestHarmoniousColor( colorHex, primaryHex, rule, ), }; } /** Get the expected hues for a given harmony rule relative to the primary hue. */ function getExpectedHues(primaryHue: number, rule: HarmonyRule): number[] { switch (rule) { case 'complementary': return [primaryHue, (primaryHue + 180) % 360]; case 'analogous': return [primaryHue, (primaryHue + 30) % 360, (primaryHue - 30 + 360) % 360]; case 'triadic': return [primaryHue, (primaryHue + 120) % 360, (primaryHue + 240) % 360]; case 'split-complementary': return [primaryHue, (primaryHue + 150) % 360, (primaryHue + 210) % 360]; default: return [primaryHue]; } } /** * Suggest a harmonious color by adjusting the hue to the nearest expected hue, * preserving the original saturation and lightness. */ export function suggestHarmoniousColor( colorHex: string, primaryHex: string, rule: HarmonyRule, ): string { const color = hexToHSL(colorHex); const expectedHues = getExpectedHues(hexToHSL(primaryHex).h, rule); // Find the closest expected hue let closestHue = expectedHues[0]; let closestDist = 360; for (const hue of expectedHues) { const dist = hueDistance(color.h, hue); if (dist < closestDist) { closestDist = dist; closestHue = hue; } } return hslToHex(closestHue, color.s, color.l); } // ── Palette-level operations ──────────────────────────────────────────────── /** * Color role definitions for the theme palette. * Each role maps to a StationConfig color field. */ export interface ColorRole { field: string; label: string; group: 'primary' | 'accent' | 'neutral' | 'semantic'; } export const COLOR_ROLES: readonly ColorRole[] = [ { field: 'color_primary', label: 'Primary', group: 'primary' }, { field: 'color_primary_light', label: 'Primary Light', group: 'primary' }, { field: 'color_primary_dark', label: 'Primary Dark', group: 'primary' }, { field: 'color_primary_muted', label: 'Primary Muted', group: 'primary' }, { field: 'color_accent', label: 'Accent', group: 'accent' }, { field: 'color_accent_light', label: 'Accent Light', group: 'accent' }, { field: 'color_accent_dark', label: 'Accent Dark', group: 'accent' }, { field: 'color_accent_muted', label: 'Accent Muted', group: 'accent' }, { field: 'color_background', label: 'Background', group: 'neutral' }, { field: 'color_text', label: 'Text', group: 'neutral' }, { field: 'color_success', label: 'Success', group: 'semantic' }, { field: 'color_danger', label: 'Danger', group: 'semantic' }, { field: 'color_info', label: 'Info', group: 'semantic' }, { field: 'color_white', label: 'White', group: 'neutral' }, { field: 'color_light', label: 'Light', group: 'neutral' }, { field: 'color_medium', label: 'Medium', group: 'neutral' }, { field: 'color_black', label: 'Black', group: 'neutral' }, ] as const; /** * Validate an entire color palette against the detected harmony rule. * Returns a map of field → validation result. */ export function validatePalette( colors: { [key: string]: string }, rule: HarmonyRule, ): { [key: string]: ColorValidation } { const primary = colors['color_primary'] || '#1a3a5c'; const result: { [key: string]: ColorValidation } = {}; for (const role of COLOR_ROLES) { const hex = colors[role.field]; if (!hex) { result[role.field] = { isHarmonious: true, deviation: 0 }; continue; } // Neutrals are always harmonious if (role.group === 'neutral') { result[role.field] = { isHarmonious: true, deviation: 0 }; continue; } // Semantic colors get wider tolerance const tolerance = role.group === 'semantic' ? 30 : 15; result[role.field] = validateColor(hex, primary, rule, tolerance); } return result; } /** * Auto-fix the entire palette by adjusting non-harmonious colors. * Returns a new colors record with corrections applied. */ export function autoFixPalette( colors: { [key: string]: string }, rule: HarmonyRule, ): { [key: string]: string } { const fixed = { ...colors }; const primary = fixed['color_primary'] || '#1a3a5c'; for (const role of COLOR_ROLES) { if (role.group === 'neutral') continue; const hex = fixed[role.field]; if (!hex) continue; const validation = validateColor(hex, primary, rule, role.group === 'semantic' ? 30 : 15); if (!validation.isHarmonious && validation.suggestedHex) { fixed[role.field] = validation.suggestedHex; } } return fixed; } // ── Harmony rule labels ───────────────────────────────────────────────────── export const HARMONY_RULE_LABELS: Record = { complementary: 'Complementary (opposite, ~180°)', analogous: 'Analogous (neighbors, ±30°)', triadic: 'Triadic (three-way, ±120°)', 'split-complementary': 'Split-Complementary (offset, ±150°)', none: 'No harmony detected', };