feat(diagram): Personen-Leiste mit Personen-Linse und Belastungsanzeige (D87)

Je @Person eine Pille mit Anteil an der offenen Pfad-Arbeit (D71-Maß),
dazu ein Eintrag für Unzugewiesenes — die Anteile summieren auf 100 %.
Ein Tipp filtert das Diagramm auf die Knoten der Person: alles andere
faltet, ihre Pakete stehen als ▸ n, die Pillen färben petrol. Die Linse
ist rein Ansicht und Sitzungssache — in den Text wird nichts geschrieben,
in geteilten Dokumenten ginge der persönliche Filter sonst an alle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-27 10:38:53 +02:00
co-authored by Claude Fable 5
parent 1e1a00f794
commit deec39e82a
10 changed files with 439 additions and 7 deletions
+143 -5
View File
@@ -1,6 +1,6 @@
import './style.css';
import { parse, setFoldMark, expandShortIds, shortIdClosed } from './parser.js';
import { computeCheapPlan, overloadedAssignee, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, presetFoldSet, lineTargets } from './model.js';
import { computeCheapPlan, overloadedAssignee, assigneeLoads, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, presetFoldSet, personFoldSet, allTags, lineTargets } from './model.js';
import { esc, renderTreeHtml, TIP_RULE } from './render.js';
import { formatWarning, warningText } from './warnings.js';
import * as live from './live.js';
@@ -52,6 +52,7 @@ const INITIAL = `%% Project structure Sprint 14
const src = document.getElementById('src');
const out = document.getElementById('out');
const warnBox = document.getElementById('warn');
const peopleBar = document.getElementById('peopleBar');
/* Zeilennummern-Streifen (D33) — hier oben geholt, weil render() ihn füllt. */
const srcWrap = document.getElementById('srcWrap');
const lineNoBox = document.getElementById('lineNos');
@@ -130,6 +131,16 @@ let newsDay = null, newsKeySet = null;
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();
/* Personen-Linse (SPEC §9, D87): `lens` ist null (aus) oder `{tag}` der
Personen-Tag bzw. `tag: null` für ohne Zuständigen". REIN Ansicht und
Sitzungssache: Sie überlagert den Faltzustand nur für die Darstellung,
in den Text wird nichts geschrieben in einem geteilten Dokument ginge
der persönliche Filter sonst als Textänderung an alle (D76/D38).
`lensOverrides` sind Hand-Faltungen WÄHREND der Linse (Label-Pfad-Schlüssel
wie `foldOverrides`); Ausschalten stellt den textdefinierten Zustand
wieder her. */
let lens = null, lensOverrides = new Map();
function clearLens(){ lens = null; lensOverrides.clear(); }
/* Zeile -> Zeile des sichtbaren Vertreters (D38-Nachtrag 4): Für Zeilen in
eingeklappten Teilbäumen zeigt sie auf den nächsten sichtbaren Vorfahren;
`nodeOfLine()` greift darauf zurück, wenn die Zeile keinen DOM-Knoten hat. */
@@ -163,11 +174,17 @@ function render(){
if(tabWarning) warnings = [tabWarning].concat(warnings); /* fremder Tab schreibt mit (D84) */
if(storeWarning) warnings = [storeWarning].concat(warnings); /* Datenverlust droht — zuoberst (D82) */
/* Personen-Linse (D87): erlischt, sobald es die Person (oder überhaupt
Tags) im Dokument nicht mehr gibt sonst zeigte sie auf nichts. */
const docTags = allTags(roots);
if(lens && (!docTags.length || (lens.tag !== null && !docTags.includes(lens.tag)))) clearLens();
if(!roots.length){
out.innerHTML = `<div class="empty">${esc(t('empty'))}</div>`;
freshSet = new Set();
foldByLine = new Map();
lineTargetMap = new Map();
peopleBar.hidden = true;
} else {
/* Günstigster Pfad auf der Dependency Closure (D42): scheitert die exakte
Suche an zu vielen gekoppelten Gruppen, wird die gierige Schätzung
@@ -215,14 +232,20 @@ function render(){
er hier eigens für die Prüfung gerechnet (dieselbe Rechnung, die sonst
ohnehin je Tastendruck läuft). */
let presetSet = null, presetMatch = true;
if(foldCycleApplied >= 0){
/* Bei aktiver Linse (D87) ruht die Prüfung: Die Faltung ist dann
Linsen-Sache, der Text unverändert die Reihum-Position bleibt. */
if(foldCycleApplied >= 0 && !lens){
const mode = FOLD_CYCLE[foldCycleApplied];
const cs = (mode === 'path' && !cheapPathOn) ? computeCheapPlan(roots).set : cheapSet;
presetSet = presetFoldSet(roots, mode, cs);
}
/* Personen-Linse (D87): Ihr Faltzustand ERSETZT den textdefinierten für
die Darstellung überlagert nur von Hand-Faltungen während der Linse. */
const lensSet = lens ? personFoldSet(roots, lens.tag) : null;
keys.forEach((key, n) => {
const ov = foldOverrides.get(key);
const collapsed = (ov !== undefined ? ov : initFold.has(n)) && n.children.length > 0;
const ov = lensSet ? lensOverrides.get(key) : foldOverrides.get(key);
const base = lensSet ? lensSet.has(n) : initFold.has(n);
const collapsed = (ov !== undefined ? ov : base) && n.children.length > 0;
if(collapsed) collapsedSet.add(n);
foldByLine.set(n.line, {key, collapsed, canFold: n.children.length > 0});
if(presetSet && n.children.length > 0 && collapsed !== presetSet.has(n)) presetMatch = false;
@@ -234,9 +257,21 @@ function render(){
const r = renderTreeHtml(roots, {t, showDiscarded, cheapPath: cheapPathOn, cheapSet, showIds,
freshSet, collapsedSet,
effStatus: effectiveStatus(roots),
overloadTag: overload ? overload.tag : null});
overloadTag: overload ? overload.tag : null,
lensTag: lens ? lens.tag : null});
out.innerHTML = r.html;
warnings = warnings.concat(r.warnings);
/* Personen-Leiste (D87): Belastung je Person aus der offenen Pfad-Arbeit
(dasselbe Maß wie die D71-Warnung). Bei ausgeschaltetem Pfad-Umschalter
wird der Pfad eigens gerechnet die Leiste fragt nach dem Pfad, nicht
nach seiner Anzeige (die D75-Linie des 'path'-Presets). Die
Bernstein-Färbung des Engpasses bleibt dagegen am SICHTBAREN Pfad. */
if(docTags.length){
const loadSet = cheapPathOn ? cheapSet : computeCheapPlan(roots).set;
renderPeopleBar(roots, docTags, loadSet, overload);
} else {
peopleBar.hidden = true;
}
}
warnings = warnings.slice().sort((a, b) => (a.line || 0) - (b.line || 0));
@@ -268,6 +303,49 @@ function render(){
updateFoldBtn(); /* Icon + Tooltip = nächster Schritt des Durchschalters (D75) */
}
/* ---------- Personen-Leiste (SPEC §9, D87) ----------
Je Person eine Pille mit Belastungs-Balken; ein Tipp schaltet die Linse
(nur ihre Knoten, alles andere gefaltet), der zweite hebt sie auf, ein
Tipp auf eine andere wechselt. Angezeigt werden ANTEILE, keine absoluten
Zahlen die Größen sind ordinal, jede Summe ist eine Näherung (D46).
Der Rest ohne Zuständigen bekommt einen eigenen Eintrag, damit die Balken
ehrlich auf 100 % summieren; sein Klick zeigt, wofür noch niemand
eingeteilt ist. Sortiert nach Last, bei Gleichstand Dokumentreihenfolge. */
function renderPeopleBar(roots, docTags, loadSet, overload){
const {loads, total} = assigneeLoads(roots, loadSet);
const share = l => total > 0 ? Math.round(l / total * 100) : 0;
const entries = docTags.map(tg => ({tag: tg, pct: share(loads.get(tg) || 0)}))
.sort((a, b) => b.pct - a.pct || docTags.indexOf(a.tag) - docTags.indexOf(b.tag));
let assigned = 0;
loads.forEach(l => { assigned += l; });
const rest = total - assigned;
if(rest > 0) entries.push({tag: null, pct: share(rest)});
peopleBar.innerHTML = entries.map(e => {
const active = !!lens && lens.tag === e.tag;
const label = e.tag === null ? t('peopleUnassigned') : e.tag;
const tip = t('peopleShare', {share: e.pct}) + ' · ' +
t(active ? 'peopleLensOff' : 'peopleLensOn');
const warm = overload && e.tag !== null && overload.tag === e.tag;
return `<button type="button" class="pbperson" aria-pressed="${active}"` +
(e.tag === null ? ' data-nobody' : ` data-tag="${esc(e.tag)}"`) +
` title="${esc(label)}: ${esc(tip)}" aria-label="${esc(label)}: ${esc(tip)}">` +
`<span class="tag${warm ? ' overload' : ''}${e.tag === null ? ' nobody' : ''}" aria-hidden="true">${esc(label)}</span>` +
`<span class="pbbar" aria-hidden="true"><span class="pbfill" style="width:${e.pct}%"></span></span>` +
`<span class="pbpct" aria-hidden="true">${e.pct}%</span></button>`;
}).join('');
peopleBar.hidden = false;
}
peopleBar.addEventListener('click', e => {
const b = e.target && e.target.closest ? e.target.closest('.pbperson') : null;
if(!b) return;
const tag = ('nobody' in b.dataset) ? null : b.dataset.tag;
const same = !!lens && lens.tag === tag;
clearLens();
if(!same) lens = {tag};
render();
});
/* 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
@@ -1290,6 +1368,10 @@ function writeAllFoldMarks(roots, want){
bei ausgeschaltetem Pfad-Umschalter (die Voreinstellung fragt nach dem
Pfad, nicht nach seiner Anzeige). */
function applyFoldPreset(mode){
/* Der Durchschalter arbeitet auf dem TEXT-Faltzustand eine aktive
Personen-Linse (D87) endet damit; sonst schriebe er Marken, die man
unter der Linse gar nicht sieht. */
clearLens();
const roots = parse(src.value).roots;
if(!roots.length) return;
const cs = mode === 'path' ? computeCheapPlan(roots).set : null;
@@ -1310,6 +1392,16 @@ function toggleFold(el){
const line = +el.dataset.line;
const st = foldByLine.get(line);
if(!st || !st.canFold) return;
/* Bei aktiver Personen-Linse (D87) bleibt die Hand-Faltung in deren
Sitzungs-Überlagerung in den Text geschrieben würde sonst der
persönliche Filter, in geteilten Dokumenten für alle. */
if(lens){
lensOverrides.set(st.key, !st.collapsed);
render();
const again2 = out.querySelector('.node[data-line="' + line + '"]');
if(again2) again2.focus({preventScroll: true});
return;
}
foldOverrides.set(st.key, !st.collapsed);
/* Das Schreiben löst per `input`-Ereignis schon ein render() aus. */
if(writeFoldToText(line, !st.collapsed)) foldOverrides.clear();
@@ -2326,6 +2418,11 @@ const I18N = {
sizeConflictTooltip:"Die Teilpakete übersteigen zusammen die angegebene Größe",
cheapApproxWarn:"Zu viele gekoppelte Alternativgruppen für die exakte Suche — der günstigste Pfad ist gierig geschätzt (je Gruppe lokal gewählt).",
assigneeOverloadWarn:"@{tag} trägt {share} % der offenen Arbeit auf dem günstigsten Pfad ({stations} von {total} Stationen) — mögliche Engstelle.",
peopleBarLabel:"Zuständige",
peopleUnassigned:"ohne Zuständigen",
peopleShare:"{share} % der offenen Arbeit auf dem günstigsten Pfad",
peopleLensOn:"Klick: nur diese Knoten zeigen, alles andere zuklappen",
peopleLensOff:"Klick: Filter aufheben",
st_idee:"Idee", st_geplant:"geplant", st_arbeit:"in Arbeit", st_durchstich:"Durchstich",
st_fertig:"fertig", st_prod:"in Produktion", st_highrisk:"High Risk", st_verworfen:"verworfen",
unknownStatusWarn:"Zeile {line}: unbekanntes Statuszeichen „{code}“ — als neutral dargestellt.",
@@ -2447,6 +2544,11 @@ const I18N = {
sizeConflictTooltip:"The sub-packages together exceed the given size",
cheapApproxWarn:"Too many coupled alternative groups for the exact search — the cheapest path is a greedy estimate (chosen locally per group).",
assigneeOverloadWarn:"@{tag} carries {share}% of the open work on the cheapest path ({stations} of {total} stations) — a possible bottleneck.",
peopleBarLabel:"Assignees",
peopleUnassigned:"unassigned",
peopleShare:"{share}% of the open work on the cheapest path",
peopleLensOn:"Click: show only these nodes, fold everything else",
peopleLensOff:"Click: remove the filter",
st_idee:"idea", st_geplant:"planned", st_arbeit:"in progress", st_durchstich:"walking skeleton",
st_fertig:"done", st_prod:"in production", st_highrisk:"high risk", st_verworfen:"discarded",
unknownStatusWarn:"Line {line}: unknown status code “{code}” — shown as neutral.",
@@ -2568,6 +2670,11 @@ const I18N = {
sizeConflictTooltip:"Los subpaquetes juntos superan el tamaño indicado",
cheapApproxWarn:"Demasiados grupos de alternativas acoplados para la búsqueda exacta — el camino más barato es una estimación voraz (elección local por grupo).",
assigneeOverloadWarn:"@{tag} lleva el {share} % del trabajo pendiente en el camino más barato ({stations} de {total} estaciones) — posible cuello de botella.",
peopleBarLabel:"Responsables",
peopleUnassigned:"sin responsable",
peopleShare:"{share} % del trabajo pendiente en el camino más barato",
peopleLensOn:"Clic: mostrar solo estos nodos y plegar el resto",
peopleLensOff:"Clic: quitar el filtro",
st_idee:"idea", st_geplant:"planificado", st_arbeit:"en curso", st_durchstich:"prototipo funcional",
st_fertig:"terminado", st_prod:"en producción", st_highrisk:"alto riesgo", st_verworfen:"descartado",
unknownStatusWarn:"Línea {line}: código de estado desconocido «{code}» — mostrado como neutral.",
@@ -2689,6 +2796,11 @@ const I18N = {
sizeConflictTooltip:"Les sous-lots dépassent ensemble la taille indiquée",
cheapApproxWarn:"Trop de groupes dalternatives couplés pour la recherche exacte — le chemin le moins cher est une estimation gloutonne (choix local par groupe).",
assigneeOverloadWarn:"@{tag} porte {share} % du travail restant sur le chemin le moins cher ({stations} stations sur {total}) — goulot d’étranglement possible.",
peopleBarLabel:"Responsables",
peopleUnassigned:"sans responsable",
peopleShare:"{share} % du travail restant sur le chemin le moins cher",
peopleLensOn:"Clic : nafficher que ces nœuds, replier le reste",
peopleLensOff:"Clic : retirer le filtre",
st_idee:"idée", st_geplant:"planifié", st_arbeit:"en cours", st_durchstich:"squelette fonctionnel",
st_fertig:"terminé", st_prod:"en production", st_highrisk:"risque élevé", st_verworfen:"abandonné",
unknownStatusWarn:"Ligne {line} : code de statut inconnu « {code} » — affiché comme neutre.",
@@ -2810,6 +2922,11 @@ const I18N = {
sizeConflictTooltip:"Podzadania razem przekraczają podany rozmiar",
cheapApproxWarn:"Zbyt wiele sprzężonych grup alternatyw dla dokładnego wyszukiwania — najtańsza ścieżka jest oszacowana zachłannie (wybór lokalny w każdej grupie).",
assigneeOverloadWarn:"@{tag} niesie {share} % otwartej pracy na najtańszej ścieżce ({stations} z {total} stacji) — możliwe wąskie gardło.",
peopleBarLabel:"Odpowiedzialni",
peopleUnassigned:"bez odpowiedzialnego",
peopleShare:"{share} % otwartej pracy na najtańszej ścieżce",
peopleLensOn:"Klik: pokaż tylko te węzły, resztę zwiń",
peopleLensOff:"Klik: usuń filtr",
st_idee:"pomysł", st_geplant:"zaplanowane", st_arbeit:"w toku", st_durchstich:"działający szkielet",
st_fertig:"gotowe", st_prod:"w produkcji", st_highrisk:"wysokie ryzyko", st_verworfen:"odrzucone",
unknownStatusWarn:"Wiersz {line}: nieznany znak statusu „{code}” — pokazany jako neutralny.",
@@ -2931,6 +3048,11 @@ const I18N = {
sizeConflictTooltip:"Подзадачи вместе превышают указанный размер",
cheapApproxWarn:"Слишком много связанных групп альтернатив для точного поиска — самый дешёвый путь оценён жадно (локальный выбор в каждой группе).",
assigneeOverloadWarn:"@{tag} несёт {share} % открытой работы на самом дешёвом пути ({stations} из {total} станций) — возможное узкое место.",
peopleBarLabel:"Ответственные",
peopleUnassigned:"без ответственного",
peopleShare:"{share} % открытой работы на самом дешёвом пути",
peopleLensOn:"Клик: показать только эти узлы, остальное свернуть",
peopleLensOff:"Клик: снять фильтр",
st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип",
st_fertig:"готово", st_prod:"в эксплуатации", st_highrisk:"высокий риск", st_verworfen:"отклонено",
unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.",
@@ -3052,6 +3174,11 @@ const I18N = {
sizeConflictTooltip:"उप-पैकेज मिलकर दिए गए आकार से बड़े हैं",
cheapApproxWarn:"सटीक खोज के लिए बहुत सारे युग्मित विकल्प-समूह — सबसे सस्ता पथ लालची अनुमान है (प्रति समूह स्थानीय चयन)।",
assigneeOverloadWarn:"@{tag} सबसे सस्ते पथ पर खुले काम का {share}% उठाए हुए है ({total} में से {stations} स्टेशन) — संभावित अड़चन।",
peopleBarLabel:"ज़िम्मेदार",
peopleUnassigned:"बिना ज़िम्मेदार",
peopleShare:"सबसे सस्ते पथ पर खुले काम का {share}%",
peopleLensOn:"क्लिक: केवल ये नोड दिखाएँ, बाकी सब समेटें",
peopleLensOff:"क्लिक: फ़िल्टर हटाएँ",
st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा",
st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_highrisk:"उच्च जोखिम", st_verworfen:"अस्वीकृत",
unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।",
@@ -3173,6 +3300,11 @@ const I18N = {
sizeConflictTooltip:"子项合计超出所标注的尺寸",
cheapApproxWarn:"耦合的备选组过多,无法精确搜索——最便宜路径为贪心估计(每组就地选择)。",
assigneeOverloadWarn:"@{tag} 承担最便宜路径上 {share}% 的未完成工作({total} 个站点中的 {stations} 个)——可能的瓶颈。",
peopleBarLabel:"负责人",
peopleUnassigned:"未分配",
peopleShare:"最便宜路径上未完成工作的 {share}%",
peopleLensOn:"点击:只显示这些节点,折叠其余部分",
peopleLensOff:"点击:取消筛选",
st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架",
st_fertig:"已完成", st_prod:"已上线", st_highrisk:"高风险", st_verworfen:"已放弃",
unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。",
@@ -3294,6 +3426,11 @@ const I18N = {
sizeConflictTooltip:"サブパッケージの合計が指定サイズを超えています",
cheapApproxWarn:"結合された選択肢グループが多すぎるため厳密探索は不可 — 最安パスは貪欲法による推定です(グループごとに局所選択)。",
assigneeOverloadWarn:"@{tag} が最安パスの未完了作業の {share}% を担っています(全 {total} 駅中 {stations} 駅)— ボトルネックの可能性。",
peopleBarLabel:"担当者",
peopleUnassigned:"担当者なし",
peopleShare:"最安パスの未完了作業の {share}%",
peopleLensOn:"クリック:このノードだけを表示し、他を折りたたむ",
peopleLensOff:"クリック:フィルターを解除",
st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン",
st_fertig:"完了", st_prod:"本番稼働", st_highrisk:"高リスク", st_verworfen:"破棄",
unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。",
@@ -4237,6 +4374,7 @@ document.addEventListener('pointerdown', e => {
});
function loadActiveIntoEditor(){ const d = activeDoc(); src.value = d ? d.text : '';
clearLens(); /* die Personen-Linse (D87) gilt je Dokument-Sitzung */
/* Vergleichsstand für den nächsten Snapshot (D54): Ohne ihn legte der
erste Takt nach dem Öffnen auch ein unverändertes Dokument weg. */
snapBase = src.value; closeSnapMenu();