Files
werkbaum/frontend/src/render.js
T
mhoennigandClaude Fable 5 afe536e33c feat: Querverbindungen der Abhängigkeiten — gekrümmt, blassgrau, Pfeil aufs Gebrauchte (SPEC §9, D41)
Eigene Zeichenebene wie der Pfad-Spline (Overlay-SVGs, /zoom): Kanten aus
den neuen data-id/data-deps-Attributen des Renderers, dünn und blassgrau
HINTER den Knoten — das Unterscheidungsmerkmal zu den orthogonalen
Baumlinien ist die Krümmung, keine neue Farbe (die Palette ist vergeben,
Rot bewusst nicht). Pfeilspitze zeigt auf das Gebrauchte, Endpunkte auf
den Knotenkanten. Die Kanten des ausgewählten Knotens (Tastaturfokus,
sonst Cursor-Zeile) liegen hervorgehoben in Tinte auf einer vorderen
Ebene. Kanten zu verborgenen Zielen entfallen; erste ID-Vergabe gewinnt;
Selbst-Abhängigkeiten zeichnen nichts. Basis-Kanten auch im SVG-Export
(Schritt 1a) und im Druck, die Hervorhebung nicht. Browser-geprüft in
allen drei Modi; Renderer-Attribute testgedeckt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 19:05:33 +02:00

217 lines
11 KiB
JavaScript

