/* ===================================================================== NUR DIE FARBEN BEWEGEN, DIE SICH WIRKLICH IN DIE QUERE KOMMEN (25.09.2026, zweiter Anlauf) Der erste Anlauf hat ALLE 45 Farben neu verteilt (die Rechnung dazu steht in der Geschichte des Verzeichnisses, Commit d7bb0f7e). Das Ergebnis war rechnerisch besser und praktisch falsch: Er ist dabei ueber zwei ausdrueckliche Wuensche gelaufen (das knallrote #ff1a1a fuer "Vertraulich melden", das Babyblau #a8d8ff fuer "Support") und hat drei Toene unter die Buntheitsgrenze von 0,12 gedrueckt. pruef-kachelfarben hat alles fuenf gefunden. DIE LEHRE IST NICHT "die Rechnung war falsch", sondern: Eine Gesamtloesung fuer ein LOKALES Problem bezahlt man mit allem, was an den nicht betroffenen Stellen schon richtig war. Acht Paare standen zu eng -- also werden acht Paare auseinandergezogen und sonst nichts angefasst. VORGEHEN. Solange irgendein Paar unter dem Mindestabstand liegt: das engste nehmen, den beweglicheren der beiden auf die naechste erlaubte Farbe schieben, die alle Bedingungen haelt. Festgelegte Toene sind unbeweglich. ALLE SCHWELLEN KOMMEN AUS tools/kachelton-regeln.mjs. Keine Zahl wird hier abgeschrieben -- genau dafuer gibt es die Datei. node tools/kachelton-entzerren.mjs rechnen und zeigen node tools/kachelton-entzerren.mjs schreiben start.css aendern ===================================================================== */ import { readFileSync, writeFileSync, mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { GRUND, MINDEST_ABSTAND, MINDEST_BUNTHEIT, HOECHST_BUNTHEIT, MINDEST_KONTRAST, FESTGELEGT, } from "./kachelton-regeln.mjs"; const DATEI = "workspace/assets/css/start.css"; /* Etwas Luft ueber jeder Schwelle -- gerundet wird auf ganze Bildpunkte, und eine Farbe, die genau auf der Grenze steht, faellt beim naechsten Runden darunter. */ const ZIEL_ABSTAND = MINDEST_ABSTAND + 0.004; const ZIEL_KONTRAST = MINDEST_KONTRAST + 0.15; const ZIEL_BUNT = MINDEST_BUNTHEIT + 0.004; const lin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); const srgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255); function oklab(h) { const [r, g, b] = srgb(h).map(lin); 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]; } const leuchte = (h) => { const [r, g, b] = srgb(h).map(lin); return 0.2126 * r + 0.7152 * g + 0.0722 * b; }; const kontrast = (h) => (leuchte(h) + 0.05) / (leuchte(GRUND) + 0.05); const bunt = (h) => { const [, a, b] = oklab(h); return Math.hypot(a, b); }; const abst = (a, b) => { const x = oklab(a), y = oklab(b); return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]); }; const hexVon = (r, g, b) => "#" + [r, g, b].map((c) => c.toString(16).padStart(2, "0")).join(""); /* ---------- Die Farben aus der Datei --------------------------------- */ const css = readFileSync(DATEI, "utf8"); const TOENE = [...css.matchAll(/\[data-ton="(\d+)"\]\s*\{\s*--ton:\s*(#[0-9a-fA-F]{6})/g)] .map((m) => ({ n: Number(m[1]), alt: m[2].toLowerCase(), hex: m[2].toLowerCase() })); if (TOENE.length < 20) { console.error("Muster passt nicht mehr"); process.exit(3); } /* ---------- Wer darf sich nicht bewegen, wer darf blass sein --------- */ const FEST = new Map(FESTGELEGT.filter((f) => f.hex).map((f) => [f.ton, f.hex.toLowerCase()])); const BLASS_ERLAUBT = new Map(FESTGELEGT.filter((f) => f.blassErlaubt).map((f) => [f.ton, f.blassErlaubt])); for (const t of TOENE) { if (FEST.has(t.n) && t.hex !== FEST.get(t.n)) { console.log(` Ton ${t.n} steht auf ${t.hex}, festgelegt ist ${FEST.get(t.n)} -- wird zurueckgesetzt`); t.hex = FEST.get(t.n); } } /* ---------- WELCHE TOENE WERDEN UEBERHAUPT BENUTZT? ------------------ Die Buntheitsgrenze gilt nur fuer BENUTZTE Toene -- so steht es in pruef-kachelfarben, und das ist der Grund, aus dem die Reparatur ueberhaupt aufgeht: Mit Buntheit >= 0,12 fuer ALLE 45 ist ein Mindestabstand von 0,09 rechnerisch unmoeglich (gemessene Decke: 0,0853). Die zwoelf unbenutzten duerfen blasser sein und nehmen damit Druck aus dem engen Blau-Tuerkis-Bereich. Gefragt wird das Programm selbst, nicht eine Liste. */ process.env.WORKSPACE_DB = process.env.WORKSPACE_DB || join(mkdtempSync(join(tmpdir(), "toene-")), "workspace.db"); const w = await import("../server/workspace.js"); const BENUTZT = new Set(); for (const r of ["admin", "hand", "linke", "modi", "gast", "manager", "scout", "creator", "spicy"]) { for (const haus of ["crew", "agentur"]) { let k = []; try { k = [...(w.bereicheFuer({ id: 1, rolle: r, name: "X" }, haus) || []), ...(w.zusatzBereicheFuer({ id: 1, rolle: r, name: "X" }, haus) || [])]; } catch { continue; } for (const kachel of k) if (kachel?.ton != null) BENUTZT.add(Number(kachel.ton)); } } if (BENUTZT.size < 20) { console.error(`nur ${BENUTZT.size} benutzte Toene gefunden`); process.exit(5); } console.log(`${BENUTZT.size} von ${TOENE.length} Toenen werden benutzt: ${[...BENUTZT].sort((a, b) => a - b).join(", ")}`); /* Auch die unbenutzten sollen so bunt bleiben, wie es noch passt -- sonst steht beim naechsten Gebrauch ein blasser Ton bereit und die Pruefung wird rot, obwohl niemand etwas falsch gemacht hat. */ const UNBENUTZT_BUNT = Number(process.env.UNBUNT || 0.045); const buntNoetig = (n) => (BENUTZT.has(n) && !BLASS_ERLAUBT.has(n) ? ZIEL_BUNT : Math.min(UNBENUTZT_BUNT, bunt(TOENE.find((t) => t.n === n).alt))); /* Der verlangte Mindestabstand kann je Ton hoeher sein: Wer blass sein darf, muss das beim Abstand ausgleichen (steht so in pruef-kachelfarben und ist dort begruendet). */ const abstandFuer = (n) => Math.max(ZIEL_ABSTAND, (BLASS_ERLAUBT.get(n) ?? 0) + 0.004); /* ---------- Alle erlaubten Farben ------------------------------------ */ const ALLE = []; for (let r = 0; r <= 255; r += 2) { for (let g = 0; g <= 255; g += 2) { for (let b = 0; b <= 255; b += 2) { const h = hexVon(r, g, b); if (kontrast(h) < ZIEL_KONTRAST) continue; const c = bunt(h); if (c > HOECHST_BUNTHEIT) continue; ALLE.push({ hex: h, bunt: c }); } } } const kandidatenFuer = (n) => ALLE.filter((x) => x.bunt >= buntNoetig(n)).map((x) => x.hex); console.log(`${ALLE.length} erlaubte Farben (Kontrast >= ${ZIEL_KONTRAST.toFixed(2)}, ` + `Buntheit bis ${HOECHST_BUNTHEIT}; benutzte Toene ab ${ZIEL_BUNT.toFixed(3)})`); /* ---------- Was steht zu eng? ---------------------------------------- */ const engstes = () => { let m = Infinity, wo = null; for (let i = 0; i < TOENE.length; i++) { for (let j = i + 1; j < TOENE.length; j++) { const noetig = Math.max(abstandFuer(TOENE[i].n), abstandFuer(TOENE[j].n)); const d = abst(TOENE[i].hex, TOENE[j].hex); if (d < noetig && d - noetig < m) { m = d - noetig; wo = { i, j, d, noetig }; } } } return wo; }; const naechsterZu = (h, ausser) => { let m = Infinity; for (const t of TOENE) { if (t === ausser) continue; m = Math.min(m, abst(h, t.hex)); } return m; }; console.log(""); let bewegt = 0; for (let runde = 0; runde < 200; runde++) { const p = engstes(); if (!p) break; const a = TOENE[p.i], b = TOENE[p.j]; /* Wer wird bewegt? Nicht der Festgelegte. Sonst der, fuer den die naechste erlaubte Stelle naeher an seinem eigenen Ton liegt. */ const kandidatFuer = (t) => { if (FEST.has(t.n)) return null; let best = null, bestWeg = Infinity; const noetig = abstandFuer(t.n); for (const h of kandidatenFuer(t.n)) { const weg = abst(t.alt, h); if (weg >= bestWeg) continue; let ok = true; for (const u of TOENE) { if (u === t) continue; if (abst(h, u.hex) < Math.max(noetig, abstandFuer(u.n))) { ok = false; break; } } if (ok) { best = h; bestWeg = weg; } } return best ? { hex: best, weg: bestWeg } : null; }; const ka = kandidatFuer(a), kb = kandidatFuer(b); if (!ka && !kb) { console.error(`Fuer ${a.hex} (Ton ${a.n}) und ${b.hex} (Ton ${b.n}) gibt es keinen Platz.`); process.exit(4); } /* WER WEICHT AUS? ZUERST DER, DEN NIEMAND SIEHT. Gemessen: In JEDEM der acht zu engen Paare ist mindestens einer der beiden Toene gar nicht in Gebrauch -- 33 der 45 tragen eine Kachel, zwoelf stehen nur bereit. Wer den unbenutzten verschiebt, loest dasselbe Problem, ohne dass sich fuer irgendjemanden auch nur eine Kachel veraendert. Das ist mehr als Bequemlichkeit: Eine Kachel, die ihre Farbe wechselt, muss neu gelernt werden. Wer zwei Jahre lang „das Tuerkise" angesteuert hat, sucht danach. Diese Kosten entstehen hier schlicht nicht. Erst wenn beide benutzt sind (oder beide nicht), entscheidet der kuerzere Weg. */ const frei = (t, k) => k && !BENUTZT.has(t.n); const nimm = frei(a, ka) && !frei(b, kb) ? { t: a, k: ka } : frei(b, kb) && !frei(a, ka) ? { t: b, k: kb } : !ka ? { t: b, k: kb } : !kb ? { t: a, k: ka } : (ka.weg <= kb.weg ? { t: a, k: ka } : { t: b, k: kb }); console.log(` ${p.d.toFixed(4)} (noetig ${p.noetig.toFixed(3)}) Ton ${a.n} ${a.hex} <-> ` + `Ton ${b.n} ${b.hex} -> Ton ${nimm.t.n} wandert nach ${nimm.k.hex} ` + `(${nimm.k.weg.toFixed(3)})`); nimm.t.hex = nimm.k.hex; bewegt++; } /* ---------- NACHZIEHEN ----------------------------------------------- Der Durchgang oben arbeitet sich vom engsten Paar nach vorn und entscheidet dabei immer nur mit dem, was GERADE dasteht. Wer frueh ausweichen muss, weicht deshalb manchmal weiter aus als noetig -- die Stelle, die ihm gepasst haette, wird erst zwei Schritte spaeter frei. Gemessen war das kein Schoenheitsfehler: Ton 10 (#ff6877, ein Korallrot) landete beim ersten Lauf auf #a86cb4, einem Violett -- 0,178 entfernt, und niemand haette die Kachel wiedererkannt. Also am Ende noch einmal von vorn: Jeder bewegte Ton darf so weit zu seiner alten Farbe zurueck, wie die Bedingungen es zulassen. Das aendert am Mindestabstand nichts -- es wird nur geprueft, ob es NAEHER geht. */ for (let runde = 0; runde < 12; runde++) { let geruckt = false; for (const t of TOENE) { if (FEST.has(t.n) || t.hex === t.alt) continue; const jetzt = abst(t.alt, t.hex); const noetig = abstandFuer(t.n); let best = null, bestWeg = jetzt; for (const h of kandidatenFuer(t.n)) { const weg = abst(t.alt, h); if (weg >= bestWeg) continue; let ok = true; for (const u of TOENE) { if (u === t) continue; if (abst(h, u.hex) < Math.max(noetig, abstandFuer(u.n))) { ok = false; break; } } if (ok) { best = h; bestWeg = weg; } } if (best) { console.log(` nachgezogen: Ton ${t.n} ${t.hex} -> ${best} ` + `(${jetzt.toFixed(3)} -> ${bestWeg.toFixed(3)} vom alten Ton)`); t.hex = best; geruckt = true; } } if (!geruckt) break; } /* ---------- Nachmessen ------------------------------------------------ */ let klein = Infinity, wo = null, unterSoll = 0; for (let i = 0; i < TOENE.length; i++) { for (let j = i + 1; j < TOENE.length; j++) { const d = abst(TOENE[i].hex, TOENE[j].hex); const noetig = Math.max(abstandFuer(TOENE[i].n), abstandFuer(TOENE[j].n)); if (d < noetig) unterSoll++; if (d < klein) { klein = d; wo = [TOENE[i], TOENE[j]]; } } } const geaendert = TOENE.filter((t) => t.hex !== t.alt); const zuBlass = TOENE.filter((t) => BENUTZT.has(t.n) && bunt(t.hex) < MINDEST_BUNTHEIT && !BLASS_ERLAUBT.has(t.n)); const zuDunkel = TOENE.filter((t) => kontrast(t.hex) < MINDEST_KONTRAST); const doppelt = TOENE.length - new Set(TOENE.map((t) => t.hex)).size; console.log(""); console.log(`geaendert ${geaendert.length} von ${TOENE.length}`); for (const t of geaendert) { console.log(` Ton ${String(t.n).padStart(2)} ${t.alt} -> ${t.hex} ` + `verschoben ${abst(t.alt, t.hex).toFixed(3)} Buntheit ${bunt(t.hex).toFixed(3)} ` + `Kontrast ${kontrast(t.hex).toFixed(2)}:1`); } console.log(`kleinster Abstand ${klein.toFixed(4)} (Ton ${wo[0].n} ${wo[0].hex} / Ton ${wo[1].n} ${wo[1].hex})`); console.log(`unter dem Soll ${unterSoll}`); console.log(`zu blass ${zuBlass.map((t) => `Ton ${t.n} ${bunt(t.hex).toFixed(3)}`).join(", ") || "keiner"}`); console.log(`zu dunkel ${zuDunkel.map((t) => `Ton ${t.n}`).join(", ") || "keiner"}`); console.log(`doppelt ${doppelt}`); for (const [n, h] of FEST) { const t = TOENE.find((x) => x.n === n); console.log(`festgelegt Ton ${n} ${t.hex} ${t.hex === h ? "(unveraendert)" : "*** ABWEICHUNG ***"}`); } if (process.argv[2] === "schreiben") { if (unterSoll || zuBlass.length || zuDunkel.length || doppelt || [...FEST].some(([n, h]) => TOENE.find((x) => x.n === n).hex !== h)) { console.error("\nNICHT geschrieben -- das Ergebnis haelt die Bedingungen nicht."); process.exit(1); } let neu = css; for (const t of TOENE) { const muster = new RegExp(`(\\[data-ton="${t.n}"\\]\\s*\\{\\s*--ton:\\s*)#[0-9a-fA-F]{6}`); if (!muster.test(neu)) { console.error(`Ton ${t.n} nicht wiedergefunden`); process.exit(2); } neu = neu.replace(muster, `$1${t.hex}`); } writeFileSync(DATEI, neu); console.log("\nstart.css geschrieben."); }