frontend: optionale Abzweige gestrichelt + Stiel trifft die Knotenmitte

Zwei Nachbesserungen aus dem ersten Blick auf `+` (D29).

**Gestrichelter Abzweig.** Der hohle Kreis allein war zu leise. Der Einwand
gegen einen dritten Linienstil bleibt richtig, greift aber nicht so weit wie
gedacht: Gestrichelt wird NUR der Abzweig zum Knoten (nicht die Sammelleiste —
die gehört der ganzen Gruppe) und zwar in TINTE, während die any-of-Linien
gestrichelt in Grau sind. Entscheidend ist, dass beide sich nie am selben
Verteiler treffen können: | darf nach SPEC §3 nicht mit -/+ gemischt werden.
Der Kreis bleibt zusätzlich — er sagt, WELCHER Knoten gemeint ist.

Umgesetzt an den vorhandenen Pseudoelementen, ohne neue Zeichenebene: im
Fächer der senkrechte Stiel (border-left von ::after, beim letzten Kind
border-right von ::before), gestapelt der waagerechte Ast (border-top von
::before); die jeweils andere Kante ist die Leiste und bleibt durchgezogen.
Dafür braucht auch das <li> die Klasse `opt` — den Abzweig zeichnet es, nicht
der Knoten. Der SVG-Export zieht mit (dash || isOpt).

**Stiel trifft die Knotenmitte auch waagerecht (`--stem-x`).** Dabei fiel ein
älterer Fehler auf, gemeldet als „die Linie zu Wahl trifft den Knoten nicht":
Im horizontalen Fächer lief der Stiel zu einem Knoten mit any-of-Kindern neben
dem Knoten vorbei (gemessen 13,4 px). Dieselbe Verwechslung, die D10 senkrecht
schon behoben hat, nur in der anderen Achse — der Stiel saß bei 50 % der ZELLE,
und das ist nur die Knotenmitte, solange der Knoten darin zentriert steht.
`li.has-or` ist aber flex-start (der Knoten steht links, damit die
any-of-Sammelleiste unter ihm aufsetzt), und die Zelle ist so breit wie der
Teilbaum.

Rein in CSS nicht lösbar: gebraucht wird die Knotenbreite, und kein Selektor
macht sie einer Elternregel zugänglich (Anchor Positioning ist Chrome-only).
`alignStems()` misst deshalb nach jedem render() und in applyLayout() die
Knotenmitte der betroffenen Zellen und setzt `--stem-x`; die Pseudoelemente
rechnen über left:var(--stem-x, 50%) bzw. right:calc(100% - var(--stem-x, 50%)).
Der Rückfallwert 50 % hält alle übrigen Zellen ohne Messung richtig, die
transponierten Modi setzen left/right ohnehin fest. Messwerte werden wie in
drawCheapPath() durch `zoom` zurückgerechnet.

