/* Kontrastrechnung nach WCAG 2.1 — dieselbe wie in der App. ---------------------------------------------------------------------------- Halo lässt jede Farbe zu, aber ein dunkles Marineblau auf fast schwarzem Grund ist als Schrift unlesbar. Solche Farben werden angehoben, bis sie den geforderten Kontrast erreichen — und es wird gesagt, statt die Farbe stillschweigend zu verändern. Zuerst die Helligkeit, weil das den Farbton erhält: eine gewählte Akzentfarbe soll erkennbar dieselbe bleiben. Reicht selbst volle Helligkeit nicht, wird die Sättigung zurückgenommen, und die Farbe wandert Richtung Weiß. */ export type RGB = [number, number, number]; const MINDESTENS = 4.5; export function leuchtdichte([r, g, b]: RGB): number { // Der Knick bei 0,03928 ist kein Rundungsfehler, sondern Teil der // sRGB-Definition: unterhalb davon ist die Kurve linear. const linear = (c: number): number => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b); } export function verhaeltnis(a: RGB, b: RGB): number { const la = leuchtdichte(a); const lb = leuchtdichte(b); return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); } export function lesbarMachen(farbe: RGB, grund: RGB): { farbe: RGB; geaendert: boolean } { if (verhaeltnis(farbe, grund) >= MINDESTENS) return { farbe, geaendert: false }; const [h, s, v] = zuHSV(farbe); // Beide Schritte sind monoton — mehr Helligkeit und weniger Sättigung // erhöhen die Leuchtdichte immer. Deshalb genügt eine binäre Suche, und // sie endet garantiert. const suche = (tief: number, hoch: number, bauen: (x: number) => RGB): RGB | null => { if (verhaeltnis(bauen(hoch), grund) < MINDESTENS) return null; let u = tief; let o = hoch; for (let i = 0; i < 24; i++) { const mitte = (u + o) / 2; if (verhaeltnis(bauen(mitte), grund) >= MINDESTENS) o = mitte; else u = mitte; } return bauen(o); }; const heller = suche(v, 1, (x) => ausHSV(h, s, x)); if (heller) return { farbe: heller, geaendert: true }; const blasser = suche(s, 0, (x) => ausHSV(h, x, 1)); return { farbe: blasser ?? [1, 1, 1], geaendert: true }; } function zuHSV([r, g, b]: RGB): RGB { const max = Math.max(r, g, b); const min = Math.min(r, g, b); const d = max - min; let h = 0; if (d) { if (max === r) h = ((g - b) / d) % 6; else if (max === g) h = (b - r) / d + 2; else h = (r - g) / d + 4; h /= 6; if (h < 0) h += 1; } return [h, max ? d / max : 0, max]; } function ausHSV(h: number, s: number, v: number): RGB { const i = Math.floor(h * 6); const f = h * 6 - i; const p = v * (1 - s); const q = v * (1 - f * s); const t = v * (1 - (1 - f) * s); const saetze: RGB[] = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]]; return saetze[i % 6]!; } export function ausHex(hex: string): RGB | null { const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); if (!m) return null; const n = parseInt(m[1]!, 16); return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]; } export function alsHex([r, g, b]: RGB): string { return ( '#' + [r, g, b] .map((c) => Math.round(Math.min(Math.max(c, 0), 1) * 255).toString(16).padStart(2, '0')) .join('') .toUpperCase() ); }