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(`