frontend: optionale Abzweige gestrichelt + Stiel trifft die Knotenmitte

Zwei Nachbesserungen aus dem ersten Blick auf `+` (D29).

**Gestrichelter Abzweig.** Der hohle Kreis allein war zu leise. Der Einwand
gegen einen dritten Linienstil bleibt richtig, greift aber nicht so weit wie
gedacht: Gestrichelt wird NUR der Abzweig zum Knoten (nicht die Sammelleiste —
die gehört der ganzen Gruppe) und zwar in TINTE, während die any-of-Linien
gestrichelt in Grau sind. Entscheidend ist, dass beide sich nie am selben
Verteiler treffen können: | darf nach SPEC §3 nicht mit -/+ gemischt werden.
Der Kreis bleibt zusätzlich — er sagt, WELCHER Knoten gemeint ist.

Umgesetzt an den vorhandenen Pseudoelementen, ohne neue Zeichenebene: im
Fächer der senkrechte Stiel (border-left von ::after, beim letzten Kind
border-right von ::before), gestapelt der waagerechte Ast (border-top von
::before); die jeweils andere Kante ist die Leiste und bleibt durchgezogen.
Dafür braucht auch das <li> die Klasse `opt` — den Abzweig zeichnet es, nicht
der Knoten. Der SVG-Export zieht mit (dash || isOpt).

**Stiel trifft die Knotenmitte auch waagerecht (`--stem-x`).** Dabei fiel ein
älterer Fehler auf, gemeldet als „die Linie zu Wahl trifft den Knoten nicht":
Im horizontalen Fächer lief der Stiel zu einem Knoten mit any-of-Kindern neben
dem Knoten vorbei (gemessen 13,4 px). Dieselbe Verwechslung, die D10 senkrecht
schon behoben hat, nur in der anderen Achse — der Stiel saß bei 50 % der ZELLE,
und das ist nur die Knotenmitte, solange der Knoten darin zentriert steht.
`li.has-or` ist aber flex-start (der Knoten steht links, damit die
any-of-Sammelleiste unter ihm aufsetzt), und die Zelle ist so breit wie der
Teilbaum.

Rein in CSS nicht lösbar: gebraucht wird die Knotenbreite, und kein Selektor
macht sie einer Elternregel zugänglich (Anchor Positioning ist Chrome-only).
`alignStems()` misst deshalb nach jedem render() und in applyLayout() die
Knotenmitte der betroffenen Zellen und setzt `--stem-x`; die Pseudoelemente
rechnen über left:var(--stem-x, 50%) bzw. right:calc(100% - var(--stem-x, 50%)).
Der Rückfallwert 50 % hält alle übrigen Zellen ohne Messung richtig, die
transponierten Modi setzen left/right ohnehin fest. Messwerte werden wie in
drawCheapPath() durch `zoom` zurückgerechnet.

