/* ===================================================================== EINE KACHELFARBE NACHZIEHEN — ohne die anderen anzufassen (19.09.2026) --------------------------------------------------------------------- WOZU ES DAS BRAUCHT, OBWOHL kachel-farben.mjs EXISTIERT Das große Werkzeug rechnet ALLE Töne gegeneinander neu. Das ist die richtige Rechnung und das falsche Mittel, wenn eine einzige Kachel dazukommt: Am 19.09.2026 nachgemessen hätte ein Lauf für die 40. Farbe alle 39 anderen verändert — jede Kachel im Haus bekäme eine neue Farbe, ohne dass jemand danach gefragt hat. Dieses Werkzeug macht das Gegenteil: Es hält alle vorhandenen Töne fest und sucht für EINEN die Farbe, die am weitesten von allen anderen entfernt ist. Gemessen am selben Tag: Für die neue Kachel „Entwicklung“ fand es einen Abstand von 0,0988 — fast das Dreifache des engsten Paares, das im Haus ohnehin schon stand (0,0362 zwischen Ton 15 und 39). Ein Gesamtneuanstrich war also gar nicht nötig. --------------------------------------------------------------------- DIE VIER BEDINGUNGEN sind dieselben wie im großen Werkzeug: 1. LESBAR. Mindestens 4,5:1 gegen den dunklen Grund (#0a121e). Nicht verhandelbar — eine Kachelfarbe trägt auch Text. 2. SPECIELL. Buntheit bis 0,30. Augenschonend heißt dunkles Schema, kein Flackern — NICHT blass. 3. TRENNBAR. Der Abstand zur nächstliegenden vorhandenen Farbe wird so groß wie möglich gemacht. Gerechnet in OKLab, weil dort der Zahlenabstand dem entspricht, was das Auge sieht. 4. NICHT SCHLECHTER ALS VORHER. Findet sich nichts Besseres, bleibt die alte Farbe stehen — und das Werkzeug sagt es. --------------------------------------------------------------------- UND DIE FÜNFTE, DIE HIER DAZUKOMMT: ES MUSS ALLES LESEN KÖNNEN Der erste Anlauf dieser Datei las 31 von 40 Tönen und rechnete fröhlich weiter. Der Grund: Einstellige Nummern stehen in start.css mit ZWEI Leerzeichen da (`[data-ton="1"] { …`), zur Ausrichtung. Das Ergebnis war eine plausible, falsche Farbe — sie hätte mit einer der neun ungelesenen kollidieren können, ohne dass es auffällt. Deshalb bricht es jetzt ab, wenn die Zahl der gelesenen Töne nicht zur Zahl der vorhandenen passt. Das ist der dritte Ausgang: in Ordnung / nicht in Ordnung / KONNTE NICHT NACHSEHEN, mit Grund. --------------------------------------------------------------------- AUFRUF node tools/kachel-farbe-einzeln.mjs 40 nur zeigen node tools/kachel-farbe-einzeln.mjs 40 --schreiben eintragen node tools/kachel-farbe-einzeln.mjs --pruefen nur nachmessen Mehrere Nummern gehen auch: `… 38 39 --schreiben` zieht sie NACHEINANDER nach, jede gegen den Stand INKLUSIVE der vorherigen. ===================================================================== */ import { readFileSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { GRUND, MINDEST_BUNTHEIT, HOECHST_BUNTHEIT, MINDEST_KONTRAST } from "./kachelton-regeln.mjs"; const HIER = dirname(fileURLToPath(import.meta.url)); const CSS = join(HIER, "..", "workspace", "assets", "css", "start.css"); /* ---------- Farbrechnung (gleich wie in kachel-farben.mjs) ---------- */ 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, ]; } 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); 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) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); /* ---------- Lesen, und zwar vollstaendig ---------------------------- */ /** Alle Toene als {nummer, hex, lab, zeile}. * * `\s*` UND NICHT EIN LEERZEICHEN: Einstellige Nummern stehen mit zwei * Leerzeichen da. Genau daran ist der erste Anlauf gescheitert. */ function lies(css) { const aus = []; for (const zeile of css.split("\n")) { const m = zeile.match(/\[data-ton="(\d+)"\]\s*\{\s*--ton:\s*(#[0-9a-fA-F]{6})/); if (!m) continue; const wofuer = (zeile.match(/\/\*\s*([^*]+?)\s*\*\//) || [])[1] || ""; aus.push({ n: Number(m[1]), hex: m[2].toLowerCase(), lab: oklab(m[2]), zeile, wofuer }); } return aus; } /** Bricht ab, wenn nicht jeder vorhandene Ton auch gelesen wurde. */ function vollstaendigOderAbbruch(css) { const roh = new Set([...css.matchAll(/\[data-ton="(\d+)"\]/g)].map((m) => Number(m[1]))); const gelesen = lies(css); const nummern = gelesen.map((t) => t.n); if (roh.size !== nummern.length) { console.error(`ABBRUCH: ${roh.size} Toene stehen in der Datei, gelesen wurden ${nummern.length}.`); console.error(" nicht gelesen: " + [...roh].filter((n) => !nummern.includes(n)).join(", ")); console.error(" Wahrscheinlich hat sich die Schreibweise der Zeile geaendert."); process.exit(2); } return gelesen; } function engstesPaar(liste) { let best = { d: Infinity, a: null, b: null }; for (let i = 0; i < liste.length; i++) { for (let j = i + 1; j < liste.length; j++) { const d = abstand(liste[i].lab, liste[j].lab); if (d < best.d) best = { d, a: liste[i], b: liste[j] }; } } return best; } /** Die Farbe mit dem groessten Abstand zu allen uebergebenen. */ function beste(andere) { let treffer = null, wert = -1; for (let H = 0; H < 360; H += 1) { for (let L = 0.56; L <= 0.94; L += 0.01) { /* Von oben herunter, damit die Farbe so satt wie erlaubt wird und nicht blass -- Filipe: „richtig geil und speziell“. */ /* DIE UNTERE GRENZE IST NICHT 0,04, SONDERN DIE DER PRUEFUNG. Bis zum 22.09.2026 lief die Suche bis 0,04 herunter -- und lieferte fuer Ton 43 ein blasses #bcdbff mit Buntheit 0,060. Abstand 0,1339, der beste Wert weit und breit, und pruef-kachelfarben hat ihn trotzdem abgelehnt: zu blass. Ein Werkzeug, dessen Vorschlaege die Pruefung danach verwirft, ist schlimmer als keines -- man haelt das Ergebnis fuer fertig. Beide lesen die Zahl jetzt aus kachelton-regeln.mjs. */ for (let C = HOECHST_BUNTHEIT; C >= MINDEST_BUNTHEIT; C -= 0.004) { const hex = ausOklch(L, C, H); if (!hex) continue; if (kontrast(hex) < 4.5) break; const lab = oklab(hex); let naechster = Infinity; for (const v of andere) naechster = Math.min(naechster, abstand(lab, v.lab)); if (naechster > wert) { wert = naechster; treffer = { hex, lab, L, C, H }; } break; } } } return { ...treffer, abstand: wert }; } /* ---------- Ablauf --------------------------------------------------- */ const argumente = process.argv.slice(2); const schreiben = argumente.includes("--schreiben"); const nurPruefen = argumente.includes("--pruefen"); const nummern = argumente.filter((a) => /^\d+$/.test(a)).map(Number); let css = readFileSync(CSS, "utf8"); const anfang = vollstaendigOderAbbruch(css); console.log(`${anfang.length} Toene gelesen — vollstaendig.`); { const p = engstesPaar(anfang); console.log(`Engstes Paar jetzt: ${p.d.toFixed(4)} ` + `Ton ${p.a.n} ${p.a.hex} (${p.a.wofuer}) <-> Ton ${p.b.n} ${p.b.hex} (${p.b.wofuer})`); } if (nurPruefen || !nummern.length) { if (!nummern.length && !nurPruefen) { console.log("\nKeine Nummer angegeben. Aufruf: node tools/kachel-farbe-einzeln.mjs [--schreiben]"); } const sortiert = []; for (let i = 0; i < anfang.length; i++) { for (let j = i + 1; j < anfang.length; j++) { sortiert.push({ d: abstand(anfang[i].lab, anfang[j].lab), a: anfang[i], b: anfang[j] }); } } sortiert.sort((x, y) => x.d - y.d); console.log("\nDie fuenf engsten Paare:"); for (const p of sortiert.slice(0, 5)) { console.log(` ${p.d.toFixed(4)} Ton ${p.a.n} (${p.a.wofuer}) <-> Ton ${p.b.n} (${p.b.wofuer})`); } const schwach = anfang.filter((t) => kontrast(t.hex) < 4.5); console.log(schwach.length ? `\nZU DUNKEL (< 4,5:1): ${schwach.map((t) => `Ton ${t.n}`).join(", ")}` : "\nAlle Toene erreichen 4,5:1 gegen den Grund."); process.exit(0); } for (const ziel of nummern) { /* JEDES MAL NEU LESEN: Wer zwei Nummern nacheinander nachzieht, soll die zweite gegen den Stand INKLUSIVE der ersten rechnen. Sonst bekommen beide dieselbe Farbe -- genau das ist beim ersten Anlauf passiert. */ const alle = lies(css); const vorher = alle.find((t) => t.n === ziel); if (!vorher) { console.error(`Ton ${ziel} gibt es nicht.`); process.exit(2); } const andere = alle.filter((t) => t.n !== ziel); const alt = Math.min(...andere.map((t) => abstand(vorher.lab, t.lab))); const neu = beste(andere); /* EIN UNGUELTIGER IST-WERT IST KEINE MESSLATTE (24.09.2026). Gefunden beim Eintragen von Ton 44 -- und es war GENAU DERSELBE FEHLER wie am 22.09., nur eine Ebene hoeher. Damals: Die Suche lief bis Buntheit 0,04 herunter, die Pruefung verlangt 0,12. Behoben, indem beide dieselbe Zahl lesen. Heute: Ich hatte fuer Ton 44 von Hand ein zu blasses #bcdbff eingetragen (Buntheit 0,060). Dieses Werkzeug las es als Ausgangswert, verglich den besten NEUEN Vorschlag dagegen -- und meldete "bleibt #bcdbff, nichts Besseres gefunden". Der Abstand war tatsaechlich groesser; nur war der Ist-Wert gar nicht zulaessig. Ein Werkzeug, das einen regelwidrigen Zustand verteidigt, weil er zufaellig gut misst, haelt genau den Fehler fest, den es beheben soll. Geprueft werden deshalb ZUERST die Regeln, dann der Abstand. */ const istBuntheit = Math.hypot(vorher.lab[1], vorher.lab[2]); const istGueltig = istBuntheit >= MINDEST_BUNTHEIT && kontrast(vorher.hex) >= MINDEST_KONTRAST; if (!istGueltig) { console.log(` Ton ${ziel} (${vorher.wofuer}): ${vorher.hex} haelt die Regeln ` + `nicht ein (Buntheit ${istBuntheit.toFixed(3)}, ` + `Kontrast ${kontrast(vorher.hex).toFixed(2)}:1) -- er wird ersetzt, ` + "egal wie gut sein Abstand ist."); } if (istGueltig && neu.abstand <= alt) { console.log(`\nTon ${ziel} (${vorher.wofuer}): bleibt ${vorher.hex} — ` + `nichts Besseres gefunden (${alt.toFixed(4)}).`); continue; } console.log(`\nTon ${ziel} (${vorher.wofuer})`); console.log(` vorher ${vorher.hex} Abstand zum Nachbarn ${alt.toFixed(4)}`); console.log(` nachher ${neu.hex} Abstand zum Nachbarn ${neu.abstand.toFixed(4)}` + ` (${(neu.abstand / alt).toFixed(1)}-fach)`); console.log(` Kontrast ${kontrast(neu.hex).toFixed(2)}:1, Buntheit ${neu.C.toFixed(3)}`); if (!schreiben) { console.log(" (nur gezeigt — mit --schreiben eintragen)"); continue; } const neueZeile = vorher.zeile.replace(vorher.hex, neu.hex); if (neueZeile === vorher.zeile) { console.error(" ABBRUCH: Die Zeile liess sich nicht ersetzen."); process.exit(2); } css = css.replace(vorher.zeile, neueZeile); } if (schreiben) { writeFileSync(CSS, css); const nachher = vollstaendigOderAbbruch(readFileSync(CSS, "utf8")); const p = engstesPaar(nachher); console.log(`\nGeschrieben. ${nachher.length} Toene, engstes Paar jetzt ${p.d.toFixed(4)} ` + `(Ton ${p.a.n} <-> Ton ${p.b.n}).`); const schwach = nachher.filter((t) => kontrast(t.hex) < 4.5); if (schwach.length) { console.error(`WARNUNG: ${schwach.map((t) => `Ton ${t.n}`).join(", ")} unter 4,5:1.`); process.exit(1); } }