Files
Rangordnung-SG/Rangordnungsbuilder.html
2026-09-18 10:09:13 +02:00

788 lines
52 KiB
HTML
Raw Permalink 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>SCP-RP Rangordnungs-Builder</title>
<style>
:root{--bg:#161a20;--panel:#1f242c;--panel2:#262c35;--line:#333b47;--ink:#e8ecf2;--muted:#9aa4b2;
--accent:#e0a83b;--accent-ink:#231a06;--danger:#e05a5a;--radius:10px;--shadow:0 6px 20px rgba(0,0,0,.35)}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{background:var(--bg);color:var(--ink);font-family:ui-sans-serif,system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;font-size:14px;line-height:1.4}
.topbar{display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:12px 16px;background:var(--panel);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:30}
.brand{display:flex;align-items:center;gap:10px;font-weight:700}
.brand .dot{width:14px;height:14px;border-radius:3px;background:var(--accent);box-shadow:0 0 0 3px rgba(224,168,59,.18)}
.brand small{display:block;font-weight:500;color:var(--muted)}
.spacer{flex:1}
.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
button{font:inherit;color:var(--ink);background:var(--panel2);border:1px solid var(--line);padding:8px 12px;border-radius:8px;cursor:pointer;transition:.12s;display:inline-flex;align-items:center;gap:6px}
button:hover{border-color:#4a5361;background:#2c333d}
button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
button.primary{background:var(--accent);color:var(--accent-ink);border-color:var(--accent);font-weight:600}
button.primary:hover{filter:brightness(1.06)}
button.ghost{background:transparent}
button.tiny{padding:4px 7px;font-size:12px;border-radius:6px}
button.danger{color:#ffd7d7}
button.danger:hover{background:#3a2222;border-color:#6b3a3a}
.layout{display:grid;grid-template-columns:460px 1fr;height:calc(100vh - 59px)}
@media (max-width:1000px){.layout{grid-template-columns:1fr;height:auto}.preview{position:relative;height:75vh}}
.editor{overflow-y:auto;border-right:1px solid var(--line);padding:16px;background:var(--bg)}
.preview{position:sticky;top:59px;overflow:auto;background:#12151a;
background-image:linear-gradient(45deg,#1a1e24 25%,transparent 25%),linear-gradient(-45deg,#1a1e24 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#1a1e24 75%),linear-gradient(-45deg,transparent 75%,#1a1e24 75%);
background-size:24px 24px;background-position:0 0,0 12px,12px -12px,-12px 0px;height:calc(100vh - 59px)}
.canvas-inner{padding:24px;min-width:min-content;transform-origin:top left}
.acc{margin-bottom:12px;border:1px solid var(--line);border-radius:var(--radius);overflow:hidden;background:var(--panel)}
.acc>summary{list-style:none;cursor:pointer;padding:11px 13px;background:var(--panel2);font-weight:600;display:flex;align-items:center;gap:8px;user-select:none}
.acc>summary::-webkit-details-marker{display:none}
.acc>summary::before{content:"▸";color:var(--muted);transition:.15s}
.acc[open]>summary::before{transform:rotate(90deg)}
.acc .body{padding:12px 13px}
.count{margin-left:auto;font-weight:500;color:var(--muted);font-size:12px}
.field{display:flex;flex-direction:column;gap:4px;margin-bottom:10px}
.field label{font-size:12px;color:var(--muted)}
input[type=text],input[type=number],select{font:inherit;color:var(--ink);background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:7px 9px;width:100%}
select{background:var(--panel2)}
input:focus,select:focus{outline:none;border-color:var(--accent)}
input[type=color]{width:34px;height:30px;padding:2px;border:1px solid var(--line);border-radius:7px;background:var(--panel);cursor:pointer;flex:none}
.row{display:flex;gap:8px;align-items:center}
.checkline{display:flex;align-items:center;gap:9px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:9px 11px}
.checkline input{width:16px;height:16px;accent-color:var(--accent);flex:none}
.checkline .txt small{display:block;color:var(--muted);font-size:11px}
.card{background:var(--panel2);border:1px solid var(--line);border-radius:9px;margin-bottom:11px;overflow:hidden}
.card-head{display:flex;align-items:center;gap:7px;padding:8px 9px;background:rgba(255,255,255,.03)}
.card-head .swatch{width:15px;height:15px;border-radius:4px;flex:none;border:1px solid rgba(255,255,255,.25)}
.card-head input[type=text]{background:transparent;border:1px solid transparent;font-weight:600;padding:5px 6px}
.card-head input[type=text]:hover{border-color:var(--line)}
.card-head input[type=text]:focus{border-color:var(--accent);background:var(--panel)}
.card-body{padding:8px 9px}
.mvbtns{display:flex;gap:3px;margin-left:auto}
.mini{font-size:11px;color:var(--muted);flex:none}
.chk{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--muted);white-space:nowrap}
.chk input{width:15px;height:15px;accent-color:var(--accent)}
input.wnum{width:60px;text-align:center;flex:none}
.sublabel{font-size:11px;color:var(--muted);margin:8px 2px 4px;font-weight:600}
.rank{display:grid;grid-template-columns:auto 1fr auto;gap:6px;align-items:center;padding:5px;border-radius:7px}
.rank:hover{background:rgba(255,255,255,.03)}
.rank .grip{display:flex;flex-direction:column;gap:2px}
.rank .grip button{padding:1px 5px;font-size:10px;line-height:1.1}
.rank .main{display:flex;flex-direction:column;gap:4px;min-width:0}
.rank .kc{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
.kcbox{display:flex;align-items:center;gap:2px;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:2px 5px}
.kcbox input{width:48px;text-align:center;padding:4px 2px;border:none;background:transparent}
.kcbox input:focus{outline:1px solid var(--accent);border-radius:4px}
.kcbox .sep{color:var(--muted);font-weight:700}
.kc .lbl{font-size:10px;color:var(--muted)}
.kc .tier{display:flex;align-items:center;gap:5px;margin-left:auto}
.kc .tier input{width:62px;text-align:center}
.addbtn{width:100%;margin-top:6px;border-style:dashed;color:var(--muted);justify-content:center}
.addbtn:hover{color:var(--ink)}
.branch{border:1px dashed var(--line);border-radius:8px;padding:7px;margin-bottom:8px}
.branch .bhead{display:flex;gap:6px;align-items:center;margin-bottom:4px}
.hint{font-size:11.5px;color:var(--muted);background:rgba(224,168,59,.06);border:1px solid rgba(224,168,59,.25);border-radius:8px;padding:9px 11px;margin-bottom:14px}
.hint code{background:rgba(255,255,255,.08);padding:1px 4px;border-radius:4px}
.zoombar{position:sticky;bottom:0;display:flex;gap:6px;align-items:center;justify-content:flex-end;padding:8px 12px;background:linear-gradient(transparent,#12151a 40%)}
.zoombar .pill{background:var(--panel);border:1px solid var(--line);border-radius:20px;padding:5px 8px;display:flex;gap:6px;align-items:center;box-shadow:var(--shadow)}
.zoombar button{padding:4px 9px}
.zval{min-width:44px;text-align:center;color:var(--muted);font-variant-numeric:tabular-nums}
input[type=file]{display:none}
svg text{font-family:ui-sans-serif,system-ui,"Segoe UI",Roboto,Arial,sans-serif}
</style>
</head>
<body>
<div class="topbar">
<div class="brand"><span class="dot"></span><div>Rangordnungs-Builder<small>SCP-RP · Wissenslevel | Keycard</small></div></div>
<div class="spacer"></div>
<div class="toolbar">
<button id="btnPng" class="primary">⬇ PNG</button>
<button id="btnSvg">⬇ SVG</button>
<button id="btnSave" class="ghost">Speichern (.json)</button>
<button id="btnLoad" class="ghost">Laden</button>
<button id="btnReset" class="ghost danger">Zurücksetzen</button>
<input type="file" id="fileInput" accept="application/json">
</div>
</div>
<div class="layout">
<aside class="editor">
<div class="hint"><b>Aufbau (oben→unten):</b> Kommando-Baum → „Einheiten" → Abteilungs-Spalten (mit Verzweigungen) + Seiten-Boxen → Sonderbereich. <b>Stufe</b> = Höhe (gleiche Stufe = gleiche Zeile). Eine Stufen-Nummer auslassen erzeugt einen <b>Puffer</b>.</div>
<details class="acc" open><summary>Allgemein</summary><div class="body">
<div class="field"><label>Titel</label><input type="text" id="title"></div>
<div class="row" style="gap:12px;margin-bottom:10px">
<div class="field" style="flex:none;margin:0"><label>Hintergrund</label><input type="color" id="bg"></div>
<label class="checkline" style="flex:1"><input type="checkbox" id="badgeColored"><span class="txt">Badge in Abteilungsfarbe<small>sonst schwarz</small></span></label>
</div>
<label class="checkline"><input type="checkbox" id="transparent"><span class="txt">Transparenter Hintergrund<small>PNG/SVG ohne Hintergrundfarbe</small></span></label>
</div></details>
<details class="acc"><summary>Kommando (Baum oben) <span class="count" id="cCmd"></span></summary><div class="body">
<p class="hint" style="margin-top:0"><b>Spalte</b> + <b>Ebene</b> setzen die Box frei ins Raster (gleiche Spalte = senkrecht untereinander). <b>über</b> / <b>& auch</b> = ein oder zwei übergeordnete Boxen (z.&nbsp;B. O5 <i>und</i> Ethik über dem Standortleiter). <b>→ Einheiten</b> zieht die Linie bis zum Einheiten-Balken. <b>↔</b> = waagerechter Partner-Pfeil.</p>
<div id="cmdNodes"></div>
<button class="addbtn" id="addCmd">+ Kommando-Box</button>
<div class="field" style="margin-top:12px"><label>Trennlinie „Einheiten" darunter</label>
<div class="row"><input type="color" id="unitsColor"><input type="text" id="unitsLabel"></div></div>
</div></details>
<details class="acc" open><summary>Abteilungen (Spalten) <span class="count" id="cCols"></span></summary><div class="body">
<div id="columns"></div>
<button class="addbtn" id="addCol">+ Abteilung</button>
</div></details>
<details class="acc"><summary>Seiten-Boxen (rechts, per Stufe) <span class="count" id="cSide"></span></summary><div class="body">
<p class="hint" style="margin-top:0">Jobs ohne eigene Rangleiter. Über die <b>Stufe</b> auf Höhe eines Rangs setzen. Bei Wissen/Keycard auch Text wie <code>N/A</code> oder <code>Omni</code> erlaubt.</p>
<div id="sideBoxes"></div>
<button class="addbtn" id="addSide">+ Seiten-Box</button>
</div></details>
<details class="acc"><summary>Ebenen-Beschriftungen (Pfeile links) <span class="count" id="cBands"></span></summary><div class="body">
<div id="bands"></div>
<button class="addbtn" id="addBand">+ Beschriftung</button>
</div></details>
<details class="acc"><summary>Sonderbereich (unten) <span class="count" id="cLv"></span></summary><div class="body">
<p class="hint" style="margin-top:0">Jede Ebene = Trennlinie mit Label + Reihe Boxen. Ebenen stehen übereinander.</p>
<div id="levels"></div>
<button class="addbtn" id="addLevel">+ Ebene</button>
</div></details>
</aside>
<main class="preview">
<div class="canvas-inner" id="canvasInner"><div id="canvas"></div></div>
<div class="zoombar"><div class="pill"><button id="zOut">−</button><span class="zval" id="zVal">100%</span><button id="zIn">+</button><button id="zFit">Einpassen</button></div></div>
</main>
</div>
<script>
"use strict";
const LS_KEY="scp_rank_builder_v4";
let idc=0; const uid=()=>"n"+(idc++)+Math.random().toString(36).slice(2,5);
/* ============ DEFAULT DATA (nach draw.io-Vorlage) ============ */
function demo(){
const R=(name,k,c,tier)=>({id:uid(),name,knowledge:k,keycard:c,tier});
const B=(label,ranks)=>({id:uid(),label,ranks});
const cm=(name,color,k,c,o)=>Object.assign({id:uid(),name,color,knowledge:k,keycard:c,parent:null,parent2:null,col:"",row:"",toUnits:false,link:null},o||{});
const o5=cm("O5-Rat","#111111",5,5,{col:1,row:0});
const ethik=cm("Ethikkommission","#e6e600",5,5,{col:0,row:0});
const site=cm("Standortleiter","#e8c37a",5,5,{col:1,row:1,parent:o5.id,parent2:ethik.id});
const eps=cm("MTF Epsilon-11","#2f6bff",4,"Omni",{col:3,row:1,parent:o5.id,toUnits:true});
const pad=cm("Personalabteilung Director","#a9c1e8",5,5,{col:2,row:2,parent:site.id});
const mgr=cm("Manager","#e8c37a",4,4,{col:1,row:3,parent:site.id,toUnits:true});
const pa=cm("Personalabteilung","#a9c1e8",4,4,{col:2,row:3,parent:pad.id});
o5.link=ethik.id;
return {
title:"Rangordnung der Foundation",
bg:"#4a4a52", badgeColored:true, transparent:false,
command:[o5,ethik,site,eps,pad,mgr,pa],
unitsLabel:"Einheiten", unitsColor:"#c79a3a",
columns:[
{id:uid(),name:"Mobile Task Force",color:"#2f6bff",branches:[],tail:[],
ranks:[R("Task Force Commander",4,4,30),R("Unit Commander",4,4,29),R("Task Force Officer",4,4,28),
R("Officer Candidate",4,4,27),R("Chief Field Instructor",3,3,26),R("Field Instructor",3,3,25),
R("Field Squadlead",3,3,24),R("Junior Field Squadlead",3,3,23),R("NCO Candidate",3,3,22),
R("Chief Operator",2,2,21),R("Specialist",2,2,19),R("Senior Operator",2,2,18),
R("Operator",2,2,17),R("Trainee",2,2,15),R("Recruit",1,0,14)]},
{id:uid(),name:"Sicherheitsdienst",color:"#7b3ff2",
ranks:[R("Director of Security",4,4,30),R("Deputy Director of Security",4,4,29),R("Chief of Security",4,4,28)],
branches:[
B("HRU | FG",[R("Security Supervisor",4,4,26),R("Security Lieutenant",3,3,25),
R("Special Security Agent",3,3,23),R("Experienced Security Agent",3,3,22),R("Security Agent",3,3,21),
R("Operator in Command",3,2,20),R("Senior Security Operator",2,2,19),R("Advanced Security Operator",2,2,18),
R("Security Operator",2,2,17),R("Junior Security Operator",2,2,16)]),
B("Containment Unit",[R("Containment Supervisor",4,4,26),R("Containment Lieutenant",3,3,25),
R("Special Containment Agent",3,3,23),R("Experienced Containment Agent",3,3,22),R("Containment Agent",3,3,21),
R("Specialist in Command",3,3,20),R("Senior Containment Specialist",3,3,19),R("Advanced Containment Specialist",3,3,18),
R("Containment Specialist",3,2,17),R("Junior Containment Specialist",3,2,16)]),
],
tail:[R("Security Guard",2,1,15),R("Rekrut",1,0,14)]},
{id:uid(),name:"Wissenschaftler",color:"#12a89a",branches:[],tail:[],
ranks:[R("Head of Research",4,4,30),R("Overseer of Research",4,4,29),R("Supervisor of Research",4,4,27),
R("Chief Researcher",4,3,26),R("Lead Researcher",3,3,25),R("Senior Researcher",3,3,23),
R("Advanced Researcher",3,2,22),R("Experienced Researcher",3,2,21),R("Researcher",3,2,20),
R("Assistant Researcher",2,2,18),R("Junior Researcher",2,1,16),R("Probationary Researcher",2,1,15),R("Intern",2,0,14)]},
{id:uid(),name:"Mediziner",color:"#1fae4a",branches:[],tail:[],
ranks:[R("Ärztlicher Direktor",4,4,30),R("Stellvertretender Ärztlicher Direktor",4,4,29),R("Chefarzt",4,4,28),
R("Leitender Oberarzt",4,4,27),R("Oberarzt",3,3,26),R("Facharzt",2,3,23),R("Arzt",2,2,22),
R("Assistenzarzt",2,2,21),R("Notfallsanitäter",2,1,18),R("Rettungssanitäter",2,1,16),
R("Rettungshelfer",2,1,15),R("Azubi",1,0,14)]},
],
sideBoxes:[
{id:uid(),name:"MTF Iota-10",color:"#2f6bff",knowledge:2,keycard:2,tier:19},
{id:uid(),name:"Personal Guards",color:"#f5820a",knowledge:2,keycard:2,tier:18},
{id:uid(),name:"Sonstiges",color:"#ffffff",knowledge:"N/A",keycard:"N/A",tier:17},
],
bands:[{tier:30,label:"Leitungsebene"},{tier:14,label:"Ausbildungsrang"}],
levels:[
{id:uid(),label:"Service-Kräfte",dividerColor:"#9aa0a6",
boxes:[{id:uid(),name:"Putzkräfte/Koch",color:"#b8bcc2",knowledge:1,keycard:1},
{id:uid(),name:"Techniker",color:"#d400ff",knowledge:1,keycard:2}]},
{id:uid(),label:"Sonstiges",dividerColor:"#6a7077",
boxes:[{id:uid(),name:"D-Klasse",color:"#f5820a",knowledge:0,keycard:0}]},
],
};
}
/* ============ STATE ============ */
let state, zoom=1;
function loadLS(){try{const s=localStorage.getItem(LS_KEY);if(s)return JSON.parse(s);}catch(e){}return null;}
function persist(){try{localStorage.setItem(LS_KEY,JSON.stringify(state));}catch(e){}}
state=loadLS()||demo();
/* ============ HELPERS ============ */
function esc(s){return String(s??"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));}
function lum(hex){const h=(hex||"").replace("#","");if(h.length<6)return 1;
const r=parseInt(h.slice(0,2),16)/255,g=parseInt(h.slice(2,4),16)/255,b=parseInt(h.slice(4,6),16)/255;
const f=c=>c<=0.03928?c/12.92:Math.pow((c+0.055)/1.055,2.4);return 0.2126*f(r)+0.7152*f(g)+0.0722*f(b);}
function ink(hex){return lum(hex)>0.5?"#111111":"#ffffff";}
function shade(hex,amt){const h=(hex||"").replace("#","");if(h.length<6)return hex;
let r=parseInt(h.slice(0,2),16),g=parseInt(h.slice(2,4),16),b=parseInt(h.slice(4,6),16);
const t=amt<0?0:255,p=Math.abs(amt);r=Math.round((t-r)*p+r);g=Math.round((t-g)*p+g);b=Math.round((t-b)*p+b);
return "#"+[r,g,b].map(x=>x.toString(16).padStart(2,"0")).join("");}
const PAD=34, BOX_W=142, BADGE_W=46, BOX_H=34, ROW_H=54, COL_GAP=38, SUBGAP=16,
SIDE_GAP=70, CMD_ROW=64, CMD_STEP=142+46+46, DIV_H=30, LINE="#9099a5",
BOXFULL=BOX_W+BADGE_W, MAXGAP=4, EXTRA_SPLIT=26;
function fitName(name,maxW){
const ap=(s,fs)=>s.length*fs*0.56;
for(const fs of [11,10.5,10,9.5,9]) if(ap(name,fs)<=maxW) return {lines:[name],fs};
const w=name.split(" ");
if(w.length>1){let best=[w[0],w.slice(1).join(" ")],bd=1e9;
for(let i=1;i<w.length;i++){const a=w.slice(0,i).join(" "),b=w.slice(i).join(" ");const d=Math.abs(a.length-b.length);if(d<bd){bd=d;best=[a,b];}}
for(const fs of [10,9.5,9,8.5,8]) if(ap(best[0],fs)<=maxW&&ap(best[1],fs)<=maxW) return {lines:best,fs};
return {lines:best,fs:8};}
const fs=8,max=Math.floor(maxW/(fs*0.56));
return {lines:[name.length>max?name.slice(0,max-1)+"…":name],fs};
}
function badgeText(k,c){const kk=(k===""||k==null)?"":String(k),cc=(c===""||c==null)?"":String(c);
if(kk===""&&cc==="")return null;return `${kk||"–"}|${cc||"–"}`;}
function box(x,y,w,h,color,name,badge,bold){
const P=[];const bw=badge?BADGE_W:0;const mainW=w-bw;const tcol=ink(color);
P.push(`<rect x="${x}" y="${y}" width="${mainW}" height="${h}" rx="5" fill="${color}" stroke="${shade(color,-0.42)}" stroke-width="1"/>`);
const fit=fitName(name||"",mainW-12);const cx=x+mainW/2;
if(fit.lines.length===1){
P.push(`<text x="${cx}" y="${y+h/2+3.9}" text-anchor="middle" font-size="${bold?12:fit.fs}" font-weight="${bold?700:600}" fill="${tcol}">${esc(fit.lines[0])}</text>`);
}else{
P.push(`<text x="${cx}" y="${y+h/2-2.4}" text-anchor="middle" font-size="${fit.fs}" font-weight="600" fill="${tcol}">${esc(fit.lines[0])}</text>`);
P.push(`<text x="${cx}" y="${y+h/2+8.6}" text-anchor="middle" font-size="${fit.fs}" font-weight="600" fill="${tcol}">${esc(fit.lines[1])}</text>`);
}
if(badge){const bx=x+mainW;const bfill=state.badgeColored?shade(color,-0.3):"#141414";
P.push(`<rect x="${bx}" y="${y}" width="${bw}" height="${h}" rx="5" fill="${bfill}" stroke="${shade(color,-0.52)}" stroke-width="1"/>`);
P.push(`<rect x="${bx}" y="${y}" width="6" height="${h}" fill="${bfill}"/>`);
P.push(`<line x1="${bx}" y1="${y+3}" x2="${bx}" y2="${y+h-3}" stroke="${shade(color,-0.52)}" stroke-width="1"/>`);
P.push(`<text x="${bx+bw/2}" y="${y+h/2+3.8}" text-anchor="middle" font-size="11" font-weight="700" fill="${ink(bfill)}">${esc(badge)}</text>`);}
return P.join("");
}
const A=arrow=>arrow?` marker-end="url(#arr)"`:"";
const vlineA=(x,y1,y2,arrow)=>`<line x1="${x}" y1="${y1}" x2="${x}" y2="${y2}" stroke="${LINE}" stroke-width="1.5"${A(arrow)}/>`;
const elbowA=(x1,y1,x2,y2,arrow)=>{const my=(y1+y2)/2;return `<path d="M ${x1} ${y1} V ${my} H ${x2} V ${y2}" fill="none" stroke="${LINE}" stroke-width="1.5"${A(arrow)}/>`;};
const hline=(x1,x2,y,col,w)=>`<line x1="${x1}" y1="${y}" x2="${x2}" y2="${y}" stroke="${col||LINE}" stroke-width="${w||1.5}"/>`;
function tierMap(tiersArr){
const uniq=[...new Set(tiersArr.map(t=>+t||0))].sort((a,b)=>b-a);
const rowOf=new Map(); let ri=0;
uniq.forEach((t,i)=>{ if(i>0) ri+=Math.min(uniq[i-1]-t,MAXGAP); rowOf.set(t,ri); });
return {rowOf, rows: uniq.length? ri+1 : 1, uniq};
}
/* ============ LAYOUT: COMMAND (grid: col/row + parent/parent2 + link + toUnits) ============ */
function layoutCommand(){
const nodes=state.command||[];
if(!nodes.length) return {width:0,height:0,draw:()=>"",pos:{},trunks:[],links:[],trunkCenter:0};
const byId={}; nodes.forEach(n=>byId[n.id]=n);
const kids={}; nodes.forEach(n=>kids[n.id]=[]);
const roots=[];
nodes.forEach(n=>{ if(n.parent&&byId[n.parent]) kids[n.parent].push(n); else roots.push(n); });
const depth={}; const seen={}; const setD=(n,d)=>{ if(seen[n.id])return; seen[n.id]=1; depth[n.id]=d; kids[n.id].forEach(c=>setD(c,d+1)); };
roots.forEach(r=>setD(r,0)); nodes.forEach(n=>{if(depth[n.id]==null)depth[n.id]=0;});
// auto x (leaf) for nodes without explicit col
let cur=0; const xr={};
const place=n=>{const c=kids[n.id]; if(!c.length){xr[n.id]=cur;cur+=1;} else {c.forEach(place);xr[n.id]=(xr[c[0].id]+xr[c[c.length-1].id])/2;}};
roots.forEach(r=>{place(r);cur+=0.4;});
const colOf=n=>(n.col===""||n.col==null)? (xr[n.id]||0) : (+n.col||0);
const rowOf=n=>(n.row===""||n.row==null)? (depth[n.id]||0) : (+n.row||0);
let rawX={}, minX=Infinity, maxX=-Infinity;
nodes.forEach(n=>{const x=colOf(n)*CMD_STEP; rawX[n.id]=x; minX=Math.min(minX,x); maxX=Math.max(maxX,x);});
const width=(maxX-minX)+BOXFULL, maxRow=Math.max(0,...nodes.map(rowOf)), height=(maxRow+1)*CMD_ROW;
const pos={};
nodes.forEach(n=>{const x=rawX[n.id]-minX, y=rowOf(n)*CMD_ROW; pos[n.id]={x,cx:x+BOXFULL/2,top:y,bot:y+BOX_H,row:rowOf(n)};});
const trunks=nodes.filter(n=>n.toUnits).map(n=>n.id);
const trunkCenter = trunks.length? pos[trunks[0]].cx : width/2;
const links=[]; nodes.forEach(n=>{ if(n.link&&byId[n.link]) links.push([n.id,n.link]); });
function draw(ox,oy){
const S=[];
nodes.forEach(n=>{ [n.parent,n.parent2].forEach(pp=>{ if(pp&&byId[pp]){const p=pos[pp],q=pos[n.id];
S.push(elbowA(ox+p.cx,oy+p.bot,ox+q.cx,oy+q.top,true));}}); });
nodes.forEach(n=>{const p=pos[n.id]; S.push(box(ox+p.x,oy+p.top,BOXFULL,BOX_H,n.color,n.name,badgeText(n.knowledge,n.keycard),true));});
return S.join("");
}
return {width,height,draw,pos,trunks,links,trunkCenter};
}
/* ============ LAYOUT: UNITS ============ */
function layoutUnits(){
const cols=state.columns||[], side=state.sideBoxes||[];
const laneW=c=>{const nb=(c.branches||[]).length; return nb>0? nb*BOXFULL+(nb-1)*SUBGAP : BOXFULL;};
const allT=[];
cols.forEach(c=>{(c.ranks||[]).forEach(r=>allT.push(r.tier));(c.branches||[]).forEach(b=>b.ranks.forEach(r=>allT.push(r.tier)));(c.tail||[]).forEach(r=>allT.push(r.tier));});
side.forEach(s=>allT.push(s.tier));
const {rowOf,rows}=tierMap(allT.length?allT:[0]);
// extra vertical space above rows where a labeled branch starts (applied globally -> keeps alignment)
const rowExtra=new Map();
cols.forEach(c=>(c.branches||[]).forEach(b=>{ if(b.label&&b.ranks.length){const ri=rowOf.get(+b.ranks[0].tier||0)||0; rowExtra.set(ri,Math.max(rowExtra.get(ri)||0,EXTRA_SPLIT));}}));
const cum=[]; let acc=0; for(let r=0;r<rows;r++){ acc+=rowExtra.get(r)||0; cum[r]=acc; }
const headerY=0, firstRowY=headerY+BOX_H+34;
const rowY=t=>{const ri=rowOf.get(+t||0)||0; return firstRowY+ri*ROW_H+(cum[ri]||0);};
const colX=[], headerCenters=[]; let x=0;
cols.forEach((c,i)=>{colX[i]=x; headerCenters[i]=x+laneW(c)/2; x+=laneW(c)+COL_GAP;});
let sideXRel=null; if(side.length){ if(cols.length) x+=SIDE_GAP-COL_GAP; sideXRel=x; x+=BOXFULL+COL_GAP; }
const width=Math.max(BOXFULL,x-COL_GAP);
const height=firstRowY+(rows-1)*ROW_H+(cum[rows-1]||0)+BOX_H;
const sideYs=side.map(s=>rowY(s.tier));
const colBottoms=cols.map((c,i)=>{const tc=[];(c.ranks||[]).forEach(r=>tc.push(+r.tier||0));(c.branches||[]).forEach(b=>b.ranks.forEach(r=>tc.push(+r.tier||0)));(c.tail||[]).forEach(r=>tc.push(+r.tier||0));
const minT=tc.length?Math.min(...tc):0; return {cx:colX[i]+laneW(c)/2, y:rowY(minT)+BOX_H};});
function draw(ox,oy){
const S=[];
cols.forEach((c,i)=>{
const lx=ox+colX[i], lw=laneW(c), lcx=lx+lw/2;
S.push(box(lcx-BOXFULL/2,oy+headerY,BOXFULL,BOX_H,c.color,c.name,null,true));
const head=c.ranks||[], branches=c.branches||[], tail=c.tail||[];
const drawLadder=(list,cx)=>{let prev=null;list.forEach(r=>{const y=oy+rowY(r.tier);
if(prev!==null)S.push(vlineA(cx,prev+BOX_H,y,true));
S.push(box(cx-BOXFULL/2,y,BOXFULL,BOX_H,c.color,r.name,badgeText(r.knowledge,r.keycard)));prev=y;});return prev;};
let headBottom=null;
if(head.length){ S.push(vlineA(lcx,oy+headerY+BOX_H,oy+rowY(head[0].tier),true)); headBottom=drawLadder(head,lcx); }
const branchCX=branches.map((b,j)=>lx+j*(BOXFULL+SUBGAP)+BOXFULL/2);
branches.forEach((b,j)=>{ if(!b.ranks.length)return;
const bcx=branchCX[j], firstY=oy+rowY(b.ranks[0].tier);
const srcBottom = headBottom!==null? headBottom+BOX_H : oy+headerY+BOX_H;
S.push(elbowA(lcx,srcBottom,bcx,firstY,true));
if(b.label){const pw=Math.min(BOXFULL-6,b.label.length*6.3+18),ph=17;
const gap=firstY-srcBottom; let py=srcBottom+Math.max(3,(gap-ph)/2); if(py+ph>firstY-3)py=firstY-3-ph;
const pf=shade(c.color,-0.08);
S.push(`<rect x="${bcx-pw/2}" y="${py}" width="${pw}" height="${ph}" rx="8" fill="${pf}" stroke="${shade(c.color,-0.42)}"/>`);
S.push(`<text x="${bcx}" y="${py+ph/2+3.3}" text-anchor="middle" font-size="9.5" font-weight="700" fill="${ink(pf)}">${esc(b.label)}</text>`);}
drawLadder(b.ranks,bcx);
});
if(tail.length){const tFirstY=oy+rowY(tail[0].tier);
if(branches.length){branches.forEach((b,j)=>{if(!b.ranks.length)return;const last=b.ranks[b.ranks.length-1];S.push(elbowA(branchCX[j],oy+rowY(last.tier)+BOX_H,lcx,tFirstY,true));});}
else if(headBottom!==null){S.push(vlineA(lcx,headBottom+BOX_H,tFirstY,true));}
drawLadder(tail,lcx);}
});
if(sideXRel!==null){const scx=ox+sideXRel+BOXFULL/2;
side.forEach(s=>{const y=oy+rowY(s.tier);S.push(box(scx-BOXFULL/2,y,BOXFULL,BOX_H,s.color,s.name,badgeText(s.knowledge,s.keycard),true));});}
return S.join("");
}
return {width,height,draw,rowY,rowOf,headerCenters,headerY,sideXRel,sideYs,firstRowY,colBottoms};
}
/* ============ LAYOUT: LEVELS (hub boxes, no arrows) ============ */
const LV_GAP1=30, LV_GAP2=28;
function layoutLevels(){
const lv=state.levels||[];
const rowW=l=>{const n=(l.boxes||[]).length;return Math.max(BOXFULL,n*BOXFULL+18*Math.max(0,n-1));};
const width=lv.length?Math.max(...lv.map(rowW)):0;
let h=0; lv.forEach(l=>{h+=DIV_H+LV_GAP1; h+=((l.boxes||[]).length?BOX_H+LV_GAP2:6);});
return {width,height:h,draw};
function draw(ox,oy,cx,fullX1,fullX2,trunkTopY){
const S=[];let cy=oy;let prevCenterY=trunkTopY;
lv.forEach(l=>{
const dc=l.dividerColor||"#8a8f96", divTop=cy, divMid=cy+DIV_H/2, divBot=cy+DIV_H;
if(prevCenterY!=null) S.push(vlineA(cx,prevCenterY,divTop,false));
S.push(hline(fullX1,fullX2,divMid,dc,2));
const lw=Math.max(96,(l.label||"").length*7.2+30);
S.push(`<rect x="${cx-lw/2}" y="${divTop}" width="${lw}" height="${DIV_H}" rx="7" fill="${dc}" stroke="${shade(dc,-0.35)}"/>`);
S.push(`<text x="${cx}" y="${divMid+4}" text-anchor="middle" font-size="12.5" font-weight="700" fill="${ink(dc)}">${esc(l.label||"")}</text>`);
cy=divBot+LV_GAP1;
const bx=l.boxes||[];
if(bx.length){
const total=bx.length*BOXFULL+18*(bx.length-1); let x0=cx-total/2; const rowTop=cy;
// converge: vertical from divider down, horizontal manifold, drops into boxes (no arrows)
const manY=divBot+Math.min(LV_GAP1-8,16);
S.push(vlineA(cx,divBot,manY,false));
const centers=bx.map((b,k)=>x0+k*(BOXFULL+18)+BOXFULL/2);
S.push(hline(Math.min(cx,...centers),Math.max(cx,...centers),manY,LINE,1.5));
centers.forEach(c2=>S.push(vlineA(c2,manY,rowTop,false)));
bx.forEach((b,k)=>S.push(box(centers[k]-BOXFULL/2,rowTop,BOXFULL,BOX_H,b.color,b.name,badgeText(b.knowledge,b.keycard))));
cy+=BOX_H+LV_GAP2;
} else cy+=6;
prevCenterY=divBot;
});
return S.join("");
}
}
/* ============ MAIN RENDER ============ */
function renderCanvas(){
const C=layoutCommand(), U=layoutUnits(), L=layoutLevels();
const bands=(state.bands||[]).filter(b=>U.rowOf.has(+b.tier));
const leftGutter = bands.length ? Math.max(96,Math.min(210,Math.max(...bands.map(b=>(b.label||"").length*6.6+44)))) : 0;
// fixed content-centered layout (independent of command box positions)
const contentW=Math.max(C.width,U.width,L.width,300);
const canvasW=PAD+leftGutter+contentW+PAD;
const CX=PAD+leftGutter+contentW/2;
let cy=16; const titleY=cy; cy+=34;
const cmdOX=CX-C.width/2, cmdOY=cy;
if(C.height) cy+=C.height+26;
let unitsDivY=null;
if(state.command&&state.command.length){ unitsDivY=cy; cy+=DIV_H+30; }
const unitsOX=CX-U.width/2, unitsOY=cy;
cy+=U.height;
const manifoldY=cy+24;
const levelsOY=manifoldY+24;
const canvasH=levelsOY+L.height+22;
const S=[];
S.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${canvasW}" height="${canvasH}" viewBox="0 0 ${canvasW} ${canvasH}">`);
S.push(`<defs><marker id="arr" markerWidth="9" markerHeight="9" refX="7.4" refY="4.5" orient="auto-start-reverse" markerUnits="userSpaceOnUse"><path d="M1,1 L8,4.5 L1,8" fill="none" stroke="${LINE}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/></marker></defs>`);
if(!state.transparent) S.push(`<rect width="${canvasW}" height="${canvasH}" fill="${esc(state.bg)}"/>`);
S.push(`<text x="${CX}" y="${titleY+22}" text-anchor="middle" font-size="19" font-weight="700" fill="${state.transparent?'#e8ecf2':'#ffffff'}">${esc(state.title||"")}</text>`);
// command
if(C.height) S.push(C.draw(cmdOX,cmdOY));
// command links (↔ horizontal double arrow)
C.links.forEach(([a,b])=>{const pa=C.pos[a],pb=C.pos[b];if(!pa||!pb)return;
const y=cmdOY+Math.min(pa.top,pb.top)+BOX_H/2;
let lx,rx; if(pa.cx<pb.cx){lx=cmdOX+pa.x+BOXFULL;rx=cmdOX+pb.x;}else{lx=cmdOX+pb.x+BOXFULL;rx=cmdOX+pa.x;}
S.push(`<line x1="${lx+3}" y1="${y}" x2="${rx-3}" y2="${y}" stroke="${LINE}" stroke-width="1.6" marker-start="url(#arr)" marker-end="url(#arr)"/>`);});
// command trunks -> converge cleanly into the Einheiten box (no arrows)
if(unitsDivY!==null && C.trunks.length){
const manCY=unitsDivY-14, xs=[];
C.trunks.forEach(id=>{const p=C.pos[id];if(p){const x=cmdOX+p.cx;xs.push(x);S.push(vlineA(x,cmdOY+p.bot,manCY,false));}});
if(xs.length){ S.push(hline(Math.min(CX,...xs),Math.max(CX,...xs),manCY,LINE,1.5)); S.push(vlineA(CX,manCY,unitsDivY,false)); }
}
// Einheiten divider bar + box
let einBoxBottom=null;
if(unitsDivY!==null){
const dc=state.unitsColor||"#c79a3a";
S.push(hline(PAD,canvasW-PAD,unitsDivY+DIV_H/2,dc,3));
const lw=Math.max(96,(state.unitsLabel||"").length*7.4+34);
S.push(`<rect x="${CX-lw/2}" y="${unitsDivY}" width="${lw}" height="${DIV_H}" rx="7" fill="${dc}" stroke="${shade(dc,-0.35)}"/>`);
S.push(`<text x="${CX}" y="${unitsDivY+DIV_H/2+4}" text-anchor="middle" font-size="13" font-weight="700" fill="${ink(dc)}">${esc(state.unitsLabel||"")}</text>`);
einBoxBottom=unitsDivY+DIV_H;
}
// side-bus geometry
const hasSide=U.sideXRel!==null&&U.sideYs.length;
const busX = hasSide ? unitsOX+U.sideXRel-24 : null;
// Einheiten -> department headers (converging manifold, no arrows) + start of side bus
if(unitsDivY!==null){
const manY=einBoxBottom+16;
S.push(vlineA(CX,einBoxBottom,manY,false));
const hc=(U.headerCenters||[]).map(c=>unitsOX+c);
const spanL=Math.min(CX,...hc,...(hasSide?[busX]:[]));
const spanR=Math.max(CX,...hc,...(hasSide?[busX]:[]));
S.push(hline(spanL,spanR,manY,LINE,1.5));
hc.forEach(x=>S.push(vlineA(x,manY,unitsOY+U.headerY,false)));
// side bus down the right, stubs into side boxes (stubs keep arrows)
if(hasSide){
const ys=U.sideYs.map(y=>unitsOY+y);
S.push(vlineA(busX,manY,manifoldY,false));
ys.forEach(y=>S.push(`<line x1="${busX}" y1="${y+BOX_H/2}" x2="${unitsOX+U.sideXRel}" y2="${y+BOX_H/2}" stroke="${LINE}" stroke-width="1.5" marker-end="url(#arr)"/>`));
}
}
// units
S.push(U.draw(unitsOX,unitsOY));
// bottom manifold: columns + side bus converge, then into first level box (no arrows)
if((U.colBottoms||[]).length || hasSide){
const cbx=(U.colBottoms||[]).map(cb=>unitsOX+cb.cx);
(U.colBottoms||[]).forEach(cb=>S.push(vlineA(unitsOX+cb.cx,unitsOY+cb.y,manifoldY,false)));
const spanL=Math.min(CX,...cbx,...(hasSide?[busX]:[]));
const spanR=Math.max(CX,...cbx,...(hasSide?[busX]:[]));
S.push(hline(spanL,spanR,manifoldY,LINE,1.5));
}
// bands as arrow pointers (left)
bands.forEach(b=>{
const ay=unitsOY+U.rowY(b.tier)+BOX_H/2, AH=24, w=leftGutter-16, x0=PAD, tip=12, y0=ay-AH/2;
const pts=`${x0},${y0} ${x0+w-tip},${y0} ${x0+w},${y0+AH/2} ${x0+w-tip},${y0+AH} ${x0},${y0+AH}`;
S.push(`<polygon points="${pts}" fill="#c9ced5" stroke="#8b93a0" stroke-width="1"/>`);
S.push(`<text x="${x0+(w-tip)/2}" y="${ay+4}" text-anchor="middle" font-size="11" font-weight="700" fill="#2a2f36">${esc(b.label)}</text>`);
});
// levels (fed from manifoldY center)
S.push(L.draw(PAD,levelsOY,CX,PAD,canvasW-PAD,manifoldY));
S.push(`</svg>`);
document.getElementById("canvas").innerHTML=S.join("");
applyZoom();
}
/* ============ EDITOR RENDER ============ */
function rankRow(colId,loc,r,i,len){
return `<div class="rank" data-col="${colId}" data-loc="${loc}" data-rank="${r.id}">
<span class="grip">
<button class="tiny" data-act="rup" data-col="${colId}" data-loc="${loc}" data-rank="${r.id}" ${i===0?"disabled":""}>▲</button>
<button class="tiny" data-act="rdn" data-col="${colId}" data-loc="${loc}" data-rank="${r.id}" ${i===len-1?"disabled":""}>▼</button></span>
<div class="main">
<input type="text" data-act="rname" data-col="${colId}" data-loc="${loc}" data-rank="${r.id}" value="${esc(r.name)}" placeholder="Rang">
<div class="kc">
<span class="kcbox" title="Wissen | Keycard"><input type="text" data-act="rk" data-col="${colId}" data-loc="${loc}" data-rank="${r.id}" value="${esc(r.knowledge)}"><span class="sep">|</span><input type="text" data-act="rc" data-col="${colId}" data-loc="${loc}" data-rank="${r.id}" value="${esc(r.keycard)}"></span>
<span class="tier" title="Höhe – gleiche Stufe = gleiche Zeile; Lücke = Puffer"><span class="lbl">Stufe</span><input type="number" data-act="rt" data-col="${colId}" data-loc="${loc}" data-rank="${r.id}" value="${esc(r.tier)}"></span>
</div>
</div>
<button class="tiny danger" data-act="rdel" data-col="${colId}" data-loc="${loc}" data-rank="${r.id}">✕</button>
</div>`;
}
function rankList(colId,loc,ranks){
return (ranks||[]).map((r,i)=>rankRow(colId,loc,r,i,ranks.length)).join("")+
`<button class="addbtn tiny" data-act="raddr" data-col="${colId}" data-loc="${loc}">+ Rang</button>`;
}
function renderEditor(){
document.getElementById("title").value=state.title||"";
document.getElementById("bg").value=state.bg||"#4a4a52";
document.getElementById("badgeColored").checked=!!state.badgeColored;
document.getElementById("transparent").checked=!!state.transparent;
document.getElementById("unitsLabel").value=state.unitsLabel||"";
document.getElementById("unitsColor").value=state.unitsColor||"#c79a3a";
const cmd=state.command||[];
document.getElementById("cCmd").textContent=cmd.length;
const optsFor=(sel,cur)=>`<option value="">${sel}</option>`+cmd.filter(o=>o.id).map(o=>`<option value="${o.id}">${esc(o.name)}</option>`).join("");
document.getElementById("cmdNodes").innerHTML=cmd.map((n,i)=>{
const P=id=>cmd.filter(o=>o.id!==n.id).map(o=>`<option value="${o.id}" ${id===o.id?"selected":""}>${esc(o.name)}</option>`).join("");
return `<div class="card"><div class="card-head">
<span class="swatch" style="background:${esc(n.color)}"></span>
<input type="color" data-act="ncolor" data-node="${n.id}" value="${esc(n.color)}">
<input type="text" data-act="nname" data-node="${n.id}" value="${esc(n.name)}" placeholder="Name">
<span class="mvbtns">
<button class="tiny" data-act="nup" data-node="${n.id}" ${i===0?"disabled":""}>▲</button>
<button class="tiny" data-act="ndn" data-node="${n.id}" ${i===cmd.length-1?"disabled":""}>▼</button>
<button class="tiny danger" data-act="ndel" data-node="${n.id}">✕</button></span></div>
<div class="card-body">
<div class="row" style="gap:8px">
<span class="kcbox" title="Wissen | Keycard"><input type="text" data-act="nk" data-node="${n.id}" value="${esc(n.knowledge)}"><span class="sep">|</span><input type="text" data-act="nc" data-node="${n.id}" value="${esc(n.keycard)}"></span>
<span class="mini">Spalte</span><input type="number" class="wnum" data-act="ncol" data-node="${n.id}" value="${esc(n.col)}" placeholder="auto">
<span class="mini">Ebene</span><input type="number" class="wnum" data-act="nrow" data-node="${n.id}" value="${esc(n.row)}" placeholder="auto">
</div>
<div class="row" style="gap:8px;margin-top:6px;flex-wrap:wrap">
<span class="mini">über</span><select data-act="nparent" data-node="${n.id}" style="flex:1;min-width:80px"><option value="">—</option>${P(n.parent)}</select>
<span class="mini">& auch</span><select data-act="nparent2" data-node="${n.id}" style="flex:1;min-width:80px"><option value="">—</option>${P(n.parent2)}</select>
</div>
<div class="row" style="gap:8px;margin-top:6px;flex-wrap:wrap">
<label class="chk"><input type="checkbox" data-act="ntounits" data-node="${n.id}" ${n.toUnits?"checked":""}>→ Einheiten</label>
<span class="mini">↔ mit</span><select data-act="nlink" data-node="${n.id}" style="flex:1;min-width:80px"><option value="">—</option>${P(n.link)}</select>
</div>
</div></div>`;
}).join("");
const cols=state.columns||[];
document.getElementById("cCols").textContent=cols.length;
document.getElementById("columns").innerHTML=cols.map((c,ci)=>`
<div class="card"><div class="card-head">
<span class="swatch" style="background:${esc(c.color)}"></span>
<input type="color" data-act="ccolor" data-col="${c.id}" value="${esc(c.color)}">
<input type="text" data-act="cname" data-col="${c.id}" value="${esc(c.name)}" placeholder="Abteilung">
<span class="mvbtns">
<button class="tiny" data-act="cleft" data-col="${c.id}" ${ci===0?"disabled":""}>◀</button>
<button class="tiny" data-act="cright" data-col="${c.id}" ${ci===cols.length-1?"disabled":""}>▶</button>
<button class="tiny danger" data-act="cdel" data-col="${c.id}">✕</button></span></div>
<div class="card-body">
<div class="sublabel">Ränge oben ${c.branches&&c.branches.length?"(gemeinsam, über der Verzweigung)":""}</div>
${rankList(c.id,"head",c.ranks)}
<div class="sublabel">Verzweigungen (optional)</div>
${(c.branches||[]).map((b,bi)=>`
<div class="branch"><div class="bhead">
<input type="text" data-act="blabel" data-col="${c.id}" data-branch="${bi}" value="${esc(b.label)}" placeholder="Zweig-Name">
<button class="tiny danger" data-act="bdel" data-col="${c.id}" data-branch="${bi}">✕ Zweig</button></div>
${rankList(c.id,"b"+bi,b.ranks)}</div>`).join("")}
<button class="addbtn tiny" data-act="baddb" data-col="${c.id}">+ Verzweigung</button>
<div class="sublabel">Ränge unten (nach Zusammenführung)</div>
${rankList(c.id,"tail",c.tail)}
</div></div>`).join("");
const side=state.sideBoxes||[];
document.getElementById("cSide").textContent=side.length;
document.getElementById("sideBoxes").innerHTML=side.map((s,i)=>`
<div class="rank" data-side="${s.id}">
<span class="grip">
<button class="tiny" data-act="sup" data-side="${s.id}" ${i===0?"disabled":""}>▲</button>
<button class="tiny" data-act="sdn" data-side="${s.id}" ${i===side.length-1?"disabled":""}>▼</button></span>
<div class="main"><div class="row" style="gap:6px">
<input type="color" data-act="scolor" data-side="${s.id}" value="${esc(s.color)}">
<input type="text" data-act="sname" data-side="${s.id}" value="${esc(s.name)}" placeholder="z.B. MTF Iota-10"></div>
<div class="kc">
<span class="kcbox"><input type="text" data-act="sk" data-side="${s.id}" value="${esc(s.knowledge)}"><span class="sep">|</span><input type="text" data-act="sc" data-side="${s.id}" value="${esc(s.keycard)}"></span>
<span class="tier"><span class="lbl">Stufe</span><input type="number" data-act="st" data-side="${s.id}" value="${esc(s.tier)}"></span></div>
</div>
<button class="tiny danger" data-act="sdel" data-side="${s.id}">✕</button>
</div>`).join("");
const bands=state.bands||[];
document.getElementById("cBands").textContent=bands.length;
document.getElementById("bands").innerHTML=bands.map((b,i)=>`
<div class="row" style="margin-bottom:7px;gap:7px">
<input type="number" class="wnum" data-act="btier" data-band="${i}" value="${esc(b.tier)}" title="Stufe">
<input type="text" data-act="blab" data-band="${i}" value="${esc(b.label)}" placeholder="Beschriftung">
<button class="tiny danger" data-act="bdelband" data-band="${i}">✕</button></div>`).join("");
const lv=state.levels||[];
document.getElementById("cLv").textContent=lv.length;
document.getElementById("levels").innerHTML=lv.map((l,li)=>`
<div class="card"><div class="card-head">
<span class="swatch" style="background:${esc(l.dividerColor)}"></span>
<input type="color" data-act="lcolor" data-level="${li}" value="${esc(l.dividerColor)}">
<input type="text" data-act="llabel" data-level="${li}" value="${esc(l.label)}" placeholder="Trennlinien-Label">
<span class="mvbtns">
<button class="tiny" data-act="lup" data-level="${li}" ${li===0?"disabled":""}>▲</button>
<button class="tiny" data-act="ldn" data-level="${li}" ${li===lv.length-1?"disabled":""}>▼</button>
<button class="tiny danger" data-act="ldel" data-level="${li}">✕</button></span></div>
<div class="card-body">
${(l.boxes||[]).map((b,bi)=>`
<div class="rank" data-level="${li}" data-lbox="${bi}">
<span class="grip">
<button class="tiny" data-act="lbup" data-level="${li}" data-lbox="${bi}" ${bi===0?"disabled":""}>◀</button>
<button class="tiny" data-act="lbdn" data-level="${li}" data-lbox="${bi}" ${bi===l.boxes.length-1?"disabled":""}>▶</button></span>
<div class="main"><div class="row" style="gap:6px">
<input type="color" data-act="lbcolor" data-level="${li}" data-lbox="${bi}" value="${esc(b.color)}">
<input type="text" data-act="lbname" data-level="${li}" data-lbox="${bi}" value="${esc(b.name)}" placeholder="Box-Name"></div>
<div class="kc"><span class="kcbox"><input type="text" data-act="lbk" data-level="${li}" data-lbox="${bi}" value="${esc(b.knowledge)}"><span class="sep">|</span><input type="text" data-act="lbc" data-level="${li}" data-lbox="${bi}" value="${esc(b.keycard)}"></span></div>
</div>
<button class="tiny danger" data-act="lbdel" data-level="${li}" data-lbox="${bi}">✕</button></div>`).join("")}
<button class="addbtn tiny" data-act="lbadd" data-level="${li}">+ Box</button>
</div></div>`).join("");
}
/* ============ MUTATION HELPERS ============ */
const col=id=>(state.columns||[]).find(c=>c.id===id);
function listOf(c,loc){ if(loc==="head")return c.ranks; if(loc==="tail")return c.tail; if(loc[0]==="b")return c.branches[+loc.slice(1)].ranks; return null; }
function findRank(cid,loc,rid){ const l=listOf(col(cid),loc); return l&&l.find(r=>r.id===rid); }
const idx=(arr,id)=>arr.findIndex(x=>x.id===id);
function moveArr(arr,i,d){const j=i+d;if(j<0||j>=arr.length)return;[arr[i],arr[j]]=[arr[j],arr[i]];}
const sideBox=id=>(state.sideBoxes||[]).find(s=>s.id===id);
const cmdNode=id=>(state.command||[]).find(n=>n.id===id);
/* ============ EVENTS ============ */
const editor=document.querySelector(".editor");
function handleInput(t){
const a=t.dataset.act;if(!a)return false;const v=t.value;
const cid=t.dataset.col,loc=t.dataset.loc,rid=t.dataset.rank,nid=t.dataset.node,sid=t.dataset.side,bi=t.dataset.band,li=t.dataset.level,lb=t.dataset.lbox;
switch(a){
case"ncolor":{cmdNode(nid).color=v;const sw=t.closest(".card-head").querySelector(".swatch");if(sw)sw.style.background=v;break;}
case"nname":cmdNode(nid).name=v;break;
case"nk":cmdNode(nid).knowledge=v;break;
case"nc":cmdNode(nid).keycard=v;break;
case"ncol":cmdNode(nid).col=v===""?"":+v;break;
case"nrow":cmdNode(nid).row=v===""?"":+v;break;
case"nparent":cmdNode(nid).parent=v||null;break;
case"nparent2":cmdNode(nid).parent2=v||null;break;
case"nlink":cmdNode(nid).link=v||null;break;
case"ntounits":cmdNode(nid).toUnits=t.checked;break;
case"ccolor":{col(cid).color=v;const sw=t.closest(".card-head").querySelector(".swatch");if(sw)sw.style.background=v;break;}
case"cname":col(cid).name=v;break;
case"rname":findRank(cid,loc,rid).name=v;break;
case"rk":findRank(cid,loc,rid).knowledge=v;break;
case"rc":findRank(cid,loc,rid).keycard=v;break;
case"rt":findRank(cid,loc,rid).tier=v===""?"":+v;break;
case"blabel":col(cid).branches[+t.dataset.branch].label=v;break;
case"scolor":sideBox(sid).color=v;break;
case"sname":sideBox(sid).name=v;break;
case"sk":sideBox(sid).knowledge=v;break;
case"sc":sideBox(sid).keycard=v;break;
case"st":sideBox(sid).tier=v===""?"":+v;break;
case"btier":state.bands[+bi].tier=v===""?"":+v;break;
case"blab":state.bands[+bi].label=v;break;
case"lcolor":state.levels[+li].dividerColor=v;break;
case"llabel":state.levels[+li].label=v;break;
case"lbcolor":state.levels[+li].boxes[+lb].color=v;break;
case"lbname":state.levels[+li].boxes[+lb].name=v;break;
case"lbk":state.levels[+li].boxes[+lb].knowledge=v;break;
case"lbc":state.levels[+li].boxes[+lb].keycard=v;break;
default:return false;
}
return true;
}
editor.addEventListener("input",e=>{ if(handleInput(e.target)){renderCanvas();persist();} });
editor.addEventListener("change",e=>{const a=e.target.dataset.act;
if(a==="ntounits"||a==="nparent"||a==="nparent2"||a==="nlink"){ if(handleInput(e.target)){renderCanvas();persist();} }});
editor.addEventListener("click",e=>{
const btn=e.target.closest("button");if(!btn)return;const a=btn.dataset.act;if(!a)return;
const cid=btn.dataset.col,loc=btn.dataset.loc,rid=btn.dataset.rank,nid=btn.dataset.node,sid=btn.dataset.side,bi=btn.dataset.band,li=btn.dataset.level,lb=btn.dataset.lbox;
let full=true;
switch(a){
case"nup":moveArr(state.command,idx(state.command,nid),-1);break;
case"ndn":moveArr(state.command,idx(state.command,nid),1);break;
case"ndel":if(confirm("Kommando-Box löschen?")){state.command=state.command.filter(n=>n.id!==nid);state.command.forEach(n=>{if(n.parent===nid)n.parent=null;if(n.parent2===nid)n.parent2=null;if(n.link===nid)n.link=null;});}break;
case"cleft":moveArr(state.columns,idx(state.columns,cid),-1);break;
case"cright":moveArr(state.columns,idx(state.columns,cid),1);break;
case"cdel":if(confirm("Abteilung löschen?"))state.columns=state.columns.filter(c=>c.id!==cid);break;
case"raddr":{listOf(col(cid),loc).push({id:uid(),name:"Neuer Rang",knowledge:"",keycard:"",tier:10});break;}
case"rdel":{const l=listOf(col(cid),loc);const k=l.findIndex(r=>r.id===rid);if(k>=0)l.splice(k,1);break;}
case"rup":{const l=listOf(col(cid),loc);moveArr(l,idx(l,rid),-1);break;}
case"rdn":{const l=listOf(col(cid),loc);moveArr(l,idx(l,rid),1);break;}
case"baddb":col(cid).branches.push({id:uid(),label:"Neuer Zweig",ranks:[]});break;
case"bdel":col(cid).branches.splice(+btn.dataset.branch,1);break;
case"sup":moveArr(state.sideBoxes,idx(state.sideBoxes,sid),-1);break;
case"sdn":moveArr(state.sideBoxes,idx(state.sideBoxes,sid),1);break;
case"sdel":state.sideBoxes=state.sideBoxes.filter(s=>s.id!==sid);break;
case"bdelband":state.bands.splice(+bi,1);break;
case"lup":moveArr(state.levels,+li,-1);break;
case"ldn":moveArr(state.levels,+li,1);break;
case"ldel":state.levels.splice(+li,1);break;
case"lbadd":state.levels[+li].boxes.push({id:uid(),name:"Neue Box",color:"#888888",knowledge:"",keycard:""});break;
case"lbdel":state.levels[+li].boxes.splice(+lb,1);break;
case"lbup":moveArr(state.levels[+li].boxes,+lb,-1);break;
case"lbdn":moveArr(state.levels[+li].boxes,+lb,1);break;
default:full=false;
}
if(full)renderAll();
});
const bind=(id,ev,fn)=>document.getElementById(id).addEventListener(ev,fn);
bind("title","input",e=>{state.title=e.target.value;renderCanvas();persist();});
bind("bg","input",e=>{state.bg=e.target.value;renderCanvas();persist();});
bind("badgeColored","change",e=>{state.badgeColored=e.target.checked;renderCanvas();persist();});
bind("transparent","change",e=>{state.transparent=e.target.checked;renderCanvas();persist();});
bind("unitsLabel","input",e=>{state.unitsLabel=e.target.value;renderCanvas();persist();});
bind("unitsColor","input",e=>{state.unitsColor=e.target.value;renderCanvas();persist();});
bind("addCmd","click",()=>{state.command.push({id:uid(),name:"Neue Box",color:"#888888",knowledge:"",keycard:"",parent:null,parent2:null,col:"",row:"",toUnits:false,link:null});renderAll();});
bind("addCol","click",()=>{const pal=["#2f6bff","#7b3ff2","#12a89a","#1fae4a","#e0a83b","#e05a5a","#e06db0","#00b3c4"];
state.columns.push({id:uid(),name:"Neue Abteilung",color:pal[state.columns.length%pal.length],
ranks:[{id:uid(),name:"Höchster Rang",knowledge:"",keycard:"",tier:30},{id:uid(),name:"Niedrigster Rang",knowledge:"",keycard:"",tier:15}],branches:[],tail:[]});renderAll();});
bind("addSide","click",()=>{state.sideBoxes.push({id:uid(),name:"Neue Box",color:"#888888",knowledge:"",keycard:"",tier:20});renderAll();});
bind("addBand","click",()=>{state.bands.push({tier:15,label:"Beschriftung"});renderAll();});
bind("addLevel","click",()=>{state.levels.push({id:uid(),label:"Neue Ebene",dividerColor:"#8a8f96",boxes:[]});renderAll();});
/* ============ EXPORT / SAVE / LOAD ============ */
bind("btnSvg","click",()=>{const svg=document.querySelector("#canvas svg");download(new Blob([new XMLSerializer().serializeToString(svg)],{type:"image/svg+xml"}),fname("svg"));});
bind("btnPng","click",()=>{const svg=document.querySelector("#canvas svg");
const xml=new XMLSerializer().serializeToString(svg);const w=svg.viewBox.baseVal.width,h=svg.viewBox.baseVal.height,s=2;
const url="data:image/svg+xml;base64,"+btoa(unescape(encodeURIComponent(xml)));const img=new Image();
img.onload=()=>{const c=document.createElement("canvas");c.width=w*s;c.height=h*s;const x=c.getContext("2d");
x.scale(s,s);if(!state.transparent){x.fillStyle=state.bg;x.fillRect(0,0,w,h);}x.drawImage(img,0,0,w,h);c.toBlob(b=>download(b,fname("png")),"image/png");};
img.onerror=()=>alert("PNG-Export fehlgeschlagen – nutze SVG.");img.src=url;});
bind("btnSave","click",()=>download(new Blob([JSON.stringify(state,null,2)],{type:"application/json"}),fname("json")));
bind("btnLoad","click",()=>document.getElementById("fileInput").click());
bind("fileInput","change",e=>{const f=e.target.files[0];if(!f)return;const rd=new FileReader();
rd.onload=()=>{try{const d=JSON.parse(rd.result);if(!d.columns)throw 0;state=d;renderAll();persist();}catch(err){alert("Datei konnte nicht gelesen werden.");}};rd.readAsText(f);e.target.value="";});
bind("btnReset","click",()=>{if(confirm("Alles zurücksetzen und Beispiel laden?")){state=demo();renderAll();persist();}});
function fname(ext){const t=(state.title||"rangordnung").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/(^-|-$)/g,"");return (t||"rangordnung")+"."+ext;}
function download(blob,name){const a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=name;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(a.href),1500);}
/* ============ ZOOM ============ */
function applyZoom(){document.getElementById("canvasInner").style.transform=`scale(${zoom})`;document.getElementById("zVal").textContent=Math.round(zoom*100)+"%";}
bind("zIn","click",()=>{zoom=Math.min(2.5,zoom+0.1);applyZoom();});
bind("zOut","click",()=>{zoom=Math.max(0.2,zoom-0.1);applyZoom();});
bind("zFit","click",()=>{const svg=document.querySelector("#canvas svg");if(!svg)return;const wrap=document.querySelector(".preview");
const w=svg.viewBox.baseVal.width,h=svg.viewBox.baseVal.height;zoom=Math.max(0.2,Math.min((wrap.clientWidth-60)/w,(wrap.clientHeight-60)/h,1.5));applyZoom();});
/* ============ INIT ============ */
function renderAll(){renderEditor();renderCanvas();persist();}
renderAll();
</script>
</body>
</html>