Compare commits
2
Commits
a4d1de27f2
...
b6877d16a4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6877d16a4 | ||
|
|
c3a6c9913a |
@@ -0,0 +1,681 @@
|
||||
{
|
||||
"title": "",
|
||||
"bg": "#4a4a52",
|
||||
"badgeColored": true,
|
||||
"transparent": false,
|
||||
"command": [
|
||||
{
|
||||
"id": "n0n0z",
|
||||
"name": "O5-Rat",
|
||||
"color": "#111111",
|
||||
"knowledge": 5,
|
||||
"keycard": 5,
|
||||
"parent": null,
|
||||
"parent2": null,
|
||||
"col": 1,
|
||||
"row": 0,
|
||||
"toUnits": false,
|
||||
"link": "n1jmu"
|
||||
},
|
||||
{
|
||||
"id": "n1jmu",
|
||||
"name": "Ethikkommission",
|
||||
"color": "#e6e600",
|
||||
"knowledge": 5,
|
||||
"keycard": 5,
|
||||
"parent": null,
|
||||
"parent2": null,
|
||||
"col": 0,
|
||||
"row": 0,
|
||||
"toUnits": false,
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"id": "n22vu",
|
||||
"name": "Standortleiter",
|
||||
"color": "#e8c37a",
|
||||
"knowledge": 5,
|
||||
"keycard": 5,
|
||||
"parent": "n0n0z",
|
||||
"parent2": "n1jmu",
|
||||
"col": 1,
|
||||
"row": 1,
|
||||
"toUnits": false,
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"id": "n3gpr",
|
||||
"name": "MTF Epsilon-11",
|
||||
"color": "#2f6bff",
|
||||
"knowledge": 4,
|
||||
"keycard": "Omni",
|
||||
"parent": "n0n0z",
|
||||
"parent2": null,
|
||||
"col": 3,
|
||||
"row": 1,
|
||||
"toUnits": true,
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"id": "n4hid",
|
||||
"name": "Personalabteilung Director",
|
||||
"color": "#a9c1e8",
|
||||
"knowledge": 5,
|
||||
"keycard": 5,
|
||||
"parent": "n22vu",
|
||||
"parent2": null,
|
||||
"col": 2,
|
||||
"row": 2,
|
||||
"toUnits": false,
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"id": "n5vb3",
|
||||
"name": "Manager",
|
||||
"color": "#e8c37a",
|
||||
"knowledge": 4,
|
||||
"keycard": 4,
|
||||
"parent": "n22vu",
|
||||
"parent2": null,
|
||||
"col": 1,
|
||||
"row": 3,
|
||||
"toUnits": true,
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"id": "n6sbd",
|
||||
"name": "Personalabteilung",
|
||||
"color": "#a9c1e8",
|
||||
"knowledge": 4,
|
||||
"keycard": 4,
|
||||
"parent": "n4hid",
|
||||
"parent2": null,
|
||||
"col": 2,
|
||||
"row": 3,
|
||||
"toUnits": true,
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"unitsLabel": "Einheiten",
|
||||
"unitsColor": "#c79a3a",
|
||||
"columns": [
|
||||
{
|
||||
"id": "n7664",
|
||||
"name": "Mobile Task Force",
|
||||
"color": "#2f6bff",
|
||||
"branches": [],
|
||||
"tail": [],
|
||||
"ranks": [
|
||||
{
|
||||
"id": "n8tx0",
|
||||
"name": "Task Force Commander",
|
||||
"knowledge": 4,
|
||||
"keycard": 4,
|
||||
"tier": 30
|
||||
},
|
||||
{
|
||||
"id": "n98zk",
|
||||
"name": "Unit Commander",
|
||||
"knowledge": 4,
|
||||
"keycard": 4,
|
||||
"tier": 29
|
||||
},
|
||||
{
|
||||
"id": "n10io5",
|
||||
"name": "Task Force Officer",
|
||||
"knowledge": 4,
|
||||
"keycard": 4,
|
||||
"tier": 28
|
||||
},
|
||||
{
|
||||
"id": "n112st",
|
||||
"name": "Officer Candidate",
|
||||
"knowledge": 4,
|
||||
"keycard": 4,
|
||||
"tier": 27
|
||||
},
|
||||
{
|
||||
"id": "n12bbu",
|
||||
"name": "Chief Field Instructor",
|
||||
"knowledge": 3,
|
||||
"keycard": 3,
|
||||
"tier": 25
|
||||
},
|
||||
{
|
||||
"id": "n13rtd",
|
||||
"name": "Field Instructor",
|
||||
"knowledge": 3,
|
||||
"keycard": 3,
|
||||
"tier": 24
|
||||
},
|
||||
{
|
||||
"id": "n14viq",
|
||||
"name": "Field Squadlead",
|
||||
"knowledge": 3,
|
||||
"keycard": 3,
|
||||
"tier": 22
|
||||
},
|
||||
{
|
||||
"id": "n15rsu",
|
||||
"name": "Junior Field Squadlead",
|
||||
"knowledge": 3,
|
||||
"keycard": 3,
|
||||
"tier": 21
|
||||
},
|
||||
{
|
||||
"id": "n165lb",
|
||||
"name": "NCO Candidate",
|
||||
"knowledge": 3,
|
||||
"keycard": 3,
|
||||
"tier": 20
|
||||
},
|
||||
{
|
||||
"id": "n171t9",
|
||||
"name": "Chief Operator",
|
||||
"knowledge": 2,
|
||||
"keycard": 2,
|
||||
"tier": 18
|
||||
},
|
||||
{
|
||||
"id": "n18d4n",
|
||||
"name": "Specialist",
|
||||
"knowledge": 2,
|
||||
"keycard": 2,
|
||||
"tier": 16
|
||||
},
|
||||
{
|
||||
"id": "n19adc",
|
||||
"name": "Senior Operator",
|
||||
"knowledge": 2,
|
||||
"keycard": 2,
|
||||
"tier": 15
|
||||
},
|
||||
{
|
||||
"id": "n20n0r",
|
||||
"name": "Operator",
|
||||
"knowledge": 2,
|
||||
"keycard": 2,
|
||||
"tier": 14
|
||||
},
|
||||
{
|
||||
"id": "n215md",
|
||||
"name": "Trainee",
|
||||
"knowledge": 2,
|
||||
"keycard": 2,
|
||||
"tier": 13
|
||||
},
|
||||
{
|
||||
"id": "n22359",
|
||||
"name": "Recruit",
|
||||
"knowledge": 1,
|
||||
"keycard": 0,
|
||||
"tier": 12
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "n237pn",
|
||||
"name": "Sicherheitsdienst",
|
||||
"color": "#7b3ff2",
|
||||
"ranks": [
|
||||
{
|
||||
"id": "n24flw",
|
||||
"name": "Director of Security",
|
||||
"knowledge": 4,
|
||||
"keycard": 4,
|
||||
"tier": 30
|
||||
},
|
||||
{
|
||||
"id": "n25pin",
|
||||
"name": "Deputy Director of Security",
|
||||
"knowledge": 4,
|
||||
"keycard": 4,
|
||||
"tier": 29
|
||||
},
|
||||
{
|
||||
"id": "n26wep",
|
||||
"name": "Chief of Security",
|
||||
"knowledge": 4,
|
||||
"keycard": 4,
|
||||
"tier": 28
|
||||
}
|
||||
],
|
||||
"branches": [
|
||||
{
|
||||
"id": "n37oqy",
|
||||
"label": "HRU | FG",
|
||||
"ranks": [
|
||||
{
|
||||
"id": "n27u3e",
|
||||
"name": "Security Supervisor",
|
||||
"knowledge": 4,
|
||||
"keycard": 4,
|
||||
"tier": 27
|
||||
},
|
||||
{
|
||||
"id": "n28iy8",
|
||||
"name": "Security Lieutenant",
|
||||
"knowledge": 3,
|
||||
"keycard": 3,
|
||||
"tier": 25
|
||||
},
|
||||
{
|
||||
"id": "n29maj",
|
||||
"name": "Special Security Agent",
|
||||
"knowledge": 3,
|
||||
"keycard": 3,
|
||||
"tier": 22
|
||||
},
|
||||
{
|
||||
"id": "n30r48",
|
||||
"name": "Experienced Security Agent",
|
||||
"knowledge": 3,
|
||||
"keycard": 3,
|
||||
"tier": 21
|
||||
},
|
||||
{
|
||||
"id": "n31g1e",
|
||||
"name": "Security Agent",
|
||||
"knowledge": 3,
|
||||
"keycard": 3,
|
||||
"tier": 20
|
||||
},
|
||||
{
|
||||
"id": "n32dei",
|
||||
"name": "Operator in Command",
|
||||
"knowledge": 3,
|
||||
"keycard": 2,
|
||||
"tier": 18
|
||||
},
|
||||
{
|
||||
"id": "n33qt9",
|
||||
"name": "Senior Security Operator",
|
||||
"knowledge": 2,
|
||||
"keycard": 2,
|
||||
"tier": 17
|
||||
},
|
||||
{
|
||||
"id": "n34kzp",
|
||||
"name": "Advanced Security Operator",
|
||||
"knowledge": 2,
|
||||
"keycard": 2,
|
||||
"tier": 16
|
||||
},
|
||||
{
|
||||
"id": "n35pfi",
|
||||
"name": "Security Operator",
|
||||
"knowledge": 2,
|
||||
"keycard": 2,
|
||||
"tier": 15
|
||||
},
|
||||
{
|
||||
"id": "n36wzo",
|
||||
"name": "Junior Security Operator",
|
||||
"knowledge": 2,
|
||||
"keycard": 2,
|
||||
"tier": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "n48l8k",
|
||||
"label": "Containment Unit",
|
||||
"ranks": [
|
||||
{
|
||||
"id": "n38ry6",
|
||||
"name": "Containment Supervisor",
|
||||
"knowledge": 4,
|
||||
"keycard": 4,
|
||||
"tier": 27
|
||||
},
|
||||
{
|
||||
"id": "n390gs",
|
||||
"name": "Containment Lieutenant",
|
||||
"knowledge": 3,
|
||||
"keycard": 3,
|
||||
"tier": 25
|
||||
},
|
||||
{
|
||||
"id": "n40s93",
|
||||
"name": "Special Containment Agent",
|
||||
"knowledge": 3,
|
||||
"keycard": 3,
|
||||
"tier": 22
|
||||
},
|
||||
{
|
||||
"id": "n41dne",
|
||||
"name": "Experienced Containment Agent",
|
||||
"knowledge": 3,
|
||||
"keycard": 3,
|
||||
"tier": 21
|
||||
},
|
||||
{
|
||||
"id": "n42kbm",
|
||||
"name": "Containment Agent",
|
||||
"knowledge": 3,
|
||||
"keycard": 3,
|
||||
"tier": 20
|
||||
},
|
||||
{
|
||||
"id": "n43iyp",
|
||||
"name": "Specialist in Command",
|
||||
"knowledge": 3,
|
||||
"keycard": 3,
|
||||
"tier": 18
|
||||
},
|
||||
{
|
||||
"id": "n446oq",
|
||||
"name": "Senior Containment Specialist",
|
||||
"knowledge": 3,
|
||||
"keycard": 3,
|
||||
"tier": 17
|
||||
},
|
||||
{
|
||||
"id": "n45clx",
|
||||
"name": "Advanced Containment Specialist",
|
||||
"knowledge": 3,
|
||||
"keycard": 3,
|
||||
"tier": 16
|
||||
},
|
||||
{
|
||||
"id": "n46igz",
|
||||
"name": "Containment Specialist",
|
||||
"knowledge": 3,
|
||||
"keycard": 2,
|
||||
"tier": 15
|
||||
},
|
||||
{
|
||||
"id": "n47mls",
|
||||
"name": "Junior Containment Specialist",
|
||||
"knowledge": 3,
|
||||
"keycard": 2,
|
||||
"tier": 14
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tail": [
|
||||
{
|
||||
"id": "n49cly",
|
||||
"name": "Security Guard",
|
||||
"knowledge": 2,
|
||||
"keycard": 1,
|
||||
"tier": 13
|
||||
},
|
||||
{
|
||||
"id": "n501wv",
|
||||
"name": "Rekrut",
|
||||
"knowledge": 1,
|
||||
"keycard": 0,
|
||||
"tier": 12
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "n51r1u",
|
||||
"name": "Wissenschaftler",
|
||||
"color": "#12a89a",
|
||||
"branches": [],
|
||||
"tail": [],
|
||||
"ranks": [
|
||||
{
|
||||
"id": "n525k7",
|
||||
"name": "Head of Research",
|
||||
"knowledge": 4,
|
||||
"keycard": 4,
|
||||
"tier": 30
|
||||
},
|
||||
{
|
||||
"id": "n536hf",
|
||||
"name": "Overseer of Research",
|
||||
"knowledge": 4,
|
||||
"keycard": 4,
|
||||
"tier": 29
|
||||
},
|
||||
{
|
||||
"id": "n549by",
|
||||
"name": "Supervisor of Research",
|
||||
"knowledge": 4,
|
||||
"keycard": 4,
|
||||
"tier": 27
|
||||
},
|
||||
{
|
||||
"id": "n55lob",
|
||||
"name": "Chief Researcher",
|
||||
"knowledge": 4,
|
||||
"keycard": 3,
|
||||
"tier": 25
|
||||
},
|
||||
{
|
||||
"id": "n56uc4",
|
||||
"name": "Lead Researcher",
|
||||
"knowledge": 3,
|
||||
"keycard": 3,
|
||||
"tier": 24
|
||||
},
|
||||
{
|
||||
"id": "n57vj0",
|
||||
"name": "Senior Researcher",
|
||||
"knowledge": 3,
|
||||
"keycard": 3,
|
||||
"tier": 22
|
||||
},
|
||||
{
|
||||
"id": "n58bh8",
|
||||
"name": "Advanced Researcher",
|
||||
"knowledge": 3,
|
||||
"keycard": 2,
|
||||
"tier": 21
|
||||
},
|
||||
{
|
||||
"id": "n59i2k",
|
||||
"name": "Experienced Researcher",
|
||||
"knowledge": 3,
|
||||
"keycard": 2,
|
||||
"tier": 20
|
||||
},
|
||||
{
|
||||
"id": "n60i6s",
|
||||
"name": "Researcher",
|
||||
"knowledge": 3,
|
||||
"keycard": 2,
|
||||
"tier": 18
|
||||
},
|
||||
{
|
||||
"id": "n61suy",
|
||||
"name": "Assistant Researcher",
|
||||
"knowledge": 2,
|
||||
"keycard": 2,
|
||||
"tier": 16
|
||||
},
|
||||
{
|
||||
"id": "n623uc",
|
||||
"name": "Junior Researcher",
|
||||
"knowledge": 2,
|
||||
"keycard": 1,
|
||||
"tier": 14
|
||||
},
|
||||
{
|
||||
"id": "n63yrc",
|
||||
"name": "Probationary Researcher",
|
||||
"knowledge": 2,
|
||||
"keycard": 1,
|
||||
"tier": 13
|
||||
},
|
||||
{
|
||||
"id": "n64mau",
|
||||
"name": "Intern",
|
||||
"knowledge": 2,
|
||||
"keycard": 0,
|
||||
"tier": 12
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "n656ww",
|
||||
"name": "Mediziner",
|
||||
"color": "#1fae4a",
|
||||
"branches": [],
|
||||
"tail": [],
|
||||
"ranks": [
|
||||
{
|
||||
"id": "n66x1u",
|
||||
"name": "Ärztlicher Direktor",
|
||||
"knowledge": 4,
|
||||
"keycard": 4,
|
||||
"tier": 30
|
||||
},
|
||||
{
|
||||
"id": "n67cc1",
|
||||
"name": "Stellvertretender Ärztlicher Direktor",
|
||||
"knowledge": 4,
|
||||
"keycard": 4,
|
||||
"tier": 29
|
||||
},
|
||||
{
|
||||
"id": "n68x49",
|
||||
"name": "Chefarzt",
|
||||
"knowledge": 4,
|
||||
"keycard": 4,
|
||||
"tier": 28
|
||||
},
|
||||
{
|
||||
"id": "n69n0g",
|
||||
"name": "Leitender Oberarzt",
|
||||
"knowledge": 4,
|
||||
"keycard": 4,
|
||||
"tier": 27
|
||||
},
|
||||
{
|
||||
"id": "n70yi9",
|
||||
"name": "Oberarzt",
|
||||
"knowledge": 3,
|
||||
"keycard": 3,
|
||||
"tier": 25
|
||||
},
|
||||
{
|
||||
"id": "n718b3",
|
||||
"name": "Facharzt",
|
||||
"knowledge": 2,
|
||||
"keycard": 3,
|
||||
"tier": 22
|
||||
},
|
||||
{
|
||||
"id": "n72nhx",
|
||||
"name": "Arzt",
|
||||
"knowledge": 2,
|
||||
"keycard": 2,
|
||||
"tier": 21
|
||||
},
|
||||
{
|
||||
"id": "n737uh",
|
||||
"name": "Assistenzarzt",
|
||||
"knowledge": 2,
|
||||
"keycard": 2,
|
||||
"tier": 20
|
||||
},
|
||||
{
|
||||
"id": "n742p3",
|
||||
"name": "Notfallsanitäter",
|
||||
"knowledge": 2,
|
||||
"keycard": 1,
|
||||
"tier": 16
|
||||
},
|
||||
{
|
||||
"id": "n75v5w",
|
||||
"name": "Rettungssanitäter",
|
||||
"knowledge": 2,
|
||||
"keycard": 1,
|
||||
"tier": 14
|
||||
},
|
||||
{
|
||||
"id": "n762xy",
|
||||
"name": "Rettungshelfer",
|
||||
"knowledge": 2,
|
||||
"keycard": 1,
|
||||
"tier": 13
|
||||
},
|
||||
{
|
||||
"id": "n775wa",
|
||||
"name": "Azubi",
|
||||
"knowledge": 1,
|
||||
"keycard": 0,
|
||||
"tier": 12
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"sideBoxes": [
|
||||
{
|
||||
"id": "n78npx",
|
||||
"name": "MTF Iota-10",
|
||||
"color": "#2f6bff",
|
||||
"knowledge": 2,
|
||||
"keycard": 2,
|
||||
"tier": 19
|
||||
},
|
||||
{
|
||||
"id": "n79ul8",
|
||||
"name": "Personal Guards",
|
||||
"color": "#f5820a",
|
||||
"knowledge": 2,
|
||||
"keycard": 2,
|
||||
"tier": 18
|
||||
},
|
||||
{
|
||||
"id": "n80ke7",
|
||||
"name": "Sonstiges",
|
||||
"color": "#ffffff",
|
||||
"knowledge": "N/A",
|
||||
"keycard": "N/A",
|
||||
"tier": 17
|
||||
}
|
||||
],
|
||||
"bands": [
|
||||
{
|
||||
"tier": 27,
|
||||
"label": "Leitungsebene"
|
||||
},
|
||||
{
|
||||
"tier": 12,
|
||||
"label": "Ausbildungsrang"
|
||||
}
|
||||
],
|
||||
"levels": [
|
||||
{
|
||||
"id": "n8136f",
|
||||
"label": "Service-Kräfte",
|
||||
"dividerColor": "#9aa0a6",
|
||||
"boxes": [
|
||||
{
|
||||
"id": "n82y1b",
|
||||
"name": "Putzkräfte/Koch",
|
||||
"color": "#b8bcc2",
|
||||
"knowledge": 1,
|
||||
"keycard": 1
|
||||
},
|
||||
{
|
||||
"id": "n83hqo",
|
||||
"name": "Techniker",
|
||||
"color": "#d400ff",
|
||||
"knowledge": 1,
|
||||
"keycard": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "n84l76",
|
||||
"label": "Sonstiges",
|
||||
"dividerColor": "#6a7077",
|
||||
"boxes": [
|
||||
{
|
||||
"id": "n859d8",
|
||||
"name": "D-Klasse",
|
||||
"color": "#f5820a",
|
||||
"knowledge": 0,
|
||||
"keycard": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,787 @@
|
||||
<!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. 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=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[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>
|
||||
Reference in New Issue
Block a user