Files
werkbaum/frontend/index.html
T
Michael HoennigandClaude Opus 4.8 f32cda676b frontend: Zoom skaliert Inhalt, Vollhöhen-Layout mit fixen Panes
Bisher wuchs beim Zoomen das Diagramm-Panel mit. Jetzt füllt die App die
Bildschirmhöhe (Body als Flex-Spalte), beide Panels sind flex-Container
mit fixem, scrollendem Inhaltsbereich. Der Zoom (CSS-`zoom` auf dem Baum)
skaliert damit nur den Inhalt; das Panel behält seine Größe, der Baum
scrollt im Diagramm-Viewport.

Splitter im Stapelmodus jetzt über die Grid-Zeile (--drow) statt
Diagramm-Höhe; Editor-Textarea füllt seinen Pane und scrollt intern.

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

787 lines
32 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);
height:100vh;overflow:hidden;
display:flex;flex-direction:column;
padding:24px 20px 12px;
}
header{max-width:1200px;width:100%;margin:0 auto 16px;flex:0 0 auto}
h1{font-size:1.25rem;font-weight:600;letter-spacing:-.01em}
header p{margin-top:4px;color:var(--muted);font-size:.9rem}
.site-footer{
max-width:1200px;margin:28px auto 0;
color:var(--muted);font-size:.78rem;text-align:center;
}
/* 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;width:100%;margin:0 auto;
flex:1 1 auto;min-height:0;
display:grid;gap:0;
grid-template-columns:minmax(0,1fr);
grid-template-rows:var(--drow,60%) 14px minmax(0,1fr);
grid-template-areas:"diagram" "gutter" "editor";
align-items:stretch;
}
.app.side{
grid-template-columns:var(--col,360px) 14px minmax(0,1fr);
grid-template-rows:minmax(0,1fr);
grid-template-areas:"editor gutter diagram";
}
.panel.editor{grid-area:editor}
.panel.right{grid-area:diagram}
.gutter{grid-area:gutter}
/* 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)}
/* Minimieren: Panel auf seine Titelzeile schrumpfen, Körper ausblenden.
'a' = Diagramm minimiert (Editor maximal), 'b' = Editor minimiert. */
.panel.collapsed .editor-body,
.panel.collapsed .diagram,
.panel.collapsed .warnings{display:none}
.panel.collapsed .copybtn,
.panel.collapsed .switch,
.panel.collapsed .seg{display:none}
/* Gestapelt: kollabiertes Panel = Titelzeile (auto), anderes füllt. */
.app:not(.side).collapse-diagram{grid-template-rows:auto 14px minmax(0,1fr)}
.app:not(.side).collapse-editor{grid-template-rows:minmax(0,1fr) 14px auto}
/* Seitlich: schmaler vertikaler Streifen mit gedrehter Beschriftung. */
.app.side.collapse-editor{grid-template-columns:auto 14px minmax(0,1fr)}
.app.side.collapse-diagram{grid-template-columns:minmax(0,1fr) 14px auto}
.app.side .panel.collapsed{align-self:stretch}
.app.side .panel.collapsed .panel-head{
flex-direction:column;align-items:center;gap:12px;
height:100%;padding:12px 6px;border-bottom:0;
}
.app.side .panel.collapsed .panel-head>span{
writing-mode:vertical-rl;transform:rotate(180deg);
margin:0;white-space:nowrap;
}
.app.side .panel.collapsed .winbtns{flex-direction:column}
/* Schmaler Viewport: Seitenmodus fällt auf gestapelt zurück. */
@media (max-width:820px){
.app.side{
grid-template-columns:minmax(0,1fr);
grid-template-rows:var(--drow,60%) 14px minmax(0,1fr);
grid-template-areas:"diagram" "gutter" "editor";
}
.app.side.collapse-diagram{grid-template-columns:minmax(0,1fr);grid-template-rows:auto 14px minmax(0,1fr)}
.app.side.collapse-editor{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr) 14px auto}
.app.side .panel.collapsed .panel-head{flex-direction:row;align-items:center;height:auto;padding:10px 16px}
.app.side .panel.collapsed .panel-head>span{writing-mode:horizontal-tb;transform:none}
.app.side .panel.collapsed .winbtns{flex-direction:row}
.app.side .gutter{height:14px;width:auto;cursor:row-resize}
.app.side .gutter::before{width:46px;height:4px}
}
/* Editor-Innenaufbau: Textarea + aufklappbare Agenda.
Horizontal nebeneinander, vertikal (.side) untereinander. */
.editor-body{display:flex;align-items:stretch;flex:1 1 auto;min-height:0}
.editor-body textarea{flex:1 1 auto;min-width:0;min-height: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{min-height:0}
.agenda[open]{overflow:hidden}
.agenda .hint{border-top:0;flex:1 1 auto;min-height: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;
display:flex;flex-direction:column;min-height:0;min-width:0;
}
.panel-head{
flex:0 0 auto;
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:0;
border:0;outline:none;resize:none;
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{
flex:1 1 auto;min-height:0;
padding:28px 16px 24px;
overflow:auto;
background:transparent;
}
.panel.right{background:rgba(255,255,255,.6);position:relative}
.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}
/* Zoom-Steuerung: schwebt unten rechts über dem Diagramm. */
.zoomctl{
position:absolute;right:14px;bottom:14px;z-index:5;
display:inline-flex;align-items:center;gap:2px;padding:2px;
background:var(--card);border:1px solid rgba(36,52,71,.18);
border-radius:8px;box-shadow:0 1px 4px rgba(36,52,71,.14);
}
.zoomctl button{
border:0;background:transparent;cursor:pointer;color:var(--muted);
font-family:'IBM Plex Mono',monospace;font-size:.82rem;line-height:1;
padding:4px 8px;border-radius:6px;min-width:30px;
}
#zoomReset{min-width:48px;font-size:.72rem}
.zoomctl button:hover{background:var(--paper);color:var(--or)}
.panel.collapsed .zoomctl{display:none}
/* ---------- 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 class="zoomctl" aria-label="Zoom (Strg/Cmd + Mausrad)">
<button type="button" id="zoomOut" title="verkleinern"></button>
<button type="button" id="zoomReset" title="zurücksetzen">100&nbsp;%</button>
<button type="button" id="zoomIn" title="vergrößern">+</button>
</div>
</div>
</div>
<footer class="site-footer">© 2026 Michael Hönnig</footer>
<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' = Diagramm minimiert (Editor maximiert),
'b' = Editor minimiert (Diagramm maximiert), 'normal' = beide sichtbar.
Minimieren schrumpft ein Panel auf seine Titelzeile; Maximieren des einen
entspricht dem Minimieren des anderen. */
let splitState = 'normal';
const editorPanel = document.querySelector('.panel.editor');
const diagramPanel = document.querySelector('.panel.right');
function applySplit(){
/* Preset-/Drag-Größen bei Preset-Wechsel zurücksetzen */
app.style.removeProperty('--col');
app.style.removeProperty('--drow');
app.classList.toggle('collapse-diagram', splitState==='a');
app.classList.toggle('collapse-editor', splitState==='b');
diagramPanel.classList.toggle('collapsed', splitState==='a');
editorPanel.classList.toggle('collapsed', splitState==='b');
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 Preset und Minimierung auf */
splitState = 'custom';
app.classList.remove('collapse-editor','collapse-diagram');
editorPanel.classList.remove('collapsed');
diagramPanel.classList.remove('collapsed');
document.querySelectorAll('.winbtn').forEach(b => b.classList.remove('active'));
e.preventDefault();
});
gutter.addEventListener('pointermove', e => {
if(!dragging) return;
const rect = app.getBoundingClientRect();
if(app.classList.contains('side')){
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 - rect.top;
h = Math.max(120, Math.min(rect.height - 140, h));
app.style.setProperty('--drow', 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);
/* ---------- Zoom für das Diagramm ---------- */
/* CSS-`zoom` skaliert die Layout-Box, dadurch greifen die Scrollbalken
des Diagramm-Containers korrekt (anders als transform: scale). */
const ZMIN = 0.3, ZMAX = 3, ZSTEP = 0.1;
let zoom = 1;
function applyZoom(){
zoom = Math.min(ZMAX, Math.max(ZMIN, Math.round(zoom * 100) / 100));
out.style.zoom = zoom;
document.getElementById('zoomReset').textContent = Math.round(zoom * 100) + ' %';
}
document.getElementById('zoomIn').addEventListener('click', () => { zoom += ZSTEP; applyZoom(); });
document.getElementById('zoomOut').addEventListener('click', () => { zoom -= ZSTEP; applyZoom(); });
document.getElementById('zoomReset').addEventListener('click', () => { zoom = 1; applyZoom(); });
diagramEl.addEventListener('wheel', e => {
if(!(e.ctrlKey || e.metaKey)) return; /* Strg/Cmd + Mausrad zoomt */
e.preventDefault();
zoom += (e.deltaY < 0 ? ZSTEP : -ZSTEP);
applyZoom();
}, {passive:false});
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>