fix: eine einzelne Station ist ein gültiger Pfad (D38-Nachtrag 3)

drawCheapPath() stieg bei weniger als zwei Stationen ganz aus. Solange ein
eingeklappter Knoten nur einen Zweig vertrat, fiel das nicht auf; klappt man
den Wurzelknoten zu, bleibt genau eine sichtbare Station — und damit
verschwand neben der Linie (richtig) auch der Stationspunkt (falsch), also
der Pfad genau dort, wo die Faltung ihn am nötigsten vertreten muss.

Die Zwei-Punkte-Schranke gehört an die Linie, nicht an die Zeichenfunktion;
im Grafikexport dieselbe Trennung.

Dabei gefunden: Der zugeklappte Wurzelknoten des Werkbaum-Plans ließ sich
nicht wieder aufklappen. Dort ist der Zustand nicht in Marken ausdrückbar
(das `<` in Zeile 160 holt seinen Teilbaum hervor), liegt also in der
Sitzungs-Überlagerung. Beim Aufklappen ergab das einen unveränderten Text —
replaceTextUndoable() meldete Erfolg, ohne Textänderung feuert aber kein
input-Ereignis, also lief kein render(). Jetzt meldet sie „nichts
geschrieben", und der Aufrufer zeichnet selbst neu.

