diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 0e5b5f6..64a162f 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -1947,3 +1947,41 @@ zusätzlich einzeln anzuprangern wäre nur Lärm (SKIP-Ziel im Parser). Einzige Ausnahme ist `%%`: Der Kommentar fällt im ganzen Dokument als Erstes weg (einheitliche Regel, §1) — so lassen sich auch Beschreibungen kommentieren. + +## D41 — Querverbindungen: Krümmung statt Farbe, Pfeil auf das Gebrauchte +Die Abhängigkeits-Kanten (SPEC §9) sind die erste Linienart, die nicht der +Zerlegung folgt — §11 verlangte dafür eine eigene Zeichenebene. Gebaut wie der +Pfad-Spline (D18): Overlay-SVGs in `#out`, die den CSS-`zoom` erben, neu +gezeichnet nach Render und Moduswechsel. Die Bau-Entscheidungen: + +**Das Unterscheidungsmerkmal ist die Krümmung, nicht eine neue Farbe.** Die +Palette ist vergeben: Tinte und Grau gehören den Baumlinien, Petrol der +Interaktion und dem günstigsten Pfad, `--warn` dem Geister-Knoten — und Rot +ist bewusst nicht vergeben (D34 zu LEAN-PATHFINDING). Also tragen die Kanten +dasselbe Blassgrau wie die any-of-Führung, aber **geschwungen** (quadratische +Kurve mit seitlichem Bauch): Alle Zerlegungslinien sind achsenparallel, jede +gekrümmte Linie ist damit auf einen Blick „keine Zerlegung“ — dasselbe Mittel, +mit dem sich der Pfad-Spline absetzt. Dünn (1,5 px) und blass (0,35), denn der +Baum trägt die Hauptaussage; die **Pfeilspitze zeigt auf das Gebrauchte** +(„braucht“-Richtung), Endpunkte auf den Knotenkanten statt -mitten, damit die +Spitze sichtbar bleibt. + +**„Ausgewählt“ hat zwei Lesarten, beide gelten:** der Knoten mit +Tastaturfokus im Diagramm, sonst der Knoten der Cursor-Zeile (D25). Dessen +ein- und ausgehende Kanten wandern auf eine vordere Ebene in Tinte (2 px, +0,9) — vorn dürfen sie den Baum überlagern, denn die Hervorhebung ist +flüchtig und gerade angefragt. Kein Klick-Auswahlzustand: Der einfache Klick +ist der Link (§6), und Knoten fokussieren sich beim Klicken ohnehin. + +**Kanten enden an sichtbaren Knoten oder gar nicht.** Ziel eingeklappt (D38) +oder als verworfen ausgeblendet → keine Kante; eine Kante zum eingeklappten +Vorfahren wäre eine falsche Aussage über das Ziel. Bei doppelter ID zielt die +Kante auf die erste Vergabe — dieselbe Auflösung wie beim effektiven Status +(D39). Die Basis-Kanten gehören in Export und Druck (Aussage über den Plan); +die Hervorhebung nicht (Interaktion). Selbst-Abhängigkeiten zeichnen keine +Kante — ein Kreis von einem Knoten zu sich selbst sagte nichts. + +**Nicht durch Tests gedeckt:** `drawDepLinks()` arbeitet wie `alignStems()` +auf dem DOM; headless getestet sind die `data-id`/`data-deps`-Attribute des +Renderers, geprüft wurde im Browser (alle drei Modi, Fokus- und +Cursor-Hervorhebung, Export). diff --git a/docs/SPEC.md b/docs/SPEC.md index 70de043..661715e 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -620,6 +620,23 @@ fokussierten Knoten (WAI-ARIA-Baum-Idiom). Siehe D38. +### Querverbindungen der Abhängigkeiten (`:#…`, §1) +Abhängigkeiten werden als **optisch sekundäre** Kanten gezeichnet: dünn, +blassgrau (`#6B7A8C`), **geschwungen** — die Krümmung unterscheidet sie von +den orthogonalen Baumlinien (dasselbe Mittel wie beim Pfad-Spline) — auf einer +eigenen Zeichenebene **hinter** den Knoten, mit einer kleinen **Pfeilspitze +auf das Gebrauchte** („braucht“-Richtung). Der Baum trägt weiterhin die +Hauptaussage. + +- Die Kanten des **ausgewählten** Knotens — Tastaturfokus im Diagramm, sonst + der Knoten der Cursor-Zeile — liegen hervorgehoben in Tinte auf einer + vorderen Ebene, ein- wie ausgehende. +- Kanten zu gerade nicht sichtbaren Knoten (eingeklappt §9, verworfen + ausgeblendet §4) entfallen; bei doppelter ID zielt die Kante auf die erste + Vergabe (D36/D39). +- Die Basis-Kanten erscheinen auch im **Grafikexport** und im **Druck**; die + Hervorhebung ist Interaktion und erscheint nicht. Siehe D41. + ### Grafikexport des Diagramms Das Diagramm wird aus der Live-Geometrie in ein eigenständiges SVG (nur Formen + Text, keine externen Ressourcen) nachgezeichnet. Zwei Icon-Schaltflächen: @@ -735,12 +752,9 @@ Diskrepanz-Marke unten links). Siehe D39. ### Querverbindungen im Diagramm -Abhängigkeiten werden als **optisch sekundäre** Querverbindungen gezeichnet -(dünn oder gestrichelt) — sie dürfen den Baum nicht überlagern, der die -Hauptaussage trägt. Bei ausgewähltem Knoten werden dessen ein- und ausgehende -Abhängigkeiten hervorgehoben. Das ist die erste Linienart, die **nicht** der -Zerlegung folgt; sie braucht deshalb eine eigene Zeichenebene (SVG, wie der -Pfad-Spline in §9), nicht die Rahmenkanten der Knoten. +**Umgesetzt** — siehe §9 (Querverbindungen der Abhängigkeiten): eigene +SVG-Zeichenebene, gekrümmte blassgraue Kanten mit Pfeilspitze hinter den +Knoten, Hervorhebung am ausgewählten Knoten. Begründung: D41. ### Günstigster Pfad mit Abhängigkeiten diff --git a/docs/TASKS.md b/docs/TASKS.md index d85128b..91a0099 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -94,8 +94,11 @@ entscheiden, **bevor** Code entsteht. Abhängigkeiten nur einmal zählen). Erweitert D18; die gierige Wahl je Alternativgruppe ist damit nicht mehr optimal — Verfahren wählen und **benennen**. -- [ ] Querverbindungen zeichnen (eigene SVG-Ebene, optisch sekundär); +- [x] Querverbindungen zeichnen (eigene SVG-Ebene, optisch sekundär); bei ausgewähltem Knoten ein-/ausgehende hervorheben. + → Umgesetzt (D41): gekrümmte blassgraue Kanten mit Pfeilspitze auf das + Gebrauchte, hinter den Knoten (`drawDepLinks`); Hervorhebung in Tinte + für Fokus-Knoten bzw. Cursor-Zeile; Basis-Kanten auch im Export/Druck. - [x] Faltmarken `>` / `<` parsen; interaktives Auf-/Zuklappen im Diagramm. → Schreibweise und Export-Verhalten entschieden (D34-Nachtrag): Marke hinter dem Zerlegungszeichen (`- > [x] …`), Export/Druck folgen der diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 27981c7..4019b69 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -292,6 +292,15 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der Knoten-Referenzen; der Parser prüft nur Existenz (`unknownDep`), Zyklen werden bewusst nicht einmal erkannt (zulässig, „gemeinsam fertig"). Keine Diagramm-Darstellung — nur Tooltip (`→ #a, #b`) und `a11yDeps`. +- Querverbindungen (SPEC §9, D41): `drawDepLinks()` zeichnet die + Abhängigkeits-Kanten als Overlay-SVGs (wie `drawCheapPath`, `/zoom` + umrechnen) aus den Renderer-Attributen `data-id`/`data-deps` — gekrümmt + (Merkmal gegenüber den orthogonalen Baumlinien), blassgrau hinter den + Knoten, Pfeil aufs Gebrauchte; Hervorhebung (Fokus bzw. Cursor-Zeile, + `activeDepNode()`) in Tinte auf der vorderen Ebene. Aufgerufen aus + `highlightCurrentNode()` (läuft in jedem `render()`), `applyLayout()` und + den focusin/focusout-Handlern — NICHT doppelt in `render()` einhängen. + Export zeichnet nur die Basis-Kanten (Schritt 1a). - Effektiver Status (SPEC §4/§9, D39): `effectiveStatus(roots)` in model.js liefert NUR die Diskrepanzen (Map Knoten → effektiver Status-Key; Minimum des Fortschritts-Rangs über die Abhängigkeits-Hülle, Fixpunkt — Zyklen diff --git a/frontend/src/app.js b/frontend/src/app.js index 0b90541..5b396a3 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -127,6 +127,8 @@ function render(){ applyOptStairs(); /* muss vor dem Messen laufen — es verschiebt Knoten */ alignStems(); drawCheapPath(); + /* Querverbindungen (D41) zeichnet highlightCurrentNode() unten mit — + es kennt die zweite Hälfte der Auswahl (Cursor-Zeile). */ /* Der Baum ist neu gebaut — die Markierung der Cursor-Zeile neu setzen (D25). Ohne Scrollen: beim Tippen soll das Diagramm stehen bleiben. */ highlightCurrentNode(false); @@ -277,6 +279,95 @@ function drawCheapPath(){ out.appendChild(front); } +/* ---------- Querverbindungen der Abhängigkeiten (SPEC §9, D41) ---------- + Die erste Linienart, die nicht der Zerlegung folgt — deshalb eine eigene + Zeichenebene wie beim Pfad-Spline. Basis-Kanten dünn, blassgrau und + GEKRÜMMT (die Krümmung unterscheidet sie von den orthogonalen Baumlinien), + hinter den Knoten, mit Pfeilspitze auf das Gebrauchte. Die Kanten des + fokussierten Knotens bzw. der Cursor-Zeile liegen hervorgehoben in Tinte + auf einer vorderen Ebene. */ +function depEdges(){ + const byId = new Map(); + out.querySelectorAll('.node[data-id]').forEach(el => { + if(!byId.has(el.dataset.id)) byId.set(el.dataset.id, el); /* erste Vergabe gewinnt (D36) */ + }); + const edges = []; + out.querySelectorAll('.node[data-deps]').forEach(el => { + for(const d of el.dataset.deps.split(' ')){ + const target = byId.get(d); + /* verborgene (eingeklappte/ausgeblendete) oder unbekannte Ziele: keine Kante */ + if(target && target !== el) edges.push([el, target]); + } + }); + return edges; +} +/* Gekrümmte Kante zwischen zwei Knotenkästen: Endpunkte auf den Kanten + (Strahl von Mitte zu Mitte, am Rechteck geklippt), Kontrollpunkt senkrecht + zur Verbindung versetzt. */ +function depCurve(a, b){ + const dx = b.cx - a.cx, dy = b.cy - a.cy; + const len = Math.hypot(dx, dy) || 1; + const clip = (r, tx, ty) => { + const ex = tx - r.cx, ey = ty - r.cy; + const sx = ex ? (r.w/2) / Math.abs(ex) : Infinity; + const sy = ey ? (r.h/2) / Math.abs(ey) : Infinity; + const s = Math.min(sx, sy, 1); + return {x: r.cx + ex*s, y: r.cy + ey*s}; + }; + const p1 = clip(a, b.cx, b.cy), p2 = clip(b, a.cx, a.cy); + const bow = Math.min(40, len/4); + const ctrl = {x:(p1.x + p2.x)/2 - dy/len*bow, y:(p1.y + p2.y)/2 + dx/len*bow}; + return {d:`M${p1.x.toFixed(1)},${p1.y.toFixed(1)} Q${ctrl.x.toFixed(1)},${ctrl.y.toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`, + end:p2, ctrl}; +} +function depArrow(end, from){ + const dx = end.x - from.x, dy = end.y - from.y; + const l = Math.hypot(dx, dy) || 1; + const ux = dx/l, uy = dy/l, s = 5; + const a = {x: end.x - ux*s*1.8 - uy*s, y: end.y - uy*s*1.8 + ux*s}; + const b = {x: end.x - ux*s*1.8 + uy*s, y: end.y - uy*s*1.8 - ux*s}; + return `M${end.x.toFixed(1)},${end.y.toFixed(1)} L${a.x.toFixed(1)},${a.y.toFixed(1)} L${b.x.toFixed(1)},${b.y.toFixed(1)} Z`; +} +/* „Ausgewählt" heißt: Tastaturfokus im Diagramm, sonst der Knoten der + Cursor-Zeile (D25) — beide Lesarten von „ich schaue auf diesen Knoten". */ +function activeDepNode(){ + const f = document.activeElement; + if(f && out.contains(f) && f.closest) { + const n = f.closest('.node[data-line]'); + if(n) return n; + } + return currentNodeEl; +} +function drawDepLinks(){ + out.querySelectorAll('svg.dep-overlay').forEach(e => e.remove()); + const edges = depEdges(); + if(!edges.length) return; + const outRect = out.getBoundingClientRect(); + if(!outRect.width || !outRect.height) return; /* Panel eingeklappt */ + const z = zoom || 1; + const rect = el => { const r = el.getBoundingClientRect(); + return {x:(r.left - outRect.left)/z, y:(r.top - outRect.top)/z, + w:r.width/z, h:r.height/z, + cx:(r.left - outRect.left + r.width/2)/z, + cy:(r.top - outRect.top + r.height/2)/z}; }; + const w = outRect.width/z, h = outRect.height/z; + const back = overlaySvg('dep-overlay dep-back', w, h); + const front = overlaySvg('dep-overlay dep-front', w, h); + const hi = activeDepNode(); + for(const [from, to] of edges){ + const hl = hi && (from === hi || to === hi); + const c = depCurve(rect(from), rect(to)); + const layer = hl ? front : back; + layer.appendChild(svgEl('path', {class:'dep-edge' + (hl ? ' hl' : ''), d:c.d})); + layer.appendChild(svgEl('path', {class:'dep-arrow' + (hl ? ' hl' : ''), d:depArrow(c.end, c.ctrl)})); + } + out.insertBefore(back, out.firstChild); + if(front.childNodes.length) out.appendChild(front); +} +/* Hervorhebung folgt dem Fokus; die Basis-Kanten selbst ändern sich nicht. */ +out.addEventListener('focusin', drawDepLinks); +out.addEventListener('focusout', () => setTimeout(drawDepLinks, 0)); + /* ---------- Diagramm als Grafik (SVG → PNG) ---------- */ /* Das gerenderte Diagramm wird aus der Live-Geometrie in ein eigenständiges SVG (nur Formen + Text, keine externen Ressourcen) nachgezeichnet und als @@ -383,6 +474,14 @@ function diagramToSvg(){ }); }); + /* 1a) Querverbindungen der Abhängigkeiten (D41) — Basis-Kanten hinter den + Knoten; die Fokus-Hervorhebung ist Interaktion und wird nicht exportiert. */ + depEdges().forEach(([from, to]) => { + const c = depCurve(R(from), R(to)); + parts.push(``); + parts.push(``); + }); + /* 1b) Günstigster-Pfad: kräftige Linie hinter den Knoten */ if(cheapPts.length >= 2) parts.push(cheapLine('0.9')); @@ -814,8 +913,11 @@ function highlightCurrentNode(scroll){ currentNodeEl = caretLine == null ? null : out.querySelector('.node[data-line="' + caretLine + '"]'); - if(!currentNodeEl) return; + if(!currentNodeEl){ drawDepLinks(); return; } currentNodeEl.classList.add('current'); + /* Die Cursor-Zeile ist die zweite Lesart von „ausgewählt" — ihre + Abhängigkeits-Kanten hervorheben (D41). */ + drawDepLinks(); /* Nur beim Zeilenwechsel scrollen, sonst ruckelte das Diagramm beim Tippen. */ if(scroll) currentNodeEl.scrollIntoView({block:'nearest', inline:'nearest', behavior:'smooth'}); } @@ -873,6 +975,7 @@ function applyLayout(mode){ 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 */ drawCheapPath(); /* Blatt-Positionen ändern sich mit dem Modus */ + drawDepLinks(); /* Knoten-Positionen ebenso (D41) */ } document.querySelectorAll('input[name="layout"]').forEach(radio => { radio.addEventListener('change', () => { if(radio.checked) applyLayout(radio.value); }); diff --git a/frontend/src/render.js b/frontend/src/render.js index cfaadd5..c699d3e 100644 --- a/frontend/src/render.js +++ b/frontend/src/render.js @@ -91,6 +91,10 @@ function nodeHtml(n, extra, opts, fold){ für die Gegenrichtung (Cursor-Zeile -> Knoten hervorheben). Der Hinweis im Tooltip macht die sonst unsichtbare Alt-Klick-Geste auffindbar. */ const lineAttr = n.line ? ` data-line="${n.line}"` : ''; + /* ID und Abhängigkeiten als data-Attribute (D41): Grundlage für die + Querverbindungs-Ebene und den Export — beide arbeiten auf dem DOM. */ + const idAttr = n.id ? ` data-id="${attr(n.id)}"` : ''; + const depsAttr = n.deps && n.deps.length ? ` data-deps="${attr(n.deps.join(' '))}"` : ''; /* Beschreibung zuerst im Tooltip (mehrzeilig, D40), dann die Kurz-Fakten. */ const tip = [n.desc || '', n.id ? '#' + n.id : '', @@ -139,8 +143,8 @@ function nodeHtml(n, extra, opts, fold){ ownChip; const aria = ` aria-label="${attr(nodeAria(n, opts, fold))}"`; const html = n.url - ? `${inner}` - : `
${inner}
`; + ? `${inner}` + : `
${inner}
`; const ghostTip = attr(t('ghostTooltip')); const ghost = `
${esc(t('ghost'))}
`; return html + (need ? ghost : ''); diff --git a/frontend/src/style.css b/frontend/src/style.css index e809bef..4986984 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -702,6 +702,14 @@ Blättern zeigen, welche Knoten wirklich auf dem Pfad enden — durchquerte Fremdknoten tragen keinen Punkt. Reihenfolge im DOM steuert vorn/hinten. */ .cheap-overlay{position:absolute;left:0;top:0;overflow:visible;pointer-events:none} + /* Querverbindungen der Abhängigkeiten (SPEC §9, D41): dünn, blassgrau, + gekrümmt — die Krümmung unterscheidet sie von den orthogonalen Baumlinien. + Hervorgehobene Kanten (fokussierter Knoten/Cursor-Zeile) in Tinte. */ + .dep-overlay{position:absolute;left:0;top:0;overflow:visible;pointer-events:none} + .dep-edge{fill:none;stroke:var(--muted);stroke-width:1.5;opacity:.35;stroke-linecap:round} + .dep-arrow{fill:var(--muted);opacity:.35} + .dep-edge.hl{stroke:var(--line);stroke-width:2;opacity:.9} + .dep-arrow.hl{fill:var(--line);opacity:.9} .cheap-path{fill:none;stroke:var(--or);stroke-width:2.5;stroke-linecap:round; stroke-dasharray:8 6;opacity:.9} .cheap-path.faint{opacity:.2} diff --git a/frontend/tests/deps.test.js b/frontend/tests/deps.test.js index cc0bf73..837cb7b 100644 --- a/frontend/tests/deps.test.js +++ b/frontend/tests/deps.test.js @@ -79,4 +79,13 @@ describe('Darstellung — Tooltip und aria, kein eigenes Zeichen', () => { expect(html).not.toContain('a11yDeps'); expect(html).not.toContain('→'); }); + + /* Grundlage der Querverbindungs-Ebene (D41): sie arbeitet auf dem DOM und + braucht ID und Abhängigkeiten als data-Attribute. */ + it('schreibt data-id und data-deps an die Knoten', () => { + const {html} = render(`[ ] A #a\n[ ] B #b\n[ ] C :#a,#b`); + expect(html).toContain('data-id="a"'); + expect(html).toContain('data-deps="a b"'); + expect(html).not.toContain('data-deps=""'); + }); });