frontend: Günstigster-Pfad-Hervorhebung per Inversion + implizites M
- Umschalter im Diagramm-Kopf (Default an, Zustand in werkbaum-ui persistiert) - nötige Knoten: all-of ⇒ alle Kinder, any-of ⇒ günstigste Alternative (kleinste rekursive Kosten; Gleichstand ⇒ erste); verworfene zählen nie mit - Darstellung per Inversion: nicht benötigte Knoten treten blass/entsättigt zurück (kein Zusatzrahmen an den ohnehin dichten Knoten-Ecken) - fehlende T-Shirt-Größe wird für die Kostenschätzung als M gewertet; invertiertes M-Badge (weiß mit Petrol-Rand) macht die Annahme sichtbar - neue Tooltips i18n (DE-Quelle + 8 Übersetzungen, 50 Keys × 9) - SPEC §9 (Günstigster Pfad) und DECISIONS D18 mitgeführt Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9438935c41
commit
c8f8160eca
@@ -150,3 +150,26 @@ Sprachleiste zeigt nur Englisch + aktive Sprache + „…“. Voreinstellung ist
|
||||
`matchMedia`), damit CSS und JS denselben Schwellwert teilen; die Vollbild-
|
||||
Voreinstellung greift nur, wenn noch **keine** gespeicherte Nutzerwahl vorliegt
|
||||
(eine bewusste Abschaltung bleibt so erhalten, siehe localStorage-Persistenz).
|
||||
|
||||
## D18 — Günstigsten Pfad per Inversion zeigen, fehlende Größe = M
|
||||
Der kostengünstigste Weg durch den Baum wird hervorgehoben (Umschalter im
|
||||
Diagramm-Kopf, Default an, Zustand persistiert). Nötig sind bei **all of** alle
|
||||
Kinder, bei **any of** nur die günstigste Alternative (kleinste rekursive
|
||||
Kosten; Gleichstand ⇒ erste).
|
||||
|
||||
**Darstellung per Inversion, nicht per Betonung:** Getestet wurde zuerst ein
|
||||
doppelt dicker Rand an den nötigen Knoten. Da der Baum aber fast überall all-of
|
||||
ist, war damit *fast alles* dick umrandet — das Signal lag ohnehin nur in der
|
||||
*Abwesenheit* des Rands bei den ausgeschlossenen Alternativen. Deshalb wird
|
||||
invertiert: die **nicht benötigten** Knoten (nicht-gewählte any-of-Alternativen
|
||||
samt Teilbaum) treten zurück (blass, entsättigt), der Pfad hebt sich von selbst
|
||||
ab. Kein Zusatzrahmen, weil die Knoten-Ecken (Größe, Tags, ↗) schon dicht sind.
|
||||
|
||||
**Fehlende T-Shirt-Größe wird für die Kostenschätzung als `M` gewertet** — die
|
||||
kleinste Größe mit Untergliederungspflicht (§5/D8), also die konservative
|
||||
Annahme „mindestens M". Damit sind auch ungrößte Alternativen vergleichbar.
|
||||
Wo `M` nur implizit angenommen ist, zeigt der Knoten ein **invertiertes**
|
||||
Größen-Badge (weiß mit Petrol-Rand statt gefüllt) samt Tooltip, damit die
|
||||
Annahme sichtbar und von einer echten `(M)`-Angabe unterscheidbar bleibt. Die
|
||||
Größen-Semantik in SPEC §5 bleibt unberührt (die Wertung gilt nur der
|
||||
Pfad-Kostenrechnung).
|
||||
|
||||
@@ -155,6 +155,27 @@ und die aktive Sprache plus „…“ (das alle Sprachen aufklappt). Der **Unter
|
||||
wird auf eine garantiert einzeilige Kurzfassung reduziert. Voreinstellung auf
|
||||
kleinem Bildschirm ist **Vollbild** (siehe D17).
|
||||
|
||||
### Günstigster Pfad (Kosten-Hervorhebung)
|
||||
Ein Umschalter (Icon-Button im Diagramm-Kopf, Voreinstellung **an**, Zustand
|
||||
persistiert) hebt den kostengünstigsten Weg durch den Baum hervor. Ermittelt
|
||||
werden die für die günstigste Realisierung **nötigen** Knoten:
|
||||
|
||||
- **all of:** alle Kinder sind nötig.
|
||||
- **any of:** nur die **günstigste** Alternative ist nötig. „Günstig" =
|
||||
kleinste rekursive Kosten (eigene T-Shirt-Größe plus — je Gate — Summe bzw.
|
||||
Minimum der Kinder). Bei Gleichstand gewinnt die **erste** Alternative.
|
||||
- Verworfene Knoten zählen nie mit (unabhängig vom „verworfene einblenden"-
|
||||
Filter).
|
||||
- **Fehlende Größe wird als `M` gewertet** (nur für diese Kostenschätzung; die
|
||||
SPEC-Semantik der Größen in §5 bleibt unberührt).
|
||||
|
||||
Darstellung per **Inversion**: nicht benötigte Knoten (nicht-gewählte
|
||||
any-of-Alternativen samt Teilbaum) treten zurück (blass, entsättigt); der
|
||||
günstige Pfad hebt sich dadurch von selbst ab — kein zusätzlicher Rahmen an den
|
||||
ohnehin dichten Knoten-Ecken. Wo die Größe **implizit** als `M` angenommen wird,
|
||||
zeigt der Knoten ein **invertiertes** Größen-Badge (weiß mit petrolfarbenem
|
||||
Rand/Text statt gefüllt) mit erläuterndem Tooltip. Siehe D18.
|
||||
|
||||
### 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:
|
||||
|
||||
+2
-1
@@ -26,7 +26,8 @@ Diagramm rechts, Toggles für transponierte Ansicht und verworfene Elemente).
|
||||
`visibleChildren()` und muss bei Renderer-Umbauten erhalten bleiben.
|
||||
- Zustand wird im `localStorage` gehalten (noch kein Backend): `werkbaum-lang`
|
||||
(Sprache), `werkbaum-src` (Editortext), `werkbaum-ui` (JSON: Modus,
|
||||
verworfene, Split-Zustand inkl. `--col`/`--drow`, Zoom, Vollbild). Neue
|
||||
verworfene, günstigster Pfad, Split-Zustand inkl. `--col`/`--drow`, Zoom,
|
||||
Vollbild). Neue
|
||||
GUI-Einstellungen in `saveUI()`/`restoreState()` mitführen; `saveUI` liefert
|
||||
während `restoring===true` nichts, damit das Wiederherstellen nicht sofort
|
||||
zurückschreibt. Fehlender `werkbaum-src` fällt auf `INITIAL` zurück, ein
|
||||
|
||||
+89
-3
@@ -354,6 +354,13 @@
|
||||
}
|
||||
.root-node{background:var(--ink);border-color:var(--ink);color:#fff;font-weight:600;padding:9px 20px}
|
||||
|
||||
/* Günstigster Pfad (Inversion): nicht benötigte Knoten (nicht-gewählte
|
||||
any-of-Alternativen samt Teilbaum) treten zurück (blass, entsättigt);
|
||||
der günstige Pfad hebt sich dadurch von allein ab. */
|
||||
.cheap-on .node:not(.cheap){opacity:.32;filter:saturate(.4)}
|
||||
/* implizit als M angenommene Größe: invertiertes Badge (weiß statt petrol) */
|
||||
.size.implicit{background:var(--card);color:var(--or);border-color:var(--or)}
|
||||
|
||||
/* --- ALL OF: Kinder nebeneinander, durchgezogener Verteiler --- */
|
||||
ul.and{display:flex;align-items:flex-start;position:relative;padding-top:14px}
|
||||
ul.and::before{
|
||||
@@ -610,6 +617,9 @@
|
||||
<button type="button" class="copybtn togglebtn" id="showc" aria-pressed="false" data-i18n-title="discardedTooltip" data-i18n-aria="discardedTooltip" title="Verworfene Knoten samt Teilbaum ein-/ausblenden" aria-label="Verworfene Knoten samt Teilbaum ein-/ausblenden">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="6" width="18" height="12" rx="2" stroke-dasharray="3 2.5"/><path d="M7 12h10"/></svg>
|
||||
</button>
|
||||
<button type="button" class="copybtn togglebtn" id="cheapBtn" aria-pressed="true" data-i18n-title="cheapTooltip" data-i18n-aria="cheapTooltip" title="Günstigsten Pfad hervorheben – nicht benötigte Alternativen treten zurück" aria-label="Günstigsten Pfad hervorheben – nicht benötigte Alternativen treten zurück">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="5" cy="6" r="2.5"/><circle cx="19" cy="18" r="2.5"/><path d="M7.5 6H14a3 3 0 0 1 0 6H10a3 3 0 0 0 0 6h6.5"/></svg>
|
||||
</button>
|
||||
<div class="seg" role="radiogroup" aria-label="Darstellung">
|
||||
<label data-i18n-title="modeHorizontal" title="Horizontal – Organigramm, Diagramm über dem Editor">
|
||||
<input type="radio" name="layout" value="horizontal" checked data-i18n-aria="modeHorizontal" aria-label="Horizontal – Organigramm">
|
||||
@@ -737,6 +747,42 @@ function visibleChildren(n){
|
||||
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. */
|
||||
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){ return cheapPathOn && cheapSet.has(n) ? 'cheap' : ''; }
|
||||
|
||||
function nodeHtml(n, extra){
|
||||
const need = needsBreakdown(n);
|
||||
const cls = ['node', extra || '', n.status ? 'st-' + n.status.key : '']
|
||||
@@ -745,9 +791,13 @@ function nodeHtml(n, extra){
|
||||
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,'"');
|
||||
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>' : '') +
|
||||
(n.size ? `<span class="size">${n.size}</span>` : '') +
|
||||
sizeBadge +
|
||||
tagsHtml;
|
||||
const html = n.url
|
||||
? `<a class="${cls}" href="${esc(n.url).replace(/"/g,'"')}" target="_blank" rel="noopener"${title}>${inner}</a>`
|
||||
@@ -769,7 +819,7 @@ function renderChildren(node, warnings){
|
||||
const vk = visibleChildren(k);
|
||||
const liCls = vk.length ? (gateOf(vk) === 'or' ? ' class="has-or"' : ' class="has-and"') : '';
|
||||
return `<li${liCls}>` +
|
||||
nodeHtml(k) +
|
||||
nodeHtml(k, cheapCls(k)) +
|
||||
renderChildren(k, warnings) +
|
||||
`</li>`;
|
||||
}).join('');
|
||||
@@ -789,11 +839,15 @@ function render(){
|
||||
return;
|
||||
}
|
||||
|
||||
cheapSet.clear();
|
||||
if(cheapPathOn) roots.forEach(markCheapest);
|
||||
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') +
|
||||
nodeHtml(root, ('root-node ' + cheapCls(root)).trim()) +
|
||||
renderChildren(root, warnings) +
|
||||
`</li>`;
|
||||
}).join('');
|
||||
@@ -1096,6 +1150,15 @@ function discardedShown(){ return showc.getAttribute('aria-pressed') === 'true';
|
||||
function setDiscarded(on){ showc.setAttribute('aria-pressed', on ? 'true' : 'false'); }
|
||||
showc.addEventListener('click', () => { setDiscarded(!discardedShown()); render(); saveUI(); });
|
||||
|
||||
/* Günstigster-Pfad-Hervorhebung an/aus */
|
||||
const cheapBtn = document.getElementById('cheapBtn');
|
||||
cheapBtn.addEventListener('click', () => {
|
||||
cheapPathOn = !cheapPathOn;
|
||||
cheapBtn.setAttribute('aria-pressed', cheapPathOn ? 'true' : 'false');
|
||||
render();
|
||||
saveUI();
|
||||
});
|
||||
|
||||
/* ---------- In die Zwischenablage kopieren ---------- */
|
||||
async function writeClipboard(text){
|
||||
try{ await navigator.clipboard.writeText(text); return; }catch(_){}
|
||||
@@ -1145,6 +1208,8 @@ const I18N = {
|
||||
legendTooltip:"Legende ein-/ausblenden",
|
||||
ghostTooltip:"Ab Größe M sollte ein Element weiter untergliedert werden.",
|
||||
discardedTooltip:"Verworfene Knoten samt Teilbaum ein-/ausblenden",
|
||||
cheapTooltip:"Günstigsten Pfad hervorheben – nicht benötigte Alternativen treten zurück",
|
||||
implicitSizeTooltip:"Keine Größe angegeben – für die Kostenschätzung als M angenommen",
|
||||
fullscreenTooltip:"Vollbild – Panels nutzen die ganze Fensterbreite",
|
||||
brandTooltip:"„Werkbaum“ bedeutet so viel wie ‚Werk-Baum‘ — der Baum des Projektstrukturplans (WBS).",
|
||||
editorTitle:"Struktur (Text)", diagramTitle:"Diagramm",
|
||||
@@ -1179,6 +1244,8 @@ const I18N = {
|
||||
legendTooltip:"Show/hide legend",
|
||||
ghostTooltip:"From size M upward, an item should be broken down further.",
|
||||
discardedTooltip:"Show/hide discarded nodes and their subtree",
|
||||
cheapTooltip:"Highlight the cheapest path – unneeded alternatives recede",
|
||||
implicitSizeTooltip:"No size given – assumed as M for the cost estimate",
|
||||
fullscreenTooltip:"Full screen – panels use the full window width",
|
||||
brandTooltip:"“Werkbaum” means roughly ‘work tree’ — the tree of the work breakdown structure (WBS).",
|
||||
editorTitle:"Structure (text)", diagramTitle:"Diagram",
|
||||
@@ -1213,6 +1280,8 @@ const I18N = {
|
||||
legendTooltip:"Mostrar u ocultar la leyenda",
|
||||
ghostTooltip:"A partir de la talla M, un elemento debería desglosarse más.",
|
||||
discardedTooltip:"Mostrar u ocultar los nodos descartados y su subárbol",
|
||||
cheapTooltip:"Resaltar la ruta más económica: las alternativas no necesarias se atenúan",
|
||||
implicitSizeTooltip:"Sin tamaño indicado: se asume M para el cálculo de costes",
|
||||
fullscreenTooltip:"Pantalla completa – los paneles usan todo el ancho de la ventana",
|
||||
brandTooltip:"«Werkbaum» significa algo así como ‘árbol de trabajo’ — el árbol de la estructura de desglose del trabajo (EDT).",
|
||||
editorTitle:"Estructura (texto)", diagramTitle:"Diagrama",
|
||||
@@ -1247,6 +1316,8 @@ const I18N = {
|
||||
legendTooltip:"Afficher/masquer la légende",
|
||||
ghostTooltip:"À partir de la taille M, un élément devrait être décomposé davantage.",
|
||||
discardedTooltip:"Afficher/masquer les nœuds abandonnés et leur sous-arbre",
|
||||
cheapTooltip:"Mettre en évidence le chemin le moins coûteux – les alternatives inutiles s'estompent",
|
||||
implicitSizeTooltip:"Aucune taille indiquée – considérée comme M pour l'estimation des coûts",
|
||||
fullscreenTooltip:"Plein écran – les panneaux occupent toute la largeur de la fenêtre",
|
||||
brandTooltip:"« Werkbaum » signifie à peu près « arbre de travail » — l’arbre de l’organigramme des tâches (WBS).",
|
||||
editorTitle:"Structure (texte)", diagramTitle:"Diagramme",
|
||||
@@ -1281,6 +1352,8 @@ const I18N = {
|
||||
legendTooltip:"Pokaż/ukryj legendę",
|
||||
ghostTooltip:"Od rozmiaru M element powinien być dalej podzielony.",
|
||||
discardedTooltip:"Pokaż/ukryj odrzucone węzły wraz z poddrzewem",
|
||||
cheapTooltip:"Wyróżnij najtańszą ścieżkę – niepotrzebne alternatywy są przygaszone",
|
||||
implicitSizeTooltip:"Nie podano rozmiaru – przyjęto M do szacowania kosztów",
|
||||
fullscreenTooltip:"Pełny ekran – panele wykorzystują całą szerokość okna",
|
||||
brandTooltip:"„Werkbaum” znaczy mniej więcej ‚drzewo pracy’ — drzewo struktury podziału pracy (WBS).",
|
||||
editorTitle:"Struktura (tekst)", diagramTitle:"Diagram",
|
||||
@@ -1315,6 +1388,8 @@ const I18N = {
|
||||
legendTooltip:"Показать/скрыть легенду",
|
||||
ghostTooltip:"Начиная с размера M элемент следует далее декомпозировать.",
|
||||
discardedTooltip:"Показать/скрыть отклонённые узлы вместе с поддеревом",
|
||||
cheapTooltip:"Выделить самый дешёвый путь — ненужные альтернативы приглушаются",
|
||||
implicitSizeTooltip:"Размер не указан — для оценки затрат принят как M",
|
||||
fullscreenTooltip:"Полный экран – панели занимают всю ширину окна",
|
||||
brandTooltip:"«Werkbaum» примерно означает ‚дерево работ’ — дерево структуры декомпозиции работ (СДР).",
|
||||
editorTitle:"Структура (текст)", diagramTitle:"Диаграмма",
|
||||
@@ -1349,6 +1424,8 @@ const I18N = {
|
||||
legendTooltip:"लेजेंड दिखाएँ/छिपाएँ",
|
||||
ghostTooltip:"आकार M से ऊपर किसी तत्व को और अधिक उप-विभाजित करना चाहिए।",
|
||||
discardedTooltip:"अस्वीकृत नोड्स और उनके उप-वृक्ष दिखाएँ/छिपाएँ",
|
||||
cheapTooltip:"सबसे किफ़ायती पथ को उजागर करें – अनावश्यक विकल्प मंद हो जाते हैं",
|
||||
implicitSizeTooltip:"कोई आकार नहीं दिया गया – लागत अनुमान के लिए M माना गया",
|
||||
fullscreenTooltip:"पूर्ण स्क्रीन – पैनल पूरी विंडो चौड़ाई का उपयोग करते हैं",
|
||||
brandTooltip:"„Werkbaum“ का अर्थ लगभग ‚कार्य-वृक्ष‘ है — कार्य विभाजन संरचना (WBS) का वृक्ष।",
|
||||
editorTitle:"संरचना (टेक्स्ट)", diagramTitle:"आरेख",
|
||||
@@ -1384,6 +1461,8 @@ const I18N = {
|
||||
brandTooltip:"「Werkbaum」大致意为‘工作之树’——即工作分解结构(WBS)之树。",
|
||||
fullscreenTooltip:"全屏——面板占据整个窗口宽度",
|
||||
discardedTooltip:"显示/隐藏已放弃的节点及其子树",
|
||||
cheapTooltip:"突出显示成本最低的路径——不需要的备选项将淡化",
|
||||
implicitSizeTooltip:"未指定尺寸——成本估算时按 M 计",
|
||||
ghostTooltip:"从 M 号起,元素应进一步细分。",
|
||||
editorTitle:"结构(文本)", diagramTitle:"图表",
|
||||
copy:"复制", copyDone:"已复制 ✓", copyTooltip:"将文本复制到剪贴板",
|
||||
@@ -1418,6 +1497,8 @@ const I18N = {
|
||||
brandTooltip:"「Werkbaum」はおおよそ『作業の木』の意味 — 作業分解構成図(WBS)のツリーです。",
|
||||
fullscreenTooltip:"全画面 — パネルがウィンドウ幅いっぱいを使用",
|
||||
discardedTooltip:"破棄したノードとその下位ツリーを表示/非表示",
|
||||
cheapTooltip:"最も低コストの経路を強調 – 不要な選択肢は控えめに表示",
|
||||
implicitSizeTooltip:"サイズ未指定 – コスト見積もりのため M として扱う",
|
||||
ghostTooltip:"サイズ M 以上の要素はさらに分解すべきです。",
|
||||
editorTitle:"構造(テキスト)", diagramTitle:"ダイアグラム",
|
||||
copy:"コピー", copyDone:"コピーしました ✓", copyTooltip:"テキストをクリップボードにコピー",
|
||||
@@ -1529,6 +1610,7 @@ function saveUI(){
|
||||
localStorage.setItem(LS_UI, JSON.stringify({
|
||||
mode: modeEl ? modeEl.value : 'horizontal',
|
||||
discarded: discardedShown(),
|
||||
cheapPath: cheapPathOn,
|
||||
split: splitState,
|
||||
col: app.style.getPropertyValue('--col') || null,
|
||||
drow: app.style.getPropertyValue('--drow') || null,
|
||||
@@ -1549,6 +1631,10 @@ function restoreState(){
|
||||
if(modeEl) modeEl.checked = true;
|
||||
if(ui){
|
||||
if(typeof ui.discarded === 'boolean') setDiscarded(ui.discarded);
|
||||
if(typeof ui.cheapPath === 'boolean'){
|
||||
cheapPathOn = ui.cheapPath;
|
||||
cheapBtn.setAttribute('aria-pressed', cheapPathOn ? 'true' : 'false');
|
||||
}
|
||||
if(typeof ui.zoom === 'number') zoom = ui.zoom;
|
||||
if(ui.split) splitState = ui.split;
|
||||
if(ui.fullscreen){
|
||||
|
||||
Reference in New Issue
Block a user