frontend: Renderer + Modell headless auslagern (src/model.js, src/render.js)

TASKS Phase 1. Baum-/Kostenlogik -> model.js (gateOf, needsBreakdown,
visibleChildren, computeCheapSet, cheapCls), HTML-Erzeugung -> render.js
(renderTreeHtml). Beide sind headless: UI-State (verworfene einblenden,
günstigster Pfad) kommt als Parameter herein statt aus Globals; nur cheapPathOn
bleibt UI-State in app.js. render() in app.js ist jetzt die dünne DOM-Anbindung.

Snapshot-Tests (tests/render.test.js): Grundzustand, günstigster Pfad,
verworfene einblenden, Geister-Knoten („Untergliederung fehlt"), plus die
Zusicherung, dass der Renderer nie eine Modus-Klasse erzeugt (Modus =
CSS-Container-Klasse in app.js -> ein Snapshot deckt alle drei Modi). 24 Tests
grün. Byte-identisches Rendering im Browser verifiziert (18 Knoten, 6 Blätter,
3 Geister, Toggles ok, keine Konsolenfehler).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-07-21 20:58:47 +02:00
co-authored by Claude Opus 4.8
parent 7c8f216589
commit 5905712aa5
7 changed files with 276 additions and 117 deletions
+16 -114
View File
@@ -1,5 +1,7 @@
import './style.css';
import { SIZE_RANK, STATUS_BY_CODE, parse } from './parser.js';
import { parse } from './parser.js';
import { computeCheapSet } from './model.js';
import { esc, renderTreeHtml } from './render.js';
const INITIAL = `%% Project structure Sprint 14
[~] Website relaunch (XL) https://wiki.example.com/relaunch
@@ -26,113 +28,18 @@ const src = document.getElementById('src');
const out = document.getElementById('out');
const warnBox = document.getElementById('warn');
function esc(s){
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
/* ---------- Renderer ---------- */
function gateOf(children){
return children.length && children[0].type === 'or' ? 'or' : 'and';
}
function needsBreakdown(n){
if(n.status && n.status.key === 'verworfen') return false;
return !!n.size && SIZE_RANK[n.size] >= SIZE_RANK.M && !n.children.length;
}
function visibleChildren(n){
if(discardedShown()) return n.children;
return n.children.filter(k => !k.status || k.status.key !== 'verworfen');
}
/* ---------- Günstigster Pfad ----------
Markiert die Knoten, die für die günstigste Realisierung nötig sind:
all-of ⇒ alle Kinder nötig; any-of ⇒ nur die günstigste Alternative.
„Günstig" = kleinste rekursive Kosten (eigene T-Shirt-Größe + Kinder;
bei any-of das Minimum). Verworfene zählen nie mit — unabhängig vom
Einblenden-Toggle. Gleichstand ⇒ erste Alternative. Fehlende Größe = M. */
/* 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;
const cheapSet = new Set();
function pathChildren(n){
return n.children.filter(k => !k.status || k.status.key !== 'verworfen');
}
/* fehlende Größe wird als M interpretiert */
function ownCost(n){ return SIZE_RANK[n.size || 'M'] + 1; }
function cheapestCost(n){
const kids = pathChildren(n);
let c = ownCost(n);
if(kids.length){
if(gateOf(kids) === 'or') c += Math.min(...kids.map(cheapestCost));
else c += kids.reduce((s, k) => s + cheapestCost(k), 0);
}
return c;
}
function markCheapest(n){
cheapSet.add(n);
const kids = pathChildren(n);
if(!kids.length) return;
if(gateOf(kids) === 'or'){
let best = null, bc = Infinity;
for(const k of kids){ const c = cheapestCost(k); if(c < bc){ bc = c; best = k; } }
if(best) markCheapest(best);
} else {
for(const k of kids) markCheapest(k);
}
}
function cheapCls(n){
if(!(cheapPathOn && cheapSet.has(n))) return '';
/* Endknoten des Pfads: kein Kind liegt (mehr) auf dem günstigen Pfad */
const leaf = !pathChildren(n).some(k => cheapSet.has(k));
return leaf ? 'cheap cheap-leaf' : 'cheap';
}
function nodeHtml(n, extra){
const need = needsBreakdown(n);
const cls = ['node', extra || '', n.status ? 'st-' + n.status.key : '']
.filter(Boolean).join(' ');
const title = n.status ? ` title="${esc(t('st_' + n.status.key)).replace(/"/g,'&quot;')}"` : '';
const tagsHtml = n.tags && n.tags.length
? `<span class="tags">${n.tags.map(tag => `<span class="tag">${esc(tag)}</span>`).join('')}</span>`
: '';
const implicitTip = esc(t('implicitSizeTooltip')).replace(/"/g,'&quot;');
const sizeBadge = n.size
? `<span class="size">${n.size}</span>`
: (cheapPathOn ? `<span class="size implicit" title="${implicitTip}">M</span>` : '');
const inner = esc(n.label) +
(n.url ? '<span class="ext">↗</span>' : '') +
sizeBadge +
tagsHtml;
const html = n.url
? `<a class="${cls}" href="${esc(n.url).replace(/"/g,'&quot;')}" target="_blank" rel="noopener"${title}>${inner}</a>`
: `<div class="${cls}"${title}>${inner}</div>`;
const ghostTip = esc(t('ghostTooltip')).replace(/"/g,'&quot;');
const ghost = `<div class="ghost-node" title="${ghostTip}">${esc(t('ghost'))}</div>`;
return html + (need ? ghost : '');
}
function renderChildren(node, warnings){
const kids = visibleChildren(node);
if(!kids.length) return '';
const types = new Set(kids.map(k => k.type));
if(types.size > 1){
warnings.push(t('mixedWarn', {line: kids[0].line, label: esc(node.label)}));
}
const gate = gateOf(kids);
const items = kids.map(k => {
const vk = visibleChildren(k);
const liCls = vk.length ? (gateOf(vk) === 'or' ? ' class="has-or"' : ' class="has-and"') : '';
return `<li${liCls}>` +
nodeHtml(k, cheapCls(k)) +
renderChildren(k, warnings) +
`</li>`;
}).join('');
return `<ul class="${gate}">${items}</ul>`;
}
/* ---------- 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(){
let {roots} = parse(src.value);
const warnings = [];
if(!discardedShown()){
const showDiscarded = discardedShown();
if(!showDiscarded){
roots = roots.filter(r => !r.status || r.status.key !== 'verworfen');
}
@@ -142,18 +49,13 @@ function render(){
return;
}
cheapSet.clear();
if(cheapPathOn) roots.forEach(markCheapest);
const cheapSet = cheapPathOn ? computeCheapSet(roots) : new Set();
out.classList.toggle('cheap-on', cheapPathOn);
out.innerHTML = roots.map(root => {
const vk = visibleChildren(root);
const liCls = vk.length ? (gateOf(vk) === 'or' ? ' class="has-or"' : ' class="has-and"') : '';
return `<li${liCls}>` +
nodeHtml(root, ('root-node ' + cheapCls(root)).trim()) +
renderChildren(root, warnings) +
`</li>`;
}).join('');
const {html, warnings} = renderTreeHtml(roots, {
t, showDiscarded, cheapPath: cheapPathOn, cheapSet
});
out.innerHTML = html;
warnBox.innerHTML = warnings.map(w => `<div>⚠ ${w}</div>`).join('');
drawCheapPath();
+70
View File
@@ -0,0 +1,70 @@
/* Werkbaum-Modell — headless Baum-/Kostenlogik über den geparsten Knotenbaum.
Kein DOM, keine UI-State-Globals: Zustand (verworfene einblenden, günstigster
Pfad) wird als Parameter hereingereicht. Grundlage für Renderer, SVG-Export
und Mermaid-Plugin. Vgl. docs/SPEC.md §3–§5, §9 und D18. */
import { SIZE_RANK } from './parser.js';
/* Gate der Geschwistergruppe: 'or', wenn das erste Kind '|' trägt, sonst 'and'
(SPEC §3 — Darstellung nach dem ersten Kind). */
export function gateOf(children){
return children.length && children[0].type === 'or' ? 'or' : 'and';
}
/* Untergliederungspflicht ab Größe M ohne Kinder (SPEC §5); verworfene nie. */
export function needsBreakdown(n){
if(n.status && n.status.key === 'verworfen') return false;
return !!n.size && SIZE_RANK[n.size] >= SIZE_RANK.M && !n.children.length;
}
/* Sichtbare Kinder: verworfene ausblenden, außer showDiscarded ist gesetzt. */
export function visibleChildren(n, showDiscarded){
if(showDiscarded) return n.children;
return n.children.filter(k => !k.status || k.status.key !== 'verworfen');
}
/* ---------- Günstigster Pfad (D18) ----------
Nötige Knoten für die günstigste Realisierung: all-of ⇒ alle Kinder,
any-of ⇒ nur die günstigste Alternative. „Günstig" = kleinste rekursive
Kosten (eigene Größe + Kinder; any-of das Minimum). Verworfene zählen nie
mit (unabhängig vom Einblenden-Toggle). Gleichstand ⇒ erste. Fehlende
Größe = M. */
export function pathChildren(n){
return n.children.filter(k => !k.status || k.status.key !== 'verworfen');
}
/* fehlende Größe wird als M interpretiert */
export function ownCost(n){ return SIZE_RANK[n.size || 'M'] + 1; }
export function cheapestCost(n){
const kids = pathChildren(n);
let c = ownCost(n);
if(kids.length){
if(gateOf(kids) === 'or') c += Math.min(...kids.map(cheapestCost));
else c += kids.reduce((s, k) => s + cheapestCost(k), 0);
}
return c;
}
export function markCheapest(n, set){
set.add(n);
const kids = pathChildren(n);
if(!kids.length) return;
if(gateOf(kids) === 'or'){
let best = null, bc = Infinity;
for(const k of kids){ const c = cheapestCost(k); if(c < bc){ bc = c; best = k; } }
if(best) markCheapest(best, set);
} else {
for(const k of kids) markCheapest(k, set);
}
}
/* Menge der nötigen Knoten über alle Wurzeln. */
export function computeCheapSet(roots){
const set = new Set();
roots.forEach(r => markCheapest(r, set));
return set;
}
/* CSS-Klassen für den günstigen Pfad. Leere `cheapSet` (Pfad aus) ⇒ ''.
Endknoten (kein Kind liegt auf dem Pfad) bekommt zusätzlich 'cheap-leaf'. */
export function cheapCls(n, cheapSet){
if(!cheapSet.has(n)) return '';
const leaf = !pathChildren(n).some(k => cheapSet.has(k));
return leaf ? 'cheap cheap-leaf' : 'cheap';
}
+77
View File
@@ -0,0 +1,77 @@
/* 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)
} */
import { gateOf, needsBreakdown, visibleChildren, cheapCls } from './model.js';
export function esc(s){
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function nodeHtml(n, extra, opts){
const { t, cheapPath } = opts;
const need = needsBreakdown(n);
const cls = ['node', extra || '', n.status ? 'st-' + n.status.key : '']
.filter(Boolean).join(' ');
const title = n.status ? ` title="${esc(t('st_' + n.status.key)).replace(/"/g,'&quot;')}"` : '';
const tagsHtml = n.tags && n.tags.length
? `<span class="tags">${n.tags.map(tag => `<span class="tag">${esc(tag)}</span>`).join('')}</span>`
: '';
const implicitTip = esc(t('implicitSizeTooltip')).replace(/"/g,'&quot;');
const sizeBadge = n.size
? `<span class="size">${n.size}</span>`
: (cheapPath ? `<span class="size implicit" title="${implicitTip}">M</span>` : '');
const inner = esc(n.label) +
(n.url ? '<span class="ext">↗</span>' : '') +
sizeBadge +
tagsHtml;
const html = n.url
? `<a class="${cls}" href="${esc(n.url).replace(/"/g,'&quot;')}" target="_blank" rel="noopener"${title}>${inner}</a>`
: `<div class="${cls}"${title}>${inner}</div>`;
const ghostTip = esc(t('ghostTooltip')).replace(/"/g,'&quot;');
const ghost = `<div class="ghost-node" title="${ghostTip}">${esc(t('ghost'))}</div>`;
return html + (need ? ghost : '');
}
function renderChildren(node, warnings, opts){
const kids = visibleChildren(node, opts.showDiscarded);
if(!kids.length) return '';
const types = new Set(kids.map(k => k.type));
if(types.size > 1){
warnings.push(opts.t('mixedWarn', {line: kids[0].line, label: esc(node.label)}));
}
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"') : '';
return `<li${liCls}>` +
nodeHtml(k, cheapCls(k, opts.cheapSet), opts) +
renderChildren(k, warnings, opts) +
`</li>`;
}).join('');
return `<ul class="${gate}">${items}</ul>`;
}
/* Baut den inneren HTML-String für #out aus (bereits gefilterten) Wurzeln und
sammelt Warnungen (gemischte Gates). Leere Wurzelliste ⇒ leerer String. */
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"') : '';
return `<li${liCls}>` +
nodeHtml(root, ('root-node ' + cheapCls(root, opts.cheapSet)).trim(), opts) +
renderChildren(root, warnings, opts) +
`</li>`;
}).join('');
return { html, warnings };
}