/* Werkbaum-Renderer — erzeugt den HTML-String des Diagrammbaums (die <li>-Liste
für #out). Headless: keine DOM-Zugriffe, kein globaler UI-State. Alles kommt
über `opts` herein. Der Darstellungsmodus (horizontal/vertikal/kompakt) ist
rein CSS (Klasse am Container, von app.js gesetzt) und ändert diesen String
NICHT. Vgl. docs/SPEC.md §4–§9, D18.
opts = {
t, // i18n-Funktion (key, vars?) -> String
showDiscarded, // verworfene einblenden?
cheapPath, // günstigster Pfad aktiv? (steuert das implizite M-Badge)
cheapSet, // Set der nötigen Knoten (leer, wenn Pfad aus)
freshSet, // optional: Knoten, die neu in Produktion sind (D28)
collapsedSet, // optional: eingeklappte Knoten (Faltung, SPEC §9/D38)
effStatus, // optional: Map Knoten -> effektiver Status-Key, nur
// Diskrepanzen (effectiveStatus() in model.js, D39)
} */
import { gateOf, needsBreakdown, visibleChildren, cheapCls } from './model.js';
/* Zusatzklassen eines Knotens: günstigster Pfad (D18), „neu in Produktion"
gegenüber der zuletzt gesehenen Fassung (D28, `freshSet` optional) und
optionale Knoten (`+`, SPEC §3/D29 — trägt den hohlen Kreis am Abzweig). */
function extraCls(n, opts){
const cheap = cheapCls(n, opts.cheapSet);
const fresh = opts.freshSet && opts.freshSet.has(n) ? 'fresh' : '';
return [cheap, fresh, n.optional ? 'opt' : '', n.focus ? 'focusmark' : ''].filter(Boolean).join(' ');
}
/* Klassen des <li>: 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) !== 'and' ? 'has-or' : 'has-and') : '',
optional ? 'opt' : ''
].filter(Boolean);
return cls.length ? ` class="${cls.join(' ')}"` : '';
}
export function esc(s){
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
/* Escaping für Attributwerte (zusätzlich " -> &quot;). */
function attr(s){ return esc(String(s)).replace(/"/g,'&quot;'); }
/* Barrierefreier Name eines Knotens: Label + Status + Aufwand + Zuständige +
Link. Die visuellen Badges (Größe, Tags, ↗) sind aria-hidden — ihre
Information steckt hier, sonst würde der Screenreader Kryptisches („M",
„anna", „↗") vorlesen. */
function nodeAria(n, opts, fold){
const { t, cheapPath } = opts;
const parts = [n.label];
/* Eingeklappt (SPEC §9/D38): das ▾/▸-Zeichen ist aria-hidden — ohne diese
Ansage wüsste ein Screenreader nicht, dass hier etwas verborgen ist. */
if(fold && fold.collapsed) parts.push(t('a11yFolded', {n: fold.count}));
if(n.status) parts.push(t('a11yStatus', {status: t('st_' + n.status.key)}));
/* Diskrepanz (D39): die Farbe zeigt den effektiven Status — der Screenreader
bekommt beide, wie der Tooltip. */
const effKey = opts.effStatus ? opts.effStatus.get(n) : undefined;
if(effKey) parts.push(t('a11yEffective', {status: t('st_' + effKey)}));
if(n.size) parts.push(t('a11ySize', {size: n.size}));
else if(cheapPath) parts.push(t('a11ySizeImplicit'));
if(n.tags && n.tags.length) parts.push(t('a11yTags', {names: n.tags.join(', ')}));
/* Knoten-ID und Abhängigkeiten (SPEC §1, D36/D37): keine eigene Darstellung
im Diagramm — sichtbar nur im Tooltip und hier. */
if(n.id) parts.push(t('a11yId', {id: n.id}));
if(n.deps && n.deps.length)
parts.push(t('a11yDeps', {ids: n.deps.map(d => '#' + d).join(', ')}));
if(n.optional) parts.push(t('a11yOptional'));
/* Die Fokusmarke ist rein als box-shadow sichtbar — ohne diese Ansage wüsste
ein Screenreader nichts davon. Zugleich der einzige Ort, an dem sie sich von
der eigenen Cursor-Zeile unterscheidet (SPEC §9). */
if(n.focus) parts.push(t('a11yFocusMark'));
if(n.url) parts.push(t('a11yLink'));
/* Beschreibung (SPEC §11/D40): der Text selbst — die ”-Marke ist aria-hidden. */
if(n.desc) parts.push(n.desc.replace(/\s+/g, ' '));
return parts.join(', ');
}
function nodeHtml(n, extra, opts, fold){
const { t, cheapPath } = opts;
const need = needsBreakdown(n);
/* Die Knotenfarbe zeigt den EFFEKTIVEN Status (SPEC §9/D39); bei Diskrepanz
trägt die Marke unten links die eigene Statusbox in den eigenen Farben. */
const effKey = opts.effStatus ? opts.effStatus.get(n) : undefined;
const cls = ['node', extra || '', fold && fold.collapsed ? 'folded' : '',
effKey ? 'held' : '',
n.status ? 'st-' + (effKey || n.status.key) : '']
.filter(Boolean).join(' ');
/* Zeilennummer am Knoten (D25): Grundlage für den Sprung ins Textfeld und
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 : '',
n.deps && n.deps.length ? '→ ' + n.deps.map(d => '#' + d).join(', ') : '',
effKey
? t('heldTooltip', {eff: t('st_' + effKey), own: t('st_' + n.status.key)})
: (n.status ? t('st_' + n.status.key) : ''),
n.optional ? t('a11yOptional') : '', t('jumpHint')]
.filter(Boolean).join(' · ');
const title = ` title="${attr(tip)}"`;
const tagsHtml = n.tags && n.tags.length
? `<span class="tags" aria-hidden="true">${n.tags.map(tag => `<span class="tag">${esc(tag)}</span>`).join('')}</span>`
: '';
const implicitTip = attr(t('implicitSizeTooltip'));
const sizeBadge = n.size
? `<span class="size" aria-hidden="true">${n.size}</span>`
: (cheapPath ? `<span class="size implicit" aria-hidden="true" title="${implicitTip}">M</span>` : '');
/* High-Risk: Warndreieck (⚠, Textpräsentation via VS15) an der oberen linken
Ecke. aria-hidden — die Information steckt bereits im Status des aria-label. */
const riskMark = n.status && n.status.key === 'highrisk'
? `<span class="risk" aria-hidden="true" title="${attr(t('riskTooltip'))}">⚠︎</span>`
: '';
/* Falt-Zeichen (SPEC §9/D38): ▾ offen, „▸ n" eingeklappt — das Klickziel
fürs Umklappen (der einfache Klick auf den Knoten bleibt der Link, §6).
aria-hidden: die Information steht im aria-label (a11yFolded). */
const foldHtml = fold
? `<span class="fold" aria-hidden="true">${fold.collapsed ? '▸ ' + fold.count : '▾'}</span>`
: '';
const expanded = fold ? ` aria-expanded="${!fold.collapsed}"` : '';
/* Diskrepanz-Marke: die eigene Statusbox in den eigenen §4-Farben —
„selbst schon [x], wartet auf Abhängigkeiten" (D39). */
const ownChip = effKey
? `<span class="chip ownst st-${n.status.key}" aria-hidden="true">[${n.status.code}]</span>`
: '';
const inner = foldHtml +
esc(n.label) +
/* ”-Marke (D40): macht die sonst unsichtbare Beschreibung
auffindbar (Lehre aus D25) — spiegelt das "-Zeichen der
Notation. Nicht im Export: Der Text selbst kann dort nicht
erscheinen, eine Marke ohne Ziel wäre Rauschen. */
(n.desc ? '<span class="desc-mark" aria-hidden="true">”</span>' : '') +
(n.url ? '<span class="ext" aria-hidden="true">↗</span>' : '') +
riskMark +
sizeBadge +
tagsHtml +
ownChip;
const aria = ` aria-label="${attr(nodeAria(n, opts, fold))}"`;
const html = n.url
? `<a class="${cls}" href="${attr(n.url)}" target="_blank" rel="noopener"${lineAttr}${idAttr}${depsAttr}${aria}${expanded}${title}>${inner}</a>`
: `<div class="${cls}" tabindex="0"${lineAttr}${idAttr}${depsAttr}${aria}${expanded}${title}>${inner}</div>`;
const ghostTip = attr(t('ghostTooltip'));
const ghost = `<div class="ghost-node" aria-label="${ghostTip}" title="${ghostTip}">${esc(t('ghost'))}</div>`;
return html + (need ? ghost : '');
}
/* Eingeklappter Teilbaum (SPEC §9/D38): Das HTML entfällt, aber die
Warnungen des verborgenen Teils werden trotzdem gemeldet — sie sind eine
Aussage über den TEXT, nicht über die Ansicht. Derselbe Lauf zählt die
verborgenen Knoten für das „▸ n"-Kennzeichen. */
function walkFolded(node, warnings, opts){
const kids = visibleChildren(node, opts.showDiscarded);
if(!kids.length) return 0;
const types = new Set(kids.map(k => k.type));
if(types.size > 1){
warnings.push({type: 'mixedGate', line: kids[0].line, label: node.label});
}
let count = kids.length;
for(const k of kids) count += walkFolded(k, warnings, opts);
return count;
}
/* Ein Knoten samt <li> und (sofern nicht eingeklappt) seiner Kinder. */
function itemHtml(n, extra, warnings, opts){
const vk = visibleChildren(n, opts.showDiscarded);
const canFold = vk.length > 0;
const collapsed = canFold && !!(opts.collapsedSet && opts.collapsedSet.has(n));
const fold = canFold
? {collapsed, count: collapsed ? walkFolded(n, warnings, opts) : 0}
: null;
/* `opt` auch am <li>: den Abzweig zeichnen dessen Pseudoelemente, er wird
für optionale Knoten gestrichelt (D29). Eingeklappt ist der Knoten ein
Blatt — kein has-*-Layout, keine Kinderliste. */
const liCls = liClass(collapsed ? [] : vk, opts, n.optional);
return `<li${liCls}>` +
nodeHtml(n, extra, opts, fold) +
(collapsed ? '' : renderChildren(n, warnings, opts)) +
`</li>`;
}
function renderChildren(node, warnings, opts){
const kids = visibleChildren(node, opts.showDiscarded);
if(!kids.length) return '';
/* Gemischte Gates (SPEC §3): Da `+` nur `optional` setzt und `type:'and'`
behält, schlägt das hier genau dann an, wenn `|` oder `=` mit `-`/`+`
(oder untereinander) gemischt wird — `-` neben `+` ist erlaubt und still. */
const types = new Set(kids.map(k => k.type));
if(types.size > 1){
/* strukturierte Warnung (Typ + Zeile); Formatierung in warnings.js */
warnings.push({type: 'mixedGate', line: kids[0].line, label: node.label});
}
const gate = gateOf(kids);
/* XOR-Gruppen (`=`, SPEC §3/§9) erben die komplette any-of-Geometrie über
die Klasse `or` (alle Modi, Export-Routing); `xor` ergänzt nur die
„1"-Plakette an der Sammelleiste (D35). */
const ulCls = gate === 'xor' ? 'or xor' : gate;
const items = kids.map(k => itemHtml(k, extraCls(k, opts), warnings, opts)).join('');
return `<ul class="${ulCls}">${items}</ul>`;
}
/* Baut den inneren HTML-String für #out aus (bereits gefilterten) Wurzeln und
sammelt strukturierte Warnungen ({type, line, ...}, siehe warnings.js).
Leere Wurzelliste ⇒ leerer String. */
export function renderTreeHtml(roots, opts){
const warnings = [];
const html = roots.map(root =>
itemHtml(root, ('root-node ' + extraCls(root, opts)).trim(), warnings, opts)
).join('');
return { html, warnings };
}