Die Notation kannte nur „erforderlich" (-) und „wählbar" (|). Ein einzelnes zusätzliches Feature — weder nötig noch Alternative zu etwas anderem — musste als normales -Kind notiert werden und log damit. Feature-Modelle (FODA) unterscheiden seit den 90ern mandatory/optional/alternative; `+` ergänzt die fehlende zweite Beziehung. Mnemonik: `-` Teilpaket, `+` Zugabe, `|` Alternative. Anlass ist nicht die Optik, sondern der günstigste Pfad (D18): markCheapest() lief bei all-of über ALLE Kinder, jede Zugabe steckte also im errechneten Minimum — systematisch zu groß, und zwar umso mehr, je ehrlicher ein Plan auch die Kür notiert. Sichtbar wird es beim Alternativenvergleich: eine Alternative mit teurer Zugabe verlor gegen eine schlichtere, obwohl die Zugabe gar nicht dazugehört. - Parser setzt `optional:true` und lässt `type:'and'` stehen — `+` gehört zum Knoten, nicht zur Gruppe. Dadurch bleiben gateOf() und die mixedGate-Warnung unverändert richtig: sie meldet weiter genau dann, wenn | mit -/+ gemischt wird. `-` neben `+` ist erlaubt und still — „diese drei sind nötig, das hier wäre schön" ist der Normalfall, nicht der Fehlerfall. - Aus dem Pfad fallen optionale Knoten über pathChildren() heraus, die eine Stelle, die cheapestCost() und markCheapest() gemeinsam nutzen — deshalb wirkt es samt Teilbaum. - Darstellung: hohler Kreis mittig auf der Knotenkante, wo der Abzweig auftrifft (FODA-Konvention). Bewusst KEIN dritter Linienstil: im kompakten Modus codiert allein der Stil das Gate (D15), gepunktet müsste sich dort gegen gestrichelt-grau behaupten. Der Kreis ist orthogonal dazu. CSS-Grundfall ist gestapelt (links/50 %), Ausnahme der horizontale Fächer (oben/50 %), Rück-Ausnahme der gestapelte all-of-Teilbaum unter any-of (D18) — andersherum wären es vier Ausnahmen statt zwei. - SVG-Export zeichnet den Kreis NACH den Knoten (optMarks, Schritt 3a): er liegt halb außerhalb der Box und würde sonst vom Knoten-Rechteck überdeckt. - Legende, Knoten-Tooltip und aria-label in allen neun Sprachen; hint_root formuliert die neue Mischregel. Bekannte Schwäche, bewusst in Kauf genommen: Bei aktivem Pfad-Umschalter wird der optionale Knoten ausgeblasst (opacity:.32) — und mit ihm sein Kreis, der die Erklärung dafür wäre. `opacity` am Elternteil schlägt auf Pseudoelemente durch, das lässt sich nicht zurücknehmen. Das Zurücktreten ist hier die Hauptaussage (wie bei nicht gewählten Alternativen), Tooltip/aria/Legende liefern die Begründung nach. Verhaltensänderung: `+` am Zeilenanfang ist jetzt ein Zeichen und nicht mehr Teil des Labels (`+ 5 % Puffer` ergibt „5 % Puffer"). Test-abgedeckt. SPEC §1/§3/§9/§10 zuerst, dann Code (CLAUDE). Das kanonische Beispiel in §10 enthält jetzt eine `+`-Zeile und ist mit der Test-Fixture wieder deckungsgleich. Der mitgelieferte Werkbaum-Plan markiert Drucklayout, „Was ist neu?" und die Personenfarben als Zugaben — „Was ist neu?" war der Auslöser der Frage. Verifiziert: 12 neue Tests (Parser setzt optional/type; Status/Größe/Tags/URL am +-Knoten; führendes + wird verbraucht; Pfad lässt Zugabe samt Teilbaum aus; Kosten des Elternknotens ohne Zugabe; Alternativenvergleich ohne Zugaben; optionale Knoten bleiben sichtbar; opt-Klasse; keine Warnung bei -/+; Warnung bei |/+; aria-label). Vitest 58/58, Snapshot zeigt `node opt` OHNE `cheap`. Im Browser in allen drei Modi angesehen: Kreis sitzt in horizontal oben mittig, in vertikal und kompakt links auf halber Höhe, jeweils genau auf dem Ende des Abzweigs; SVG-Export enthält beide Kreise an denselben Punkten (gerendert geprüft, nicht nur im Quelltext). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
117 lines
4.7 KiB
JavaScript
117 lines
4.7 KiB
JavaScript
/* 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.
|
|
Optionale Kinder (`+`, SPEC §3/D29) fallen hier ebenfalls heraus — sie sind
|
|
per Definition entbehrlich, also weder Kostenanteil noch Pfadknoten. Da beide
|
|
Nutzer (`cheapestCost`, `markCheapest`) über diese Funktion gehen, gilt das
|
|
samt Teilbaum. */
|
|
export function pathChildren(n){
|
|
return n.children.filter(k =>
|
|
!k.optional && (!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';
|
|
}
|
|
|
|
/* ---------- Was ist neu? (D28) ----------
|
|
„Neu" heißt hier bewusst nicht „Zeile hinzugefügt", sondern **neu in
|
|
Produktion**: ein Knoten, der jetzt `[^]` trägt und es in der zuletzt
|
|
gesehenen Fassung noch nicht tat (weil er anders stand oder fehlte). Das ist
|
|
die Änderungsmeldung, die einen Plan-Leser wirklich interessiert.
|
|
|
|
Knoten-Identität ist der **Pfad der Labels** von der Wurzel, nicht die
|
|
Zeilennummer: Umeinrücken oder Umsortieren erzeugt so keine Falschmeldungen.
|
|
Gleichnamige Geschwister werden über einen Index unterschieden. Ein
|
|
umbenanntes Label gilt als neuer Knoten — gewollt, der Text ist der Vertrag
|
|
(D14). */
|
|
function walkKeys(nodes, parentKey, fn){
|
|
const seen = new Map();
|
|
for(const n of nodes){
|
|
const base = parentKey + ' > ' + n.label;
|
|
const i = seen.get(base) || 0;
|
|
seen.set(base, i + 1);
|
|
const key = i ? base + '#' + i : base;
|
|
fn(key, n);
|
|
walkKeys(n.children, key, fn);
|
|
}
|
|
}
|
|
/* key -> Status-Schlüssel ('' für neutrale Knoten) über den ganzen Baum. */
|
|
export function statusByKey(roots){
|
|
const map = new Map();
|
|
walkKeys(roots, '', (key, n) => map.set(key, n.status ? n.status.key : ''));
|
|
return map;
|
|
}
|
|
/* Menge der Knoten, die gegenüber `prevRoots` NEU in Produktion sind.
|
|
`prevRoots == null` (noch keine Vergleichsfassung) ⇒ leere Menge — sonst
|
|
leuchtete beim ersten Ansehen der ganze fertige Teil des Plans auf. */
|
|
export function freshProdSet(prevRoots, currRoots){
|
|
const set = new Set();
|
|
if(!prevRoots) return set;
|
|
const before = statusByKey(prevRoots);
|
|
walkKeys(currRoots, '', (key, n) => {
|
|
if(n.status && n.status.key === 'prod' && before.get(key) !== 'prod') set.add(n);
|
|
});
|
|
return set;
|
|
}
|