/* =================================================================== DIE KACHELFARBEN -- gerechnet, nicht gewaehlt. ANLASS (08.09.2026). Filipe: "viele kacheln haben noch fast die gleiche farben, ähneln sich sehr ... alle seine eigenen farben und so dass sie sich nicht ähneln. sehr wichtig nicht ähneln!!!!" NEU GERECHNET AM 17.09.2026, und der Anlass war derselbe Satz noch einmal: "ich will das jede kachel eine andere farbe hat ... keine die sich irgendwie ähnlich sind ... die farben sollen auch richtig geil und speziell sein." ER HATTE WIEDER RECHT, UND ES WAR SCHLIMMER ALS 2026-09-08. Gemessen am 17.09. an den Farben, die WIRKLICH in start.css stehen: kleinster Abstand 0,0529 (Rueckmeldung / Steckbrief) Paare unter 0,10 18 von 325 Paare unter 0,15 61 von 325 schlechtester Kontrast 1,64:1 (Ton 35, praktisch unlesbar) sieben Toene unter 4,5:1 WIE KONNTE DAS PASSIEREN, obwohl es dieses Werkzeug schon gab? Weil es nur 21 Farben kannte. Seit dem 08.09. sind SECHZEHN Toene von Hand dazugekommen (22 bis 37) -- jeder einzeln plausibel gewaehlt, keiner gegen die anderen gerechnet. Das Werkzeug meldete weiter "0,0973, alle Bedingungen erfuellt" und meinte damit einen Stand, den es nicht mehr gab. Das ist dieselbe Krankheit wie eine abgeschriebene Spaltenliste: Die Zahl wird nicht falsch, sie wird UNZUSTAENDIG. Deshalb liest diese Fassung die Anzahl jetzt aus den Dateien, statt sie zu kennen. --------------------------------------------------------------------- GERECHNET WIRD IN OKLAB. Dort entspricht der Zahlenabstand dem, was das Auge als Unterschied empfindet -- in RGB tut er das nicht. VIER BEDINGUNGEN, die jede Farbe halten muss: 1. LESBAR. Mindestens 4,5:1 gegen den dunklen Grund (#0a121e). Eine Kachelfarbe traegt auch Text und Zahlen. Das ist die einzige Bedingung, die nicht verhandelbar ist. 2. SPEZIELL (neu am 17.09.). Die Obergrenze der Buntheit steigt von 0,17 auf 0,30. Filipe: "richtig geil und speziell". Augenschonend heisst dunkles Schema, kein Flackern, kein Blenden -- NICHT blass. Eine satte Farbe auf dunklem Grund blendet niemanden; der Ton 5 (Magenta, Buntheit 0,276) steht seit dem 09.09. auf seinen ausdruecklichen Wunsch genau so da. 3. TRENNBAR. Der kleinste Abstand ZWISCHEN ALLEN PAAREN wird so gross wie moeglich gemacht, nicht nur zwischen Nachbarn im Raster -- die Reihenfolge ist auf jeder Seite anders. 4. NICHT SCHLECHTER ALS VORHER. Das Werkzeug bricht ab, wenn das Ergebnis den alten Stand unterbietet. Aufrufen mit: node tools/kachel-farben.mjs node tools/kachel-farben.mjs --schreiben =================================================================== */ import { readFileSync, writeFileSync } from "node:fs"; const CSS = "workspace/assets/css/start.css"; const GRUND = "#0a121e"; /* ---------- Farbrechnung -------------------------------------------- */ const linear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); const zurueck = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055); const srgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); function oklab(hex) { const [r, g, b] = srgb(hex).map(linear); const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); return [ 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s, ]; } /** OKLCH -> Hex, oder null wenn es ausserhalb von sRGB liegt. */ function ausOklch(L, C, Hgrad) { const h = (Hgrad * Math.PI) / 180; const a = C * Math.cos(h), b2 = C * Math.sin(h); const l_ = (L + 0.3963377774 * a + 0.2158037573 * b2) ** 3; const m_ = (L - 0.1055613458 * a - 0.0638541728 * b2) ** 3; const s_ = (L - 0.0894841775 * a - 1.2914855480 * b2) ** 3; const r = +4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_; const g = -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_; const b = -0.0041960863 * l_ - 0.7034186147 * m_ + 1.7076147010 * s_; const kanal = (x) => { const v = zurueck(x); /* Winzige Rundungsfehler sind kein Grund, eine Farbe zu verwerfen -- ein echtes Ueberschreiten schon. */ if (v < -0.001 || v > 1.001) return null; return Math.round(Math.min(1, Math.max(0, v)) * 255); }; const [R, G, B] = [kanal(r), kanal(g), kanal(b)]; if (R === null || G === null || B === null) return null; return "#" + [R, G, B].map((x) => x.toString(16).padStart(2, "0")).join(""); } const lum = (hex) => { const [r, g, b] = srgb(hex).map(linear); return 0.2126 * r + 0.7152 * g + 0.0722 * b; }; const kontrast = (hex) => (lum(hex) + 0.05) / (lum(GRUND) + 0.05); const abstand = (a, b) => { const x = a.lab || oklab(a.hex || a), y = b.lab || oklab(b.hex || b); return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]); }; const kleinsterAbstand = (menge) => { let m = Infinity; for (let i = 0; i < menge.length; i++) { for (let j = i + 1; j < menge.length; j++) m = Math.min(m, abstand(menge[i], menge[j])); } return m; }; /* ---------- WIE VIELE FARBEN? Aus der Datei, nicht aus dem Kopf ------ Genau hier ist das Werkzeug beim letzten Mal unzustaendig geworden: Es kannte 21, und niemand hat es angefasst, als sechzehn dazukamen. Eine Zahl, die niemand pflegt, kann nicht veralten. */ const cssText = readFileSync(CSS, "utf8"); const VORHER = new Map( [...cssText.matchAll(/\[data-ton="(\d+)"\]\s*\{\s*--ton:\s*(#[0-9a-fA-F]{6})/g)] .map((m) => [Number(m[1]), m[2].toLowerCase()])); const NUMMERN = [...VORHER.keys()].sort((a, b) => a - b); /* Und wofuer steht jede Nummer? Der Kommentar dahinter sagt es -- er wird uebernommen, damit die Liste lesbar bleibt. */ const WOFUER = new Map( [...cssText.matchAll(/\[data-ton="(\d+)"\][^\n]*?\/\*\s*([^*]+?)\s*\*\//g)] .map((m) => [Number(m[1]), m[2].trim()])); /* ---------- Kandidaten ---------------------------------------------- */ const KANDIDATEN = []; /* FEIN ABGETASTET. Mit 2-Grad-Schritten und fuenf Helligkeitsstufen kam die Packung auf 0,0937; das Raster war der Engpass, nicht der Raum. Jedes Kandidatenkorn mehr gibt der Optimierung eine Stelle, an der sie ausweichen kann. Nach UNTEN ist bei L = 0,56 Schluss -- darunter faellt der Kontrast gegen den dunklen Grund unter 4,5:1, und das ist die einzige Bedingung, die nicht verhandelbar ist. */ for (let H = 0; H < 360; H += 1) { for (let L = 0.56; L <= 0.94; L += 0.02) { /* Die groesste darstellbare Buntheit suchen, von oben herunter -- damit die Farbe so satt wie erlaubt wird und nicht blass. */ for (let C = 0.30; C >= 0.04; C -= 0.004) { const hex = ausOklch(L, C, H); if (!hex) continue; if (kontrast(hex) < 4.5) break; KANDIDATEN.push({ hex, lab: oklab(hex), L, C, H }); break; } } } /* ---------- Auswahl: den kleinsten Abstand so gross wie moeglich ----- Erst gierig (nimm immer die Farbe, die am weitesten von allen schon gewaehlten weg ist), dann tauschen, solange es besser wird. MEHRERE STARTPUNKTE. Ein gieriger Anfang haengt davon ab, wo er beginnt -- und beim letzten Mal wurde genau EIN Startpunkt probiert (die dunkelste Farbe). Jetzt laufen viele, und der beste gewinnt. Das kostet Sekunden und bringt hier rund ein Drittel mehr Abstand. */ function auswahl(startIndex, anzahl) { const gewaehlt = [KANDIDATEN[startIndex]]; while (gewaehlt.length < anzahl) { let beste = null, besterWert = -1; for (const k of KANDIDATEN) { if (gewaehlt.includes(k)) continue; let naechster = Infinity; for (const g of gewaehlt) { const d = abstand(k, g); if (d < naechster) naechster = d; if (naechster <= besterWert) break; // kann nicht mehr gewinnen } if (naechster > besterWert) { besterWert = naechster; beste = k; } } gewaehlt.push(beste); } return gewaehlt; } function verbessern(menge) { let lauf = true; while (lauf) { lauf = false; for (let i = 0; i < menge.length; i++) { const jetzt = kleinsterAbstand(menge); for (const k of KANDIDATEN) { if (menge.includes(k)) continue; const probe = menge.slice(); probe[i] = k; if (kleinsterAbstand(probe) > jetzt) { menge = probe; lauf = true; break; } } } } return menge; } const ANZAHL = NUMMERN.length; console.log(`${ANZAHL} Farben gesucht, ${KANDIDATEN.length} Kandidaten im Raum.`); let beste = null, besterWert = -1; const starts = 16; for (let i = 0; i < starts; i++) { const idx = Math.floor((i / starts) * KANDIDATEN.length); const m = verbessern(auswahl(idx, ANZAHL)); const w = kleinsterAbstand(m); if (w > besterWert) { besterWert = w; beste = m; } process.stdout.write(`\r Startpunkt ${i + 1}/${starts} -- bester Abstand ${besterWert.toFixed(4)} `); } console.log(""); /* ---------- Zuteilung: welche Farbe an welche Nummer? ---------------- Die Nummern bleiben an ihren Kacheln -- eine Farbe gehoert zu einem Bereich, nicht zu einem Platz im Raster. Damit die Aenderung nicht groesser wird als noetig, bekommt jede Nummer die neue Farbe, die ihrer alten am naechsten liegt: Wer "Chat" als Orange kennt, findet es danach wieder als Orange. */ const frei = beste.slice(); const zuteilung = new Map(); for (const nr of NUMMERN) { const alt = VORHER.get(nr); let bester = 0, bestAbw = Infinity; for (let i = 0; i < frei.length; i++) { const d = abstand(frei[i].hex, alt); if (d < bestAbw) { bestAbw = d; bester = i; } } zuteilung.set(nr, frei.splice(bester, 1)[0]); } /* ---------- Bericht -------------------------------------------------- */ const altListe = NUMMERN.map((n) => ({ hex: VORHER.get(n), lab: oklab(VORHER.get(n)) })); const altKlein = kleinsterAbstand(altListe); const altUnter10 = (() => { let n = 0; for (let i = 0; i < altListe.length; i++) { for (let j = i + 1; j < altListe.length; j++) if (abstand(altListe[i], altListe[j]) < 0.10) n++; } return n; })(); const altKontrast = Math.min(...altListe.map((x) => kontrast(x.hex))); const neuListe = NUMMERN.map((n) => zuteilung.get(n)); const neuUnter10 = (() => { let n = 0; for (let i = 0; i < neuListe.length; i++) { for (let j = i + 1; j < neuListe.length; j++) if (abstand(neuListe[i], neuListe[j]) < 0.10) n++; } return n; })(); const neuKontrast = Math.min(...neuListe.map((x) => kontrast(x.hex))); const paare = (ANZAHL * (ANZAHL - 1)) / 2; console.log(""); console.log(`ALT: kleinster Abstand ${altKlein.toFixed(4)} · ${altUnter10} von ${paare} Paaren unter 0,10` + ` · schlechtester Kontrast ${altKontrast.toFixed(2)}:1`); console.log(`NEU: kleinster Abstand ${besterWert.toFixed(4)} · ${neuUnter10} von ${paare} Paaren unter 0,10` + ` · schlechtester Kontrast ${neuKontrast.toFixed(2)}:1`); console.log(` Buntheit ${Math.min(...neuListe.map((x) => x.C)).toFixed(3)}` + ` bis ${Math.max(...neuListe.map((x) => x.C)).toFixed(3)}` + ` · Helligkeit ${Math.min(...neuListe.map((x) => x.L)).toFixed(2)}` + ` bis ${Math.max(...neuListe.map((x) => x.L)).toFixed(2)}`); /* BEDINGUNG 4: nicht schlechter als vorher. Ein Werkzeug, das auch Verschlechterungen ausgibt, ist eines, dem man beim naechsten Mal nicht mehr glaubt. */ if (besterWert <= altKlein || neuKontrast < 4.5) { console.error("\nABBRUCH: Das Ergebnis waere nicht besser als der alte Stand."); process.exit(1); } const zeilen = NUMMERN.map((n) => { const f = zuteilung.get(n); const was = WOFUER.get(n) ? ` /* ${WOFUER.get(n)} */` : ""; return `[data-ton="${n}"]`.padEnd(16) + `{ --ton: ${f.hex}; }${was}`; }); console.log("\n" + zeilen.join("\n")); console.log(`\n${ANZAHL} Farben, alle Bedingungen erfuellt.`); if (process.argv.includes("--schreiben")) { let neu = cssText; for (const n of NUMMERN) { const f = zuteilung.get(n); neu = neu.replace( new RegExp(`(\\[data-ton="${n}"\\]\\s*\\{\\s*--ton:\\s*)#[0-9a-fA-F]{6}`), `$1${f.hex}`); } writeFileSync(CSS, neu); console.log(`\nIn ${CSS} eingetragen.`); }