Verifiziert: Vitest 60/60 (2 neue Tests: `opt` auch am <li> neben dem Gate der
eigenen Kinder; <li> ohne Attribut, wenn weder Kinder noch optional).
Im Browser gemessen: Abweichung Stiel↔Knotenmitte bei „Wahl" 13,4 px → 0,0 px,
alle übrigen Zellen unverändert 0,0 px ohne gesetzte Variable. Angesehen in
horizontal und kompakt: gestrichelter Ast in Tinte zum Kreis, Sammelleiste
durchgezogen, deutlich unterscheidbar vom gestrichelt-grauen any-of-Ast
daneben. SVG-Export gerendert geprüft: 2 von 9 Tinte-Linien gestrichelt, beide
Kreise am Auftreffpunkt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-07-27 21:11:20 +02:00
co-authored by Claude Opus 4.8
parent aa3e087ee6
commit 091e8849d8
8 changed files with 157 additions and 26 deletions
+28 -5
View File
@@ -84,6 +84,7 @@ function render(){
warnings = warnings.slice().sort((a, b) => (a.line || 0) - (b.line || 0));
warnBox.innerHTML = warnings.map(w => `<div>⚠ ${formatWarning(w, t)}</div>`).join('');
alignStems();
drawCheapPath();
/* Der Baum ist neu gebaut — die Markierung der Cursor-Zeile neu setzen (D25).
Ohne Scrollen: beim Tippen soll das Diagramm stehen bleiben. */
@@ -119,6 +120,25 @@ function overlaySvg(cls, w, h){
return svgEl('svg', {class:'cheap-overlay ' + cls, width:w, height:h,
viewBox:`0 0 ${w.toFixed(1)} ${h.toFixed(1)}`});
}
/* Stielposition der all-of-Abzweige im horizontalen Fächer (siehe style.css).
Nur `li.has-or` braucht die Messung: dort steht der Knoten linksbündig, das
<li> ist aber so breit wie sein any-of-Teilbaum — der Stiel bei 50 % liefe am
Knoten vorbei. Die transponierten Modi setzen left/right ohnehin fest und
bleiben deshalb unberührt. Messwerte sind durch `zoom` skaliert und werden
wie in drawCheapPath() zurückgerechnet. */
function alignStems(){
out.querySelectorAll('ul.and>li').forEach(li => li.style.removeProperty('--stem-x'));
if(out.classList.contains('vertical') || out.classList.contains('kompakt')) return;
const z = zoom || 1;
out.querySelectorAll('ul.and>li.has-or').forEach(li => {
const node = li.querySelector(':scope > .node');
if(!node) return;
const lr = li.getBoundingClientRect(), nr = node.getBoundingClientRect();
if(!lr.width) return; /* Panel eingeklappt */
li.style.setProperty('--stem-x', ((nr.left - lr.left + nr.width/2)/z).toFixed(1) + 'px');
});
}
function drawCheapPath(){
out.querySelectorAll('svg.cheap-overlay').forEach(e => e.remove());
if(!cheapPathOn) return;
@@ -190,7 +210,7 @@ function diagramToSvg(){
.map(cli => cli.querySelector(':scope > .node, :scope > a.node')).filter(Boolean);
const kids = kidEls.map(R);
if(!kids.length) return;
const markOpt = (i, x, y) => { if(kidEls[i].classList.contains('opt')) optMarks.push({x, y}); };
const isOpt = i => kidEls[i].classList.contains('opt');
const p = R(parentEl);
const avgdx = kids.reduce((s,k)=>s+(k.cx-p.cx),0)/kids.length;
const avgdy = kids.reduce((s,k)=>s+(k.cy-p.cy),0)/kids.length;
@@ -202,8 +222,9 @@ function diagramToSvg(){
parts.push(seg(px, p.cy, busX, p.cy, stroke, dash));
parts.push(seg(busX, Math.min(...ys), busX, Math.max(...ys), stroke, dash));
kids.forEach((k, i) => {
parts.push(seg(busX, k.cy, toRight?k.x:k.r, k.cy, stroke, dash));
markOpt(i, toRight?k.x:k.r, k.cy);
const x = toRight ? k.x : k.r, o = isOpt(i);
parts.push(seg(busX, k.cy, x, k.cy, stroke, dash || o));
if(o) optMarks.push({x, y: k.cy});
});
} else { /* oben→unten */
const toDown = avgdy >= 0;
@@ -213,8 +234,9 @@ function diagramToSvg(){
parts.push(seg(p.cx, py, p.cx, busY, stroke, dash));
parts.push(seg(Math.min(...xs), busY, Math.max(...xs), busY, stroke, dash));
kids.forEach((k, i) => {
parts.push(seg(k.cx, busY, k.cx, toDown?k.y:k.b, stroke, dash));
markOpt(i, k.cx, toDown?k.y:k.b);
const y = toDown ? k.y : k.b, o = isOpt(i);
parts.push(seg(k.cx, busY, k.cx, y, stroke, dash || o));
if(o) optMarks.push({x: k.cx, y});
});
}
});
@@ -530,6 +552,7 @@ function applyLayout(mode){
out.classList.toggle('kompakt', mode === 'kompakt');
app.classList.toggle('side', mode !== 'horizontal');
if(!isMobile()) applySplit(); /* Desktop: Preset neu setzen. Mobil: freie --drow-Aufteilung behalten */
alignStems(); /* Stiel gilt nur im Fächer — beim Moduswechsel neu setzen/löschen */
drawCheapPath(); /* Blatt-Positionen ändern sich mit dem Modus */
}
document.querySelectorAll('input[name="layout"]').forEach(radio => {
+15 -2
View File
@@ -23,6 +23,17 @@ function extraCls(n, opts){
return [cheap, fresh, n.optional ? 'opt' : ''].filter(Boolean).join(' ');
}
/* Klassen des <li>: Gate der eigenen Kinder (steuert die Anordnung) plus
`opt`, wenn der Knoten selbst optional ist (steuert den Strich des
Abzweigs). Leere Liste ⇒ gar kein Attribut. */
function liClass(visibleKids, opts, optional){
const cls = [
visibleKids.length ? (gateOf(visibleKids) === 'or' ? 'has-or' : 'has-and') : '',
optional ? 'opt' : ''
].filter(Boolean);
return cls.length ? ` class="${cls.join(' ')}"` : '';
}
export function esc(s){
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
@@ -98,7 +109,9 @@ function renderChildren(node, warnings, opts){
const gate = gateOf(kids);
const items = kids.map(k => {
const vk = visibleChildren(k, opts.showDiscarded);
const liCls = vk.length ? (gateOf(vk) === 'or' ? ' class="has-or"' : ' class="has-and"') : '';
/* `opt` auch am <li>: den Abzweig zeichnen dessen Pseudoelemente, er wird
für optionale Knoten gestrichelt (D29). */
const liCls = liClass(vk, opts, k.optional);
return `<li${liCls}>` +
nodeHtml(k, extraCls(k, opts), opts) +
renderChildren(k, warnings, opts) +
@@ -114,7 +127,7 @@ export function renderTreeHtml(roots, opts){
const warnings = [];
const html = roots.map(root => {
const vk = visibleChildren(root, opts.showDiscarded);
const liCls = vk.length ? (gateOf(vk) === 'or' ? ' class="has-or"' : ' class="has-and"') : '';
const liCls = liClass(vk, opts, root.optional);
return `<li${liCls}>` +
nodeHtml(root, ('root-node ' + extraCls(root, opts)).trim(), opts) +
renderChildren(root, warnings, opts) +
+27 -4
View File
@@ -570,12 +570,19 @@
content:'';position:absolute;top:0;left:50%;height:14px;border-left:2px solid var(--line);
}
ul.and>li{padding:16px 12px 0}
/* Der Stiel liegt bei `--stem-x` — normalerweise 50 % des <li>, was die
Knotenmitte ist, solange der Knoten im <li> zentriert steht. Bei
`li.has-or` ist er das NICHT (linksbündig, damit die any-of-Leiste unter
ihm aufsetzt) und der Teilbaum macht das <li> breiter als den Knoten: der
Stiel liefe daneben vorbei. `alignStems()` in app.js misst dort die
Knotenmitte und setzt `--stem-x` — D10 in der Waagerechten. */
ul.and>li::before,
ul.and>li::after{
content:'';position:absolute;top:0;right:50%;
border-top:2px solid var(--line);width:50%;height:16px;
content:'';position:absolute;top:0;
left:0;right:calc(100% - var(--stem-x, 50%));
border-top:2px solid var(--line);height:16px;
}
ul.and>li::after{right:auto;left:50%;border-left:2px solid var(--line)}
ul.and>li::after{left:var(--stem-x, 50%);right:0;border-left:2px solid var(--line)}
ul.and>li:only-child{padding-top:14px}
ul.and>li:only-child::before,ul.and>li:only-child::after{border-top:none}
ul.and>li:only-child::after{border-left:2px solid var(--line);height:14px}
@@ -631,7 +638,23 @@
font-weight:400;
}
/* --- OPTIONAL (`+`, SPEC §3/D29): hohler Kreis am Abzweig ---
/* --- OPTIONAL (`+`, SPEC §3/D29): gestrichelter Abzweig + hohler Kreis ---
Gestrichelt wird NUR der Abzweig zum Knoten, nicht die Sammelleiste — die
gehört der ganzen Geschwistergruppe. Im Fächer ist der Abzweig der
senkrechte Stiel (border-left von ::after, beim letzten Kind border-right
von ::before), in den gestapelten Anordnungen der waagerechte Ast
(border-top von ::before); die Leiste ist jeweils die andere Kante.
Gestrichelt in TINTE — any-of ist gestrichelt in Grau (D15), die Farbe
hält beide auseinander, der Kreis unten bestätigt es. */
ul.and>li.opt::after{border-left-style:dashed}
ul.and>li.opt:last-child::before{border-right-style:dashed}
.tree.vertical ul.and>li.opt::before,
.tree.kompakt ul.and>li.opt::before,
.tree:not(.vertical):not(.kompakt) ul.or>li.has-and>ul.and>li.opt::before{
border-top-style:dashed;
}
/* Hohler Kreis am Abzweig ---
Kein dritter Linienstil: gestrichelt gehört any-of, und im kompakten Modus
codiert allein der Stil das Gate (D15). Stattdessen die FODA-Konvention —
gefüllter Punkt = erforderlich (hier: gar keiner), hohler Punkt = optional.