diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index a2f625c..f2f6b0e 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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 `
  • ` +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. diff --git a/docs/SPEC.md b/docs/SPEC.md index eeef18e..3c18bb8 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -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° diff --git a/docs/examples/example-werkbaum.werkbaum b/docs/examples/example-werkbaum.werkbaum index 1787275..889b58c 100644 --- a/docs/examples/example-werkbaum.werkbaum +++ b/docs/examples/example-werkbaum.werkbaum @@ -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) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 7588bb8..42e4a90 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -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 „`
  • ` + 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 `
  • `. 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 diff --git a/frontend/src/app.js b/frontend/src/app.js index 1ae7bc7..837db2c 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -84,6 +84,7 @@ function render(){ warnings = warnings.slice().sort((a, b) => (a.line || 0) - (b.line || 0)); warnBox.innerHTML = warnings.map(w => `
    ⚠ ${formatWarning(w, t)}
    `).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
  • 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 */ } diff --git a/frontend/src/style.css b/frontend/src/style.css index 8395e04..14e319a 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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)}