Alle Etiketten trugen denselben blauen Ton. Auf "Regeln & Hilfe"
stehen siebzehn Karten untereinander, auf dem Treff acht -- das Auge
hatte nichts, woran es sich festhalten konnte. Man liest dann jede
Ueberschrift, statt zu ueberfliegen.
NACH BEDEUTUNG, NICHT NACH REIHENFOLGE. Lob gruen, "etwas stimmt
nicht" warmrot, Fragen blau, Regeln golden. Deshalb traegt die Art
"frage" auf drei Brettern denselben Ton: Wer das Muster einmal
gelernt hat, soll es wiedererkennen.
WARUM EIN WERKZEUG UND KEINE FARBLISTE. Der erste Versuch war von
Hand: feste Helligkeit, Buntheit nach Gefuehl. Vier Toene lagen
ausserhalb des darstellbaren Bereichs, drei Paare zu nah. Nach dem
Nachbessern waren es fuenf zu nahe Paare -- wer die Buntheit eines
Violetts senkt, schiebt es naeher an alles andere Gedaempfte. Zwei
Zahlen von Hand zu stimmen, waehrend eine dritte Bedingung mitlaeuft,
geht nicht auf. Das ist eine Suche, keine Wahl.
tools/art-farben.mjs sucht deshalb selbst: Der Farbwinkel steht fest
(das ist die Entscheidung), Helligkeit und Buntheit werden gesucht --
unter drei Bedingungen gleichzeitig: in sRGB, Kontrast mindestens
4,5:1 auf dunklem Grund, und mindestens 0,10 OKLab-Abstand zu jeder
Art, die auf DEMSELBEN Brett vorkommt. Ueber Bretter hinweg darf sich
ein Ton wiederholen -- man sieht nie zwei gleichzeitig.
UND ES SUCHT DIE LEISESTE FASSUNG, nicht die kraeftigste. Der erste
Durchlauf maximierte die Buntheit; heraus kamen #35f695 und #f584fd --
Neongruen und Neonviolett. Das widerspricht der Hausregel ("gedeckte
statt reisserisch-grelle Neon-Optik"), und bei siebzehn Karten waere
es eine Leuchtreklame. Jetzt: gerade bunt genug, um sie zu
unterscheiden. Buntheit ist eine Kostenstelle, kein Ziel.
Ergebnis: 23 Toene, 39 Paare geprueft, engstes Paar je Brett 0,100.
DAS WERKZEUG PRUEFT AUCH DAS CSS gegen seine eigene Rechnung. Ohne
das misst es nur sich selbst: Wer einen Ton von Hand aendert, bliebe
unbemerkt -- genau die Art Pruefung, die immer bestaetigt. Gegenprobe:
ein Ton von Hand verstellt -> rot mit Angabe von Soll und Ist.
Co-Authored-By: Claude Opus 5 <[email protected]>
358 lines
15 KiB
JavaScript
358 lines
15 KiB
JavaScript
/* =====================================================================
|
|
EIN TON JE ART -- UND DER BEWEIS, DASS MAN SIE UNTERSCHEIDET
|
|
|
|
Aufruf: node tools/art-farben.mjs (nur messen)
|
|
node tools/art-farben.mjs schreiben (CSS erzeugen)
|
|
|
|
---------------------------------------------------------------------
|
|
WARUM ÜBERHAUPT
|
|
|
|
Alle Etiketten trugen denselben blauen Ton. Auf „Regeln & Hilfe"
|
|
stehen siebzehn Karten untereinander, auf dem Treff acht -- und das
|
|
Auge hatte nichts, woran es sich festhalten konnte. Man liest dann
|
|
jede Überschrift, statt die Seite zu überfliegen.
|
|
|
|
---------------------------------------------------------------------
|
|
NACH BEDEUTUNG, NICHT NACH REIHENFOLGE
|
|
|
|
Der Ton folgt dem, was die Art SAGT: Lob grün, „etwas stimmt nicht"
|
|
warmrot, Fragen blau, Regeln golden. Deshalb trägt `frage` auf drei
|
|
Brettern denselben Ton -- wer das Muster einmal gelernt hat, soll es
|
|
überall wiedererkennen. Eine Vergabe nach Position („die erste Art
|
|
wird blau") würde genau das verhindern.
|
|
|
|
---------------------------------------------------------------------
|
|
UND DESHALB MISST DIESES WERKZEUG ZWEIERLEI
|
|
|
|
1. ABSTAND: Zwei Arten, die auf DEMSELBEN Brett vorkommen, müssen
|
|
im OKLab-Raum weit genug auseinanderliegen. Über Bretter hinweg
|
|
ist eine Wiederholung dagegen erlaubt -- man sieht nie zwei
|
|
Bretter gleichzeitig.
|
|
2. KONTRAST: Jeder Ton muss auf dem dunklen Grund lesbar sein.
|
|
Ein hübscher Ton, den man nicht lesen kann, ist kein Ton.
|
|
|
|
Ohne diese Messung wäre die Farbwahl eine Behauptung. Bei den
|
|
Kacheln hat genau dieselbe Rechnung sieben Töne aussortiert, die
|
|
„eigentlich verschieden aussahen".
|
|
===================================================================== */
|
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
import { join, dirname } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const HIER = dirname(fileURLToPath(import.meta.url));
|
|
const CSS = join(HIER, "..", "workspace", "assets", "css", "bereich.css");
|
|
|
|
/* Der Grund, auf dem die Etiketten liegen. */
|
|
const GRUND = "#0a121e";
|
|
|
|
/* ---------- Farbrechnung (aus tools/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 abstand = (a, b) => {
|
|
const [x, y] = [oklab(a), oklab(b)];
|
|
return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]);
|
|
};
|
|
|
|
const leuchte = (hex) => {
|
|
const [r, g, b] = srgb(hex).map(linear);
|
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
};
|
|
const kontrast = (a, b) => {
|
|
const [x, y] = [leuchte(a), leuchte(b)].sort((p, q) => q - p);
|
|
return (x + 0.05) / (y + 0.05);
|
|
};
|
|
|
|
/* =====================================================================
|
|
DIE TÖNE -- hier steht nur die BEDEUTUNG, also der Farbwinkel
|
|
|
|
Helligkeit und Buntheit sucht das Werkzeug selbst (siehe unten).
|
|
|
|
WARUM NICHT VON HAND: Genau das war der erste Versuch -- feste
|
|
Helligkeit 0.80 für alle, Buntheit nach Gefühl. Ergebnis: vier Töne
|
|
lagen ausserhalb des darstellbaren Bereichs, drei Paare zu nah
|
|
beieinander. Nach dem Nachbessern waren es fünf zu nahe Paare: Wer
|
|
die Buntheit eines Violetts senkt, schiebt es näher an alles andere
|
|
Gedämpfte heran. Zwei Zahlen von Hand zu stimmen, während eine
|
|
dritte Bedingung mitläuft, geht nicht auf -- das ist eine Suche,
|
|
keine Wahl.
|
|
|
|
Der Farbwinkel dagegen IST die Entscheidung und bleibt von Hand:
|
|
Lob grün, „etwas stimmt nicht" warmrot, Fragen blau.
|
|
===================================================================== */
|
|
const TOENE = {
|
|
/* Begrüßen, fragen, loben, melden -- der Treff */
|
|
hallo: 200, // Türkis: freundlich, offen
|
|
frage: 245, // Blau: die neutrale Frage
|
|
lob: 140, // Grün: etwas war gut
|
|
problem: 25, // Warmrot: hier stimmt etwas nicht
|
|
|
|
/* Das Anschlagbrett -- was gilt */
|
|
ansage: 50, // Gold: eine Ansage des Teams
|
|
regel: 100, // Oliv-Gold: was fest gilt
|
|
hinweis: 255, // Blaugrau: zur Kenntnis
|
|
danke: 340, // Rosé: Dank an die Community
|
|
|
|
/* Was ansteht -- Termine */
|
|
stream: 300, // Violett: die Sendung
|
|
event: 345, // Magenta: der besondere Abend
|
|
turnier: 45, // Orange: Wettkampf
|
|
pause: 250, // fast grau: es fällt aus
|
|
|
|
/* Die Wunschliste */
|
|
technik: 220, // Stahlblau: Geräte und Ton
|
|
gemeinsam: 165, // Grün-Türkis: miteinander
|
|
sonstiges: 300, // gedämpft: alles Übrige
|
|
|
|
/* Highlights */
|
|
clip: 285, // Violett: bewegtes Bild
|
|
bild: 195, // Türkis: Standbild
|
|
fanart: 325, // Rosé: selbst gemacht
|
|
moment: 60, // Bernstein: der Augenblick
|
|
|
|
/* Regeln & Hilfe */
|
|
hilfe: 155, // Grün: hier wird geholfen
|
|
folge: 5, // Rot: was passiert, wenn
|
|
|
|
/* Mitmachen */
|
|
gesucht: 85, // Gold: wir suchen
|
|
lust: 135, // Grün: ich hätte Lust
|
|
};
|
|
|
|
/* Welche Arten auf DEMSELBEN Brett stehen -- nur hier muss der
|
|
Abstand stimmen. Abgeleitet aus den Brettern selbst, nicht von Hand
|
|
gepflegt: eine Liste, die niemand pflegt, kann nicht veralten. */
|
|
const BRETTER = {
|
|
treff: ["hallo", "frage", "lob", "problem"],
|
|
anschlag: ["ansage", "regel", "hinweis", "danke"],
|
|
ansteht: ["stream", "event", "turnier", "pause"],
|
|
wunsch: ["stream", "technik", "gemeinsam", "sonstiges"],
|
|
highlight: ["clip", "bild", "fanart", "moment"],
|
|
regeln: ["regel", "frage", "hilfe", "folge"],
|
|
mitmachen: ["gesucht", "lust", "frage"],
|
|
};
|
|
|
|
/* Ab hier gilt ein Unterschied als erkennbar. Bei den Kacheln war die
|
|
Grenze 0.14; Etiketten sind kleiner und stehen direkt untereinander,
|
|
deshalb hier etwas strenger. */
|
|
const MIN_ABSTAND = 0.10;
|
|
/* WCAG AA für kleine Schrift. */
|
|
const MIN_KONTRAST = 4.5;
|
|
|
|
/* ---------------------------------------------------------------------
|
|
MESSEN
|
|
--------------------------------------------------------------------- */
|
|
/* ---------------------------------------------------------------------
|
|
DIE SUCHE
|
|
|
|
Für jede Art steht der Farbwinkel fest. Gesucht werden Helligkeit
|
|
und Buntheit -- so bunt wie möglich, aber nur, wenn der Ton
|
|
|
|
1. in sRGB liegt,
|
|
2. auf dunklem Grund lesbar ist und
|
|
3. von jeder Art, die auf DEMSELBEN Brett steht, weit genug
|
|
entfernt bleibt.
|
|
|
|
Die Arten werden nach Anzahl der Nachbarn sortiert: Wer die meisten
|
|
Bedingungen erfüllen muss, wählt zuerst. Umgekehrt bliebe für ihn am
|
|
Ende nichts übrig -- dieselbe Überlegung wie bei den Kacheln.
|
|
--------------------------------------------------------------------- */
|
|
const nachbarn = {};
|
|
for (const arten of Object.values(BRETTER)) {
|
|
for (const a of arten) {
|
|
nachbarn[a] = nachbarn[a] || new Set();
|
|
for (const b of arten) if (b !== a) nachbarn[a].add(b);
|
|
}
|
|
}
|
|
|
|
const hex = {};
|
|
let fehlt = 0;
|
|
const reihenfolge = Object.keys(TOENE)
|
|
.sort((a, b) => (nachbarn[b]?.size || 0) - (nachbarn[a]?.size || 0));
|
|
|
|
for (const art of reihenfolge) {
|
|
const H = TOENE[art];
|
|
/* SO GEDECKT WIE MOEGLICH, nicht so bunt wie moeglich.
|
|
|
|
Der erste Versuch suchte die kraeftigste Fassung -- heraus kamen
|
|
Toene wie #35f695 und #f584fd: Neongruen und Neonviolett. Das
|
|
widerspricht der Hausregel („gedeckte statt reisserisch-grelle
|
|
Neon-Optik"), und auf einem Brett mit siebzehn Karten waere es
|
|
eine Leuchtreklame.
|
|
|
|
Gesucht wird deshalb die LEISESTE Farbe, die noch reicht: gerade
|
|
bunt genug, um sie von den Nachbarn zu unterscheiden, und gerade
|
|
hell genug, um lesbar zu sein. Buntheit ist hier eine Kostenstelle,
|
|
kein Ziel.
|
|
|
|
Helligkeit von der Mitte aus nach aussen: 0.78 ist die Vorgabe;
|
|
nur wo sie nicht reicht, wird abgewichen. */
|
|
let bester = null, besteBuntheit = 9;
|
|
const helligkeiten = [0.78];
|
|
for (let d = 0.01; d <= 0.10; d += 0.01) helligkeiten.push(0.78 + d, 0.78 - d);
|
|
|
|
for (const Lv of helligkeiten) {
|
|
for (let C = 0.03; C <= 0.20; C += 0.005) {
|
|
const f = ausOklch(Lv, C, H);
|
|
if (!f) continue;
|
|
if (kontrast(f, GRUND) < MIN_KONTRAST) continue;
|
|
let passt = true;
|
|
for (const n of (nachbarn[art] || [])) {
|
|
if (hex[n] && abstand(f, hex[n]) < MIN_ABSTAND) { passt = false; break; }
|
|
}
|
|
if (!passt) continue;
|
|
/* Der erste Treffer je Helligkeit ist schon der leiseste. */
|
|
if (C < besteBuntheit) { besteBuntheit = C; bester = f; }
|
|
break;
|
|
}
|
|
/* Reicht die Vorgabe-Helligkeit, wird gar nicht weitergesucht. */
|
|
if (bester && besteBuntheit <= 0.08) break;
|
|
}
|
|
if (!bester) {
|
|
console.log(`FEHL ${art}: kein Ton gefunden, der alle drei Bedingungen erfuellt`);
|
|
fehlt++;
|
|
continue;
|
|
}
|
|
hex[art] = bester;
|
|
}
|
|
|
|
console.log("=== Kontrast auf dunklem Grund ===");
|
|
let zuDunkel = 0;
|
|
for (const [art, f] of Object.entries(hex)) {
|
|
const k = kontrast(f, GRUND);
|
|
if (k < MIN_KONTRAST) { zuDunkel++; console.log(` FEHL ${art.padEnd(10)} ${f} ${k.toFixed(2)}`); }
|
|
}
|
|
console.log(zuDunkel ? ` ${zuDunkel} Ton/Töne zu dunkel` : ` alle ${Object.keys(hex).length} Töne lesbar`);
|
|
|
|
console.log("");
|
|
console.log("=== Abstand INNERHALB eines Bretts ===");
|
|
let zuNah = 0, paare = 0;
|
|
for (const [brett, arten] of Object.entries(BRETTER)) {
|
|
let kleinster = 9, wo = "";
|
|
for (let i = 0; i < arten.length; i++) {
|
|
for (let j = i + 1; j < arten.length; j++) {
|
|
if (!hex[arten[i]] || !hex[arten[j]]) continue;
|
|
paare++;
|
|
const d = abstand(hex[arten[i]], hex[arten[j]]);
|
|
if (d < kleinster) { kleinster = d; wo = `${arten[i]}/${arten[j]}`; }
|
|
if (d < MIN_ABSTAND) {
|
|
zuNah++;
|
|
console.log(` FEHL ${brett}: ${arten[i]} und ${arten[j]} nur ${d.toFixed(3)} auseinander`);
|
|
}
|
|
}
|
|
}
|
|
console.log(` ${brett.padEnd(11)} engstes Paar ${wo.padEnd(22)} ${kleinster.toFixed(3)}`);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
STIMMT DAS CSS NOCH MIT DIESER RECHNUNG UEBEREIN?
|
|
|
|
Ohne diese Frage prueft das Werkzeug nur sich selbst. Jemand aendert
|
|
einen Ton von Hand im CSS -- weil er dunkler aussehen soll --, und
|
|
die Messung hier bleibt gruen, weil sie ihre eigene Tabelle misst
|
|
und nicht das, was ausgeliefert wird. Genau die Art Pruefung, die
|
|
immer bestaetigt.
|
|
--------------------------------------------------------------------- */
|
|
let abweichend = 0, verglichen = 0;
|
|
if (process.argv[2] !== "schreiben") {
|
|
let css = "";
|
|
try { css = readFileSync(CSS, "utf8"); } catch { css = ""; }
|
|
if (!css) {
|
|
console.log("");
|
|
console.log(" -- bereich.css nicht lesbar -- nicht abgeglichen");
|
|
} else {
|
|
console.log("");
|
|
console.log("=== Stimmt das CSS mit dieser Rechnung ueberein? ===");
|
|
for (const [art, f] of Object.entries(hex)) {
|
|
const treffer = new RegExp(
|
|
`\\.marke-art\\[data-art="${art}"\\]\\s*\\{\\s*--art-ton:\\s*(#[0-9a-f]{6})`, "i").exec(css);
|
|
verglichen++;
|
|
if (!treffer) {
|
|
abweichend++;
|
|
console.log(` FEHL ${art.padEnd(10)} steht gar nicht im CSS`);
|
|
} else if (treffer[1].toLowerCase() !== f.toLowerCase()) {
|
|
abweichend++;
|
|
console.log(` FEHL ${art.padEnd(10)} CSS ${treffer[1]} statt ${f}`);
|
|
}
|
|
}
|
|
/* Die Anzahl gehoert in die Bedingung -- sonst waere „0 Abweichungen"
|
|
auch bei leerer Tabelle wahr. */
|
|
console.log(verglichen >= 20 && abweichend === 0
|
|
? ` alle ${verglichen} Toene im CSS stimmen`
|
|
: ` ${abweichend} von ${verglichen} weichen ab -- „node tools/art-farben.mjs schreiben" hilft`);
|
|
}
|
|
}
|
|
|
|
console.log("");
|
|
if (fehlt || zuDunkel || zuNah || abweichend || (process.argv[2] !== "schreiben" && verglichen < 20)) {
|
|
console.log(`NICHT IN ORDNUNG -- ${fehlt} ausserhalb, ${zuDunkel} zu dunkel, `
|
|
+ `${zuNah} zu nah, ${abweichend} weichen vom CSS ab`);
|
|
process.exit(1);
|
|
}
|
|
console.log(`ALLES IN ORDNUNG -- ${Object.keys(hex).length} Töne, ${paare} Paare geprüft`);
|
|
|
|
/* ---------------------------------------------------------------------
|
|
SCHREIBEN
|
|
--------------------------------------------------------------------- */
|
|
if (process.argv[2] === "schreiben") {
|
|
const zeilen = Object.entries(hex).map(([art, f]) =>
|
|
`.marke-art[data-art="${art}"] { --art-ton: ${f}; }`);
|
|
const block = `
|
|
/* ---- EIN TON JE ART (18.09.2026) ----------------------------------
|
|
Erzeugt von tools/art-farben.mjs -- NICHT von Hand ändern.
|
|
Das Werkzeug misst zweierlei und lässt sich nicht überreden:
|
|
den Abstand zwischen zwei Arten auf DEMSELBEN Brett (OKLab, ab
|
|
${MIN_ABSTAND}) und den Kontrast auf dunklem Grund (ab ${MIN_KONTRAST}:1).
|
|
|
|
Warum überhaupt: Alle Etiketten trugen denselben blauen Ton. Bei
|
|
siebzehn Karten untereinander hatte das Auge nichts, woran es sich
|
|
festhalten konnte -- man liest dann jede Überschrift, statt zu
|
|
überfliegen. Der Ton folgt der BEDEUTUNG, nicht der Reihenfolge:
|
|
Lob grün, „etwas stimmt nicht" warmrot, Fragen blau. Deshalb trägt
|
|
die Art „frage" auf drei Brettern denselben Ton. */
|
|
.marke-art[data-art] {
|
|
background: color-mix(in srgb, var(--art-ton) 16%, transparent);
|
|
color: var(--art-ton);
|
|
}
|
|
${zeilen.join("\n")}
|
|
`;
|
|
let css = readFileSync(CSS, "utf8");
|
|
const marke = "/* ---- EIN TON JE ART (18.09.2026) ---";
|
|
const i = css.indexOf(marke);
|
|
if (i >= 0) css = css.slice(0, i).replace(/\n+$/, "\n");
|
|
writeFileSync(CSS, css + block, "utf8");
|
|
console.log(`\n${zeilen.length} Töne nach bereich.css geschrieben.`);
|
|
}
|