feat: XOR-Gruppen (=) — genau eine Alternative (SPEC §3, D35)

Parser erkennt `=` als disjunktives Gate (nur mit folgendem Leerraum —
`=SUMME(…)` bleibt Label); Mischungen melden mixedGate wie gehabt. Die
XOR-Regel warnt je WEITERER realisierter Alternative (xorConflict mit
Zeilennummer; realisiert = [~]/[/]/[x]/[^], siehe D35). Darstellung erbt
die komplette any-of-Geometrie (ul class="or xor"), ergänzt um eine
„1"-Plakette am Austritt der Sammelleiste — auch im Grafikexport.
Legende (+ hint_root) und Warntext in allen 9 Sprachen; SPEC §11-Eintrag
in §1/§3/§9 überführt; 13 neue Tests (tests/xor.test.js).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-22 16:08:37 +02:00
co-authored by Claude Fable 5
parent 2754fa40f6
commit d276c94840
14 changed files with 291 additions and 61 deletions
+41 -9
View File
@@ -288,11 +288,16 @@ function diagramToSvg(){
sitzt mittig auf der Kante, das Knoten-Rechteck würde ihn sonst halb
überdecken. */
const optMarks = [];
/* „1"-Plaketten der XOR-Gruppen (`=`, SPEC §9/D35) — wie die Optional-Kreise
erst NACH den Knoten gezeichnet, sitzen aber auf der Leitung, nie auf
einer Knotenkante. */
const xorMarks = [];
nodes.forEach(parentEl => {
const li = parentEl.closest('li');
const childUl = li && [...li.children].find(c => c.tagName === 'UL');
if(!childUl) return;
const gate = childUl.classList.contains('or') ? 'or' : 'and';
const isXor = childUl.classList.contains('xor');
const stroke = gate === 'or' ? '#6B7A8C' : '#41556E';
const dash = gate === 'or';
/* Die Treppe (D29) ist eine Anordnung, keine Ebene: alle Stufen sind Kinder
@@ -324,6 +329,7 @@ function diagramToSvg(){
const busX = toRight ? Math.min(...kids.map(k=>k.x))-14 : Math.max(...kids.map(k=>k.r))+14;
const ys = kids.map(k=>k.cy).concat(p.cy);
parts.push(seg(px, p.cy, busX, p.cy, stroke, dash));
if(isXor) xorMarks.push({x:(px+busX)/2, y:p.cy});
parts.push(seg(busX, Math.min(...ys), busX, Math.max(...ys), stroke, dash));
kids.forEach((k, i) => {
const x = toRight ? k.x : k.r, o = isOpt(i);
@@ -336,6 +342,7 @@ function diagramToSvg(){
const busY = toDown ? Math.min(...kids.map(k=>k.y))-14 : Math.max(...kids.map(k=>k.b))+14;
const xs = kids.map(k=>k.cx).concat(p.cx);
parts.push(seg(p.cx, py, p.cx, busY, stroke, dash));
if(isXor) xorMarks.push({x:p.cx, y:(py+busY)/2});
parts.push(seg(Math.min(...xs), busY, Math.max(...xs), busY, stroke, dash));
kids.forEach((k, i) => {
const y = toDown ? k.y : k.b, o = isOpt(i);
@@ -393,6 +400,12 @@ function diagramToSvg(){
optMarks.forEach(m => parts.push(
`<circle cx="${m.x.toFixed(1)}" cy="${m.y.toFixed(1)}" r="4" fill="#ffffff" stroke="#41556E" stroke-width="2"/>`));
/* 3a) XOR-Gruppen: „1"-Plakette am Austritt der Sammelleiste (D35) */
xorMarks.forEach(m => {
parts.push(`<circle cx="${m.x.toFixed(1)}" cy="${m.y.toFixed(1)}" r="6.5" fill="#ffffff" stroke="#6B7A8C" stroke-width="1.5"/>`);
parts.push(`<text x="${m.x.toFixed(1)}" y="${(m.y+3).toFixed(1)}" text-anchor="middle" fill="#6B7A8C" font-size="9" font-weight="600">1</text>`);
});
/* 3b) Günstigster-Pfad: abgetönte Kopie über den Knoten + Stationspunkte */
if(cheapPts.length >= 2){
parts.push(cheapLine('0.2'));
@@ -1088,6 +1101,7 @@ const I18N = {
zoomAria:"Zoom (Strg/Cmd + Mausrad)", langMore:"weitere Sprachen",
empty:"Noch keine Struktur — einfach lostippen.", ghost:"…",
mixedWarn:"Zeile {line}: Unter „{label}“ sind - und | gemischt — dargestellt nach dem ersten Kind.",
xorConflictWarn:"Zeile {line}: „{label}“ ist eine weitere realisierte Alternative — eine =-Gruppe erlaubt genau eine.",
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.",
@@ -1096,9 +1110,10 @@ const I18N = {
a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yOptional:"optional", a11yFocusMark:"hierhin schauen", a11yLink:"verlinkt",
hint_indent:"Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.",
hint_all:"Teilpaket, alle erforderlich", hint_any:"Alternative, eine wählen",
hint_xor:"Alternative, genau eine",
hint_opt:"Zugabe, nicht erforderlich",
hint_focus:"hierhin schauen (gemeinsamer Zeigefinger)",
hint_root:"Zeile ohne Zeichen = Wurzelknoten. | nicht mit - / + mischen.",
hint_root:"Zeile ohne Zeichen = Wurzelknoten. |, = und - / + nicht mischen.",
hint_status:"Status als Kästchen nach dem Zeichen, z. B.",
hint_size:"Aufwand als T-Shirt-Größe in Klammern, Link einfach als URL anhängen:",
hint_break:"Ab (M) gilt: weiter untergliedern — fehlt die Untergliederung, erscheint ein Platzhalter im Diagramm.",
@@ -1152,6 +1167,7 @@ const I18N = {
zoomAria:"Zoom (Ctrl/Cmd + mouse wheel)", langMore:"more languages",
empty:"No structure yet — just start typing.", ghost:"…",
mixedWarn:"Line {line}: under “{label}”, - and | are mixed — rendered by the first child.",
xorConflictWarn:"Line {line}: “{label}” is another realized alternative — an = group allows exactly one.",
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.",
@@ -1160,9 +1176,10 @@ const I18N = {
a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yOptional:"optional", a11yFocusMark:"look here", a11yLink:"has link",
hint_indent:"Indentation (2 spaces or a tab) defines the hierarchy.",
hint_all:"sub-task, all required", hint_any:"alternative, choose one",
hint_xor:"alternative, exactly one",
hint_opt:"extra, not required",
hint_focus:"look here (a shared pointer)",
hint_root:"Line without a marker = root node. Do not mix | with - / +.",
hint_root:"Line without a marker = root node. Do not mix |, = and - / +.",
hint_status:"Status as a checkbox after the marker, e.g.",
hint_size:"Effort as a T-shirt size in parentheses; add a link simply as a URL:",
hint_break:"From (M) on: break it down further — if the breakdown is missing, a placeholder appears in the diagram.",
@@ -1216,6 +1233,7 @@ const I18N = {
zoomAria:"Zoom (Ctrl/Cmd + rueda del ratón)", langMore:"más idiomas",
empty:"Aún no hay estructura — simplemente empieza a escribir.", ghost:"…",
mixedWarn:"Línea {line}: bajo «{label}» se mezclan - y | — se representa según el primer hijo.",
xorConflictWarn:"Línea {line}: «{label}» es otra alternativa realizada — un grupo = permite exactamente una.",
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.",
@@ -1224,9 +1242,10 @@ const I18N = {
a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yOptional:"opcional", a11yFocusMark:"mirar aquí", a11yLink:"con enlace",
hint_indent:"La sangría (2 espacios o un tabulador) define la jerarquía.",
hint_all:"subtarea, todas obligatorias", hint_any:"alternativa, elige una",
hint_xor:"alternativa, exactamente una",
hint_opt:"extra, no obligatorio",
hint_focus:"mirar aquí (un puntero compartido)",
hint_root:"Línea sin marcador = nodo raíz. No mezcles | con - / +.",
hint_root:"Línea sin marcador = nodo raíz. No mezcles |, = y - / +.",
hint_status:"Estado como casilla tras el marcador, p. ej.",
hint_size:"Esfuerzo como talla de camiseta entre paréntesis; añade un enlace simplemente como URL:",
hint_break:"A partir de (M): sigue desglosando — si falta el desglose, aparece un marcador de posición en el diagrama.",
@@ -1280,6 +1299,7 @@ const I18N = {
zoomAria:"Zoom (Ctrl/Cmd + molette)", langMore:"plus de langues",
empty:"Pas encore de structure — commencez à taper.", ghost:"…",
mixedWarn:"Ligne {line} : sous « {label} », - et | sont mélangés — rendu selon le premier enfant.",
xorConflictWarn:"Ligne {line} : « {label} » est une alternative réalisée de plus — un groupe = nen autorise quune seule.",
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.",
@@ -1288,9 +1308,10 @@ const I18N = {
a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yOptional:"facultatif", a11yFocusMark:"regarder ici", a11yLink:"avec lien",
hint_indent:"L'indentation (2 espaces ou une tabulation) définit la hiérarchie.",
hint_all:"sous-tâche, toutes requises", hint_any:"alternative, en choisir une",
hint_xor:"alternative, exactement une",
hint_opt:"supplément, non requis",
hint_focus:"regarder ici (un pointeur partagé)",
hint_root:"Ligne sans marqueur = nœud racine. Ne mélangez pas | avec - / +.",
hint_root:"Ligne sans marqueur = nœud racine. Ne mélangez pas |, = et - / +.",
hint_status:"Statut sous forme de case après le marqueur, p. ex.",
hint_size:"Effort en taille de T-shirt entre parenthèses ; ajoutez un lien simplement comme URL :",
hint_break:"À partir de (M) : décomposer davantage — si la décomposition manque, un espace réservé apparaît dans le diagramme.",
@@ -1344,6 +1365,7 @@ const I18N = {
zoomAria:"Powiększenie (Ctrl/Cmd + kółko myszy)", langMore:"więcej języków",
empty:"Brak struktury — zacznij pisać.", ghost:"…",
mixedWarn:"Wiersz {line}: pod „{label}” mieszają się - i | — renderowane według pierwszego dziecka.",
xorConflictWarn:"Wiersz {line}: „{label}” to kolejna zrealizowana alternatywa — grupa = dopuszcza dokładnie jedną.",
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.",
@@ -1352,9 +1374,10 @@ const I18N = {
a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", a11yOptional:"opcjonalny", a11yFocusMark:"spójrz tutaj", a11yLink:"z linkiem",
hint_indent:"Wcięcie (2 spacje lub tabulator) definiuje hierarchię.",
hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną",
hint_xor:"alternatywa, dokładnie jedna",
hint_opt:"dodatek, niewymagany",
hint_focus:"spójrz tutaj (wspólny wskaźnik)",
hint_root:"Wiersz bez znacznika = węzeł główny. Nie mieszaj | z - / +.",
hint_root:"Wiersz bez znacznika = węzeł główny. Nie mieszaj |, = i - / +.",
hint_status:"Status jako pole wyboru po znaczniku, np.",
hint_size:"Nakład jako rozmiar koszulki w nawiasach; link dodaj po prostu jako URL:",
hint_break:"Od (M): dziel dalej — gdy brakuje podziału, w diagramie pojawia się symbol zastępczy.",
@@ -1408,6 +1431,7 @@ const I18N = {
zoomAria:"Масштаб (Ctrl/Cmd + колесо мыши)", langMore:"ещё языки",
empty:"Пока нет структуры — просто начните печатать.", ghost:"…",
mixedWarn:"Строка {line}: под «{label}» смешаны - и | — отображается по первому потомку.",
xorConflictWarn:"Строка {line}: «{label}» — ещё одна реализованная альтернатива, а группа = допускает ровно одну.",
st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип",
st_fertig:"готово", st_prod:"в эксплуатации", st_highrisk:"высокий риск", st_verworfen:"отклонено",
unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.",
@@ -1416,9 +1440,10 @@ const I18N = {
a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yOptional:"необязательно", a11yFocusMark:"смотрите здесь", a11yLink:"со ссылкой",
hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.",
hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну",
hint_xor:"альтернатива, ровно одна",
hint_opt:"дополнение, не обязательно",
hint_focus:"смотрите здесь (общая указка)",
hint_root:"Строка без маркера = корневой узел. Не смешивайте | с - / +.",
hint_root:"Строка без маркера = корневой узел. Не смешивайте |, = и - / +.",
hint_status:"Статус в виде флажка после маркера, напр.",
hint_size:"Трудоёмкость как размер футболки в скобках; ссылку добавьте просто как URL:",
hint_break:"С (M): дробите дальше — если декомпозиции нет, в диаграмме появляется заполнитель.",
@@ -1472,6 +1497,7 @@ const I18N = {
zoomAria:"ज़ूम (Ctrl/Cmd + माउस-व्हील)", langMore:"और भाषाएँ",
empty:"अभी कोई संरचना नहीं — बस टाइप करना शुरू करें।", ghost:"…",
mixedWarn:"पंक्ति {line}: „{label}“ के अंतर्गत - और | मिश्रित हैं — पहले चाइल्ड के अनुसार दिखाया गया।",
xorConflictWarn:"पंक्ति {line}: „{label}“ एक और साकार विकल्प है — = समूह में केवल एक की अनुमति है।",
st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा",
st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_highrisk:"उच्च जोखिम", st_verworfen:"अस्वीकृत",
unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।",
@@ -1480,9 +1506,10 @@ const I18N = {
a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yOptional:"वैकल्पिक", a11yFocusMark:"यहाँ देखें", a11yLink:"लिंक सहित",
hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।",
hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें",
hint_xor:"विकल्प, ठीक एक",
hint_opt:"अतिरिक्त, आवश्यक नहीं",
hint_focus:"यहाँ देखें (साझा संकेतक)",
hint_root:"बिना मार्कर वाली पंक्ति = मूल नोड। | को - / + के साथ न मिलाएँ।",
hint_root:"बिना मार्कर वाली पंक्ति = मूल नोड। |, = और - / + को आपस में न मिलाएँ।",
hint_status:"मार्कर के बाद चेकबॉक्स के रूप में स्थिति, जैसे",
hint_size:"प्रयास कोष्ठक में टी-शर्ट आकार के रूप में; लिंक बस URL के रूप में जोड़ें:",
hint_break:"(M) से आगे: और विभाजित करें — विभाजन न होने पर आरेख में प्लेसहोल्डर दिखता है।",
@@ -1536,6 +1563,7 @@ const I18N = {
zoomAria:"缩放(Ctrl/Cmd + 鼠标滚轮)", langMore:"更多语言",
empty:"还没有结构——直接开始输入吧。", ghost:"…",
mixedWarn:"第 {line} 行:在「{label}」下 - 与 | 混用——按第一个子项渲染。",
xorConflictWarn:"第 {line} 行:「{label}」是又一个已实现的备选项——= 组只允许恰好一个。",
st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架",
st_fertig:"已完成", st_prod:"已上线", st_highrisk:"高风险", st_verworfen:"已放弃",
unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。",
@@ -1544,9 +1572,10 @@ const I18N = {
a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yOptional:"可选", a11yFocusMark:"看这里", a11yLink:"含链接",
hint_indent:"缩进(2 个空格或制表符)定义层级。",
hint_all:"子任务,全部必需", hint_any:"备选项,择其一",
hint_xor:"备选项,恰好一个",
hint_opt:"附加项,非必需",
hint_focus:"看这里(共享的指针)",
hint_root:"无标记的行 = 根节点。请勿将 | 与 - / + 混用。",
hint_root:"无标记的行 = 根节点。请勿混用 |、= 与 - / +。",
hint_status:"在标记后用方框表示状态,例如",
hint_size:"用括号中的 T 恤尺码表示工作量;链接直接作为 URL 附加:",
hint_break:"从 (M) 起:继续细分——若缺少细分,图表中会出现占位符。",
@@ -1600,6 +1629,7 @@ const I18N = {
zoomAria:"ズーム(Ctrl/Cmd + マウスホイール)", langMore:"その他の言語",
empty:"まだ構造がありません — 入力を始めてください。", ghost:"…",
mixedWarn:"{line} 行目:「{label}」の下で - と | が混在 — 最初の子に従って表示。",
xorConflictWarn:"{line} 行目:「{label}」も実現済みの選択肢です — = グループで実現できるのは 1 つだけです。",
st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン",
st_fertig:"完了", st_prod:"本番稼働", st_highrisk:"高リスク", st_verworfen:"破棄",
unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。",
@@ -1608,9 +1638,10 @@ const I18N = {
a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yOptional:"任意", a11yFocusMark:"ここを見る", a11yLink:"リンクあり",
hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。",
hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ",
hint_xor:"選択肢、ちょうど1つ",
hint_opt:"追加、必須ではない",
hint_focus:"ここを見る(共有の指さし)",
hint_root:"マーカーのない行 = ルートノード。|- / + 混在させないでください。",
hint_root:"マーカーのない行 = ルートノード。|・=・- / + 混在させないでください。",
hint_status:"マーカーの後にチェックボックスで状態、例:",
hint_size:"工数は括弧内の T シャツサイズで;リンクは URL としてそのまま追加:",
hint_break:"(M) 以上:さらに分解 — 分解がないと図にプレースホルダーが表示されます。",
@@ -1631,6 +1662,7 @@ function buildHint(){
<code>-</code>&nbsp; ${esc(t('hint_all'))} <em>(all of)</em><br>
<code>+</code>&nbsp; ${esc(t('hint_opt'))} <em>(optional)</em><br>
<code class="or-code">|</code>&nbsp; ${esc(t('hint_any'))} <em>(any of)</em><br>
<code class="or-code">=</code>&nbsp; ${esc(t('hint_xor'))} <em>(xor)</em><br>
${esc(t('hint_root'))}<br>
${esc(t('hint_status'))} <code>- [~] Frontend</code>:
<div class="chips">