diff --git a/server/mess-fokus.mjs b/server/mess-fokus.mjs new file mode 100644 index 00000000..81828c28 --- /dev/null +++ b/server/mess-fokus.mjs @@ -0,0 +1,152 @@ +/* ===================================================================== + SIEHT MAN, WO DER TASTATURFOKUS STEHT? + ===================================================================== + + Gebaut am 25.09.2026, weil `pruef-neue-seiten` einen Kontrast von + 1,07 meldete ("Schoen, dass du da bist") und die Stelle im CSS + tadellos aussah. Diese Messung liest die Farben an Ort und Stelle + aus und geht die Elternkette hoch, bis sie eine deckende Flaeche + findet -- genau das, was die Pruefung tut, nur sichtbar. + + Ergebnis damals: dunkler Text auf HELLBLAUER Flaeche, rund 10:1. + Die Pruefung konnte das Format `color(srgb ...)` nicht lesen, ging + deshalb zum fast schwarzen Elternteil weiter und rechnete dunkel + gegen dunkel. Ohne diese Messung haette ich an einem CSS geaendert, + an dem nichts war. + + Aufrufen mit: + node server/mess-farbe-am-text.mjs "" + ===================================================================== */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const SEITE = process.argv[2] || "wissen.html"; +const SUCHE = process.argv[3] || ".kachel"; + +const PORT = 5457; + +/* IST DIE NUMMER FREI? Sonst misst dieses Werkzeug an einem fremden + Server -- und liefert ein Ergebnis, das zu einem ganz anderen Stand + gehoert. Die Pruefungen holen sich ihre Nummer ueber helfer-port; + ein Messwerkzeug, das von Hand gestartet wird, braucht wenigstens + diese eine Frage. */ +{ + const netz = await import("node:net"); + const frei = await new Promise((fertig) => { + const s = netz.createServer(); + s.once("error", () => fertig(false)); + s.once("listening", () => s.close(() => fertig(true))); + s.listen(PORT, "127.0.0.1"); + }); + if (!frei) { + console.error(`Port ${PORT} ist belegt -- hier laeuft schon etwas. ` + + "Gemessen wuerde sonst ein fremder Stand. Abgebrochen."); + process.exit(3); + } +} +const ordner = mkdtempSync(join(tmpdir(), "ws-kontrast-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = String(PORT); +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { + return ec.call(this, n, w, { ...(o || {}), secure: false }); +}; +import { notbremse } from "./helfer-notbremse.mjs"; +await import("./index.js"); +notbremse(200_000, "mess-kontrast"); +await new Promise((r) => setTimeout(r, 900)); + +const BASIS = `http://127.0.0.1:${PORT}`; +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +const sch = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", sch, jetzt); +const anlegen = (name, rolle, code) => { + const s = randomBytes(16).toString("hex"); + const h = scryptSync(code, s, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + return d.prepare("INSERT INTO personen" + + " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,?,1,?) RETURNING id") + .get(name, rolle, h, s, 32768, + createHmac("sha256", sch).update(code).digest("hex"), jetzt).id; +}; +anlegen("Filipe", "admin", "CODE-DOGI-0001"); +anlegen("Sina", "scout", "CODE-SCOUT-0001"); +d.close(); + +const an = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "scout", code: "CODE-SCOUT-0001" }), +}); +const keks = (an.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; "); + +const pw = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch(); +const kontext = await browser.newContext({ viewport: { width: 1440, height: 900 } }); +await kontext.addCookies(keks.split("; ").map((k) => { + const [n, ...r] = k.split("="); + return { name: n, value: r.join("="), domain: "127.0.0.1", path: "/" }; +})); +const seite = await kontext.newPage(); +await seite.goto(`${BASIS}/workspace/${SEITE}`, { waitUntil: "networkidle" }); +await seite.waitForTimeout(900); + +const stil = (e) => { const c = getComputedStyle(e); return { + outlineStyle: c.outlineStyle, outlineWidth: c.outlineWidth, outlineColor: c.outlineColor, + boxShadow: c.boxShadow, borderColor: c.borderColor, backgroundColor: c.backgroundColor, + textDecorationLine: c.textDecorationLine, filter: c.filter, clipPath: c.clipPath }; }; + +const vorher = await seite.evaluate((sel) => { + const e = document.querySelector(sel); + if (!e) return null; + e.setAttribute("data-mess", "1"); + const c = getComputedStyle(e); + return { text: (e.textContent || "").trim().slice(0, 30), tag: e.tagName, + outline: `${c.outlineStyle} ${c.outlineWidth} ${c.outlineColor}`, + boxShadow: c.boxShadow, borderColor: c.borderColor, backgroundColor: c.backgroundColor, + filter: c.filter, clipPath: c.clipPath.slice(0, 60) }; +}, SUCHE); +if (!vorher) { console.log(`${SUCHE} nicht gefunden auf ${SEITE}`); process.exit(1); } + +await seite.evaluate(() => document.body.focus?.()); +let gefunden = null; +for (let i = 0; i < 40 && !gefunden; i++) { + await seite.keyboard.press("Tab"); + await seite.waitForTimeout(140); + gefunden = await seite.evaluate(() => { + const e = document.activeElement; + if (!e || !e.hasAttribute?.("data-mess")) return null; + const c = getComputedStyle(e); + return { outline: `${c.outlineStyle} ${c.outlineWidth} ${c.outlineColor}`, + boxShadow: c.boxShadow, borderColor: c.borderColor, backgroundColor: c.backgroundColor, + filter: c.filter, clipPath: c.clipPath.slice(0, 60), + fv: e.matches(":focus-visible"), f: e.matches(":focus") }; + }); +} + +console.log(` +=== ${SUCHE} auf ${SEITE} (${vorher.tag}, "${vorher.text}") ===`); +if (!gefunden) console.log(" mit der Tastatur NICHT erreicht"); +else { + console.log(` :focus ${gefunden.f} :focus-visible ${gefunden.fv}`); + for (const k of ["outline", "boxShadow", "borderColor", "backgroundColor", "filter", "clipPath"]) { + const gleich = vorher[k] === gefunden[k]; + console.log(` ${k.padEnd(16)} ${gleich ? "gleich " : "ANDERS "} ` + + `${String(vorher[k]).slice(0, 55)}` + + (gleich ? "" : ` + -> ${String(gefunden[k]).slice(0, 55)}`)); + } +} +await browser.close(); +process.exit(0); diff --git a/server/pruef-kachel-universum.mjs b/server/pruef-kachel-universum.mjs index ad756d81..522315f6 100644 --- a/server/pruef-kachel-universum.mjs +++ b/server/pruef-kachel-universum.mjs @@ -120,52 +120,63 @@ for (let i = 0; i < liste.length; i++) { } paare.sort((x, y) => x.d - y.d); -/* ==== DIE GRENZE WAR FUER EINUNDZWANZIG FARBEN GERECHNET (25.09.2026) +/* ==== DIE GRENZE WAR FUER EINUNDZWANZIG FARBEN GESETZT (25.09.2026) Hier standen ZWEI Bedingungen: kleinster Abstand >= 0,09 und - „kein Paar unter 0,10". Die zweite war seit dem 17.09. nicht mehr - erfuellbar -- nicht schwer, sondern UNMOEGLICH. + „kein Paar unter 0,10". Die zweite hat seit dem 17.09. niemand + mehr erfuellt, und niemand konnte sagen, wie es gehen sollte. - Nachgerechnet (tools/_toene-packen.mjs): Aus allen sRGB-Farben, - die 4,8:1 gegen den Grund halten, nicht blenden und bunt genug - sind, laesst sich fuer eine gegebene Anzahl ein groesster - moeglicher Mindestabstand bestimmen -- eine Kugelpackung: + Gesucht wurde mit tools/kachelton-decke.mjs: Aus allen sRGB-Farben, + die den Kontrast halten, nicht blenden und bunt genug sind, werden + 45 so gewaehlt, dass der kleinste Abstand moeglichst gross wird + (weitester Punkt, dann tauschen). Bestes gefundenes Ergebnis: rund + 0,097 -- und das nur in einer von mehreren Einstellungen; in einer + anderen kam dasselbe Verfahren nur auf 0,0876. - 30 Farben -> 0,115 48 Farben -> 0,0925 - 40 Farben -> 0,103 52 Farben -> 0,0840 - 45 Farben -> 0,0974 60 Farben -> 0,0805 + WAS DAS BEWEIST UND WAS NICHT -- der Unterschied ist wichtig: - Bei 21 Farben war 0,10 muehelos. Bei den heutigen 45 liegt die - DECKE bei 0,0974 -- die Forderung „kein Paar unter 0,10" konnte - also niemand erfuellen, mit keiner Palette der Welt. + Es beweist, dass 0,094 ERREICHBAR ist. Dafuer gibt es eine + Anordnung, und sie steht in start.css. - WAS DAS ANGERICHTET HAT, ist das eigentlich Schlimme: Diese - Pruefung war dadurch DAUERHAFT rot. Und weil sie ohnehin rot war, - ist die Zeile darueber nicht mehr gelesen worden -- die stand - naemlich auf „kleinster Abstand 0,0154", also auf zwei Farben, - die mit blossem Auge dieselbe sind (#ff8fb4 und #fe8ebe). Acht - solche Paare gab es. Der echte Befund lag acht Tage lang offen - da, hinter einem Fehlalarm versteckt. + Es beweist NICHT, dass 0,10 unmoeglich ist. Ein Suchverfahren, + das nichts Besseres findet, sagt etwas ueber das Verfahren, nicht + ueber die Welt. Wer aus „nicht gefunden" ein „gibt es nicht" + macht, hat die Ungleichung in die falsche Richtung gelesen. - JETZT STEHT HIER EINE BEDINGUNG, DIE ERFUELLBAR IST -- und - daneben die zweite, die den Grund bewacht: Sobald jemand mehr als - 48 Kacheln anlegt, ist 0,09 nicht mehr erreichbar, und dann muss - ueber die Palette gesprochen werden statt still darunter zu - rutschen. Die Zahl ist gemessen, nicht gesetzt. */ + Deshalb steht die Zahl hier als das da, was sie ist: als der Stand, + den die Palette nachweislich haelt, mit etwas Luft nach unten. Sie + heisst nicht mehr „Decke", denn eine Decke ist sie nicht. + + WAS DIE ALTE BEDINGUNG ANGERICHTET HAT, ist das eigentlich + Schlimme: Diese Pruefung war dauerhaft rot. Und weil sie ohnehin + rot war, ist die Zeile darueber nicht mehr gelesen worden -- die + stand naemlich auf „kleinster Abstand 0,0154", also auf zwei + Farben, die mit blossem Auge dieselbe sind (#ff8fb4 und #fe8ebe). + Acht solche Paare gab es. Der echte Befund lag acht Tage lang + offen da, hinter einem Fehlalarm versteckt. + + Die zweite Bedingung bewacht jetzt den GRUND statt eine + Unmoeglichkeit zu fordern: Die Palette ist zwischen dem 08.09. und + dem 17.09. von 21 auf 45 Farben gewachsen, jede einzeln gewaehlt, + keine gegen die anderen geprueft -- SO sind die acht Paare + entstanden. Ab 49 Farben hat das Suchverfahren keine Anordnung + mehr gefunden, die 0,09 haelt. Das ist kein Verbot, sondern ein + Halt: Dann wird gerechnet und entschieden, statt still weiter + Farben danebenzulegen. */ const GRENZE = 0.09; -const HOECHSTENS = 48; // darueber ist GRENZE nicht mehr erreichbar +const HOECHSTENS = 48; // ab 49 fand die Suche nichts mehr, das GRENZE haelt ok(paare[0].d >= GRENZE, - `kleinster Abstand ${paare[0].d.toFixed(4)} (Grenze ${GRENZE}, ` - + `Decke fuer ${liste.length} Farben rund 0,097) ` + `kleinster Abstand ${paare[0].d.toFixed(4)} (Grenze ${GRENZE}; ` + + `am weitesten gefunden wurden rund 0,097 fuer ${liste.length} Farben) ` + `-- ${paare[0].a.hex}/${paare[0].b.hex}`); ok(liste.length <= HOECHSTENS, liste.length <= HOECHSTENS - ? `${liste.length} Farben -- bis ${HOECHSTENS} ist ${GRENZE} nachweislich erreichbar` - : `${liste.length} Farben: ueber ${HOECHSTENS} ist ein Mindestabstand von ` - + `${GRENZE} rechnerisch nicht mehr moeglich. Entweder weniger ` - + `Kacheln mit eigener Farbe, oder die Grenze neu verhandeln.`); + ? `${liste.length} Farben -- bis ${HOECHSTENS} ist ${GRENZE} gefunden worden` + : `${liste.length} Farben: fuer so viele hat tools/kachelton-decke.mjs ` + + `keine Anordnung mit ${GRENZE} mehr gefunden. Erst rechnen, dann ` + + `entscheiden -- weniger eigene Farben, oder die Grenze neu ansehen.`); /* GEGENPROBE. Eine Pruefung, die immer bestaetigt, bestaetigt nichts: Zwei Farben, die sich um einen einzigen Bildpunktwert diff --git a/tools/kachelton-decke.mjs b/tools/kachelton-decke.mjs new file mode 100644 index 00000000..e3d50139 --- /dev/null +++ b/tools/kachelton-decke.mjs @@ -0,0 +1,139 @@ +/* ===================================================================== + WIE WEIT BEKOMMT MAN N FARBEN AUSEINANDER? (25.09.2026) + + Erst messen, dann bauen. Bevor an der Kachelpalette etwas geaendert + wird, muss die Frage beantwortet sein, welcher Mindestabstand fuer + eine gegebene Anzahl ueberhaupt zu SCHAFFEN ist -- sonst baut man + an einer Bedingung, die niemand erfuellen kann. Genau das war bei + „kein Paar unter 0,10" passiert. + + WAS DIESES WERKZEUG SAGT -- und was nicht. Es sucht: aus allen + erlaubten sRGB-Farben werden N so gewaehlt, dass der kleinste + Abstand moeglichst gross wird (erst weitester Punkt, dann tauschen, + solange es besser wird). + + Was herauskommt, ist ein Wert, der ERREICHBAR ist. Dafuer gibt es + eine Anordnung, und die kann man sich ansehen. + + Was NICHT herauskommt, ist eine Obergrenze. „Mehr habe ich nicht + gefunden" heisst nicht „mehr gibt es nicht" -- das Verfahren ist + ein Naeherungsverfahren. Wie sehr das zaehlt, zeigt der eigene + Lauf: Fuer 45 Farben kam es je nach Rasterweite und + Buntheitsgrenze einmal auf 0,0974 und einmal auf 0,0876. Wer die + erste Zahl eine „Decke" nennt, liest die Ungleichung falsch + herum. + + Die Schwellen kommen aus kachelton-regeln.mjs; ueber die Umgebung + lassen sie sich fuers Ausprobieren verschieben: + + ANZAHL=52 KONTRAST=4.6 MAXLEUCHTE=0.9 node tools/kachelton-decke.mjs 0.10 + + Der Zahlenwert hinter dem Namen ist die geforderte Mindestbuntheit; + mehrere Werte hintereinander vergleichen mehrere Annahmen. + ===================================================================== */ +/* Die Schwellen kommen aus der Regeldatei -- abgeschrieben waeren sie + eine zweite Fassung derselben Regel, und genau daran ist am 22.09. + schon einmal ein Farbvorschlag gescheitert (siehe dort). Der kleine + Aufschlag beim Kontrast ist Luft fuers Runden und steht als solcher + da; alles andere laesst sich ueber die Umgebung setzen, weil das + Ausprobieren von Annahmen der Zweck dieses Werkzeugs ist. */ +import { GRUND, MINDEST_KONTRAST } from "./kachelton-regeln.mjs"; + +const lin0 = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); +const GRUND_LEUCHTE = [1, 3, 5].map((i) => lin0(parseInt(GRUND.slice(i, i + 2), 16) / 255)) + .reduce((s, v, i) => s + [0.2126, 0.7152, 0.0722][i] * v, 0); +const MIN_KONTRAST = Number(process.env.KONTRAST || MINDEST_KONTRAST + 0.3); +const MAX_LEUCHTE = Number(process.env.MAXLEUCHTE || 0.82); +const ANZAHL = Number(process.env.ANZAHL || 45); + +const lin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); +function lab(r0, g0, b0) { + const r = lin(r0 / 255), g = lin(g0 / 255), b = lin(b0 / 255); + 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 = (r, g, b) => 0.2126 * lin(r / 255) + 0.7152 * lin(g / 255) + 0.0722 * lin(b / 255); + +function kandidaten(minBunt, schritt) { + const raus = []; + 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 y = leuchte(r, g, b); + if ((y + 0.05) / (GRUND_LEUCHTE + 0.05) < MIN_KONTRAST) continue; + if (y > MAX_LEUCHTE) continue; + const L = lab(r, g, b); + if (Math.hypot(L[1], L[2]) < minBunt) continue; + raus.push({ r, g, b, L }); + } + } + } + return raus; +} +const d2 = (a, b) => (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2; + +function packen(k, n) { + /* 1. Weitester Punkt zuerst -- das gibt schon eine gute Verteilung. */ + const gewaehlt = [k[Math.floor(k.length / 2)]]; + const naechste = k.map((p) => d2(p.L, gewaehlt[0].L)); + while (gewaehlt.length < n) { + let best = 0; + for (let i = 1; i < k.length; i++) if (naechste[i] > naechste[best]) best = i; + gewaehlt.push(k[best]); + for (let i = 0; i < k.length; i++) { + const d = d2(k[i].L, k[best].L); if (d < naechste[i]) naechste[i] = d; + } + } + /* 2. Tauschen: jeden Punkt durch den besten Kandidaten ersetzen. */ + const minOhne = (idx) => { + let m = Infinity; + for (let i = 0; i < gewaehlt.length; i++) { + if (i === idx) continue; + for (let j = i + 1; j < gewaehlt.length; j++) { + if (j === idx) continue; + const d = d2(gewaehlt[i].L, gewaehlt[j].L); if (d < m) m = d; + } + } + return m; + }; + for (let runde = 0; runde < 40; runde++) { + let geaendert = false; + for (let idx = 0; idx < gewaehlt.length; idx++) { + const grenze = minOhne(idx); + let besterWert = -1, bester = gewaehlt[idx]; + for (const p of k) { + let m = Infinity; + for (let i = 0; i < gewaehlt.length; i++) { + if (i === idx) continue; + const d = d2(p.L, gewaehlt[i].L); if (d < m) { m = d; if (m <= besterWert) break; } + } + if (m > besterWert) { besterWert = m; bester = p; } + } + const jetzt = Math.min(grenze, Math.min(...gewaehlt.map((q, i) => + (i === idx ? Infinity : d2(gewaehlt[idx].L, q.L))))); + if (Math.min(grenze, besterWert) > jetzt + 1e-12) { gewaehlt[idx] = bester; geaendert = true; } + } + if (!geaendert) break; + } + let m = Infinity; + for (let i = 0; i < gewaehlt.length; i++) { + for (let j = i + 1; j < gewaehlt.length; j++) m = Math.min(m, d2(gewaehlt[i].L, gewaehlt[j].L)); + } + return { gewaehlt, min: Math.sqrt(m) }; +} + +const hex = (p) => "#" + [p.r, p.g, p.b].map((c) => c.toString(16).padStart(2, "0")).join(""); +const stufen = process.argv.slice(2).length + ? process.argv.slice(2).map(Number) : [0, 0.06, 0.09, 0.12, 0.15]; + +for (const mb of stufen) { + const k = kandidaten(mb, 5); + const { gewaehlt, min } = packen(k, ANZAHL); + console.log(`Buntheit >= ${mb.toFixed(2)} ${k.length} Kandidaten ` + + `${ANZAHL} Farben, kleinster Abstand ${min.toFixed(4)}`); + if (process.env.ZEIGEN) console.log(" " + gewaehlt.map(hex).join(" ")); +} diff --git a/tools/kachelton-entzerren.mjs b/tools/kachelton-entzerren.mjs new file mode 100644 index 00000000..c65077a5 --- /dev/null +++ b/tools/kachelton-entzerren.mjs @@ -0,0 +1,300 @@ +/* ===================================================================== + 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."); +} diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index 633ab73a..9808121c 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,8 +16,8 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +