Files
werkbaum/frontend/index.html
T
Michael HoennigandClaude Opus 4.8 5bd25aca0d frontend: SEO-Texte – Titel, Description, Untertitel
- <title>: WBS-Editor für Projektstrukturplan & Feature-Tree (Und/Oder-Baum)
- <meta name="description"> mit den Kern-Suchbegriffen ergänzt
- Untertitel über dem Diagramm: knappe Tool-Beschreibung

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 10:56:14 +02:00

694 lines
27 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Werkbaum WBS-Editor für Projektstrukturplan &amp; Feature-Tree (Und/Oder-Baum)</title>
<meta name="description" content="Werkbaum ist ein textbasierter Editor für Projektstrukturpläne (PSP / Work Breakdown Structure, WBS): Struktur als Text schreiben, das Und/Oder-Baum-Diagramm entsteht live. Ebenso als Feature-Tree für Anforderungen (Requirements) und Aufwandsschätzung.">
<link rel="icon" type="image/svg+xml" href="../docs/brand/favicon.svg">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{
--paper:#F3F6F9;
--grid:rgba(36,52,71,.055);
--ink:#243447;
--line:#41556E;
--card:#FFFFFF;
--muted:#6B7A8C;
--or:#0F766E;
--warn:#B45309;
--r:8px;
}
*{box-sizing:border-box;margin:0;padding:0}
body{
font-family:'IBM Plex Sans',system-ui,sans-serif;
color:var(--ink);
background:
repeating-linear-gradient(0deg,transparent 0 23px,var(--grid) 23px 24px),
repeating-linear-gradient(90deg,transparent 0 23px,var(--grid) 23px 24px),
var(--paper);
min-height:100vh;
padding:32px 20px 56px;
}
header{max-width:1200px;margin:0 auto 24px}
h1{font-size:1.25rem;font-weight:600;letter-spacing:-.01em}
header p{margin-top:4px;color:var(--muted);font-size:.9rem}
/* Horizontal (Toggle aus): Diagramm oben, Editor unten — je volle Breite.
Vertikal (Toggle an, .side): Editor links, Diagramm rechts.
Zwischen beiden liegt ein verschiebbarer Gutter (Splitter). */
.app{
max-width:1200px;margin:0 auto;
display:grid;gap:0;
grid-template-columns:1fr;
grid-template-areas:"diagram" "gutter" "editor";
align-items:start;
}
.app.side{
grid-template-columns:var(--col,360px) 14px 1fr;
grid-template-areas:"editor gutter diagram";
}
.panel.editor{grid-area:editor}
.panel.right{grid-area:diagram}
.gutter{grid-area:gutter}
/* Im gestapelten Modus die Diagrammhöhe per Gutter begrenzen (scrollt intern). */
.app:not(.side) .diagram{max-height:var(--diagram-h,none)}
@media (max-width:820px){
.app.side{
grid-template-columns:1fr;
grid-template-areas:"diagram" "gutter" "editor";
}
}
/* Splitter/Gutter: stapelweise horizontal, seitlich vertikal. */
.gutter{
position:relative;align-self:stretch;justify-self:stretch;
height:14px;cursor:row-resize;touch-action:none;
}
.app.side .gutter{height:auto;width:14px;cursor:col-resize}
.gutter::before{
content:'';position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
width:46px;height:4px;border-radius:999px;background:rgba(36,52,71,.16);
transition:background .15s;
}
.app.side .gutter::before{width:4px;height:46px}
.gutter:hover::before,.gutter.dragging::before{background:var(--or)}
/* Editor-Innenaufbau: Textarea + aufklappbare Agenda.
Horizontal nebeneinander, vertikal (.side) untereinander. */
.editor-body{display:flex;align-items:stretch}
.editor-body textarea{flex:1 1 auto;min-width:0}
.agenda{
flex:0 0 auto;
border-left:1px dashed rgba(36,52,71,.18);
display:flex;flex-direction:column;
}
.agenda[open]{width:300px}
.agenda>summary{
list-style:none;cursor:pointer;white-space:nowrap;
padding:10px 16px;
font-family:'IBM Plex Mono',monospace;
font-size:.7rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);
display:flex;align-items:center;gap:7px;
}
.agenda>summary::-webkit-details-marker{display:none}
.agenda>summary::before{content:'▸';display:inline-block;transition:transform .15s}
.agenda[open]>summary::before{transform:rotate(90deg)}
.agenda .hint{border-top:0;overflow:auto}
.side .editor-body{flex-direction:column;align-items:stretch}
.side .agenda{border-left:0;border-top:1px dashed rgba(36,52,71,.18)}
.side .agenda[open]{width:auto}
.panel{
background:var(--card);
border:1px solid rgba(36,52,71,.14);
border-radius:12px;
overflow:hidden;
}
.panel-head{
padding:10px 16px;
display:flex;justify-content:space-between;align-items:center;gap:14px;
font-family:'IBM Plex Mono',monospace;
font-size:.7rem;letter-spacing:.08em;text-transform:uppercase;
color:var(--muted);
border-bottom:1px solid rgba(36,52,71,.1);
flex-wrap:wrap;
}
.panel-head>span{margin-right:auto}
.switch{
display:flex;align-items:center;gap:7px;cursor:pointer;
text-transform:none;letter-spacing:0;
font-family:'IBM Plex Sans',sans-serif;font-size:.78rem;color:var(--muted);
}
.switch input{position:absolute;opacity:0;width:0;height:0}
.switch i{
width:30px;height:17px;border-radius:999px;background:#C6CFD8;
position:relative;transition:background .15s;flex:0 0 auto;
}
.switch i::before{
content:'';position:absolute;top:2px;left:2px;width:13px;height:13px;
border-radius:50%;background:#fff;transition:left .15s;
box-shadow:0 1px 2px rgba(36,52,71,.25);
}
.switch input:checked + i{background:var(--or)}
.switch input:checked + i::before{left:15px}
.seg{
display:inline-flex;align-items:stretch;
border:1px solid rgba(36,52,71,.2);border-radius:999px;overflow:hidden;
}
.seg label{display:flex;cursor:pointer}
.seg input{position:absolute;opacity:0;width:0;height:0}
.seg span{
padding:4px 12px;
font-family:'IBM Plex Sans',sans-serif;font-size:.75rem;color:var(--muted);
transition:background .15s,color .15s;
}
.seg label + label span{border-left:1px solid rgba(36,52,71,.14)}
.seg input:checked + span{background:var(--or);color:#fff}
.seg input:focus-visible + span{outline:2px solid var(--or);outline-offset:-2px}
.winbtns{display:inline-flex;gap:4px;align-items:center}
.winbtn{
width:19px;height:19px;display:grid;place-items:center;
border:1px solid rgba(36,52,71,.2);border-radius:5px;
background:var(--card);color:var(--muted);cursor:pointer;
font-size:.72rem;line-height:1;padding:0;
}
.winbtn:hover{border-color:var(--or);color:var(--or)}
.winbtn.active{background:var(--or);color:#fff;border-color:var(--or)}
.copybtn{
font-family:'IBM Plex Sans',sans-serif;
font-size:.72rem;text-transform:none;letter-spacing:0;
color:var(--muted);background:var(--card);
border:1px solid rgba(36,52,71,.2);border-radius:6px;
padding:2px 9px;cursor:pointer;
display:inline-flex;align-items:center;gap:5px;
}
.copybtn:hover,.copybtn.done{border-color:var(--or);color:var(--or)}
textarea{
display:block;width:100%;min-height:380px;
border:0;outline:none;resize:vertical;
padding:14px 16px;
font-family:'IBM Plex Mono',monospace;
font-size:.85rem;line-height:1.7;
color:var(--ink);background:var(--card);
tab-size:2;
}
.hint{
padding:12px 16px;border-top:1px dashed rgba(36,52,71,.18);
font-size:.8rem;color:var(--muted);line-height:1.7;
}
.hint code{
font-family:'IBM Plex Mono',monospace;font-size:.75rem;
background:var(--paper);border:1px solid rgba(36,52,71,.12);
border-radius:4px;padding:0 5px;
}
.hint .or-code{color:var(--or);border-color:rgba(15,118,110,.4)}
.diagram{
padding:28px 16px 24px;
overflow:auto;
background:transparent;
}
.panel.right{background:rgba(255,255,255,.6)}
.warnings{padding:0 16px 14px;font-size:.8rem;color:var(--warn)}
.warnings div{margin-top:4px}
.empty{color:var(--muted);font-size:.85rem;padding:8px 4px}
/* ---------- Baum ---------- */
.tree{width:max-content;margin:0 auto;list-style:none}
.tree ul{list-style:none}
.tree li{display:flex;flex-direction:column;align-items:center;position:relative}
.tree > li + li{margin-top:36px}
.node{
position:relative;
color:var(--ink);
background:var(--card);
border:1.5px solid var(--line);
border-radius:var(--r);
padding:7px 14px;
font-size:.88rem;font-weight:500;line-height:1.3;
white-space:nowrap;
box-shadow:0 1px 2px rgba(36,52,71,.08);
}
a.node{text-decoration:none;cursor:pointer}
a.node:hover{box-shadow:0 2px 8px rgba(36,52,71,.22)}
.ext{font-size:.72em;margin-left:5px;opacity:.55}
.size{
position:absolute;top:-9px;right:-9px;
font-family:'IBM Plex Mono',monospace;
font-size:.58rem;font-weight:500;letter-spacing:.02em;
background:var(--or);color:#fff;
border:1.5px solid var(--card);
border-radius:999px;padding:1px 6px;line-height:1.4;
}
.tags{
position:absolute;right:-9px;bottom:-9px;
display:flex;gap:3px;
}
.tag{
font-family:'IBM Plex Mono',monospace;
font-size:.58rem;font-weight:500;letter-spacing:.02em;
background:#EEF1F4;color:var(--ink);
border:1.5px solid var(--line);
border-radius:999px;padding:1px 6px;line-height:1.4;
box-shadow:0 0 0 1.5px var(--card);
}
.ghost-node{
position:relative;margin-top:14px;
border:1.5px dashed var(--warn);
color:var(--warn);background:rgba(180,83,9,.06);
border-radius:var(--r);padding:4px 12px;
font-size:.74rem;font-style:italic;white-space:nowrap;
}
.ghost-node::before{
content:'';position:absolute;top:-14px;left:50%;height:14px;
border-left:2px dashed var(--warn);
}
.root-node{background:var(--ink);border-color:var(--ink);color:#fff;font-weight:600;padding:9px 20px}
/* --- ALL OF: Kinder nebeneinander, durchgezogener Verteiler --- */
ul.and{display:flex;align-items:flex-start;position:relative;padding-top:14px}
ul.and::before{
content:'';position:absolute;top:0;left:50%;height:14px;border-left:2px solid var(--line);
}
ul.and>li{padding:16px 12px 0}
ul.and>li::before,
ul.and>li::after{
content:'';position:absolute;top:0;right:50%;
border-top:2px solid var(--line);width:50%;height:16px;
}
ul.and>li::after{right:auto;left:50%;border-left:2px solid var(--line)}
ul.and>li:only-child{padding-top:14px}
ul.and>li:only-child::before,ul.and>li:only-child::after{border-top:none}
ul.and>li:only-child::after{border-left:2px solid var(--line);height:14px}
ul.and>li:first-child::before,
ul.and>li:last-child::after{border:0 none}
ul.and>li:last-child::before{border-right:2px solid var(--line);border-radius:0 6px 0 0}
ul.and>li:first-child::after{border-radius:6px 0 0 0}
/* --- ANY OF: durchgehend gestrichelte graue Linien (Haupt- + Abzweige) --- */
li.has-or{align-items:flex-start}
ul.or{
display:flex;flex-direction:column;
align-items:flex-start;
position:relative;
padding-top:14px;
}
ul.or::before{
content:'';position:absolute;top:0;left:9px;height:14px;border-left:2px dashed var(--muted);
}
ul.or>li{position:relative;align-items:flex-start;padding:5px 0 5px 30px}
/* Abzweig auf Höhe der Knoten-Mitte (5px li-Padding + halbe Knotenhöhe), nicht Mitte des Teilbaums */
ul.or>li::before{
content:'';position:absolute;left:9px;top:23px;width:21px;border-top:2px dashed var(--muted);
}
ul.or>li::after{
content:'';position:absolute;left:9px;top:0;height:100%;border-left:2px dashed var(--muted);
}
ul.or>li:last-child::after{height:23px}
ul.or .node{
border-color:var(--muted);
box-shadow:none;
font-weight:400;
}
/* ---------- Status (Pastell) ---------- */
.node.st-idee {background:#EBEDEF;border-color:#A2ABB5;color:var(--ink)}
.node.st-geplant {background:#EBE4F6;border-color:#A991D4;color:var(--ink)}
.node.st-arbeit {background:#FADDE4;border-color:#D897A8;color:var(--ink)}
.node.st-durchstich{background:#FBF2CE;border-color:#D9BE63;color:var(--ink)}
.node.st-fertig {background:#DCF1DE;border-color:#86C293;color:var(--ink)}
.node.st-prod {background:#DBEAF8;border-color:#85ACD7;color:var(--ink)}
.node.st-verworfen{
background:#F1F2F4;border-color:#B3BAC2;border-style:dashed;
color:var(--muted);text-decoration:line-through;box-shadow:none;
}
.chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
.chip{
font-family:'IBM Plex Mono',monospace;font-size:.72rem;
border:1.5px solid;border-radius:6px;padding:2px 8px;
color:var(--ink);white-space:nowrap;
}
.chip.st-idee {background:#EBEDEF;border-color:#A2ABB5}
.chip.st-geplant {background:#EBE4F6;border-color:#A991D4}
.chip.st-arbeit {background:#FADDE4;border-color:#D897A8}
.chip.st-durchstich{background:#FBF2CE;border-color:#D9BE63}
.chip.st-fertig {background:#DCF1DE;border-color:#86C293}
.chip.st-prod {background:#DBEAF8;border-color:#85ACD7}
.chip.st-verworfen{background:#F1F2F4;border-color:#B3BAC2;border-style:dashed;color:var(--muted);text-decoration:line-through}
/* ---------- Transponierte Modi (vertikal + kompakt) ---------- */
/* Gemeinsame Basis: Kinder untereinander, Sammelleiste links,
Abzweig auf Knotenmitte (23-px-Offset). */
.tree.vertical, .tree.kompakt{margin:0}
.tree.vertical li, .tree.kompakt li{align-items:flex-start}
.tree.vertical ul.and, .tree.kompakt ul.and{flex-direction:column;align-items:flex-start;padding-top:14px}
.tree.vertical ul.and::before, .tree.kompakt ul.and::before{left:9px;height:14px}
.tree.vertical ul.and>li, .tree.kompakt ul.and>li{padding:5px 0 5px 30px}
.tree.vertical ul.and>li::before, .tree.kompakt ul.and>li::before{
left:9px;right:auto;top:23px;width:21px;height:0;
border:0;border-top:2px solid var(--line);border-radius:0;
}
.tree.vertical ul.and>li::after, .tree.kompakt ul.and>li::after{
left:9px;right:auto;top:0;width:0;height:100%;
border:0;border-left:2px solid var(--line);border-radius:0;
}
.tree.vertical ul.and>li:last-child::after, .tree.kompakt ul.and>li:last-child::after{height:23px}
/* Nur vertikal: All-of tritt rechts aus dem Überknoten heraus (wie Richtung LR),
Any-of bleibt unten-links — klar unterscheidbare Austrittsseiten, aber breit. */
.tree.vertical li.has-and{flex-direction:row;align-items:flex-start}
.tree.vertical li.has-and>ul.and{padding-top:0}
.tree.vertical li.has-and>ul.and::before{
left:0;top:17px;width:9px;height:0;
border-left:0;border-top:2px solid var(--line);
}
.tree.vertical li.has-and>ul.and>li:first-child::after{top:17px;height:calc(100% - 17px)}
.tree.vertical li.has-and>ul.and>li:only-child::after{top:17px;height:6px}
/* Kompakt: auch All-of läuft nach unten (spart Breite) — kein Rechts-Fächer.
Die graue, gestrichelte Any-of-Führung kommt aus der Basis und gilt hier
wie in allen Modi. Gate-Codierung: und = durchgezogen dunkel (Tinte),
oder = gestrichelt grau. */
</style>
</head>
<body>
<header>
<h1 style="display:flex;align-items:center;gap:11px">
<svg width="34" height="34" viewBox="0 0 64 64" aria-hidden="true">
<rect x="22" y="5" width="20" height="12" rx="3.5" fill="#243447"/>
<path d="M32 17 V22 M14 22 H50 M14 22 V27 M50 22 V27" fill="none" stroke="#243447" stroke-width="3"/>
<rect x="4" y="27" width="20" height="12" rx="3.5" fill="#fff" stroke="#243447" stroke-width="3"/>
<rect x="40" y="27" width="20" height="12" rx="3.5" fill="#fff" stroke="#243447" stroke-width="3"/>
<path d="M50 39 V46" fill="none" stroke="#0F766E" stroke-width="3" stroke-dasharray="4 4"/>
<rect x="40" y="46" width="20" height="12" rx="3.5" fill="#fff" stroke="#0F766E" stroke-width="3"/>
</svg>
<span>Werk<span style="color:var(--or)">baum</span></span>
</h1>
<p>Werkbaum Ein Text-basierter WBS/Projektstrukturplan/Feature-Tree Diagramm-Editor.</p>
</header>
<div class="app" id="app">
<div class="panel editor">
<div class="panel-head">
<span>Struktur (Text)</span>
<button type="button" class="copybtn" id="copy" title="Text in die Zwischenablage kopieren">kopieren</button>
<div class="winbtns" aria-label="Editor-Größe">
<button type="button" class="winbtn" data-state="b" title="minimieren"></button>
<button type="button" class="winbtn" data-state="normal" title="normal"></button>
<button type="button" class="winbtn" data-state="a" title="maximieren"></button>
</div>
</div>
<div class="editor-body">
<textarea id="src" spellcheck="false"></textarea>
<details class="agenda">
<summary>Agenda</summary>
<div class="hint">
Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.<br>
<code>-</code>&nbsp; Teilpaket, alle erforderlich <em>(all of)</em><br>
<code class="or-code">|</code>&nbsp; Alternative, eine wählen <em>(any of)</em><br>
Zeile ohne Zeichen = Wurzelknoten. Geschwister sollten dasselbe Zeichen tragen.<br>
Status als Kästchen nach dem Zeichen, z.&nbsp;B. <code>- [~] Frontend</code>:
<div class="chips">
<span class="chip st-idee">[?] Idee</span>
<span class="chip st-geplant">[&nbsp;] geplant</span>
<span class="chip st-arbeit">[~] in Arbeit</span>
<span class="chip st-durchstich">[/] Durchstich</span>
<span class="chip st-fertig">[x] fertig</span>
<span class="chip st-prod">[^] in Produktion</span>
<span class="chip st-verworfen">[-] verworfen</span>
</div>
Aufwand als T-Shirt-Größe in Klammern, Link einfach als URL anhängen:<br>
<code>- [ ] Backend (L) https://…</code><br>
Ab <code>(M)</code> gilt: weiter untergliedern — fehlt die Untergliederung, erscheint ein Platzhalter im Diagramm.<br>
Kommentare mit <code>%%</code> — als ganze Zeile oder am Zeilenende.
Personen mit <code>@name</code> — erscheinen unten rechts am Knoten.
</div>
</details>
</div>
</div>
<div class="gutter" id="gutter" role="separator" aria-label="Bereiche größenverändern" title="Ziehen zum Verschieben"></div>
<div class="panel right">
<div class="panel-head">
<span>Diagramm</span>
<label class="switch">
<input type="checkbox" id="showc"><i></i>verworfene
</label>
<div class="seg" role="radiogroup" aria-label="Darstellung">
<label><input type="radio" name="layout" value="horizontal" checked><span>horizontal</span></label>
<label><input type="radio" name="layout" value="vertikal"><span>vertikal</span></label>
<label><input type="radio" name="layout" value="kompakt"><span>kompakt</span></label>
</div>
<div class="winbtns" aria-label="Diagramm-Größe">
<button type="button" class="winbtn" data-state="a" title="minimieren"></button>
<button type="button" class="winbtn" data-state="normal" title="normal"></button>
<button type="button" class="winbtn" data-state="b" title="maximieren"></button>
</div>
</div>
<div class="diagram"><ul class="tree" id="out"></ul></div>
<div class="warnings" id="warn"></div>
</div>
</div>
<script>
const INITIAL = `%% Projektstruktur Stand Sprint 14
[~] Website-Relaunch (XL) https://wiki.example.de/relaunch
- [x] Konzeption (M)
- [x] Zielgruppenanalyse (S)
- [x] Sitemap (XS)
- [~] Umsetzung (XL)
- [/] Frontend (S) https://git.example.de/frontend @anna
- [ ] Backend (L) @ben @carla
- [ ] CMS-Anbindung (M)
| [ ] WordPress
| [?] Headless CMS
| [-] Eigenentwicklung %% Aufwand zu hoch
- [?] Hosting (M)
| Cloud
| On-Premise`;
const SIZE_RANK = {XS:0, S:1, M:2, L:3, XL:4, XXL:5};
const STATUS_BY_CODE = {
'?': {key:'idee', name:'Idee'},
' ': {key:'geplant', name:'geplant'},
'~': {key:'arbeit', name:'in Arbeit'},
'/': {key:'durchstich', name:'Durchstich funktionsbereit, Feinarbeiten offen'},
'x': {key:'fertig', name:'fertig'},
'^': {key:'prod', name:'in Produktion'},
'-': {key:'verworfen', name:'verworfen'}
};
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;');
}
/* ---------- Parser ---------- */
function parse(text){
const virtualRoot = {label:'', type:'and', children:[]};
const stack = [{node:virtualRoot, width:-1}];
const warnings = [];
text.split('\n').forEach((raw, i) => {
raw = raw.replace(/%%.*$/, ''); /* %%-Kommentare entfernen (Mermaid-Konvention) */
if(!raw.trim()) return;
const m = raw.match(/^([ \t]*)([-|])?\s*(?:\[([ ?~xX^\/-])]\s*)?(.*)$/);
const width = m[1].replace(/\t/g,' ').length;
const type = m[2] === '|' ? 'or' : 'and';
const status = m[3] ? STATUS_BY_CODE[m[3].toLowerCase()] : null;
let rest = m[4], url = null, size = null;
const tags = [];
rest = rest.replace(/https?:\/\/\S+/i, s => { url = s; return ''; });
rest = rest.replace(/\((XXL|XS|XL|S|M|L)\)/i, (s, g) => { size = g.toUpperCase(); return ''; });
rest = rest.replace(/@([\p{L}\p{N}._-]+)/gu, (s, g) => { tags.push(g); return ''; });
const label = rest.replace(/\s+/g, ' ').trim();
if(!label) return;
while(stack.length > 1 && stack[stack.length-1].width >= width) stack.pop();
const parent = stack[stack.length-1].node;
const node = {label, type, status, url, size, tags, children:[], line:i+1};
parent.children.push(node);
stack.push({node, width});
});
return {roots: virtualRoot.children, warnings};
}
/* ---------- 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(showc.checked) return n.children;
return n.children.filter(k => !k.status || k.status.key !== 'verworfen');
}
const GHOST = '<div class="ghost-node">Untergliederung fehlt</div>';
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="${n.status.name}"` : '';
const tagsHtml = n.tags && n.tags.length
? `<span class="tags">${n.tags.map(t => `<span class="tag">${esc(t)}</span>`).join('')}</span>`
: '';
const inner = esc(n.label) +
(n.url ? '<span class="ext">↗</span>' : '') +
(n.size ? `<span class="size">${n.size}</span>` : '') +
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>`;
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(`Zeile ${kids[0].line}: Unter „${esc(node.label)}“ sind - und | gemischt — dargestellt nach dem ersten Kind.`);
}
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) +
renderChildren(k, warnings) +
`</li>`;
}).join('');
return `<ul class="${gate}">${items}</ul>`;
}
function render(){
let {roots} = parse(src.value);
const warnings = [];
if(!showc.checked){
roots = roots.filter(r => !r.status || r.status.key !== 'verworfen');
}
if(!roots.length){
out.innerHTML = '<div class="empty">Noch keine Struktur — einfach lostippen.</div>';
warnBox.innerHTML = '';
return;
}
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') +
renderChildren(root, warnings) +
`</li>`;
}).join('');
warnBox.innerHTML = warnings.map(w => `<div>⚠ ${w}</div>`).join('');
}
/* Tab-Taste rückt ein statt den Fokus zu wechseln */
src.addEventListener('keydown', e => {
if(e.key === 'Tab'){
e.preventDefault();
const {selectionStart:s, selectionEnd:eEnd, value} = src;
src.value = value.slice(0, s) + ' ' + value.slice(eEnd);
src.selectionStart = src.selectionEnd = s + 2;
render();
}
});
src.addEventListener('input', render);
const app = document.getElementById('app');
function applyLayout(mode){
out.classList.toggle('vertical', mode === 'vertikal');
out.classList.toggle('kompakt', mode === 'kompakt');
app.classList.toggle('side', mode !== 'horizontal');
applySplit(); /* Preset für die neue Orientierung neu setzen */
}
document.querySelectorAll('input[name="layout"]').forEach(radio => {
radio.addEventListener('change', () => { if(radio.checked) applyLayout(radio.value); });
});
/* ---------- Min/Normal/Max je Bereich (Fenster-Buttons) ---------- */
/* Zustand 'a' = Editor bevorzugt, 'b' = Diagramm bevorzugt, 'normal' = Default.
Maximieren des einen entspricht Minimieren des anderen. */
let splitState = 'normal';
function applySplit(){
if(app.classList.contains('side')){
app.style.removeProperty('--diagram-h');
app.style.setProperty('--col', splitState==='a' ? '72%' : splitState==='b' ? '20%' : '360px');
} else {
app.style.removeProperty('--col');
if(splitState==='a') app.style.setProperty('--diagram-h','160px');
else if(splitState==='b') app.style.setProperty('--diagram-h','72vh');
else app.style.removeProperty('--diagram-h');
}
document.querySelectorAll('.winbtn').forEach(b => b.classList.toggle('active', b.dataset.state===splitState));
}
document.querySelectorAll('.winbtn').forEach(b => {
b.addEventListener('click', () => { splitState = b.dataset.state; applySplit(); });
});
/* ---------- Splitter: Bereiche per Drag verteilen ---------- */
const gutter = document.getElementById('gutter');
const diagramEl = document.querySelector('.diagram');
let dragging = false;
gutter.addEventListener('pointerdown', e => {
dragging = true;
gutter.classList.add('dragging');
gutter.setPointerCapture(e.pointerId);
document.body.style.userSelect = 'none';
/* Freies Ziehen hebt das aktive Preset auf */
splitState = 'custom';
document.querySelectorAll('.winbtn').forEach(b => b.classList.remove('active'));
e.preventDefault();
});
gutter.addEventListener('pointermove', e => {
if(!dragging) return;
if(app.classList.contains('side')){
const rect = app.getBoundingClientRect();
let w = e.clientX - rect.left;
w = Math.max(220, Math.min(rect.width - 280, w));
app.style.setProperty('--col', w + 'px');
} else {
let h = e.clientY - diagramEl.getBoundingClientRect().top;
h = Math.max(140, h);
app.style.setProperty('--diagram-h', h + 'px');
}
});
function endDrag(e){
if(!dragging) return;
dragging = false;
gutter.classList.remove('dragging');
document.body.style.userSelect = '';
try{ gutter.releasePointerCapture(e.pointerId); }catch(_){}
}
gutter.addEventListener('pointerup', endDrag);
gutter.addEventListener('pointercancel', endDrag);
const showc = document.getElementById('showc');
showc.addEventListener('change', render);
/* ---------- Text in die Zwischenablage kopieren ---------- */
const copyBtn = document.getElementById('copy');
copyBtn.addEventListener('click', async () => {
try{
await navigator.clipboard.writeText(src.value);
}catch(_){
src.select();
document.execCommand('copy');
src.setSelectionRange(0, 0);
src.blur();
}
copyBtn.textContent = 'kopiert ✓';
copyBtn.classList.add('done');
setTimeout(() => { copyBtn.textContent = 'kopieren'; copyBtn.classList.remove('done'); }, 1500);
});
applySplit(); /* Startzustand: normal, markiert die normal-Buttons aktiv */
src.value = INITIAL;
render();
</script>
</body>
</html>