import './style.css';
import { parse } from './parser.js';
import { computeCheapPlan, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus } from './model.js';
import { esc, renderTreeHtml } from './render.js';
import { formatWarning } from './warnings.js';
import { padUrls } from './remote.js';
/* Werkbaum, mit Werkbaum geplant — als mitgeliefertes Dokument „Werkbank" (D27).
Dieselbe Datei, die auch per ?sourceUrl= geladen werden kann; `?raw` bettet
sie beim Build in die eine Ausgabedatei ein (D19), es wird nichts nachgeladen
(D20). Quelle bleibt docs/examples/ — keine Kopie, die auseinanderläuft. */
import WERKBAUM_DOC from '../../docs/examples/werkbaum.werkbaum?raw';
const INITIAL = `%% Project structure – Sprint 14
[~] Website relaunch (XL) https://wiki.example.com/relaunch
" Folded chapters are done — click the ▸ to peek inside.
- > [x] Concept (M)
- [x] Audience analysis (S)
- [x] Sitemap (XS)
- [~] Implementation (XL)
- [~] Frontend (M) https://git.example.com/frontend @anna
| [ ] PWA (S)
| [ ] Web+Native
- [/] Web (S)
- [ ] Android (M)
- [ ] iOS (M)
- [!] Backend (L) @ben @carla
- [ ] CMS integration #cms (M)
| [ ] WordPress
| [?] Headless CMS
| [-] Custom build %% too much effort
- [x] Landing page (S) :#cms %% done, but effectively waiting for the CMS
+ [?] Dark mode (S) %% nice to have, never on the cheapest path
- [?] Hosting (M) %% exactly one of these, hence =
= [ ] Cooperative Community Cloud https://hostsharing.net
= [?] On-premise
---
#cms
The articles live in the CMS, so everything that shows content
depends on it — the dotted arrows point at what is needed.`;
const src = document.getElementById('src');
const out = document.getElementById('out');
const warnBox = document.getElementById('warn');
/* Zeilennummern-Streifen (D33) — hier oben geholt, weil render() ihn füllt. */
const srcWrap = document.getElementById('srcWrap');
const lineNoBox = document.getElementById('lineNos');
const lineNoInner = lineNoBox.firstElementChild;
/* Baum-/Kostenlogik (gateOf, needsBreakdown, visibleChildren, günstigster
Pfad) lebt headless in model.js, das HTML-Erzeugen in render.js. Hier bleibt
nur der UI-State des Günstigster-Pfad-Toggles (persistiert). */
let cheapPathOn = true;
/* Ansicht bei einem Pad-Dokument (D31): beide | nur Pad | nur Spiegel. Hier oben
deklariert, weil saveUI() sie liest und schon aus applySplit() heraus laufen
kann — weiter unten stünde sie dann noch in der temporalen Todeszone. */
const PAD_VIEWS = ['both', 'pad', 'text'];
let padView = 'both';
/* Cursor-Zeile (D25) — bleibt `null`, bis der Cursor das erste Mal bewegt wurde,
sonst wäre nach dem Laden ungefragt die Wurzel markiert. Hier oben deklariert
wie `padView`: der Zeilennummern-Streifen (D33) liest sie, und der hängt an
render(). */
let caretLine = null, currentNodeEl = null;
/* Warnung des ?sourceUrl-Ladens (D23) — zeilenlos und persistent, siehe render(). */
let sourceWarning = null;
/* „Was ist neu?" (D28): Knoten, die gegenüber der zuletzt GESEHENEN Fassung neu
in Produktion sind. Gilt immer für genau ein Dokument (`freshDocId`) — nur
Dokumente von außen (mitgeliefert oder ?sourceUrl=) haben eine
Vergleichsfassung. `freshBaseline` ist der Text, gegen den verglichen wurde;
er wird erst beim Bestätigen fortgeschrieben. */
let freshSet = new Set(), freshDocId = null, freshBaseline = null, freshPrevRoots = null;
/* Faltung (SPEC §9, D38): `foldOverrides` sind die interaktiven Eingriffe des
Nutzers (Schlüssel = Label-Pfad wie bei D28, damit sie das Neu-Parsen bei
jedem Tastendruck überleben); sie überlagern den Anfangszustand aus den
Textmarken, gelten nur für die Sitzung und fallen beim Dokumentwechsel weg.
`foldByLine` ist der Zustand des letzten Renders für Klick/Tastatur. */
let foldOverrides = new Map(), foldByLine = new Map();
/* ---------- Renderer (Anbindung an den DOM) ----------
parse -> Wurzeln filtern (verworfene) -> günstigen Pfad markieren ->
render.js baut den HTML-String -> in #out schreiben -> Pfadlinie zeichnen. */
function render(){
const parsed = parse(src.value);
let roots = parsed.roots;
const showDiscarded = discardedShown();
if(!showDiscarded){
roots = roots.filter(r => !r.status || r.status.key !== 'verworfen');
}
/* Warnungen aus Parser (unbekannte Statuszeichen) + Renderer (gemischte
Gates) zusammenführen, nach Zeile sortiert anzeigen. `sourceWarning`
(?sourceUrl nicht ladbar, D23) gehört keiner Zeile und bleibt über
Neu-Renderings bestehen, bis das Laden gelingt. */
let warnings = sourceWarning ? [sourceWarning].concat(parsed.warnings) : parsed.warnings;
if(!roots.length){
out.innerHTML = `
${esc(t('empty'))}
`;
freshSet = new Set();
foldByLine = new Map();
} else {
/* Günstigster Pfad auf der Dependency Closure (D42): scheitert die exakte
Suche an zu vielen gekoppelten Gruppen, wird die gierige Schätzung
BENANNT statt stillschweigend geliefert (zeilenlose Warnung). */
let cheapSet = new Set();
if(cheapPathOn){
const plan = computeCheapPlan(roots);
cheapSet = plan.set;
if(!plan.exact) warnings = warnings.concat([{type: 'cheapApprox'}]);
}
out.classList.toggle('cheap-on', cheapPathOn);
/* Die Menge MUSS aus denselben Knotenobjekten gebildet werden, die gerade
gerendert werden — `freshProdSet` liefert Knoten aus `roots`. Eine früher
berechnete Menge stammte aus einem anderen Parse-Durchlauf und träfe per
Objektidentität nie zu (D28). */
freshSet = (freshDocId === activeId && freshPrevRoots)
? freshProdSet(freshPrevRoots, roots) : new Set();
/* Faltung (D38): Anfangszustand aus den Textmarken (`!!!` holt sich mit
hervor), überlagert von den Sitzungs-Eingriffen des Nutzers. Wie bei
`freshSet` muss die Menge aus den gerade geparsten Knoten bestehen. */
const initFold = initialCollapsed(roots, true);
const keys = nodeKeys(roots);
const collapsedSet = new Set();
foldByLine = new Map();
keys.forEach((key, n) => {
const ov = foldOverrides.get(key);
const collapsed = (ov !== undefined ? ov : initFold.has(n)) && n.children.length > 0;
if(collapsed) collapsedSet.add(n);
foldByLine.set(n.line, {key, collapsed, canFold: n.children.length > 0});
});
const r = renderTreeHtml(roots, {t, showDiscarded, cheapPath: cheapPathOn, cheapSet,
freshSet, collapsedSet,
effStatus: effectiveStatus(roots)});
out.innerHTML = r.html;
warnings = warnings.concat(r.warnings);
}
warnings = warnings.slice().sort((a, b) => (a.line || 0) - (b.line || 0));
warnBox.innerHTML = warnings.map(w => `
⚠ ${formatWarning(w, t)}
`).join('');
/* Der Zeilennummern-Streifen zeigt genau die Zeilen an, die hier genannt
werden — deshalb hängt er an derselben Warnungsliste (D33). */
lineNoWarn = new Set(warnings.map(w => w.line).filter(Boolean));
renderLineNos();
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);
revealFocusMark(); /* `!!!` ins Bild holen, wenn die Marke neu ist (SPEC §1) */
updateFreshBtn(); /* Zähler folgt der gerade gerenderten Menge (D28) */
}
/* Fokusmarke `!!!` (SPEC §1): Der erste markierte Knoten wird ins Bild geholt —
aber nur, wenn sich die Marke **geändert** hat. Sonst zöge jeder Neubau des
Baums den Blick zurück und man käme nicht weg. Verglichen wird das Label des
Knotens, nicht die Zeilennummer: Umsortieren im Pad soll nicht als neue Marke
gelten. Die Hervorhebung selbst macht die Klasse `focusmark` aus dem Renderer,
hier geht es nur ums Scrollen. */
let lastFocusMark = null;
function revealFocusMark(){
const el = out.querySelector('.node.focusmark');
const key = el ? el.textContent : null;
if(key && key !== lastFocusMark){
el.scrollIntoView({block:'center', inline:'center', behavior:'smooth'});
}
lastFocusMark = key;
}
/* ---------- Günstigster-Pfad-Linie ----------
Eine gestrichelte, geschwungene Petrol-Linie fädelt durch die Endknoten
(Blätter) des günstigen Pfads. Das Overlay-SVG liegt in #out und erbt damit
dessen CSS-`zoom`; die Punkte werden in unskalierte #out-Koordinaten
umgerechnet (getBoundingClientRect / zoom). Neu gezeichnet nach jedem
render() und nach Moduswechsel (applyLayout ruft nicht render). */
const SVGNS = 'http://www.w3.org/2000/svg';
function catmullRom(p){
if(p.length < 2) return '';
if(p.length === 2) return `M${p[0].x.toFixed(1)},${p[0].y.toFixed(1)} L${p[1].x.toFixed(1)},${p[1].y.toFixed(1)}`;
let d = `M${p[0].x.toFixed(1)},${p[0].y.toFixed(1)}`;
for(let i = 0; i < p.length - 1; i++){
const p0 = p[i-1] || p[i], p1 = p[i], p2 = p[i+1], p3 = p[i+2] || p2;
const c1x = p1.x + (p2.x - p0.x)/6, c1y = p1.y + (p2.y - p0.y)/6;
const c2x = p2.x - (p3.x - p1.x)/6, c2y = p2.y - (p3.y - p1.y)/6;
d += ` C${c1x.toFixed(1)},${c1y.toFixed(1)} ${c2x.toFixed(1)},${c2y.toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`;
}
return d;
}
function svgEl(name, attrs){
const e = document.createElementNS(SVGNS, name);
for(const k in attrs) e.setAttribute(k, attrs[k]);
return e;
}
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)}`});
}
/* ---------- Treppe für optionale Endknoten (D29, Nachtrag 3) ----------
Im horizontalen Fächer kostet jedes optionale Geschwister eine eigene Spalte
— Breite für gerade das, was am wenigsten wichtig ist. Aufeinanderfolgende
optionale Endknoten werden deshalb zu einer Kaskade gestapelt, die an einem
Punkt hängt.
Warum hier und nicht im Renderer: Die Gruppierung ist reine Darstellung. So
bleiben der Renderer-String (SPEC §9: der Modus ändert nur die Anordnung) und
die hand-getunte Geometrie der drei übrigen Anordnungen unberührt — dort gibt
es das Platzproblem gar nicht, die Kinder stehen ohnehin untereinander.
Nur ENDknoten: Der Platzgewinn entsteht gerade daraus, dass kein Teilbaum
mitgestapelt werden muss; außerdem setzt die Stufengeometrie voraus, dass die
Zelle so hoch ist wie ihr Knoten (kein Teilbaum, kein Geister-Knoten). */
const STAIR_MIN = 2;
function applyOptStairs(){
/* Erst auflösen: applyLayout() arbeitet auf einem bereits gruppierten Baum. */
out.querySelectorAll('li.opt-group').forEach(group => {
const stair = group.firstElementChild;
while(stair.firstElementChild){
const li = stair.firstElementChild;
li.style.removeProperty('--i');
group.parentNode.insertBefore(li, group);
}
group.remove();
});
if(out.classList.contains('vertical') || out.classList.contains('kompakt')) return;
out.querySelectorAll('ul.and').forEach(ul => {
let run = [];
const flush = () => {
if(run.length >= STAIR_MIN){
const group = document.createElement('li');
group.className = 'opt-group';
const stair = document.createElement('ul');
stair.className = 'opt-stair';
ul.insertBefore(group, run[0]);
group.appendChild(stair);
run.forEach((li, i) => { li.style.setProperty('--i', i); stair.appendChild(li); });
}
run = [];
};
for(const li of [...ul.children]){
const leaf = li.classList.contains('opt') && li.children.length === 1
&& li.firstElementChild.classList.contains('node');
if(leaf) run.push(li); else flush();
}
flush();
});
}
/* 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;
/* `li.opt-group` (Treppe) hat keinen eigenen Knoten — der Stiel zielt auf den
ERSTEN Knoten der Kaskade. */
out.querySelectorAll('ul.and>li.has-or, ul.and>li.opt-group').forEach(li => {
const node = li.querySelector(':scope > .node') || li.querySelector('.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;
const leaves = [...out.querySelectorAll('.node.cheap-leaf')]; /* Dokument-Reihenfolge = Lese-Reihenfolge */
if(leaves.length < 2) return;
const outRect = out.getBoundingClientRect();
const z = zoom || 1;
if(!outRect.width || !outRect.height) return; /* Panel eingeklappt */
const pts = leaves.map(el => {
const r = el.getBoundingClientRect();
return {x:(r.left + r.width/2 - outRect.left)/z, y:(r.top + r.height/2 - outRect.top)/z};
});
const w = outRect.width/z, h = outRect.height/z;
const d = catmullRom(pts);
/* kräftige Linie HINTER die Knoten (als erstes Kind → hinterste Paint-Ebene) */
const back = overlaySvg('cheap-back', w, h);
back.appendChild(svgEl('path', {class:'cheap-path', d}));
out.insertBefore(back, out.firstChild);
/* davor: abgetönte Kopie (deutet den Verlauf über Knoten an) + Stationspunkte */
const front = overlaySvg('cheap-front', w, h);
front.appendChild(svgEl('path', {class:'cheap-path faint', d}));
pts.forEach(p => front.appendChild(
svgEl('circle', {class:'cheap-dot', cx:p.x.toFixed(1), cy:p.y.toFixed(1), r:10})));
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
PNG in die Zwischenablage gelegt. Knotenfarben, Größen-Badges, Tags und
der Geister-Knoten werden übernommen; Verbindungslinien werden je Gate
(und = durchgezogen Tinte, oder = gestrichelt Grau) neu gezogen und treffen
so garantiert die Knoten — unabhängig vom Darstellungsmodus. */
function diagramToSvg(){
const treeRect = out.getBoundingClientRect();
const PAD = 24;
const W = Math.ceil(treeRect.width) + PAD*2;
const H = Math.ceil(treeRect.height) + PAD*2;
const ox = -treeRect.left + PAD, oy = -treeRect.top + PAD;
const R = el => { const r = el.getBoundingClientRect();
return {x:r.left+ox, y:r.top+oy, w:r.width, h:r.height,
cx:r.left+ox+r.width/2, cy:r.top+oy+r.height/2, r:r.right+ox, b:r.bottom+oy}; };
const parts = [``];
const nodes = [...out.querySelectorAll('.node')];
const cheapPts = cheapPathOn
? [...out.querySelectorAll('.node.cheap-leaf')].map(el => { const b = R(el); return {x:b.cx, y:b.cy}; })
: [];
const cheapLine = op =>
``;
/* 1) Verbindungslinien (hinter den Knoten) */
const seg = (x1,y1,x2,y2,stroke,dash) =>
``;
/* Auftreffpunkte der Abzweige an optionalen Knoten (`+`, SPEC §3/D29).
Gesammelt beim Linienzeichnen, gezeichnet erst NACH den Knoten — der Kreis
sitzt mittig auf der Kante, das Knoten-Rechteck würde ihn sonst halb
überdecken. */
const optMarks = [];
/* „1"-Plaketten der XOR-Gruppen (`=`, SPEC §9/D35) — wie die Optional-Kreise
erst NACH den Knoten gezeichnet, sitzen aber auf der Leitung, nie auf
einer Knotenkante. */
const xorMarks = [];
nodes.forEach(parentEl => {
const li = parentEl.closest('li');
const childUl = li && [...li.children].find(c => c.tagName === 'UL');
if(!childUl) return;
const gate = childUl.classList.contains('or') ? 'or' : 'and';
const isXor = childUl.classList.contains('xor');
const stroke = gate === 'or' ? '#6B7A8C' : '#41556E';
const dash = gate === 'or';
/* Die Treppe (D29) ist eine Anordnung, keine Ebene: alle Stufen sind Kinder
DIESES Elternknotens. An die Sammelleiste kommt nur die erste Stufe; die
übrigen hängen an der Kaskade und werden unten nachgezogen. Zöge man jede
Stufe einzeln an die Leiste, liefe die Linie zur dritten Stufe hinter der
zweiten hindurch — und läse sich wie eine Eltern-Kind-Beziehung. */
const kidEls = [], stairs = [];
for(const cli of childUl.children){
if(cli.classList.contains('opt-group')){
const st = [...cli.querySelectorAll(':scope > ul.opt-stair > li > .node')];
if(!st.length) continue;
kidEls.push(st[0]);
if(st.length > 1) stairs.push(st);
} else {
const n = cli.querySelector(':scope > .node');
if(n) kidEls.push(n);
}
}
const kids = kidEls.map(R);
if(!kids.length) return;
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;
if(Math.abs(avgdx) >= Math.abs(avgdy)){ /* links→rechts */
const toRight = avgdx >= 0;
const px = toRight ? p.r : p.x;
const busX = toRight ? Math.min(...kids.map(k=>k.x))-14 : Math.max(...kids.map(k=>k.r))+14;
const ys = kids.map(k=>k.cy).concat(p.cy);
parts.push(seg(px, p.cy, busX, p.cy, stroke, dash));
if(isXor) xorMarks.push({x:(px+busX)/2, y:p.cy});
parts.push(seg(busX, Math.min(...ys), busX, Math.max(...ys), stroke, dash));
kids.forEach((k, i) => {
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;
const py = toDown ? p.b : p.y;
const busY = toDown ? Math.min(...kids.map(k=>k.y))-14 : Math.max(...kids.map(k=>k.b))+14;
const xs = kids.map(k=>k.cx).concat(p.cx);
parts.push(seg(p.cx, py, p.cx, busY, stroke, dash));
if(isXor) xorMarks.push({x:p.cx, y:(py+busY)/2});
parts.push(seg(Math.min(...xs), busY, Math.max(...xs), busY, stroke, dash));
kids.forEach((k, i) => {
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});
});
}
/* Kaskade ab der zweiten Stufe: an der linken Kante der vorigen Stufe
herab, dann waagerecht in die eigene — dieselbe Führung wie am Bildschirm. */
stairs.forEach(st => {
for(let j = 1; j < st.length; j++){
const a = R(st[j-1]), b = R(st[j]);
parts.push(seg(a.x, a.b, a.x, b.cy, stroke, true));
parts.push(seg(a.x, b.cy, b.x, b.cy, stroke, true));
optMarks.push({x: b.x, y: b.cy});
}
});
});
/* 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'));
/* 2) Badge/Pille (Größe, Tags) */
const drawBadge = (el, fill, textColor, strokeColor) => {
const b = R(el);
parts.push(``);
parts.push(`${esc(el.textContent.trim())}`);
};
/* 3) Knoten */
nodes.forEach(node => {
const b = R(node), cs = getComputedStyle(node);
const dashed = cs.borderTopStyle === 'dashed';
parts.push(``);
const clone = node.cloneNode(true);
/* Das „▸ n"-Kennzeichen eingeklappter Knoten gehört in den Export (SPEC
§9/D38 — das Bild darf keine Vollständigkeit behaupten); das ▾ offener
Knoten ist Bedienelement und fällt weg. */
const stripFold = node.classList.contains('folded') ? '' : ',.fold';
clone.querySelectorAll('.size,.tags,.ext,.risk,.ownst,.desc-mark' + stripFold).forEach(e => e.remove());
const label = clone.textContent.replace(/\s+/g,' ').trim();
const deco = cs.textDecorationLine.includes('line-through') ? ' text-decoration="line-through"' : '';
parts.push(`${esc(label)}`);
const riskEl = node.querySelector('.risk');
if(riskEl){
const rb = R(riskEl);
parts.push(``);
parts.push(`⚠︎`);
}
const sizeEl = node.querySelector('.size');
if(sizeEl) drawBadge(sizeEl, '#0F766E', '#ffffff');
node.querySelectorAll('.tag').forEach(tg => {
const t = getComputedStyle(tg);
drawBadge(tg, t.backgroundColor, t.color, t.borderTopColor);
});
/* Diskrepanz-Marke des effektiven Status (D39) — Aussage über den Plan,
gehört in den Export wie Größe und Tags. */
node.querySelectorAll('.ownst').forEach(c => {
const s = getComputedStyle(c);
drawBadge(c, s.backgroundColor, s.color, s.borderTopColor);
});
});
/* 3a) Optionale Knoten: hohler Kreis auf der Kante (nach den Knoten) */
optMarks.forEach(m => parts.push(
``));
/* 3a′) XOR-Gruppen: „1"-Plakette am Austritt der Sammelleiste (D35) */
xorMarks.forEach(m => {
parts.push(``);
parts.push(`1`);
});
/* 3b) Günstigster-Pfad: abgetönte Kopie über den Knoten + Stationspunkte */
if(cheapPts.length >= 2){
parts.push(cheapLine('0.2'));
cheapPts.forEach(p => parts.push(
``));
}
/* 4) Geister-Knoten „Untergliederung fehlt“ */
out.querySelectorAll('.ghost-node').forEach(g => {
const b = R(g);
parts.push(seg(b.cx, b.y-14, b.cx, b.y, '#B45309', true));
parts.push(``);
parts.push(`${esc(g.textContent.trim())}`);
});
const svg = ``;
return {svg, W, H};
}
function svgToPng(svg, W, H, scale){
return new Promise(resolve => {
const img = new Image();
img.onload = () => {
const c = document.createElement('canvas');
c.width = Math.round(W*scale); c.height = Math.round(H*scale);
const ctx = c.getContext('2d');
ctx.scale(scale, scale);
ctx.drawImage(img, 0, 0);
c.toBlob(b => resolve({blob:b, dataUrl:c.toDataURL('image/png')}), 'image/png');
};
img.onerror = () => resolve(null);
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
});
}
/* Kopiert das Diagramm als PNG-Bild. Zusätzlich wird eine text/html-Variante
mit eingebettetem PNG mitgegeben — Office-Programme wie LibreOffice Writer
bevorzugen den HTML-Flavor und betten das Bild dann korrekt ein. */
async function copyDiagramImage(){
const {svg, W, H} = diagramToSvg();
const png = await svgToPng(svg, W, H, 2);
if(png && png.blob && navigator.clipboard && window.ClipboardItem){
const html = new Blob(
[``],
{type:'text/html'});
try{
await navigator.clipboard.write([new ClipboardItem({'image/png':png.blob, 'text/html':html})]);
return;
}catch(_){
try{ await navigator.clipboard.write([new ClipboardItem({'image/png':png.blob})]); return; }catch(_){}
}
}
await writeClipboard(svg); /* Fallback: SVG-Quelltext (ebenfalls Grafik) */
}
/* Als Datei speichern (SVG, Vektor). Verlässlicher Weg z. B. für LibreOffice
Writer: Einfügen → Bild → die Datei; das Bild-Clipboard aus dem Browser
erkennt LibreOffice nicht zuverlässig. */
function saveBlob(blob, filename){
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function downloadDiagramSvg(){
const {svg} = diagramToSvg();
saveBlob(new Blob([`\n` + svg], {type:'image/svg+xml'}),
'werkbaum-diagramm.svg');
}
async function downloadDiagramPng(){
const {svg, W, H} = diagramToSvg();
const png = await svgToPng(svg, W, H, 2);
if(png && png.blob) saveBlob(png.blob, 'werkbaum-diagramm.png');
}
/* Tab-Taste rückt ein statt den Fokus zu wechseln */
src.addEventListener('keydown', e => {
if(e.key === 'Tab'){
e.preventDefault();
const {selectionStart:s, selectionEnd:eEnd, value} = src;
src.value = value.slice(0, s) + ' ' + value.slice(eEnd);
src.selectionStart = src.selectionEnd = s + 2;
render();
saveSrc();
}
});
src.addEventListener('input', render);
src.addEventListener('input', saveSrc);
/* ---------- Sprung zwischen Diagramm und Text (D25) ----------
Jeder Knoten trägt seine Zeilennummer als `data-line` (render.js).
Diagramm -> Text: Alt+Klick (bzw. Alt+Enter am fokussierten Knoten, mobil
langer Druck) markiert die Zeile im Textfeld. Text -> Diagramm: die Zeile
des Cursors hebt den zugehörigen Knoten hervor. Alt statt einfachem Klick,
weil ein Knoten mit URL als den ganzen Kasten belegt (SPEC §6). */
/* Zeichenbereich einer 1-basierten Zeile; null, wenn es sie nicht (mehr) gibt. */
function lineRange(line){
const lines = src.value.split('\n');
if(!(line >= 1 && line <= lines.length)) return null;
let start = 0;
for(let i = 0; i < line - 1; i++) start += lines[i].length + 1;
return {start, end: start + lines[line - 1].length};
}
/* Vertikale Position eines Zeichenoffsets im Textfeld. Zeilenhöhe × n scheitert
an weichen Umbrüchen (lange Zeilen belegen mehrere Bildzeilen), deshalb ein
unsichtbarer Spiegel mit gleicher Typografie und Breite plus Marker-Span. */
let mirrorEl = null;
function syncMirror(){
if(!mirrorEl){
mirrorEl = document.createElement('div');
mirrorEl.setAttribute('aria-hidden', 'true');
/* `box-sizing:border-box` ist Pflicht: `src.clientWidth` **enthält** die
Innenabstände. Ohne das ist der Spiegel um genau diese 32 px breiter als
das Textfeld und bricht später um — lange Zeilen landeten dadurch zu weit
oben (fiel beim Bau der Zeilennummern auf, D33; betraf auch schon das
Scrollen beim Sprung, D25). */
mirrorEl.style.cssText = 'position:absolute;visibility:hidden;top:0;left:-9999px;' +
'box-sizing:border-box;' +
'white-space:pre-wrap;overflow-wrap:break-word;';
document.body.appendChild(mirrorEl);
}
const cs = getComputedStyle(src);
for(const p of ['fontFamily','fontSize','fontWeight','lineHeight','letterSpacing',
'paddingTop','paddingLeft','paddingRight','borderTopWidth','tabSize']){
mirrorEl.style[p] = cs[p];
}
mirrorEl.style.width = src.clientWidth + 'px';
return mirrorEl;
}
const ZWSP = '';
function offsetTopInEditor(offset){
const m = syncMirror();
m.textContent = src.value.slice(0, offset);
const marker = document.createElement('span');
marker.textContent = ZWSP;
m.appendChild(marker);
const top = marker.offsetTop;
m.textContent = '';
return top;
}
/* Oberkante jeder **logischen** Zeile, im selben Koordinatensystem wie
`src.scrollTop` (die Innenabstände stecken schon drin). Ein Marker je Zeile
im selben Spiegel: einmal schreiben, dann alle `offsetTop` in einem Durchgang
lesen — sonst erzwingt jede Messung ein eigenes Neu-Layout. */
function lineTops(){
const m = syncMirror();
m.textContent = '';
const marks = [];
for(const line of src.value.split('\n')){
const s = document.createElement('span');
s.textContent = ZWSP;
m.appendChild(s);
marks.push(s);
m.appendChild(document.createTextNode(line + '\n'));
}
const tops = marks.map(s => s.offsetTop);
m.textContent = '';
return tops;
}
/* ---------- Zeilennummern (D33) ----------
Die Warnungen nennen Zeilennummern (SPEC §4); ohne Streifen muss man sie im
Textfeld abzählen. Der Streifen ist ein eigener Kasten neben dem Textfeld —
in den Textfluss lässt sich nichts einfügen, ein `