frontend: Treppe für aufeinanderfolgende optionale Endknoten

Im horizontalen Fächer kostet jedes optionale Geschwister eine eigene Spalte —
Breite für gerade das, was am entbehrlichsten ist. Zwei oder mehr
aufeinanderfolgende optionale ENDknoten werden deshalb als Kaskade gestapelt:
erste Stufe am Stiel von oben wie ein gewöhnliches Kind, jede weitere eine
Stufe tiefer und weiter rechts, an einem gestrichelten Winkel, der an der
linken Kante der vorigen Stufe herabfällt und waagerecht in ihren Kreis
einbiegt.

Verworfen: die naheliegendere SENKRECHTE Spalte unter einem Anschlusspunkt.
Schmaler und in einem Bruchteil der Zeit gebaut — sähe aber fast genau aus wie
eine any-of-Gruppe (gestapelte Spalte an gestrichelter Leiste), unterschieden
nur durch Tinte statt Grau. Genau diese Verwechslung zu vermeiden ist der Zweck
von Kreis und Farbgebung. Ebenfalls verworfen: eine echte Diagonale —
Rahmenkanten sind achsenparallel, sie bräuchte die SVG-Ebene und wäre eine
zweite Zeichenebene neben allen anderen Linien, nachzuführen bei jedem Rendern,
Moduswechsel und Zoom. Rechte Winkel geben denselben Kaskaden-Eindruck im
vorhandenen Mechanismus.

- Nur Endknoten: Der Platzgewinn entsteht gerade daraus, dass kein Teilbaum
  mitgestapelt wird — ein optionaler Knoten MIT Kindern spart nichts und behält
  seine Spalte. Technisch dasselbe: die Stufengeometrie setzt Zellenhöhe ==
  Knotenhöhe voraus. Geprüft wird genau das (ein Element-Kind, und das ist der
  Knoten) — schließt auch den Geister-Knoten aus.
- Gruppiert wird in app.js, nicht im Renderer. Die DOM-Ebene gibt es semantisch
  nicht und müsste in den drei übrigen Anordnungen (vertikal, kompakt, all-of
  unter any-of) wieder neutralisiert werden, jede mit hand-getunter Geometrie.
  `display:contents` löst das nicht: richtet die Boxen, aber die >-Selektoren
  jener Regeln greifen weiter auf dem DOM. applyOptStairs() baut die Gruppe nur
  im Fächer und löst sie beim Moduswechsel auf — dieselbe Kategorie wie
  alignStems() und drawCheapPath(); SPEC §9 bleibt für den RENDERER wörtlich
  wahr, Lese- und Fokusreihenfolge unberührt.
- Der Export folgt der Kaskade. Erste Fassung reihte alle Stufen flach als
  Kinder ein und ließ den Export selbst routen — die Linie zur dritten Stufe
  lief dann HINTER der zweiten hindurch und las sich wie eine Eltern-Kind-
  Beziehung. Keine Schönheitsfrage, sondern eine falsche Aussage über die
  Struktur. Jetzt hängt nur die erste Stufe an der Sammelleiste.

Nicht durch Tests gedeckt: applyOptStairs() arbeitet auf dem DOM, und für
app.js gibt es keine Testumgebung (die Suite prüft die headless-Module).
Verifiziert im Browser: Kaskade in horizontal (drei Stufen, Winkel treffen die
Kreise), Moduswechsel horizontal↔kompakt↔vertikal mehrfach hin und zurück —
Gruppe entsteht und löst sich rückstandsfrei auf, Knotenzahl (9) und
Dokumentordnung in allen drei Modi unverändert, keine übrig gebliebenen
--i-Variablen. SVG-Export gerendert geprüft: drei Kreise, Winkelkette statt
kreuzender Linien. Vitest 60/60 (Renderer unverändert).

