329 lines
11 KiB
TypeScript
329 lines
11 KiB
TypeScript
// =============================================================================
|
|
// 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<HarmonyRule, string> = {
|
|
complementary: 'Complementary (opposite, ~180°)',
|
|
analogous: 'Analogous (neighbors, ±30°)',
|
|
triadic: 'Triadic (three-way, ±120°)',
|
|
'split-complementary': 'Split-Complementary (offset, ±150°)',
|
|
none: 'No harmony detected',
|
|
};
|