fix: vertikale Sammelleiste erreicht den Eltern-Stub wieder (D65)

Zwei Geometrie-Fehler bei Gruppen mit wenigen Kindern: :only-child schaltete
die Leiste ab, obwohl Stub (50 %) und Abzweig (23 px) seit dem 20-px-
Zusatzabstand nicht mehr zusammenfallen (6,6–15,8 px Lücke); und bei einem
großen Teilbaum im letzten Kind endete die Leiste über dem Stub (bis 98 px).
Fix: CSS-Verbinder 23px→50 % am Einzelkind, alignVRails() + --vrail-ext für
das letzte Kind. Export und Kompakt-Modus waren nie betroffen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-24 18:19:19 +02:00
co-authored by Claude Fable 5
parent 94aa16efbd
commit 07e4116bd9
5 changed files with 129 additions and 2 deletions
+1
View File
@@ -19,6 +19,7 @@ reverse.
## 2026-08-24 ## 2026-08-24
- Fix: in the vertical layout, the line from a parent to one or two children was torn — the collector rail now always reaches the parent's stub
- The legend shows the high-risk status `[!]` and mentions the size check - The legend shows the high-risk status `[!]` and mentions the size check
- `llms.md` tabulates the size ranges and aligns its table columns - `llms.md` tabulates the size ranges and aligns its table columns
- Long node titles wrap into evenly balanced lines of at most forty characters - Long node titles wrap into evenly balanced lines of at most forty characters
+74
View File
@@ -4842,3 +4842,77 @@ mitten im Wort, `\n` im Knotentext); die showIds-Tests sind auf die eigene
Zeile umgeschrieben. Der neue Plan-Knoten `#ed.render.wrap (S)` kippte prompt Zeile umgeschrieben. Der neue Plan-Knoten `#ed.render.wrap (S)` kippte prompt
die Größenprüfung des eigenen Plans (`#ed.render (M)` mit nun 4×S, D62) — die Größenprüfung des eigenen Plans (`#ed.render (M)` mit nun 4×S, D62) —
ehrlich nachgezogen auf `(L)`, danach wieder 0 Warnungen. ehrlich nachgezogen auf `(L)`, danach wieder 0 Warnungen.
## D65 — Abgerissene Linien im vertikalen Modus: Geometrie-Fehler, kein Rendering-Problem
Gemeldet: „Es geschieht immer wieder, dass die Verbindungslinien zu Sub-Knoten
nicht durchgängig sind, sondern Lücken haben — meistens fehlen in der
vertikalen Ansicht kurze vertikale Linien", mit der Frage, ob das stabil zu
fixen sei oder ein Umstieg auf Canvas-Rendering nötig würde. Der entscheidende
Hinweis kam nachgereicht: „meistens, wenn es nur einen einzigen oder zwei
Unterknoten gibt."
**Es ist kein Rundungs- oder Rendering-Problem, sondern ein deterministischer
Geometrie-Fehler** — gemessen statt geraten: Ein Scanner über die
Pseudo-Element-Geometrie aller 45 vertikalen all-of-Gruppen des mitgelieferten
Plans fand **8 kaputte**, alle mit 13 Kindern, alle exakt reproduzierbar.
Zwei Fehlerarten, eine gemeinsame Wurzel: **Der Eltern-Stub dockt bei 50 % der
Gruppenhöhe an** (`li.has-and{align-items:center}`, D9), **die Sammelleiste
endet aber am Abzweigpunkt des Rand-Kindes** — und nichts garantierte, dass
die 50 % dazwischen liegen.
- **Einziges Kind:** `li:only-child::after{border:0}` schaltete die Leiste ganz
ab — in der Annahme, Stub (50 %) und Kind-Abzweig (fest 23 px) fielen
zusammen. Das taten sie, solange das `<li>` symmetrisch gepolstert war
(5+5 px: Mitte 22,15 ≈ 23). Der **20-px-Zusatzabstand nach unten**
(D-Transponiert, gegen Badge/Tag-Überlappung) verschob die Mitte auf 29,65 —
**6,6 px Lücke**; mehrzeilige Knoten (D64) machten daraus **15,8 px**. Der
Fehler war also alt und wurde schrittweise sichtbarer — daher „immer wieder".
- **Letztes Kind mit großem Teilbaum:** Die Leiste läuft vom ersten bis zum
letzten Abzweig (je 23 px unter der Zellen-Oberkante). Trägt das letzte Kind
einen großen Teilbaum, liegt die Gruppen-**Mitte** unterhalb seines Abzweigs
— der Stub hing frei in der Luft (gemessen: 4,5 bis **98 px**).
**Fix in zwei Teilen, je auf dem billigsten tragfähigen Weg:**
- **Einziges Kind rein in CSS:** Bei `:only-child` ist die Gruppenhöhe die
Kindhöhe (`padding-top:0`), 50 % ist also im `<li>` ausdrückbar —
`top:23px; height:max(0px, calc(50% - 23px))` verbindet Abzweig und Stub
exakt. Für ein has-and-Einzelkind bleibt `border:0` (Abzweig liegt dort
selbst bei 50 %, beides fällt zusammen).
- **Letztes Kind per Messung:** Die Gruppenmitte relativ zum letzten `<li>`
kann CSS nicht ausdrücken — dieselbe Lage wie bei `--stem-x`
(D29-Nachtrag 2), also derselbe Griff: `alignVRails()` misst nach jedem
Rendern/Moduswechsel und setzt `--vrail-ext` (unskalierte px, durch
`effZoom()` zurückgerechnet); die CSS-Regel
`li:last-child:not(.has-and):not(:only-child)::after{height:var(--vrail-ext, 23px)}`
verlängert die Leiste bis zum Stub. **has-and-Letztkinder brauchen das
nie** — deren Abzweig liegt bei 50 % ihrer Zelle, und die Gruppenmitte kann
rechnerisch nie darunter liegen (H/2 > H h/2 hieße h > H). Nach **oben**
kann die Mitte ebenfalls nie herausfallen (der erste Abzweig liegt höchstens
23 px unter dem Gruppenanfang, und H/2 ≥ 23 gilt ab 46 px Gruppenhöhe —
ein einzelner Knoten ist schon höher).
**Canvas (oder eine SVG-Volleinzeichnung) ist damit nicht nötig.** Die Frage
war berechtigt — viele einzeln positionierte Border-Segmente sind die
fehleranfälligere Bauart als ein durchgezogener Pfad —, aber der konkrete
Fehler lag in zwei falschen Annahmen der Geometrie, nicht im Mechanismus.
Ein Umstieg kostete die CSS-gestützte Selbstverständlichkeit von Fokus,
Hover, Zoom und Druck und müsste alle über D9D64 austarierten Sonderfälle
(Treppe, only-child-Leiterstück, has-and-Zentrierung) neu beweisen. Sollte
je das **zweite** Phänomen auftreten — 1-px-Haarlinien an Segment-Stößen
unter `zoom` ≠ 1 —, wäre das ein eigener Fall mit eigenem Mittel
(Segmente an Stößen minimal überlappen lassen), kein Grund für einen Umbau.
**Export und Kompakt-Modus waren nie betroffen:** `diagramToSvg()` spannt die
Leiste seit jeher über die Kinder **und** die Elternmitte
(`kids.map(cy).concat(p.cy)`, D29-Nachtrag 4); im kompakten Modus dockt der
Stub oben an (kein Zentrieren). Beides nachgemessen (Scanner: 0 Befunde in
45 Kompakt-Gruppen; 0 verwaiste `--vrail-ext` nach Moduswechsel).
**Nachgemessen** nach dem Fix (mitgelieferter Plan, vertikal): 0 Befunde in
45 Gruppen; der Only-Child-Verbinder endet auf 0,0 px genau am Stub
(336,5 → 352,3 = Stub-Höhe); die 98-px-Lücke trägt jetzt eine
121-px-Verlängerung bis zum Stub; Zoom-Gegenprobe bei 0,9 sauber (die Variable
ist zoom-invariant, wie `--stem-x`). 405 Tests grün — die Regeln sind
DOM-Geometrie und damit Browser-geprüft, nicht unit-testbar (dieselbe Grenze
wie `alignStems()`, D29).
+12
View File
@@ -474,6 +474,18 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
müssen **hinter** den first/last-Regeln stehen: Ein einziges Kind ist auch müssen **hinter** den first/last-Regeln stehen: Ein einziges Kind ist auch
das letzte, und bei gleicher Spezifität gewinnt die spätere Regel — genau das letzte, und bei gleicher Spezifität gewinnt die spätere Regel — genau
daran war `:only-child::after` jahrelang wirkungslos. daran war `:only-child::after` jahrelang wirkungslos.
- `--vrail-ext` (D65): Im **vertikalen** Modus dockt der Eltern-Stub bei 50 %
der Gruppenhöhe an, die Sammelleiste endet aber am 23-px-Abzweig des letzten
Kindes — trägt das einen großen Teilbaum, liegt die Gruppenmitte darunter
und der Stub hinge in der Luft. `alignVRails()` (läuft an denselben drei
Stellen wie `alignStems()`) misst das und verlängert die Leiste per
`--vrail-ext`; das **einzige** Kind löst CSS allein
(`:only-child::after{top:23px;height:calc(50% - 23px)}` — dort ist
Gruppenhöhe == Kindhöhe). Kein `border:0` mehr am nicht-has-and-Einzelkind:
Genau das war die Lücke („kurze vertikale Linien fehlen"). has-and-Kinder
brauchen beides nie (Abzweig bei 50 % der Zelle). Wer an den
vertikalen first/last/only-Regeln dreht, prüft mit dem Geometrie-Scanner
(Pseudo-Element-Rects: Leisten-Segmente vs. Stub-Höhe) statt mit dem Auge.
- Kleiner Bildschirm: `body.mobile` (per `matchMedia`, ≤ 640 px) zeigt **genau - Kleiner Bildschirm: `body.mobile` (per `matchMedia`, ≤ 640 px) zeigt **genau
einen** Bereich — `body.pane-diagram` bzw. `body.pane-text`, umgeschaltet über einen** Bereich — `body.pane-diagram` bzw. `body.pane-text`, umgeschaltet über
je einen festen Knopf pro Titelzeile (`#paneToText`/`#paneToDiagram`, je einen festen Knopf pro Titelzeile (`#paneToText`/`#paneToDiagram`,
+29 -1
View File
@@ -206,6 +206,7 @@ function render(){
renderLineNos(); renderLineNos();
applyOptStairs(); /* muss vor dem Messen laufen — es verschiebt Knoten */ applyOptStairs(); /* muss vor dem Messen laufen — es verschiebt Knoten */
alignStems(); alignStems();
alignVRails();
drawCheapPath(); drawCheapPath();
/* Querverbindungen (D41) zeichnet highlightCurrentNode() unten mit /* Querverbindungen (D41) zeichnet highlightCurrentNode() unten mit
es kennt die zweite Hälfte der Auswahl (Cursor-Zeile). */ es kennt die zweite Hälfte der Auswahl (Cursor-Zeile). */
@@ -332,6 +333,32 @@ function alignStems(){
}); });
} }
/* Sammelleisten-Verlängerung im vertikalen Modus (D65). Der Eltern-Stub dockt
bei 50 % der Gruppenhöhe an, die Leiste endet aber am 23-px-Abzweig des
letzten Kindes trägt das einen großen Teilbaum, liegt die Gruppenmitte
TIEFER und der Stub hinge in der Luft (gemessen: 4,5 bis 98 px Lücke). CSS
kann die Gruppenmitte relativ zum letzten <li> nicht ausdrücken; wie bei
`alignStems()` misst deshalb JS und setzt `--vrail-ext` (unskalierte px,
durch effZoom() zurückgerechnet). Nur nicht-has-and-Letztkinder: Bei
has-and liegt der Abzweig bei 50 % der Zelle, und die Gruppenmitte liegt
beweisbar nie darunter. Nach oben kann die Mitte nie aus der Leiste fallen
(der erste Abzweig liegt höchstens 23 px unter dem Gruppenanfang). */
function alignVRails(){
out.querySelectorAll('ul.and>li').forEach(li => li.style.removeProperty('--vrail-ext'));
if(!out.classList.contains('vertical')) return;
const z = effZoom() || 1;
out.querySelectorAll('li.has-and>ul.and').forEach(ul => {
const kids = [...ul.children].filter(e => e.tagName === 'LI');
if(kids.length < 2) return; /* :only-child löst CSS allein (50 %) */
const last = kids[kids.length - 1];
if(last.classList.contains('has-and')) return;
const ur = ul.getBoundingClientRect(), lr = last.getBoundingClientRect();
if(!ur.height) return; /* Panel eingeklappt */
const ext = (ur.top + ur.height/2 - lr.top)/z;
if(ext > 23.5) last.style.setProperty('--vrail-ext', ext.toFixed(1) + 'px');
});
}
function drawCheapPath(){ function drawCheapPath(){
out.querySelectorAll('svg.cheap-overlay').forEach(e => e.remove()); out.querySelectorAll('svg.cheap-overlay').forEach(e => e.remove());
if(!cheapPathOn) return; if(!cheapPathOn) return;
@@ -1752,6 +1779,7 @@ function applyLayout(mode){
if(!isMobile()) applySplit(); /* Desktop: Preset neu setzen. Mobil: freie --drow-Aufteilung behalten */ if(!isMobile()) applySplit(); /* Desktop: Preset neu setzen. Mobil: freie --drow-Aufteilung behalten */
applyOptStairs(); /* Treppe gilt nur im Fächer — beim Moduswechsel bauen/auflösen */ 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 */ alignStems(); /* Stiel gilt nur im Fächer — beim Moduswechsel neu setzen/löschen */
alignVRails(); /* Leisten-Verlängerung gilt nur vertikal — ebenso (D65) */
drawCheapPath(); /* Blatt-Positionen ändern sich mit dem Modus */ drawCheapPath(); /* Blatt-Positionen ändern sich mit dem Modus */
drawDepLinks(); /* Knoten-Positionen ebenso (D41) */ drawDepLinks(); /* Knoten-Positionen ebenso (D41) */
} }
@@ -1805,7 +1833,7 @@ function setMobilePane(pane, save){
Live-Geometrie zeichnet, muss deshalb nach dem Sichtbarwerden neu laufen Live-Geometrie zeichnet, muss deshalb nach dem Sichtbarwerden neu laufen
im Diagramm dieselben vier Schritte wie beim Moduswechsel (applyLayout), im Diagramm dieselben vier Schritte wie beim Moduswechsel (applyLayout),
im Editor der Zeilennummern-Streifen, der am Spiegel misst (D33). */ im Editor der Zeilennummern-Streifen, der am Spiegel misst (D33). */
if(pane === 'diagram'){ applyOptStairs(); alignStems(); drawCheapPath(); drawDepLinks(); } if(pane === 'diagram'){ applyOptStairs(); alignStems(); alignVRails(); drawCheapPath(); drawDepLinks(); }
else renderLineNos(); else renderLineNos();
if(save) saveUI(); if(save) saveUI();
} }
+13 -1
View File
@@ -1317,7 +1317,19 @@
Linien zu zentrierten Zwischenknoten ins Leere. Die Enden der Sammelleiste Linien zu zentrierten Zwischenknoten ins Leere. Die Enden der Sammelleiste
folgen demselben Abzweigpunkt des jeweiligen Rand-Kindes. */ folgen demselben Abzweigpunkt des jeweiligen Rand-Kindes. */
.tree.vertical li.has-and>ul.and>li:first-child::after{top:23px;height:calc(100% - 23px)} .tree.vertical li.has-and>ul.and>li:first-child::after{top:23px;height:calc(100% - 23px)}
.tree.vertical li.has-and>ul.and>li:only-child::after{border:0} /* Einziges Kind: KEIN border:0 mehr (D65). Der Eltern-Stub dockt bei 50 % der
Gruppenhöhe an, der Kind-Abzweig liegt fest bei 23 px — seit dem
20-px-Zusatzabstand unten (und erst recht bei mehrzeiligen Knoten, D64)
fallen die beiden NICHT mehr zusammen. Das Verbindungsstück ist hier rein
in CSS ausdrückbar, weil Gruppenhöhe == Kindhöhe (ul hat padding-top:0). */
.tree.vertical li.has-and>ul.and>li:only-child::after{top:23px;height:max(0px, calc(50% - 23px))}
/* Letztes Kind mit großem Teilbaum (D65): Die Leiste endet am 23-px-Abzweig,
der Eltern-Stub dockt aber bei 50 % der GRUPPE an — liegt der tiefer
(großer Teilbaum im letzten Kind), hinge er in der Luft. `alignVRails()`
in app.js misst das und verlängert die Leiste per --vrail-ext bis zum
Stub. has-and-Kinder brauchen das nie (Abzweig bei 50 % der Zelle, und
50 % der Gruppe liegt beweisbar darüber). */
.tree.vertical li.has-and>ul.and>li:last-child:not(.has-and):not(:only-child)::after{height:var(--vrail-ext, 23px)}
.tree.vertical li.has-and>ul.and>li.has-and::before{top:50%} .tree.vertical li.has-and>ul.and>li.has-and::before{top:50%}
.tree.vertical li.has-and>ul.and>li.has-and:first-child::after{top:50%;height:50%} .tree.vertical li.has-and>ul.and>li.has-and:first-child::after{top:50%;height:50%}
.tree.vertical li.has-and>ul.and>li.has-and:last-child::after{top:0;height:50%} .tree.vertical li.has-and>ul.and>li.has-and:last-child::after{top:0;height:50%}