diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 03b26b6..a2f625c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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 **`
  • `** 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. diff --git a/docs/SPEC.md b/docs/SPEC.md index e37c05e..eeef18e 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -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 diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 32b9394..7588bb8 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -192,7 +192,17 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der **oben/50 %** im horizontalen Fächer, Rück-Ausnahme wieder links für `ul.or>li.has-and>ul.and>li` (D18). Im SVG-Export muss er **nach** den Knoten gezeichnet werden (`optMarks`, Schritt 3a) — er liegt halb außerhalb der Box - und würde sonst vom Knoten-Rechteck überdeckt. + und würde sonst vom Knoten-Rechteck überdeckt. Der **Abzweig** ist zusätzlich + gestrichelt (Tinte); dafür trägt auch das **`
  • `** die Klasse `opt`, denn den + Abzweig zeichnen dessen Pseudoelemente. Gestrichelt wird nur die Kante zum + Knoten, nie die Sammelleiste — im Fächer `border-left`/`-right`, gestapelt + `border-top`. +- `--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 + der any-of-Teilbaum. `alignStems()` misst deshalb nach jedem `render()` (und + in `applyLayout`) die Knotenmitte und setzt `--stem-x`; Fallback im CSS ist + `50%`. Messwerte durch `zoom` zurückrechnen, sonst stimmt es nur bei 100 %. - Kleiner Bildschirm: `body.mobile` (per `matchMedia`, ≤ 640 px) stapelt Diagramm/Editor mit **stufenlosem** Splitter (kein Snap/Collapse wie auf Desktop): der Gutter-Drag ruft `setMobileDrow()` (klemmt `--drow` zwischen den diff --git a/frontend/src/app.js b/frontend/src/app.js index 87c6164..1ae7bc7 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(''); + alignStems(); drawCheapPath(); /* Der Baum ist neu gebaut — die Markierung der Cursor-Zeile neu setzen (D25). Ohne Scrollen: beim Tippen soll das Diagramm stehen bleiben. */ @@ -119,6 +120,25 @@ 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)}`}); } +/* 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 + Knoten vorbei. Die transponierten Modi setzen left/right ohnehin fest und + bleiben deshalb unberührt. Messwerte sind durch `zoom` skaliert und werden + wie in drawCheapPath() zurückgerechnet. */ +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'); + if(!node) return; + const lr = li.getBoundingClientRect(), nr = node.getBoundingClientRect(); + if(!lr.width) return; /* Panel eingeklappt */ + li.style.setProperty('--stem-x', ((nr.left - lr.left + nr.width/2)/z).toFixed(1) + 'px'); + }); +} + function drawCheapPath(){ out.querySelectorAll('svg.cheap-overlay').forEach(e => e.remove()); if(!cheapPathOn) return; @@ -190,7 +210,7 @@ function diagramToSvg(){ .map(cli => cli.querySelector(':scope > .node, :scope > a.node')).filter(Boolean); const kids = kidEls.map(R); if(!kids.length) return; - const markOpt = (i, x, y) => { if(kidEls[i].classList.contains('opt')) optMarks.push({x, y}); }; + const isOpt = i => kidEls[i].classList.contains('opt'); const p = R(parentEl); const avgdx = kids.reduce((s,k)=>s+(k.cx-p.cx),0)/kids.length; const avgdy = kids.reduce((s,k)=>s+(k.cy-p.cy),0)/kids.length; @@ -202,8 +222,9 @@ function diagramToSvg(){ parts.push(seg(px, p.cy, busX, p.cy, stroke, dash)); parts.push(seg(busX, Math.min(...ys), busX, Math.max(...ys), stroke, dash)); kids.forEach((k, i) => { - parts.push(seg(busX, k.cy, toRight?k.x:k.r, k.cy, stroke, dash)); - markOpt(i, toRight?k.x:k.r, k.cy); + const x = toRight ? k.x : k.r, o = isOpt(i); + parts.push(seg(busX, k.cy, x, k.cy, stroke, dash || o)); + if(o) optMarks.push({x, y: k.cy}); }); } else { /* oben→unten */ const toDown = avgdy >= 0; @@ -213,8 +234,9 @@ function diagramToSvg(){ parts.push(seg(p.cx, py, p.cx, busY, stroke, dash)); parts.push(seg(Math.min(...xs), busY, Math.max(...xs), busY, stroke, dash)); kids.forEach((k, i) => { - parts.push(seg(k.cx, busY, k.cx, toDown?k.y:k.b, stroke, dash)); - markOpt(i, k.cx, toDown?k.y:k.b); + const y = toDown ? k.y : k.b, o = isOpt(i); + parts.push(seg(k.cx, busY, k.cx, y, stroke, dash || o)); + if(o) optMarks.push({x: k.cx, y}); }); } }); @@ -530,6 +552,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 */ + alignStems(); /* Stiel gilt nur im Fächer — beim Moduswechsel neu setzen/löschen */ drawCheapPath(); /* Blatt-Positionen ändern sich mit dem Modus */ } document.querySelectorAll('input[name="layout"]').forEach(radio => { diff --git a/frontend/src/render.js b/frontend/src/render.js index 3c4a38e..059f2be 100644 --- a/frontend/src/render.js +++ b/frontend/src/render.js @@ -23,6 +23,17 @@ function extraCls(n, opts){ return [cheap, fresh, n.optional ? 'opt' : ''].filter(Boolean).join(' '); } +/* Klassen des
  • : Gate der eigenen Kinder (steuert die Anordnung) plus + `opt`, wenn der Knoten selbst optional ist (steuert den Strich des + Abzweigs). Leere Liste ⇒ gar kein Attribut. */ +function liClass(visibleKids, opts, optional){ + const cls = [ + visibleKids.length ? (gateOf(visibleKids) === 'or' ? 'has-or' : 'has-and') : '', + optional ? 'opt' : '' + ].filter(Boolean); + return cls.length ? ` class="${cls.join(' ')}"` : ''; +} + export function esc(s){ return s.replace(/&/g,'&').replace(//g,'>'); } @@ -98,7 +109,9 @@ function renderChildren(node, warnings, opts){ const gate = gateOf(kids); const items = kids.map(k => { const vk = visibleChildren(k, opts.showDiscarded); - const liCls = vk.length ? (gateOf(vk) === 'or' ? ' class="has-or"' : ' class="has-and"') : ''; + /* `opt` auch am
  • : den Abzweig zeichnen dessen Pseudoelemente, er wird + für optionale Knoten gestrichelt (D29). */ + const liCls = liClass(vk, opts, k.optional); return `` + nodeHtml(k, extraCls(k, opts), opts) + renderChildren(k, warnings, opts) + @@ -114,7 +127,7 @@ export function renderTreeHtml(roots, opts){ const warnings = []; const html = roots.map(root => { const vk = visibleChildren(root, opts.showDiscarded); - const liCls = vk.length ? (gateOf(vk) === 'or' ? ' class="has-or"' : ' class="has-and"') : ''; + const liCls = liClass(vk, opts, root.optional); return `` + nodeHtml(root, ('root-node ' + extraCls(root, opts)).trim(), opts) + renderChildren(root, warnings, opts) + diff --git a/frontend/src/style.css b/frontend/src/style.css index b2a93e4..8395e04 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -570,12 +570,19 @@ content:'';position:absolute;top:0;left:50%;height:14px;border-left:2px solid var(--line); } ul.and>li{padding:16px 12px 0} + /* Der Stiel liegt bei `--stem-x` — normalerweise 50 % des
  • , was die + Knotenmitte ist, solange der Knoten im
  • zentriert steht. Bei + `li.has-or` ist er das NICHT (linksbündig, damit die any-of-Leiste unter + ihm aufsetzt) und der Teilbaum macht das
  • breiter als den Knoten: der + Stiel liefe daneben vorbei. `alignStems()` in app.js misst dort die + Knotenmitte und setzt `--stem-x` — D10 in der Waagerechten. */ ul.and>li::before, ul.and>li::after{ - content:'';position:absolute;top:0;right:50%; - border-top:2px solid var(--line);width:50%;height:16px; + content:'';position:absolute;top:0; + left:0;right:calc(100% - var(--stem-x, 50%)); + border-top:2px solid var(--line);height:16px; } - ul.and>li::after{right:auto;left:50%;border-left:2px solid var(--line)} + ul.and>li::after{left:var(--stem-x, 50%);right:0;border-left:2px solid var(--line)} ul.and>li:only-child{padding-top:14px} ul.and>li:only-child::before,ul.and>li:only-child::after{border-top:none} ul.and>li:only-child::after{border-left:2px solid var(--line);height:14px} @@ -631,7 +638,23 @@ font-weight:400; } - /* --- OPTIONAL (`+`, SPEC §3/D29): hohler Kreis am Abzweig --- + /* --- OPTIONAL (`+`, SPEC §3/D29): gestrichelter Abzweig + hohler Kreis --- + Gestrichelt wird NUR der Abzweig zum Knoten, nicht die Sammelleiste — die + gehört der ganzen Geschwistergruppe. 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 Leiste ist jeweils die andere Kante. + Gestrichelt in TINTE — any-of ist gestrichelt in Grau (D15), die Farbe + hält beide auseinander, der Kreis unten bestätigt es. */ + ul.and>li.opt::after{border-left-style:dashed} + ul.and>li.opt:last-child::before{border-right-style:dashed} + .tree.vertical ul.and>li.opt::before, + .tree.kompakt ul.and>li.opt::before, + .tree:not(.vertical):not(.kompakt) ul.or>li.has-and>ul.and>li.opt::before{ + border-top-style:dashed; + } + + /* Hohler Kreis am Abzweig --- Kein dritter Linienstil: gestrichelt gehört any-of, und im kompakten Modus codiert allein der Stil das Gate (D15). Stattdessen die FODA-Konvention — gefüllter Punkt = erforderlich (hier: gar keiner), hohler Punkt = optional. diff --git a/frontend/tests/__snapshots__/render.test.js.snap b/frontend/tests/__snapshots__/render.test.js.snap index ff1a780..13df568 100644 --- a/frontend/tests/__snapshots__/render.test.js.snap +++ b/frontend/tests/__snapshots__/render.test.js.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`renderTreeHtml — kanonisches Beispiel > Grundzustand (Pfad aus, verworfene aus): Struktur-Snapshot 1`] = `"
  • Website-Relaunch
  • "`; +exports[`renderTreeHtml — kanonisches Beispiel > Grundzustand (Pfad aus, verworfene aus): Struktur-Snapshot 1`] = `"
  • Website-Relaunch
  • "`; -exports[`renderTreeHtml — kanonisches Beispiel > günstigster Pfad an: cheap/cheap-leaf + implizite M-Badges 1`] = `"
  • Website-Relaunch
  • "`; +exports[`renderTreeHtml — kanonisches Beispiel > günstigster Pfad an: cheap/cheap-leaf + implizite M-Badges 1`] = `"
  • Website-Relaunch
  • "`; -exports[`renderTreeHtml — kanonisches Beispiel > verworfene einblenden: Eigenentwicklung erscheint (durchgestrichen) 1`] = `"
  • Website-Relaunch
  • "`; +exports[`renderTreeHtml — kanonisches Beispiel > verworfene einblenden: Eigenentwicklung erscheint (durchgestrichen) 1`] = `"
  • Website-Relaunch
  • "`; diff --git a/frontend/tests/optional.test.js b/frontend/tests/optional.test.js index b3d6a26..bb5a7ba 100644 --- a/frontend/tests/optional.test.js +++ b/frontend/tests/optional.test.js @@ -76,6 +76,18 @@ describe('Renderer — Kennzeichnung und Gemischt-Warnung', () => { expect(html).toContain('>Zugabe<'); }); + /* Der Abzweig wird von den
  • -Pseudoelementen gezeichnet — für den + gestrichelten Ast braucht das
  • die Klasse ebenfalls (D29). */ + it('gibt auch dem
  • die Klasse `opt`, neben dem Gate der eigenen Kinder', () => { + const {html} = render(`[ ] Wurzel\n + [ ] Zugabe\n | [ ] A\n | [ ] B`); + expect(html).toContain('
  • '); + }); + + it('lässt das
  • ohne Attribut, wenn weder Kinder noch optional', () => { + const {html} = render(`[ ] Wurzel\n - [ ] Pflicht`); + expect(html).toContain('
  • '); + }); + it('warnt NICHT, wenn `-` und `+` nebeneinander stehen', () => { const {warnings} = render(`[ ] Wurzel\n - [ ] Pflicht\n + [ ] Zugabe`); expect(warnings).toEqual([]);