/* ===================================================================== DIE KACHELFARBEN AUSEINANDERZIEHEN -- SO WENIG WIE MOEGLICH (25./26.09.2026) Filipe, 17.09.: „ich will das jede kachel eine andere farbe hat, es soll keine die gleiche farben haben bitte und auch keine die sich irgendwie aehnlich sind ... die farben sollen auch richtig geil und speziell sein." Gemessen lagen acht Paare zwischen 0,015 und 0,054 OKLab-Abstand. #ff8fb4 gegen #fe8ebe ist mit blossem Auge DIESELBE Farbe. --------------------------------------------------------------------- ZWEI ANLAEUFE HAT DAS GEKOSTET, UND BEIDE FEHLER STEHEN HIER ERSTER ANLAUF: alle 45 Farben neu verteilt. Rechnerisch besser, praktisch falsch -- er lief ueber zwei ausdrueckliche Wuensche hinweg (das knallrote #ff1a1a, das Babyblau #a8d8ff) und drueckte drei Toene unter die Buntheitsgrenze. ZWEITER ANLAUF: nur die Kollisionen angefasst, dafuer immer nur EINEN der beiden Toene bewegt -- den, dessen Ausweichen billiger war. Das sieht sparsam aus und ist es nicht: Ist die Nachbarschaft voll, muss dieser eine quer durch den Farbraum. Gemessen wanderte Ton 1 von einem Tuerkis (#0c86af) auf ein Altrosa (#b4649c), 0,197 weit. Eine Kachel, die von Tuerkis auf Rosa springt, erkennt niemand wieder -- und der Zweck der ganzen Uebung war ja, dass man sie auseinanderhaelt. DRITTER ANLAUF, und der steht hier: BEIDE duerfen sich bewegen, und zwar beide nur ein bisschen. Zwei Toene, die 0,02 auseinanderliegen und 0,09 brauchen, koennen sich das teilen -- jeder rueckt 0,045, und beide bleiben, was sie waren. Umgesetzt als wachsender Spielraum: Erst darf jeder Ton 0,02 von seiner Farbe abweichen, dann 0,03, dann 0,04 ... Es wird immer die KLEINSTE Abweichung genommen, mit der es aufgeht. Wer gar nicht abweichen muss, bleibt unberuehrt. --------------------------------------------------------------------- ALLE SCHWELLEN KOMMEN AUS tools/kachelton-regeln.mjs Keine Zahl wird hier abgeschrieben -- genau dafuer gibt es die Datei. Und die FESTGELEGTEN Toene stehen ebenfalls dort: Sie werden nie bewegt, auch nicht um einen Bildpunkt. node tools/kachelton-entzerren.mjs rechnen und zeigen node tools/kachelton-entzerren.mjs schreiben start.css aendern Umgebung: ABSTAND=0.09 (Ziel), SCHRITT=3 (Rasterweite des Suchfelds) ===================================================================== */ import { readFileSync, writeFileSync } from "node:fs"; 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 = Number(process.env.ABSTAND || MINDEST_ABSTAND) + 0.0005; const ZIEL_KONTRAST = MINDEST_KONTRAST + 0.15; const ZIEL_BUNT = MINDEST_BUNTHEIT + 0.004; const SCHRITT = Number(process.env.SCHRITT || 3); /* ---------- Farbrechnung ---------------------------------------------- */ 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 labVon(r, g, b) { const rr = lin(r / 255), gg = lin(g / 255), bb = lin(b / 255); const l = Math.cbrt(0.4122214708 * rr + 0.5363325363 * gg + 0.0514459929 * bb); const m = Math.cbrt(0.2119034982 * rr + 0.6806995451 * gg + 0.1073969566 * bb); const s = Math.cbrt(0.0883024619 * rr + 0.2817188376 * gg + 0.6299787005 * bb); 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 labHex = (h) => labVon(...[1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16))); const leuchteVon = (r, g, b) => 0.2126 * lin(r / 255) + 0.7152 * lin(g / 255) + 0.0722 * lin(b / 255); const kontrastVon = (y) => (y + 0.05) / (leuchteVon(...[1, 3, 5].map((i) => parseInt(GRUND.slice(i, i + 2), 16))) + 0.05); const buntVon = (L) => Math.hypot(L[1], L[2]); const d2 = (a, b) => (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 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); } for (const t of TOENE) t.altLab = labHex(t.alt); 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} stand auf ${t.hex}, festgelegt ist ${FEST.get(t.n)} -- zurueckgesetzt`); t.hex = FEST.get(t.n); } t.lab = labHex(t.hex); } /* ---------- WELCHE TOENE WERDEN BENUTZT? ------------------------------- ALLE FUENFUNDVIERZIG. Und das ist der Befund, an dem der zweite Anlauf gescheitert ist. Wer das PROGRAMM fragt (bereicheFuer() je Rolle), bekommt 33. Die uebrigen zwoelf sehen dann aus wie freie Reserve -- sie duerfen blasser werden und weiter wandern. Sie sind keine Reserve: Fuer die fuenf Rollen des Agenturhauses gibt bereicheFuer() `null` zurueck, und das heisst „nimm die Liste aus dem Browser". Die steht in workspace/assets/js/bereiche.js, und dort stehen die Toene 1 bis 22 und 44 -- genau die zwoelf. Gemessen: Ton 1 traegt „Dashboard", Ton 2 „Zahlen", Ton 21 „Scout-Pipeline", Ton 22 „Team-Lage". Alles Kacheln, die jeden Tag jemand ansieht. Gezaehlt wird deshalb ueber dieselben Quellen, die auch pruef-kachel-universum liest. Die Frage lautet nicht „wem gibt der Server eine Kachel", sondern „steht diese Nummer irgendwo an einer Kachel". */ const QUELLEN = ["../server/workspace.js", "../workspace/assets/js/bereiche.js", "../workspace/assets/js/start.js", "../workspace/assets/js/kopf.js", "../workspace/assets/js/befinden.js"]; const BENUTZT = new Set(); for (const q of QUELLEN) { for (const m of readFileSync(new URL(q, import.meta.url), "utf8").matchAll(/ton:\s*(\d+)/g)) { BENUTZT.add(Number(m[1])); } } 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`); /* Wer blass sein darf, muss das beim Abstand ausgleichen -- so steht es mit Messung in kachelton-regeln.mjs. */ const abstandFuer = (n) => Math.max(ZIEL_ABSTAND, (BLASS_ERLAUBT.get(n) ?? 0) + 0.0005); const BLASS_BIS = new Map(FESTGELEGT.filter((f) => f.blassBis).map((f) => [f.ton, f.blassBis])); const buntFuer = (n) => (BLASS_BIS.has(n) ? BLASS_BIS.get(n) + 0.002 : ZIEL_BUNT); /* ---------- Das Suchfeld ----------------------------------------------- */ const KANDIDATEN = []; for (let r = 0; r <= 255; r += SCHRITT) { for (let g = 0; g <= 255; g += SCHRITT) { for (let b = 0; b <= 255; b += SCHRITT) { const rr = Math.min(255, r), gg = Math.min(255, g), bb = Math.min(255, b); if (kontrastVon(leuchteVon(rr, gg, bb)) < ZIEL_KONTRAST) continue; const L = labVon(rr, gg, bb); const c = buntVon(L); if (c > HOECHST_BUNTHEIT) continue; KANDIDATEN.push({ hex: hexVon(rr, gg, bb), L, bunt: c }); } } } console.log(`${KANDIDATEN.length} Farben im Suchfeld (Kontrast >= ${ZIEL_KONTRAST.toFixed(2)}, ` + `Buntheit bis ${HOECHST_BUNTHEIT}, Raster ${SCHRITT})`); /* ---------- Messen ------------------------------------------------------ */ function engstes() { let fehl = 0, klein = 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 = Math.sqrt(d2(TOENE[i].lab, TOENE[j].lab)); if (d < noetig) fehl++; if (d < klein) { klein = d; wo = [TOENE[i], TOENE[j]]; } } } return { fehl, klein, wo }; } const zuBlass = () => TOENE.filter((t) => buntVon(t.lab) < (BLASS_BIS.get(t.n) ?? MINDEST_BUNTHEIT)); /** Wie gut steht dieser Ton gerade? (Abstand zum naechsten, in Einheiten * dessen, was er braucht -- >= 1 heisst „in Ordnung".) */ function guete(lab, selbst) { let m = Infinity; for (const u of TOENE) { if (u === selbst) continue; const noetig = Math.max(abstandFuer(selbst.n), abstandFuer(u.n)); const v = Math.sqrt(d2(lab, u.lab)) / noetig; if (v < m) m = v; } return m; } /* ---------- Der wachsende Spielraum ------------------------------------- */ const anfang = engstes(); console.log(`vorher: kleinster Abstand ${anfang.klein.toFixed(4)}, ` + `${anfang.fehl} Paare unter dem Soll, ${zuBlass().length} zu blass`); console.log(""); /* Der Spielraum waechst in jeder Runde ein wenig. Er MUSS wachsen, auch wenn sich gerade etwas bewegt hat: Sonst haengt ein Ton, der mehr Platz braucht als die anderen, fuer immer fest, waehrend die uebrigen noch hin und her rutschen -- gemessen blieben so vier zu blasse Toene stehen, obwohl das Verfahren noch lange nicht am Ende war. */ let spielraum = 0.015; for (let runde = 0; runde < 300; runde++) { const lage = engstes(); const blass = zuBlass(); if (!lage.fehl && !blass.length) break; let bewegt = 0; for (const t of TOENE) { if (FEST.has(t.n)) continue; const jetztGuete = Math.min(guete(t.lab, t), 1); const jetztBunt = buntVon(t.lab); const bunkOk = jetztBunt >= (BLASS_BIS.get(t.n) ?? MINDEST_BUNTHEIT); if (jetztGuete >= 1 && bunkOk) continue; // dieser ist fertig const grenze2 = spielraum * spielraum; const mindBunt = buntFuer(t.n); /* IST DIE BUNTHEIT DAS PROBLEM, ZAEHLT DIE JETZIGE LAGE NICHT. Hier stand `bestGuete = jetztGuete`. Fuer einen Ton, der weit genug von allen wegliegt (Guete 1) und nur zu blass ist, konnte damit KEIN Kandidat gewinnen -- kein Wert ist groesser als 1, wenn bei 1 gedeckelt wird. Vier Toene blieben deshalb blass stehen, obwohl das Suchfeld voller passender Farben war. Mit -1 als Ausgangswert gewinnt jeder Kandidat, der ueberhaupt bunt genug ist -- und unter denen wieder der naechste. */ let best = null, bestGuete = bunkOk ? jetztGuete : -1, bestWeg = 0; for (const k of KANDIDATEN) { if (k.bunt < mindBunt) continue; const weg2 = d2(t.altLab, k.L); if (weg2 > grenze2) continue; /* GEDECKELT BEI 1. „Weiter weg als noetig" ist kein Gewinn -- ohne den Deckel sucht das Verfahren das Maximum und wandert dafuer quer durch den Farbraum. Mit ihm gewinnt unter allen Farben, die reichen, die NAECHSTE. */ const g = Math.min(guete(k.L, t), 1); if (g > bestGuete + 1e-9 || (g > bestGuete - 1e-9 && best && weg2 < bestWeg)) { best = k; bestGuete = g; bestWeg = weg2; } } if (best && best.hex !== t.hex) { t.hex = best.hex; t.lab = best.L; bewegt++; } } spielraum += 0.0015; if (spielraum > 0.26) { console.error("Spielraum ausgeschoepft -- es geht nicht auf."); break; } } /* ---------- Nachziehen: so weit zurueck wie moeglich --------------------- */ 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 jetztWeg = d2(t.altLab, t.lab); const mindBunt = buntFuer(t.n); let best = null, bestWeg = jetztWeg; for (const k of KANDIDATEN) { if (k.bunt < mindBunt) continue; const weg2 = d2(t.altLab, k.L); if (weg2 >= bestWeg) continue; if (guete(k.L, t) < 1) continue; best = k; bestWeg = weg2; } if (best) { t.hex = best.hex; t.lab = best.L; geruckt = true; } } if (!geruckt) break; } /* ---------- Ergebnis ----------------------------------------------------- */ const ende = engstes(); const blassEnde = zuBlass(); const geaendert = TOENE.filter((t) => t.hex !== t.alt); const doppelt = TOENE.length - new Set(TOENE.map((t) => t.hex)).size; console.log(`geaendert ${geaendert.length} von ${TOENE.length}`); for (const t of geaendert.sort((a, b) => d2(b.altLab, b.lab) - d2(a.altLab, a.lab))) { console.log(` Ton ${String(t.n).padStart(2)} ${t.alt} -> ${t.hex} ` + `verschoben ${Math.sqrt(d2(t.altLab, t.lab)).toFixed(3)} ` + `Buntheit ${buntVon(t.lab).toFixed(3)} ` + `Kontrast ${kontrastVon(leuchteVon(...[1, 3, 5].map((i) => parseInt(t.hex.slice(i, i + 2), 16)))).toFixed(2)}:1`); } console.log(`kleinster Abstand ${ende.klein.toFixed(4)} ` + `(Ton ${ende.wo[0].n} ${ende.wo[0].hex} / Ton ${ende.wo[1].n} ${ende.wo[1].hex})`); console.log(`unter dem Soll ${ende.fehl}`); console.log(`zu blass ${blassEnde.map((t) => `Ton ${t.n} ${buntVon(t.lab).toFixed(3)}`).join(", ") || "keiner"}`); console.log(`doppelt ${doppelt}`); console.log(`groesste Aenderung ${geaendert.length ? Math.max(...geaendert.map((t) => Math.sqrt(d2(t.altLab, t.lab)))).toFixed(3) : "0"}`); 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 (ende.fehl || blassEnde.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."); }