Verifiziert: Vitest 60/60 (2 neue Tests: `opt` auch am <li> neben dem Gate der
eigenen Kinder; <li> ohne Attribut, wenn weder Kinder noch optional).
Im Browser gemessen: Abweichung Stiel↔Knotenmitte bei „Wahl" 13,4 px → 0,0 px,
alle übrigen Zellen unverändert 0,0 px ohne gesetzte Variable. Angesehen in
horizontal und kompakt: gestrichelter Ast in Tinte zum Kreis, Sammelleiste
durchgezogen, deutlich unterscheidbar vom gestrichelt-grauen any-of-Ast
daneben. SVG-Export gerendert geprüft: 2 von 9 Tinte-Linien gestrichelt, beide
Kreise am Auftreffpunkt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-07-27 21:11:20 +02:00
co-authored by Claude Opus 4.8
parent aa3e087ee6
commit 091e8849d8
8 changed files with 157 additions and 26 deletions
+38
View File
@@ -812,3 +812,41 @@ Stärke.
**Verhaltensänderung:** Ein `+` am Zeilenanfang ist jetzt ein Zeichen und
gehört nicht mehr zum Label (`+ 5 % Puffer` ergibt das Label „5 % Puffer").
Test-abgedeckt, damit es niemanden unbemerkt trifft.
**Nachtrag 1 — der Abzweig wird doch gestrichelt.** Der hohle Kreis allein war
zu leise; „ein gestrichelter Ast wäre deutlicher" (Nutzer). Der oben notierte
Einwand gegen einen dritten Linienstil bleibt richtig, greift hier aber nicht
so weit wie gedacht: Gestrichelt wird **nur der Abzweig zum Knoten**, nicht die
Sammelleiste, und zwar in **Tinte** — die any-of-Linien sind gestrichelt in
**Grau**. Entscheidend ist, dass beide sich in einer Gruppe nie begegnen können:
`|` darf nach §3 nicht mit `-`/`+` gemischt werden, ein gestrichelt-grauer und
ein gestrichelt-blauer Ast hängen also nie am selben Verteiler. Der Kreis bleibt
zusätzlich — er sagt, **welcher** Knoten gemeint ist, und trägt die Kennzeichnung
auch dort, wo der Strich im Gedränge untergeht.
Umgesetzt an den vorhandenen Pseudoelementen, ohne neue Zeichenebene: Im Fächer
ist der Abzweig der senkrechte Stiel (`border-left` von `::after`, beim letzten
Kind `border-right` von `::before`), in den gestapelten Anordnungen der
waagerechte Ast (`border-top` von `::before`) — die jeweils andere Kante ist die
Leiste und bleibt durchgezogen. Dafür braucht auch das **`<li>`** die Klasse
`opt` (den Abzweig zeichnet es, nicht der Knoten).
**Nachtrag 2 — Stiel trifft die Knotenmitte auch waagerecht (`--stem-x`).**
Beim Prüfen fiel ein älterer Fehler auf: Im horizontalen Fächer lief der Stiel
zu einem Knoten mit any-of-Kindern **neben dem Knoten vorbei** (gemessen 13,4 px).
Ursache ist dieselbe Verwechslung, die D10 schon senkrecht behoben hat, nur in
der anderen Achse: Der Stiel saß bei 50 % der **Zelle**, und das ist nur dann
die Knotenmitte, wenn der Knoten in der Zelle zentriert steht. `li.has-or` ist
aber `align-items:flex-start` — der Knoten steht links, damit die
any-of-Sammelleiste unter ihm aufsetzt —, und die Zelle ist so breit wie der
Teilbaum.
Rein in CSS ist das nicht lösbar: Die nötige Größe ist die **Knotenbreite**, und
kein Selektor kann sie einer Elternregel zugänglich machen (Anchor Positioning
ist Chrome-only). Deshalb misst `alignStems()` nach jedem Rendern die Knotenmitte
der betroffenen Zellen und setzt sie als `--stem-x`; die Pseudoelemente rechnen
über `left:var(--stem-x, 50%)` / `right:calc(100% - var(--stem-x, 50%))`. Der
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.
+23 -11
View File
@@ -131,17 +131,23 @@ nebeneinander (schmales Diagramm rechts).
ist grau (`#6B7A8C`) — kein Petrol mehr im Diagramm. Der Modus ändert nur die
**Anordnung**, nicht die Linienfarbe.
**Optionale Knoten (`+`, §3):** Sie hängen an der normalen all-of-Linie —
Anordnung und Linienstil bleiben unverändert. Gekennzeichnet wird der Knoten
selbst durch einen **kleinen hohlen Kreis** (weiß gefüllt, Rand in Tinte) genau
dort, wo der Abzweig ihn trifft: in der horizontalen Fächer-Anordnung **oben
mittig**, in den gestapelten Anordnungen (vertikal, kompakt, unterhalb einer
any-of-Gruppe) **links auf halber Höhe**. Übernommen aus den Feature-Diagrammen
(FODA: gefüllter Punkt = erforderlich, hohler Punkt = optional). Bewusst **kein
weiterer Linienstil**: gestrichelt gehört der any-of-Zerlegung, und im kompakten
Modus trägt allein der Linienstil die Gate-Codierung (D15) — ein dritter Stil
wäre dort nicht mehr sicher unterscheidbar. Der Kreis erscheint auch im
Grafikexport. Siehe D29.
**Optionale Knoten (`+`, §3):** Sie hängen an der normalen all-of-Zerlegung,
die Anordnung bleibt unverändert. Zwei Kennzeichen, beide auch im Grafikexport:
- Der **Abzweig zum Knoten** ist **gestrichelt in Tinte** (`#41556E`). Nur der
Abzweig — die **Sammelleiste bleibt durchgezogen**, sie gehört der ganzen
Geschwistergruppe. Die any-of-Linien sind ebenfalls gestrichelt, aber in
**Grau** (`#6B7A8C`); die Farbe hält beide auseinander, und weil `|` nicht mit
`-`/`+` gemischt werden darf (§3), treffen sie in einer Gruppe nie aufeinander.
- Am Auftreffpunkt sitzt ein **kleiner hohler Kreis** (weiß gefüllt, Rand in
Tinte): in der horizontalen Fächer-Anordnung **oben mittig**, in den
gestapelten Anordnungen (vertikal, kompakt, unterhalb einer any-of-Gruppe)
**links auf halber Höhe**. Übernommen aus den Feature-Diagrammen (FODA:
gefüllter Punkt = erforderlich, hohler Punkt = optional). Er markiert
eindeutig, **welcher** Knoten optional ist — auch dort, wo der Strich allein
mit den grauen any-of-Linien verwechselt werden könnte.
Siehe D29.
### Horizontal (Normalmodus)
- **all of:** Kinder nebeneinander, klassischer Organigramm-Fächer.
@@ -169,6 +175,12 @@ Grafikexport. Siehe D29.
deterministisch auf Knotenmitte liegen (Offset 23 px = 5 px Listenabstand
+ halbe Knotenhöhe). Abzweige zielen auf den **Knoten**, nie auf die Mitte
des Teilbaums.
- Dasselbe gilt **waagerecht**: Im horizontalen Fächer trifft der Stiel die
**Knotenmitte**, nicht die Mitte der Zelle. Beides fällt nur zusammen, solange
der Knoten in seiner Zelle zentriert steht — ein Knoten mit any-of-Kindern
steht dort aber **linksbündig** (damit die Sammelleiste unter ihm aufsetzt),
während seine Zelle so breit ist wie der Teilbaum. Der Stiel wird deshalb an
der gemessenen Knotenmitte ausgerichtet.
- In den transponierten Modi (vertikal, kompakt) stehen untereinander
gestapelte Geschwister mit **zusätzlichem Abstand nach unten** (damit das
Größen-Badge oben rechts nicht mit den Tags unten rechts des darüber