Im mitgelieferten Werkbaum-Plan stehen die beiden Editor-Zugaben jetzt
nebeneinander und zeigen die Treppe. Reihenfolgeänderung ist für „Was ist neu?"
folgenlos — die Knoten-Identität ist der Label-Pfad (D28), test-abgedeckt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-07-27 21:25:22 +02:00
co-authored by Claude Opus 4.8
parent 091e8849d8
commit 56c18cb8cb
6 changed files with 203 additions and 5 deletions
+54
View File
@@ -850,3 +850,57 @@ Rückfallwert 50 % hält alle übrigen Zellen ohne Messung richtig, und die
transponierten Modi setzen `left`/`right` ohnehin fest und bleiben unberührt.
Gemessen wird — wie in `drawCheapPath()` — durch `zoom` zurückgerechnet, sodass
der Wert beim Zoomen gültig bleibt.
**Nachtrag 3 — Treppe für mehrere optionale Endknoten.** Im horizontalen Fächer
kostet jedes optionale Geschwister eine eigene Spalte — Breite für gerade das,
was am entbehrlichsten ist. Aufeinanderfolgende optionale Endknoten werden
deshalb als **Kaskade** gestapelt (Nutzerwunsch: „mehrere optionale Knoten in
einer diagonalen Linie an eine Anschlussstelle").
Verworfen wurde die naheliegendere **senkrechte Spalte** unter einem
Anschlusspunkt: Sie wäre schmaler und in einem Bruchteil der Zeit gebaut, sähe
aber fast genau aus wie eine **any-of-Gruppe** (gestapelte Spalte an
gestrichelter Leiste), unterschieden nur durch Tinte statt Grau. Genau diese
Verwechslung zu vermeiden ist der Zweck von Kreis und Farbgebung. Die Treppe
kauft Eindeutigkeit für etwas Breite — schon die Form ist eine andere.
Ebenfalls verworfen: eine **echte** Diagonale. Rahmenkanten sind achsenparallel;
sie bräuchte die SVG-Ebene (die es für den Pfad-Spline gibt) und wäre damit eine
**zweite Zeichenebene** neben allen anderen Linien, nachzuführen bei jedem
Rendern, Moduswechsel und Zoom. Der gestufte Anschluss aus rechten Winkeln gibt
denselben Kaskaden-Eindruck im vorhandenen Mechanismus.
**Nur Endknoten.** Der Platzgewinn entsteht gerade daraus, dass kein Teilbaum
mitgestapelt werden muss — ein optionaler Knoten *mit* Kindern spart in der
Treppe nichts und behält seine Spalte. Technisch kommt dasselbe heraus: Die
Stufengeometrie rechnet mit einem festen Abstand von Knotenunterkante zur
nächsten Stufe und setzt deshalb voraus, dass die Zelle so hoch ist wie ihr
Knoten (kein Teilbaum, kein Geister-Knoten). Geprüft wird genau das: das `<li>`
hat exakt ein Element-Kind, und das ist der Knoten.
**Gruppiert wird in app.js, nicht im Renderer.** Die Gruppierung
(`li.opt-group > ul.opt-stair`, Stufennummer als `--i`) ist reine Darstellung.
Im Renderer hätte sie eine DOM-Ebene erzeugt, die es semantisch nicht gibt —
und die in den **drei übrigen Anordnungen** (vertikal, kompakt, all-of unter
any-of) wieder hätte neutralisiert werden müssen, jede mit hand-getunter
Geometrie. `display:contents` löst das nicht: Es richtet die Boxen, aber die
`>`-Selektoren jener Regeln greifen weiter auf dem DOM und passen dann nicht
mehr. `applyOptStairs()` baut die Gruppe deshalb nur im Fächer und löst sie beim
Moduswechsel wieder auf — dieselbe Kategorie wie `alignStems()` und
`drawCheapPath()`, und SPEC §9 („der Modus ändert nur die Anordnung") bleibt für
den **Renderer** wörtlich wahr. Lese- und Fokusreihenfolge bleiben unberührt,
weil nur umgehängt und nichts umsortiert wird.
**Der Export folgt der Kaskade.** Erste Fassung reihte alle Stufen flach als
Kinder ein und ließ den Export selbst routen (er zieht Linien ohnehin unabhängig
vom Darstellungsmodus neu). Ergebnis: Die Linie zur dritten Stufe lief **hinter
der zweiten hindurch** und las sich wie eine Eltern-Kind-Beziehung — keine
Schönheitsfrage, sondern eine falsche Aussage über die Struktur. Jetzt hängt nur
die **erste** Stufe an der Sammelleiste, die übrigen bekommen denselben Winkel
wie am Bildschirm.
**Nicht durch Tests gedeckt:** `applyOptStairs()` arbeitet auf dem DOM, und für
`app.js` gibt es keine Testumgebung (die Vitest-Suite prüft die headless-Module).
Geprüft wurde im Browser — auch der Moduswechsel hin und zurück, mehrfach: Die
Gruppe entsteht und löst sich rückstandsfrei auf, Knotenzahl und Dokumentordnung
bleiben in allen drei Modi gleich.
+13
View File
@@ -153,6 +153,19 @@ Siehe D29.
- **all of:** Kinder nebeneinander, klassischer Organigramm-Fächer.
- **any of:** Alternativen untereinander; gestrichelte graue Sammelleiste links
unterhalb des Parents, gestrichelte graue Abzweige zu den Alternativen.
- **Treppe für optionale Endknoten:** Stehen **zwei oder mehr** optionale
**Endknoten** (`+`, §3) unmittelbar nebeneinander, bekommen sie nicht je eine
eigene Spalte, sondern werden als **Kaskade** gestapelt: Die erste Stufe hängt
am Stiel von oben wie ein gewöhnliches Kind, jede weitere steht eine Stufe
tiefer und weiter rechts und hängt an einem gestrichelten Winkel, der an der
linken Kante der vorigen Stufe herabfällt und waagerecht in ihren Kreis
einbiegt. Das spart Breite genau dort, wo das Entbehrlichste steht.
Es bleiben **Geschwister** — die Treppe ist eine Anordnung, keine Ebene, und
ändert weder Lese-/Fokusreihenfolge noch `aria-label`. Nur Endknoten, weil der
Platzgewinn gerade daher rührt, dass kein Teilbaum mitgestapelt wird; ein
optionaler Knoten **mit** Kindern behält seine Spalte. Die transponierten Modi
kennen die Treppe nicht — dort stehen die Kinder ohnehin untereinander. Der
Grafikexport folgt der Kaskade.
### Vertikal (transponiert)
- **all of:** exakter transponierter Organigramm-Fächer (horizontal um 90°
+1 -1
View File
@@ -37,8 +37,8 @@
- [^] Switcher in the editor title bar (S)
- [^] Load a document from ?sourceUrl= (S)
- [^] Jump between diagram and text (S)
+ [^] Show what is new since your last visit (S)
- [ ] Open and save .werkbaum files (S)
+ [^] Show what is new since your last visit (S)
+ [?] A pastel colour per person (S)
- [?] Dates and milestones (M)
| [?] An attribute in the line (S)
+12
View File
@@ -197,6 +197,18 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
Abzweig zeichnen dessen Pseudoelemente. Gestrichelt wird nur die Kante zum
Knoten, nie die Sammelleiste — im Fächer `border-left`/`-right`, gestapelt
`border-top`.
- Treppe optionaler Endknoten (D29, Nachtrag 3): `applyOptStairs()` gruppiert
**in app.js**, nicht im Renderer — die DOM-Ebene `li.opt-group > ul.opt-stair`
gibt es semantisch nicht und müsste in den drei übrigen Anordnungen wieder
neutralisiert werden. Deshalb: nur im Fächer bauen, beim Moduswechsel
auflösen (die Funktion räumt immer zuerst auf, `applyLayout` arbeitet auf dem
bereits gruppierten Baum). Reihenfolge: `applyOptStairs()` **vor**
`alignStems()`/`drawCheapPath()`, es verschiebt Knoten. Bedingung ist „`<li>`
hat genau ein Element-Kind, und das ist der Knoten" — also kein Teilbaum und
kein Geister-Knoten, weil die Stufengeometrie Zellenhöhe == Knotenhöhe
voraussetzt. Der SVG-Export hängt nur die **erste** Stufe an die Leiste und
zieht die übrigen als Winkelkette nach; flach eingereiht lief die Linie zur
dritten Stufe hinter der zweiten hindurch.
- `--stem-x` (D29, Nachtrag 2): Im horizontalen Fächer sitzt der Stiel bei 50 %
des `<li>`. Das ist nur dann die Knotenmitte, wenn der Knoten in der Zelle
zentriert steht — `li.has-or` ist aber linksbündig und die Zelle so breit wie
+83 -4
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('');
applyOptStairs(); /* muss vor dem Messen laufen — es verschiebt Knoten */
alignStems();
drawCheapPath();
/* Der Baum ist neu gebaut — die Markierung der Cursor-Zeile neu setzen (D25).
@@ -120,6 +121,56 @@ 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)}`});
}
/* ---------- Treppe für optionale Endknoten (D29, Nachtrag 3) ----------
Im horizontalen Fächer kostet jedes optionale Geschwister eine eigene Spalte
— Breite für gerade das, was am wenigsten wichtig ist. Aufeinanderfolgende
optionale Endknoten werden deshalb zu einer Kaskade gestapelt, die an einem
Punkt hängt.
Warum hier und nicht im Renderer: Die Gruppierung ist reine Darstellung. So
bleiben der Renderer-String (SPEC §9: der Modus ändert nur die Anordnung) und
die hand-getunte Geometrie der drei übrigen Anordnungen unberührt — dort gibt
es das Platzproblem gar nicht, die Kinder stehen ohnehin untereinander.
Nur ENDknoten: Der Platzgewinn entsteht gerade daraus, dass kein Teilbaum
mitgestapelt werden muss; außerdem setzt die Stufengeometrie voraus, dass die
Zelle so hoch ist wie ihr Knoten (kein Teilbaum, kein Geister-Knoten). */
const STAIR_MIN = 2;
function applyOptStairs(){
/* Erst auflösen: applyLayout() arbeitet auf einem bereits gruppierten Baum. */
out.querySelectorAll('li.opt-group').forEach(group => {
const stair = group.firstElementChild;
while(stair.firstElementChild){
const li = stair.firstElementChild;
li.style.removeProperty('--i');
group.parentNode.insertBefore(li, group);
}
group.remove();
});
if(out.classList.contains('vertical') || out.classList.contains('kompakt')) return;
out.querySelectorAll('ul.and').forEach(ul => {
let run = [];
const flush = () => {
if(run.length >= STAIR_MIN){
const group = document.createElement('li');
group.className = 'opt-group';
const stair = document.createElement('ul');
stair.className = 'opt-stair';
ul.insertBefore(group, run[0]);
group.appendChild(stair);
run.forEach((li, i) => { li.style.setProperty('--i', i); stair.appendChild(li); });
}
run = [];
};
for(const li of [...ul.children]){
const leaf = li.classList.contains('opt') && li.children.length === 1
&& li.firstElementChild.classList.contains('node');
if(leaf) run.push(li); else flush();
}
flush();
});
}
/* 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
@@ -130,8 +181,10 @@ 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');
/* `li.opt-group` (Treppe) hat keinen eigenen Knoten — der Stiel zielt auf den
ERSTEN Knoten der Kaskade. */
out.querySelectorAll('ul.and>li.has-or, ul.and>li.opt-group').forEach(li => {
const node = li.querySelector(':scope > .node') || li.querySelector('.node');
if(!node) return;
const lr = li.getBoundingClientRect(), nr = node.getBoundingClientRect();
if(!lr.width) return; /* Panel eingeklappt */
@@ -206,8 +259,23 @@ function diagramToSvg(){
const gate = childUl.classList.contains('or') ? 'or' : 'and';
const stroke = gate === 'or' ? '#6B7A8C' : '#41556E';
const dash = gate === 'or';
const kidEls = [...childUl.children]
.map(cli => cli.querySelector(':scope > .node, :scope > a.node')).filter(Boolean);
/* Die Treppe (D29) ist eine Anordnung, keine Ebene: alle Stufen sind Kinder
DIESES Elternknotens. An die Sammelleiste kommt nur die erste Stufe; die
übrigen hängen an der Kaskade und werden unten nachgezogen. Zöge man jede
Stufe einzeln an die Leiste, liefe die Linie zur dritten Stufe hinter der
zweiten hindurch — und läse sich wie eine Eltern-Kind-Beziehung. */
const kidEls = [], stairs = [];
for(const cli of childUl.children){
if(cli.classList.contains('opt-group')){
const st = [...cli.querySelectorAll(':scope > ul.opt-stair > li > .node')];
if(!st.length) continue;
kidEls.push(st[0]);
if(st.length > 1) stairs.push(st);
} else {
const n = cli.querySelector(':scope > .node');
if(n) kidEls.push(n);
}
}
const kids = kidEls.map(R);
if(!kids.length) return;
const isOpt = i => kidEls[i].classList.contains('opt');
@@ -239,6 +307,16 @@ function diagramToSvg(){
if(o) optMarks.push({x: k.cx, y});
});
}
/* Kaskade ab der zweiten Stufe: an der linken Kante der vorigen Stufe
herab, dann waagerecht in die eigene — dieselbe Führung wie am Bildschirm. */
stairs.forEach(st => {
for(let j = 1; j < st.length; j++){
const a = R(st[j-1]), b = R(st[j]);
parts.push(seg(a.x, a.b, a.x, b.cy, stroke, true));
parts.push(seg(a.x, b.cy, b.x, b.cy, stroke, true));
optMarks.push({x: b.x, y: b.cy});
}
});
});
/* 1b) Günstigster-Pfad: kräftige Linie hinter den Knoten */
@@ -552,6 +630,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 */
applyOptStairs(); /* Treppe gilt nur im Fächer — beim Moduswechsel bauen/auflösen */
alignStems(); /* Stiel gilt nur im Fächer — beim Moduswechsel neu setzen/löschen */
drawCheapPath(); /* Blatt-Positionen ändern sich mit dem Modus */
}
+40
View File
@@ -678,6 +678,46 @@
left:-5px;top:50%;transform:translateY(-50%);
}
/* --- TREPPE: aufeinanderfolgende optionale Endknoten (D29, Nachtrag 3) ---
Nur im horizontalen Fächer, wo jedes Geschwister sonst eine eigene Spalte
kostet. `applyOptStairs()` in app.js gruppiert sie in
`li.opt-group > ul.opt-stair` und nummeriert die Stufen als `--i` (0-basiert).
Geometrie: Stufe 0 hängt am Stiel von oben wie ein gewöhnliches Kind
(`--stem-x` zielt auf sie). Jede weitere Stufe steht `--stair-step` weiter
rechts und hängt an einem gestrichelten Winkel, der bündig an der linken
Kante der VORIGEN Stufe herabfällt und waagerecht in den eigenen Kreis
einbiegt. Das setzt voraus, dass die Zelle so hoch ist wie ihr Knoten —
deshalb nur Endknoten. */
.tree:not(.vertical):not(.kompakt) ul.opt-stair{
--stair-step:26px; --stair-gap:12px;
display:flex;flex-direction:column;align-items:flex-start;position:relative;
}
.tree:not(.vertical):not(.kompakt) ul.opt-stair>li{
align-items:flex-start;
padding:var(--stair-gap) 0 0 calc(var(--i) * var(--stair-step));
}
.tree:not(.vertical):not(.kompakt) ul.opt-stair>li:first-child{padding-top:0}
/* Winkel zur vorigen Stufe: senkrecht herab, dann waagerecht in den Knoten.
Der Knick liegt auf der linken Kante der vorigen Stufe. */
.tree:not(.vertical):not(.kompakt) ul.opt-stair>li+li::before,
.tree:not(.vertical):not(.kompakt) ul.opt-stair>li+li::after{
content:'';position:absolute;
left:calc((var(--i) - 1) * var(--stair-step));
}
.tree:not(.vertical):not(.kompakt) ul.opt-stair>li+li::after{
top:0;height:calc(var(--stair-gap) + 16px);
border-left:2px dashed var(--line);
}
.tree:not(.vertical):not(.kompakt) ul.opt-stair>li+li::before{
top:calc(var(--stair-gap) + 16px);width:var(--stair-step);
border-top:2px dashed var(--line);
}
/* Stufe 0 wird von oben getroffen (Kreis oben mittig), alle weiteren von
links — das ist bereits der Grundfall von `.node.opt::before`. */
.tree:not(.vertical):not(.kompakt) ul.opt-stair>li:first-child>.node.opt::before{
left:50%;top:-5px;transform:translateX(-50%);
}
/* ---------- Status (Pastell) ---------- */
.node.st-idee {background:#EBEDEF;border-color:#A2ABB5;color:var(--ink)}
.node.st-geplant {background:#EBE4F6;border-color:#A991D4;color:var(--ink)}