diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index ac4013f..0262969 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -2223,6 +2223,7 @@ Und der günstigste Pfad rechnet unverändert über den ganzen Baum; seine Spline-Linie läuft ohnehin nur durch DOM-Knoten, führt also durch die sichtbaren Endknoten. Ein eingeklappter Zweig kann Pfadknoten verbergen — hinnehmbar, die Inversion an den sichtbaren Knoten bleibt richtig. +**Das war falsch — siehe den Nachtrag am Ende dieses Eintrags.** **Export und Druck:** Verborgene Kinder stehen nicht im DOM — Export, Stiel-Messung, Treppe und Pfadlinie sind damit ohne Zusatzcode konsistent @@ -2231,6 +2232,38 @@ per CSS zu verstecken). Die Kennzeichnung „▸ n“ ist Teil des Knotentexts u wandert von selbst in den SVG-Export; das ▾ offener Knoten wird dort und im Druck entfernt — es ist Bedienelement, keine Aussage über den Plan. +**Nachtrag — der eingeklappte Knoten vertritt seinen Teilbaum auch auf dem +günstigsten Pfad.** Oben steht, ein eingeklappter Zweig dürfe Pfadknoten +verbergen, „die Inversion an den sichtbaren Knoten bleibt richtig". Das war zu +kurz gedacht, und der Nutzer hat es benannt: Die **Linie** blieb eben nicht +richtig. Sie fädelt durch die `cheap-leaf`-Knoten im DOM; ein eingeklappter +Pfadknoten ist `cheap`, aber kein Blatt (seine Pfad-Kinder sind ja im Modell +vorhanden) — also bekam er keine Station, und die Linie **umging den ganzen +Zweig**. Im mitgelieferten Beispiel nachgemessen: „Concept" war +`node cheap folded`, verbarg zwei Pfadknoten und trug keinen Punkt; die Linie +begann erst bei „PWA". Das Bild behauptete damit, in diesem Zweig sei nichts +zu tun — und das ist eine Aussage über den Plan, keine über die Ansicht. Genau +die Grenze, die dieser Eintrag sonst zieht. + +**Regel: Eingeklappt ist der Knoten die tiefste noch sichtbare Station.** +`cheapCls(n, cheapSet, collapsed)` überspringt für eingeklappte Knoten die +Blatt-Prüfung und fragt stattdessen den Teilbaum: Liegt darin etwas auf dem +Pfad (oder der Knoten selbst), ist er `cheap cheap-leaf`. Beim Aufklappen +geben die Kinder die Station zurück — nachgemessen 6 → 7 → 6 Stationen beim +Hin- und Herklappen, Punkte jeweils gleich. + +**Auch ohne eigene Pfad-Mitgliedschaft.** Ein per `:#…` gezogenes Ziel kann in +einem Zweig liegen, dessen Wurzel selbst nicht gebraucht wird (D42, etwa unter +einem `+`-Knoten). Ist dieser Zweig eingeklappt, ist seine Wurzel der einzige +sichtbare Griff auf nötige Arbeit — sie wird deshalb Station **und** zählt als +`cheap`, tritt also nicht zurück. Das ist keine Ausnahme, sondern dieselbe +Regel: Der eingeklappte Knoten **steht für** seinen Teilbaum, und der enthält +Nötiges. Ihn auszublassen wäre die Lüge. + +Der Preis ist benannt und klein: Ein eingeklappter Zweig zeigt **eine** Station +statt mehrerer. Das ist keine Ungenauigkeit, sondern die Aussage der Faltung — +„hier drin liegt noch Pfad", genauer geht es ohne Aufklappen nicht. + ## D39 — Effektiver Status: die Farbe sagt die Wahrheit, die Marke die Box Mit den Abhängigkeiten (D37) gibt es zwei Aussagen je Knoten: was in der Statusbox steht (intrinsisch) und wie weit er wirklich ist (effektiv, §4). diff --git a/docs/SPEC.md b/docs/SPEC.md index a8cf609..52293e0 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -670,8 +670,16 @@ fokussierten Knoten (WAI-ARIA-Baum-Idiom). dauerhafte Aussage steht im Text (D34). Ein Dokumentwechsel setzt zurück. - Faltung ist **reine Ansicht**: Warnungen aus eingeklappten Teilbäumen werden weiter gemeldet (sie gelten dem Text), und der günstigste Pfad rechnet - unverändert über den ganzen Baum — seine Linie zeigt nur die sichtbaren - Endknoten. + unverändert über den ganzen Baum. +- **Ein eingeklappter Knoten vertritt seinen Teilbaum auch auf dem günstigsten + Pfad:** Liegt darin etwas auf dem Pfad, ist er dessen tiefste noch sichtbare + Station — die Linie führt zu ihm und endet dort mit einem Stationspunkt + („hier drin liegt noch Pfad"). Ohne das überspränge sie den ganzen Zweig, als + wäre dort nichts zu tun. Beim Aufklappen geben die Station wieder die + Endknoten darunter. Das gilt auch, wenn der eingeklappte Knoten selbst nicht + gebraucht wird, sein Teilbaum aber schon (etwa ein per `:#…` gezogenes Ziel): + Er ist dann der einzige sichtbare Griff auf nötige Arbeit und tritt deshalb + auch nicht zurück. - **Export und Druck folgen der sichtbaren Faltung** (dieselbe Regel wie beim „verworfene einblenden“-Filter): Verborgene Teilbäume fehlen, eingeklappte Knoten behalten die Kennzeichnung „▸ n“ — das Bild behauptet damit keine diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 4e45ff4..9feb662 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -303,7 +303,15 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der Dokumentwechsel geleert) und übergibt `collapsedSet` an den Renderer; der lässt eingeklappte Kinder **weg** (nicht CSS-verstecken — Export, Messungen und Pfadlinie bleiben so von selbst konsistent), meldet deren Warnungen aber - weiter (`walkFolded`, zählt zugleich fürs „▸ n"). Umklappen: Klick aufs + weiter (`walkFolded`, zählt zugleich fürs „▸ n"). + **Auf dem günstigsten Pfad vertritt ein eingeklappter Knoten seinen + Teilbaum** (D38-Nachtrag): `cheapCls(n, cheapSet, collapsed)` überspringt für + ihn die Blatt-Prüfung und fragt `hidesCheap()` — sonst umginge die Pfad-Linie + den ganzen Zweig, als wäre dort nichts zu tun. Gilt auch ohne eigene + Pfad-Mitgliedschaft (ein per `:#…` gezogenes Ziel unter einem `+`-Knoten): + Dann ist er der einzige sichtbare Griff darauf und zählt als `cheap`. + `extraCls()` muss dieselbe Eingeklappt-Bedingung bilden wie `itemHtml` + (nur mit sichtbaren Kindern). Umklappen: Klick aufs `.fold`-Zeichen (preventDefault — es sitzt bei Link-Knoten im ``) oder ←/→ am fokussierten Knoten; nach `render()` den Fokus per `data-line` wiederherstellen. Export/Druck: „▸ n" bleibt, das ▾ offener Knoten fällt weg diff --git a/frontend/src/model.js b/frontend/src/model.js index bb9ba9c..a34b993 100644 --- a/frontend/src/model.js +++ b/frontend/src/model.js @@ -168,7 +168,25 @@ export function computeCheapSet(roots){ } /* CSS-Klassen für den günstigen Pfad. Leere `cheapSet` (Pfad aus) ⇒ ''. Endknoten (kein Kind liegt auf dem Pfad) bekommt zusätzlich 'cheap-leaf'. */ -export function cheapCls(n, cheapSet){ +/* Liegt im Teilbaum eines Knotens etwas auf dem Pfad? Gebraucht für + eingeklappte Knoten (siehe `cheapCls`). Verworfene sind nie in `cheapSet` + (SPEC §9), es braucht also keine eigene Filterung. */ +export function hidesCheap(n, cheapSet){ + for(const k of n.children || []){ + if(cheapSet.has(k) || hidesCheap(k, cheapSet)) return true; + } + return false; +} + +export function cheapCls(n, cheapSet, collapsed){ + /* Eingeklappt steht der Knoten stellvertretend für seinen ganzen Teilbaum + (SPEC §9/D38): Liegt darin etwas auf dem Pfad, ist er dessen tiefste noch + SICHTBARE Station — sonst überspränge die Linie den Zweig, als wäre dort + nichts zu tun. Das gilt auch, wenn er selbst nicht gebraucht wird, sein + Teilbaum aber schon (eine per `:#…` gezogene Alternative, D42): Er ist + dann der einzige sichtbare Griff auf nötige Arbeit und darf deshalb auch + nicht von der Inversion ausgeblasst werden. */ + if(collapsed) return (cheapSet.has(n) || hidesCheap(n, cheapSet)) ? 'cheap cheap-leaf' : ''; if(!cheapSet.has(n)) return ''; const leaf = !pathChildren(n).some(k => cheapSet.has(k)); return leaf ? 'cheap cheap-leaf' : 'cheap'; diff --git a/frontend/src/render.js b/frontend/src/render.js index 88f8472..97f2be4 100644 --- a/frontend/src/render.js +++ b/frontend/src/render.js @@ -21,7 +21,12 @@ import { gateOf, needsBreakdown, visibleChildren, cheapCls } from './model.js'; gegenüber der zuletzt gesehenen Fassung (D28, `freshSet` optional) und optionale Knoten (`+`, SPEC §3/D29 — trägt den hohlen Kreis am Abzweig). */ function extraCls(n, opts){ - const cheap = cheapCls(n, opts.cheapSet); + /* Dieselbe Bedingung wie in `itemHtml`: eingeklappt ist ein Knoten nur, wenn + er überhaupt sichtbare Kinder hat. Der Pfad braucht sie hier, weil ein + eingeklappter Knoten seine verborgenen Pfad-Knoten vertritt (D38). */ + const collapsed = !!(opts.collapsedSet && opts.collapsedSet.has(n)) + && visibleChildren(n, opts.showDiscarded).length > 0; + const cheap = cheapCls(n, opts.cheapSet, collapsed); const fresh = opts.freshSet && opts.freshSet.has(n) ? 'fresh' : ''; return [cheap, fresh, n.optional ? 'opt' : '', n.focus ? 'focusmark' : ''].filter(Boolean).join(' '); } diff --git a/frontend/tests/fold.test.js b/frontend/tests/fold.test.js index 832efb5..ab6dea3 100644 --- a/frontend/tests/fold.test.js +++ b/frontend/tests/fold.test.js @@ -126,3 +126,64 @@ describe('Renderer — eingeklappte Teilbäume', () => { .toEqual(['A', 'A2', 'W']); }); }); + +/* Der eingeklappte Knoten vertritt seinen Teilbaum auch auf dem günstigsten + Pfad (SPEC §9, D38-Nachtrag): Sonst überspränge die Pfad-Linie den ganzen + Zweig, als wäre dort nichts zu tun. */ +describe('Günstigster Pfad an eingeklappten Knoten', () => { + /* Nimmt die BEREITS geparsten Wurzeln: `collapsedSet` und `cheapSet` prüfen + auf Objektidentität — ein zweiter Parse-Durchlauf liefert andere Objekte + und die Mengen träfen nie zu (dieselbe Falle wie in D28). */ + const cheapRender = (r, collapsedSet) => + renderTreeHtml(r, {t, showDiscarded: false, cheapPath: true, + cheapSet: computeCheapSet(r), collapsedSet: collapsedSet || new Set()}).html; + /* Labels der Stationen (`cheap-leaf`) in Dokumentreihenfolge — genau die + Menge, durch die drawCheapPath() die Linie fädelt. Gelesen aus dem + `aria-label` (erstes Glied = Label): Im Knoteninneren steht bei + eingeklappten Knoten das Falt-Zeichen vor dem Text. */ + const stations = html => + [...html.matchAll(/class="node[^"]*cheap-leaf[^"]*"[^>]*aria-label="([^",]*)/g)] + .map(m => m[1]); + + const TXT = `[ ] W (XS)\n - [ ] A (S)\n - [ ] A1 (S)\n - [ ] A2 (S)\n - [ ] B (S)`; + + it('macht den eingeklappten Knoten zur Station statt den Zweig zu überspringen', () => { + const r = roots(TXT); + const a = r[0].children[0]; + expect(stations(cheapRender(r))).toEqual(['A1', 'A2', 'B']); /* offen */ + expect(stations(cheapRender(r, new Set([a])))).toEqual(['A', 'B']); /* zu */ + }); + + it('gibt die Station beim Aufklappen wieder an die Kinder ab', () => { + const r = roots(TXT); + const a = r[0].children[0]; + const zu = stations(cheapRender(r, new Set([a]))); + const auf = stations(cheapRender(r, new Set())); + expect(zu).not.toContain('A1'); + expect(auf).toContain('A1'); + expect(auf).not.toContain('A'); + }); + + it('vertritt auch einen Teilbaum, den der Knoten selbst nicht braucht', () => { + /* `+ O` ist entbehrlich und nicht auf dem Pfad — sein Kind wird aber per + Abhängigkeit gezogen (D42). Eingeklappt ist `O` der einzige sichtbare + Griff darauf und darf deshalb weder fehlen noch ausgeblasst sein. */ + const txt = `[ ] W (XS)\n - [ ] N (S) :#t\n + [ ] O (S)\n - [ ] #t: T (S)`; + const r = roots(txt); + const o = r[0].children[1]; + const set = computeCheapSet(r); + expect([...set].map(n => n.label).sort()).toEqual(['N', 'T', 'W']); /* O selbst nicht */ + const {html} = renderTreeHtml(r, {t, showDiscarded: false, cheapPath: true, + cheapSet: set, collapsedSet: new Set([o])}); + expect(stations(html)).toContain('O'); + }); + + it('macht einen eingeklappten Knoten ohne Pfad im Teilbaum NICHT zur Station', () => { + const txt = `[ ] W (XS)\n - [ ] N (S)\n + [ ] O (S)\n - [ ] P (S)`; + const r = roots(txt); + const o = r[0].children[1]; + const {html} = renderTreeHtml(r, {t, showDiscarded: false, cheapPath: true, + cheapSet: computeCheapSet(r), collapsedSet: new Set([o])}); + expect(stations(html)).toEqual(['N']); + }); +});