Außerdem: Faltmarken-Stellung auch in den Schreibregeln von llms.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-23 10:14:08 +02:00
co-authored by Claude Fable 5
parent be729239ce
commit 9cbdf5ab58
5 changed files with 73 additions and 16 deletions
+9 -2
View File
@@ -316,7 +316,10 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
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).
(nur mit sichtbaren Kindern). **Eine einzelne Station ist ein gültiger Pfad**
(D38-Nachtrag 3): `drawCheapPath()` steigt nur bei **null** Stationen aus,
die Zwei-Punkte-Schranke gilt allein der Linie (`catmullRom`) — im Export
ebenso. Sonst verschwindet der Pfad beim eingeklappten Wurzelknoten ganz.
**Umklappen schreibt in den TEXT zurück** (`writeFoldToText`, D38-Nachtrag 2):
Die Ableitung Text → Zustand ist nicht umkehrbar (mehrere Markensätze ergeben
denselben Zustand; `<` faltet Knoten ohne eigene Marke), deshalb **minimal
@@ -332,7 +335,11 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
Mobil die Tastatur hoch (`keyboardOnJump(true)`). Bei `src.readOnly` (Pad,
D31) wird nicht geschrieben, dort trägt weiter `foldOverrides` — die
Überlagerungen werden nach erfolgreichem Schreiben geleert, sonst maskieren
sie den Text. Umklappen: Klick aufs
sie den Text. **`replaceTextUndoable()` meldet bei unveränderten Text
`false`** (D38-Nachtrag 3): Ohne Textänderung feuert kein `input`, also läuft
kein `render()` — der Aufrufer muss dann selbst zeichnen. Sonst bleibt das
Bild stehen (aufgefallen beim Aufklappen eines nur per Überlagerung
gefalteten Knotens: der Klick tat sichtbar nichts mehr). Umklappen: Klick aufs
`.fold`-Zeichen (preventDefault — es sitzt bei Link-Knoten im `<a>`) oder
←/→ am fokussierten Knoten; nach `render()` den Fokus per `data-line`
wiederherstellen. Export/Druck: „▸ n" bleibt, das ▾ offener Knoten fällt weg
+2
View File
@@ -155,6 +155,8 @@ One node per line. Everything except the label is optional.
- Never mix `|` or `=` with other gates in one sibling group.
- Decompose everything `(M)` or larger.
- Put a node ID in front of the title, separated by a colon: `#auth: Backend`.
- Put a fold mark after the status box, right before the label:
`- [x] > Concept`. Before the box it would shift the box by one indent step.
- Give a node its own status only; express "blocked by" with `:#…`
dependencies instead of understating the status.
- Mark nice-to-haves with `+` — otherwise they inflate the cheapest path.
+26 -13
View File
@@ -275,7 +275,12 @@ function drawCheapPath(){
out.querySelectorAll('svg.cheap-overlay').forEach(e => e.remove());
if(!cheapPathOn) return;
const leaves = [...out.querySelectorAll('.node.cheap-leaf')]; /* Dokument-Reihenfolge = Lese-Reihenfolge */
if(leaves.length < 2) return;
/* EINE Station ist ein gültiger Pfad: Ein eingeklappter Knoten vertritt
seinen ganzen Teilbaum (D38-Nachtrag), oben im Baum bleibt davon leicht
nur eine einzige sichtbare Station übrig. Die Linie entfällt dann (durch
einen Punkt führt keine), der Stationspunkt darf es nicht — sonst
verschwände der Pfad genau dort ganz, wo er am dichtesten gefaltet ist. */
if(!leaves.length) return;
const outRect = out.getBoundingClientRect();
const z = effZoom() || 1;
if(!outRect.width || !outRect.height) return; /* Panel eingeklappt */
@@ -284,16 +289,18 @@ function drawCheapPath(){
return {x:(r.left + r.width/2 - outRect.left)/z, y:(r.top + r.height/2 - outRect.top)/z};
});
const w = outRect.width/z, h = outRect.height/z;
const d = catmullRom(pts);
const d = pts.length > 1 ? catmullRom(pts) : null;
/* kräftige Linie HINTER die Knoten (als erstes Kind → hinterste Paint-Ebene) */
const back = overlaySvg('cheap-back', w, h);
back.appendChild(svgEl('path', {class:'cheap-path', d}));
out.insertBefore(back, out.firstChild);
if(d){
const back = overlaySvg('cheap-back', w, h);
back.appendChild(svgEl('path', {class:'cheap-path', d}));
out.insertBefore(back, out.firstChild);
}
/* davor: abgetönte Kopie (deutet den Verlauf über Knoten an) + Stationspunkte */
const front = overlaySvg('cheap-front', w, h);
front.appendChild(svgEl('path', {class:'cheap-path faint', d}));
if(d) front.appendChild(svgEl('path', {class:'cheap-path faint', d}));
pts.forEach(p => front.appendChild(
svgEl('circle', {class:'cheap-dot', cx:p.x.toFixed(1), cy:p.y.toFixed(1), r:10})));
out.appendChild(front);
@@ -574,12 +581,13 @@ function diagramToSvg(){
parts.push(`<text x="${m.x.toFixed(1)}" y="${(m.y+3).toFixed(1)}" text-anchor="middle" fill="#6B7A8C" font-size="9" font-weight="600">1</text>`);
});
/* 3b) Günstigster-Pfad: abgetönte Kopie über den Knoten + Stationspunkte */
if(cheapPts.length >= 2){
parts.push(cheapLine('0.2'));
cheapPts.forEach(p => parts.push(
`<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="10" fill="#0F766E" fill-opacity="0.2" stroke="#0F766E" stroke-opacity="0.35" stroke-width="1.5"/>`));
}
/* 3b) Günstigster-Pfad: abgetönte Kopie über den Knoten + Stationspunkte.
Die Linie braucht zwei Punkte, die Stationen nicht — bei stark gefaltetem
Baum bleibt leicht nur eine sichtbare Station übrig (siehe
`drawCheapPath`). */
if(cheapPts.length >= 2) parts.push(cheapLine('0.2'));
cheapPts.forEach(p => parts.push(
`<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="10" fill="#0F766E" fill-opacity="0.2" stroke="#0F766E" stroke-opacity="0.35" stroke-width="1.5"/>`));
/* 4) Geister-Knoten „Untergliederung fehlt“ */
out.querySelectorAll('.ghost-node').forEach(g => {
@@ -898,7 +906,12 @@ function foldStateMatches(txt, want){
Stück zwischen gemeinsamem Anfang und Ende. */
function replaceTextUndoable(neu){
const alt = src.value;
if(alt === neu) return true;
/* Nichts zu schreiben heißt: kein `input`-Ereignis, also auch kein render().
Deshalb false — der Aufrufer zeichnet dann selbst neu. Sonst bliebe das
Bild stehen, wie es war: Der Fall tritt auf, wenn die Marken den
gewünschten Zustand schon beschreiben (etwa beim Aufklappen eines Knotens,
dessen Faltung nur in der Sitzungs-Überlagerung stand). */
if(alt === neu) return false;
let s = 0;
while(s < alt.length && s < neu.length && alt[s] === neu[s]) s++;
let e = 0;