Files
Gleisplaner/gleisplaner.html
T
2026-08-22 04:44:22 +00:00

3641 lines
175 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<title>Gleisplaner</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='11' fill='none' stroke='%236C6E68' stroke-width='7'/%3E%3Ccircle cx='16' cy='16' r='13.2' fill='none' stroke='%23A0A5A9' stroke-width='1.6'/%3E%3Ccircle cx='16' cy='16' r='8.8' fill='none' stroke='%23A0A5A9' stroke-width='1.6'/%3E%3Ccircle cx='16' cy='5' r='2.8' fill='%23C0342C'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,400..700&family=IBM+Plex+Mono:wght@400;500;600&display=swap">
<style>
:root{
--paper:#E9EDF2; --surface:#FFFFFF; --surface-2:#F3F6F9; --sunk:#DFE5EC;
--ink:#10161E; --ink-2:#46505E; --ink-3:#78838F;
--line:#CBD4DE; --line-soft:#DCE3EA;
--grid:#D7DEE7; --grid-major:#B9C5D2;
--accent:#C0342C; --accent-soft:#F3DCD9; --accent-ink:#FFFFFF;
--ok:#2F7D5B; --ok-soft:#DBEDE4;
--plate:#E4E9EF;
--shadow:0 1px 2px rgba(16,22,30,.06), 0 8px 24px -12px rgba(16,22,30,.18);
/* LEGO-Bauteilfarben */
--tie:#6C6E68; --tie-dark:#5A5C57; --rail:#A0A5A9; --rail-hi:#C3C8CB;
--road:#9BA0A4; --buffer:#B2231F;
}
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){
--paper:#131922; --surface:#1A222C; --surface-2:#212B37; --sunk:#0E141B;
--ink:#E8EDF3; --ink-2:#A9B5C3; --ink-3:#77838F;
--line:#2F3A47; --line-soft:#242E3A;
--grid:#1F2833; --grid-major:#2C3947;
--accent:#E2574A; --accent-soft:#3A211F; --accent-ink:#160B0A;
--ok:#5CB68C; --ok-soft:#1B2E26;
--plate:#161D26;
--shadow:0 1px 2px rgba(0,0,0,.4), 0 10px 30px -14px rgba(0,0,0,.7);
--tie:#767973; --tie-dark:#63665F; --rail:#AAB0B4; --rail-hi:#C9CED1;
--road:#8A8F93; --buffer:#D24A44;
}
}
:root[data-theme="dark"]{
--paper:#131922; --surface:#1A222C; --surface-2:#212B37; --sunk:#0E141B;
--ink:#E8EDF3; --ink-2:#A9B5C3; --ink-3:#77838F;
--line:#2F3A47; --line-soft:#242E3A;
--grid:#1F2833; --grid-major:#2C3947;
--accent:#E2574A; --accent-soft:#3A211F; --accent-ink:#160B0A;
--ok:#5CB68C; --ok-soft:#1B2E26;
--plate:#161D26;
--shadow:0 1px 2px rgba(0,0,0,.4), 0 10px 30px -14px rgba(0,0,0,.7);
--tie:#767973; --tie-dark:#63665F; --rail:#AAB0B4; --rail-hi:#C9CED1;
--road:#8A8F93; --buffer:#D24A44;
}
*{box-sizing:border-box}
body{
margin:0; background:var(--paper); color:var(--ink);
font-family:"Archivo","Segoe UI",system-ui,sans-serif; font-size:15px; line-height:1.5;
-webkit-font-smoothing:antialiased;
}
h1,h2,h3{margin:0; text-wrap:balance; font-weight:600; letter-spacing:-.01em}
.num{font-family:"IBM Plex Mono",ui-monospace,Consolas,monospace; font-variant-numeric:tabular-nums}
.label{font-stretch:78%; text-transform:uppercase; letter-spacing:.09em; font-size:11px; font-weight:600; color:var(--ink-3)}
header.top{
position:sticky; top:0; z-index:20; display:flex; align-items:center; gap:16px; flex-wrap:wrap;
padding:14px 24px; background:var(--surface); border-bottom:1px solid var(--line);
}
.mark{display:flex; align-items:center; gap:10px}
.mark h1{font-size:19px; font-stretch:88%}
.mark .sub{font-stretch:78%; font-size:11px; letter-spacing:.09em; text-transform:uppercase; color:var(--ink-3)}
header.top .spacer{flex:1}
.ghost{
border:1px solid var(--line); background:var(--surface-2); color:var(--ink-2);
border-radius:6px; padding:7px 12px; font:inherit; font-size:13px; cursor:pointer;
}
.ghost:hover{border-color:var(--ink-3); color:var(--ink)}
:focus-visible{outline:2px solid var(--accent); outline-offset:2px}
main{
display:grid; grid-template-columns:344px minmax(0,1fr); gap:26px; align-items:start;
max-width:1560px; margin:0 auto; padding:26px 24px 80px;
}
@media (max-width:940px){
main{grid-template-columns:1fr; gap:20px; padding:18px 14px 60px}
aside{position:static !important}
#groups{max-height:none; overflow:visible}
}
aside{position:sticky; top:82px; align-self:start; display:flex; flex-direction:column; gap:14px}
/* Nur die Teileliste scrollt, nicht die ganze Spalte — sonst rutschen die
Einstellungen bei langen Beständen aus dem Bild. */
#groups{max-height:min(44vh,420px); overflow-y:auto; overscroll-behavior:contain}
#edSvg{touch-action:none; cursor:grab}
#edSvg:active{cursor:grabbing}
#edSvg path[data-uid]{cursor:move}
.panel{background:var(--surface); border:1px solid var(--line); border-radius:10px; box-shadow:var(--shadow); overflow:hidden}
.panel > .head{padding:11px 15px; background:var(--surface-2); border-bottom:1px solid var(--line-soft); display:flex; align-items:center; gap:8px}
.panel > .head .spacer{flex:1}
.panel-body{padding:14px 15px; display:flex; flex-direction:column; gap:13px}
/* Bestandsliste */
.group{border-top:1px solid var(--line-soft)}
.group:first-of-type{border-top:0}
.group > summary{
list-style:none; cursor:pointer; padding:9px 15px; display:flex; align-items:center; gap:8px;
font-stretch:78%; text-transform:uppercase; letter-spacing:.08em; font-size:10.5px; font-weight:600; color:var(--ink-3);
background:var(--surface);
}
.group > summary::-webkit-details-marker{display:none}
.group > summary::before{content:"▸"; font-size:9px; transition:transform .15s}
.group[open] > summary::before{transform:rotate(90deg)}
.group > summary .n{margin-left:auto; font-family:"IBM Plex Mono",monospace; color:var(--ink-2)}
.rows{padding:2px 15px 12px; display:flex; flex-direction:column; gap:7px}
.row{display:grid; grid-template-columns:40px 1fr auto; align-items:center; gap:10px}
.row.on .nm{color:var(--ink)}
.row .ic{width:40px; height:26px; border-radius:4px; background:var(--sunk); display:grid; place-items:center; overflow:hidden}
.row .ic svg{display:block}
.nm{font-size:13.5px; line-height:1.2; color:var(--ink-2)}
.nm small{display:block; font-size:11px; color:var(--ink-3)}
.stepper{display:flex; align-items:center; border:1px solid var(--line); border-radius:6px; overflow:hidden; background:var(--surface-2)}
.stepper button{width:24px; height:28px; border:0; background:transparent; color:var(--ink-2); font-size:15px; line-height:1; cursor:pointer}
.stepper button:hover{background:var(--sunk); color:var(--ink)}
.stepper input{
width:40px; height:28px; border:0; background:transparent; text-align:center; color:var(--ink);
font-family:"IBM Plex Mono",monospace; font-size:13.5px; font-weight:500; -moz-appearance:textfield;
}
.stepper input::-webkit-outer-spin-button,.stepper input::-webkit-inner-spin-button{-webkit-appearance:none; margin:0}
.row .del{border:0; background:transparent; color:var(--ink-3); cursor:pointer; font-size:14px; padding:0 2px}
.row .del:hover{color:var(--accent)}
.addrow{display:grid; grid-template-columns:1fr 74px auto; gap:6px; align-items:center; padding:0 15px 14px}
.addrow select,.addrow input{
font:inherit; font-size:12.5px; padding:6px 7px; color:var(--ink);
background:var(--surface-2); border:1px solid var(--line); border-radius:6px; width:100%;
}
.addrow button{border:1px solid var(--line); background:var(--surface-2); color:var(--ink-2); border-radius:6px; padding:6px 10px; font:inherit; font-size:12.5px; cursor:pointer}
.addrow button:hover{border-color:var(--accent); color:var(--accent)}
.presets{display:flex; flex-wrap:wrap; gap:6px}
.chip{border:1px solid var(--line); background:var(--surface-2); color:var(--ink-2); border-radius:20px; padding:5px 11px; font:inherit; font-size:12.5px; cursor:pointer}
.chip:hover{border-color:var(--accent); color:var(--accent)}
.total{display:flex; justify-content:space-between; align-items:baseline; padding:11px 15px; border-top:1px dashed var(--line); font-size:13px; color:var(--ink-3)}
.total b{font-family:"IBM Plex Mono",monospace; font-size:15px; color:var(--ink); font-weight:600}
.triple{display:grid; grid-template-columns:1fr 1fr 1fr; gap:6px}
.triple label{display:flex; flex-direction:column; gap:3px; font-size:11px; color:var(--ink-3)}
.triple select, .triple input{font:inherit; font-size:13px; padding:6px 5px; color:var(--ink);
background:var(--surface-2); border:1px solid var(--line); border-radius:6px; width:100%;
-moz-appearance:textfield}
.triple input::-webkit-outer-spin-button,.triple input::-webkit-inner-spin-button{-webkit-appearance:none; margin:0}
.stationbox{border:1px solid var(--line-soft); border-radius:8px; padding:10px; background:var(--surface-2)}
.opt{display:flex; align-items:flex-start; gap:9px; font-size:13.5px; color:var(--ink-2); cursor:pointer}
.opt input{margin-top:3px; accent-color:var(--accent); width:15px; height:15px; flex:none}
.opt small{display:block; color:var(--ink-3); font-size:11.5px; line-height:1.35}
select.wide{width:100%; font:inherit; font-size:13.5px; padding:7px 9px; color:var(--ink); background:var(--surface-2); border:1px solid var(--line); border-radius:7px}
button.go{
width:100%; padding:12px 16px; border:0; border-radius:8px; cursor:pointer;
background:var(--accent); color:var(--accent-ink); font:inherit; font-weight:600; font-size:15px;
}
button.go:hover{filter:brightness(1.08)}
button.go:disabled{opacity:.55; cursor:progress}
/* Ergebnisse */
.rhead{display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-bottom:14px}
.rhead h2{font-size:17px}
.rhead .count{font-size:13px; color:var(--ink-3)}
.rhead .spacer{flex:1}
.seg{display:flex; border:1px solid var(--line); border-radius:7px; overflow:hidden; background:var(--surface-2)}
.seg button{border:0; background:transparent; color:var(--ink-3); font:inherit; font-size:12.5px; padding:6px 11px; cursor:pointer}
.seg button[aria-pressed="true"]{background:var(--surface); color:var(--ink); box-shadow:inset 0 0 0 1px var(--line)}
.bar{height:3px; background:var(--sunk); border-radius:2px; overflow:hidden; margin:0 0 14px}
.bar i{display:block; height:100%; width:0; background:var(--accent); transition:width .12s linear}
.grid{display:grid; grid-template-columns:repeat(auto-fill,minmax(292px,1fr)); gap:16px}
.card{
background:var(--surface); border:1px solid var(--line); border-radius:10px; box-shadow:var(--shadow);
overflow:hidden; cursor:pointer; text-align:left; padding:0; font:inherit; color:inherit; display:flex; flex-direction:column;
}
.card:hover{border-color:var(--accent)}
.card .plan{background:var(--plate); border-bottom:1px solid var(--line-soft); height:200px; display:grid; place-items:center}
.card .plan svg{display:block; width:100%; height:100%}
.card .meta{padding:11px 13px; display:flex; flex-direction:column; gap:8px}
.card .r1{display:flex; align-items:baseline; justify-content:space-between; gap:8px}
.card .r1 h3{font-size:14.5px; font-stretch:88%}
.card .dim{font-family:"IBM Plex Mono",monospace; font-size:12px; color:var(--ink-2)}
.tags{display:flex; flex-wrap:wrap; gap:5px}
.tag{font-family:"IBM Plex Mono",monospace; font-size:11.5px; border:1px solid var(--line); border-radius:4px; padding:2px 6px; color:var(--ink-2); background:var(--surface-2); display:inline-flex; align-items:center; gap:5px}
.tag.full{background:var(--ok-soft); border-color:transparent; color:var(--ok)}
.tag.rest{background:var(--accent-soft); border-color:transparent; color:var(--accent)}
.sw{width:9px; height:9px; border-radius:2px; flex:none}
/* Editor */
.edtools{display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:14px}
.edtools .spacer{flex:1}
.edgrid{display:grid; grid-template-columns:minmax(0,1fr) 290px; gap:16px; align-items:start}
@media (max-width:1100px){.edgrid{grid-template-columns:1fr}}
.edcanvas{background:var(--plate); border:1px solid var(--line); border-radius:10px; box-shadow:var(--shadow); overflow:hidden}
.edcanvas svg{display:block; width:100%; height:auto; max-height:70vh}
.edside{display:flex; flex-direction:column; gap:12px}
.pal{display:flex; flex-direction:column; gap:6px}
.palrow{display:grid; grid-template-columns:38px 1fr auto; gap:9px; align-items:center}
.palrow .ic{width:38px; height:26px; border-radius:4px; background:var(--sunk); display:grid; place-items:center}
.palrow .nm2{font-size:13px; line-height:1.2; color:var(--ink-2)}
.palrow .nm2 small{display:block; font-size:11px; color:var(--ink-3)}
.palrow .btns{display:flex; gap:4px}
.palrow button{border:1px solid var(--line); background:var(--surface-2); color:var(--ink-2);
border-radius:6px; min-width:30px; height:28px; font:inherit; font-size:13px; cursor:pointer; padding:0 8px}
.palrow button:hover:not(:disabled){border-color:var(--accent); color:var(--accent)}
.palrow button:disabled{opacity:.35; cursor:not-allowed}
.endlist{display:flex; flex-wrap:wrap; gap:6px}
.endlist button{border:1px solid var(--line); background:var(--surface-2); color:var(--ink-2);
border-radius:20px; padding:5px 11px; font:inherit; font-size:12.5px; cursor:pointer}
.endlist button[aria-pressed="true"]{border-color:var(--accent); color:var(--accent); background:var(--accent-soft)}
#storeDlg{width:min(760px,94vw)}
#storeText{width:100%; font-family:"IBM Plex Mono",monospace; font-size:11.5px; color:var(--ink);
background:var(--surface-2); border:1px solid var(--line); border-radius:8px; padding:9px; resize:vertical}
.storerow{display:grid; grid-template-columns:1fr auto auto; gap:8px; align-items:center;
padding:9px 0; border-bottom:1px solid var(--line-soft); font-size:14px}
.storerow small{display:block; color:var(--ink-3); font-size:12px}
.warn{color:var(--accent); font-size:12.5px; line-height:1.4}
.more{display:flex; align-items:center; gap:12px; justify-content:center; padding:22px 0 4px}
.more button{padding:9px 18px; font-size:14px}
.empty{border:1px dashed var(--line); border-radius:10px; padding:34px 26px; text-align:center; color:var(--ink-2); background:var(--surface)}
.empty h3{font-size:16px; margin-bottom:8px; color:var(--ink)}
.empty p{margin:0 auto; max-width:54ch; font-size:14px}
.empty ul{max-width:54ch; margin:12px auto 0; text-align:left; font-size:14px; padding-left:20px}
.empty li{margin:4px 0}
/* Detail */
dialog{width:min(1060px,95vw); max-height:93vh; padding:0; border:1px solid var(--line); border-radius:12px; background:var(--surface); color:var(--ink); box-shadow:var(--shadow)}
dialog::backdrop{background:rgba(6,10,16,.62)}
.dhead{display:flex; align-items:center; gap:10px; padding:13px 16px; border-bottom:1px solid var(--line); background:var(--surface-2); flex-wrap:wrap}
.dhead h2{font-size:17px; font-stretch:88%}
.dhead .spacer{flex:1}
.dbody{padding:16px; overflow:auto; max-height:calc(93vh - 58px)}
.dgrid{display:grid; grid-template-columns:minmax(0,1fr) 276px; gap:18px; align-items:start}
@media (max-width:800px){.dgrid{grid-template-columns:1fr}}
.bigplan{background:var(--plate); border:1px solid var(--line-soft); border-radius:8px}
.bigplan svg{display:block; width:100%; height:auto}
.side{display:flex; flex-direction:column; gap:15px}
dl.facts{margin:0; display:grid; grid-template-columns:auto 1fr; gap:5px 12px; font-size:13.5px}
dl.facts dt{color:var(--ink-3)}
dl.facts dd{margin:0; font-family:"IBM Plex Mono",monospace; text-align:right}
.legend{display:flex; flex-direction:column; gap:4px}
.legend div{display:grid; grid-template-columns:34px 1fr auto; gap:9px; align-items:center; font-size:13px}
.legend.tinted div{grid-template-columns:12px 34px 1fr auto}
.legend .mini{display:grid; place-items:center; background:var(--sunk); border-radius:4px; padding:2px 0}
.legend .cnt{font-family:"IBM Plex Mono",monospace; color:var(--ink-2)}
ol.steps{margin:0; padding:0; list-style:none; display:flex; flex-direction:column; gap:2px}
ol.steps li{display:grid; grid-template-columns:22px 1fr; gap:9px; align-items:baseline; font-size:13.5px; padding:4px 0; border-bottom:1px solid var(--line-soft)}
ol.steps li:last-child{border-bottom:0}
ol.steps .n{font-family:"IBM Plex Mono",monospace; font-size:11px; color:#fff; background:var(--ink-3); border-radius:50%; width:19px; height:19px; display:grid; place-items:center}
ol.steps .dir{color:var(--accent); font-weight:600}
ol.steps li.branch{background:var(--surface-2); border-radius:5px; padding:4px 6px; margin:3px 0}
ol.steps li.ringhead{display:block; margin:10px 0 2px; padding:0 0 3px; border-bottom:0;
font-stretch:78%; text-transform:uppercase; letter-spacing:.08em; font-size:10.5px; font-weight:600; color:var(--ink-3)}
.note{font-size:12px; color:var(--ink-3); line-height:1.45}
/* Druck */
#printRoot{display:none}
@media print{
/* Papier ist immer hell — unabhängig davon, wie der Bildschirm eingestellt ist. */
:root, :root[data-theme="dark"], :root[data-theme="light"]{
--paper:#FFFFFF; --surface:#FFFFFF; --surface-2:#F5F7F9; --sunk:#EDF0F3; --plate:#FFFFFF;
--ink:#101010; --ink-2:#3A3A3A; --ink-3:#6B6B6B;
--line:#B9C2CB; --line-soft:#D6DCE2; --grid:#E6EAEE; --grid-major:#D2D8DE;
--tie:#8B9299; --tie-dark:#6F757B; --rail:#EFF2F4; --road:#B7BCC0; --buffer:#B2231F;
--accent:#B32E26; --ok:#1F7A55;
}
body{background:#fff; color:#000}
body > *{display:none !important}
#printRoot{display:block !important;
/* Ohne das druckt der Browser Flächen- und Hintergrundfarben nicht. */
print-color-adjust:exact; -webkit-print-color-adjust:exact}
@page{size:A4 landscape; margin:9mm}
.sheet{break-after:page; break-inside:avoid}
.sheet:last-child{break-after:auto}
.ph{display:flex; align-items:baseline; gap:10px; border-bottom:1.4pt solid #000;
padding-bottom:2.5mm; margin-bottom:3.5mm}
.ph h1{font-size:15pt}
.ph .d{margin-left:auto; font-family:"IBM Plex Mono",monospace; font-size:10pt}
/* Der Plan bekommt die ganze Seite */
.planwrap{height:172mm; display:flex; align-items:center; justify-content:center}
.planwrap svg{width:100%; height:100%; object-fit:contain}
.pcols{display:grid; grid-template-columns:1fr 1.15fr; gap:9mm; font-size:9.5pt}
.pcols h3{font-size:11pt; margin-bottom:3mm}
.plist{display:grid; grid-template-columns:auto auto 1fr auto; gap:1.6mm 3mm; align-items:center}
.plist .pn{font-family:"IBM Plex Mono",monospace; font-weight:600}
.plist .pd{color:#666; font-size:8.5pt; text-align:right}
.psw{width:3.4mm; height:3.4mm; display:block; flex:none}
.psteps li .psw{display:inline-block; vertical-align:-0.4mm; margin-right:1.6mm; width:2.9mm; height:2.9mm}
.prest{margin:4mm 0 0; color:#555; font-size:8.5pt}
.psteps{margin:0; padding-left:6mm; columns:2; column-gap:7mm}
.psteps li{margin:.9mm 0; break-inside:avoid}
}
</style>
<header class="top">
<div class="mark">
<svg width="30" height="30" viewBox="0 0 30 30" aria-hidden="true">
<circle cx="15" cy="15" r="10.5" fill="none" stroke="var(--tie)" stroke-width="7"/>
<circle cx="15" cy="15" r="12.4" fill="none" stroke="var(--rail)" stroke-width="1.5"/>
<circle cx="15" cy="15" r="8.6" fill="none" stroke="var(--rail)" stroke-width="1.5"/>
<circle cx="15" cy="4.5" r="2.6" fill="var(--accent)"/>
</svg>
<div>
<h1>Gleisplaner</h1>
<div class="sub">Eisenbahn · City-Schienen</div>
</div>
</div>
<div class="spacer"></div>
<button class="ghost" id="themeBtn" type="button">Ansicht wechseln</button>
<button class="ghost" id="printAllBtn" type="button">Alle als PDF</button>
</header>
<main>
<aside>
<section class="panel">
<div class="head"><span class="label">Dein Schienenbestand</span><span class="spacer"></span><button class="ghost" id="resetBtn" type="button" style="padding:3px 8px;font-size:11.5px">Leeren</button></div>
<div id="groups"></div>
<div class="addrow">
<select id="addKind">
<option value="straight">Eigene Gerade</option>
<option value="curve">Eigene Kurve</option>
</select>
<input type="number" id="addVal" min="1" max="200" value="12" aria-label="Länge in Noppen bzw. Radius in Noppen">
<button id="addBtn" type="button">+ Noppen</button>
</div>
<div class="total"><span>Teile insgesamt</span><b id="totalCount">0</b></div>
<div class="panel-body" style="padding-top:0">
<div class="presets">
<button class="chip" data-preset="startset" type="button">Startset</button>
<button class="chip" data-preset="mittel" type="button">Mittlere Sammlung</button>
<button class="chip" data-preset="gross" type="button">Große Sammlung</button>
</div>
</div>
</section>
<section class="panel">
<div class="head"><span class="label">Wie geplant wird</span></div>
<div class="panel-body">
<label class="opt"><input type="checkbox" id="useAll">
<span>Alle Teile verbauen<small>Nur Pläne, die den Bestand restlos aufbrauchen.</small></span></label>
<label class="opt"><input type="checkbox" id="wantSidings" checked>
<span>Weichen einplanen<small>Abkürzungen quer durch die Runde, Ausweichgleise parallel zum Hauptgleis, Abstellgleise mit Prellbock.</small></span></label>
<label class="opt"><input type="checkbox" id="wantCross" checked>
<span>Kreuzungen nutzen<small>Erlaubt Achterbahnen und sich kreuzende Strecken.</small></span></label>
<div class="stationbox">
<label class="opt" style="margin-bottom:8px"><input type="checkbox" id="useTable">
<span>Nur was auf den Tisch passt<small>Größere Pläne werden aussortiert.</small></span></label>
<div class="triple" id="tableFields" hidden>
<label>Breite cm<input type="number" id="tableW" min="40" max="900" value="200"></label>
<label>Tiefe cm<input type="number" id="tableH" min="40" max="900" value="100"></label>
<label>&nbsp;<span class="note" style="font-size:10.5px;line-height:1.3">Drehen erlaubt</span></label>
</div>
</div>
<div class="stationbox">
<span class="label" style="display:block;margin-bottom:6px">Bahnhöfe</span>
<div class="triple">
<label>Anzahl
<select id="stations">
<option value="0" selected>keine</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</select></label>
<label>Gleise
<select id="stationTracks">
<option value="2" selected>2</option>
<option value="3">3</option>
</select></label>
<label>Bahnsteig
<select id="platform">
<option value="2" selected>2 Ger.</option>
<option value="3">3 Ger.</option>
<option value="4">4 Ger.</option>
<option value="5">5 Ger.</option>
</select></label>
</div>
<p class="note" style="margin:7px 0 0">Ein Bahnsteiggleis läuft 16 Noppen neben dem Hauptgleis. Es braucht je Bahnhof <b id="needRunHint">8</b> Geraden am Stück und 2 Weichen — beim dritten Gleis 4.</p>
</div>
<div>
<span class="label" style="display:block;margin-bottom:5px">Mindestabstand der Gleise</span>
<select class="wide" id="gap">
<option value="64" selected>8 Noppen — Gleis an Gleis</option>
<option value="96">12 Noppen — Luft dazwischen</option>
<option value="128">16 Noppen — LEGO-Achsabstand</option>
</select>
</div>
<div>
<span class="label" style="display:block;margin-bottom:5px">Anzahl Vorschläge</span>
<select class="wide" id="want"><option>6</option><option selected>12</option><option>24</option></select>
</div>
<div>
<span class="label" style="display:block;margin-bottom:5px">Rechenzeit</span>
<select class="wide" id="effort">
<option value="2000">Schnell (2 s)</option>
<option value="5000" selected>Normal (5 s)</option>
<option value="12000">Gründlich (12 s)</option>
</select>
</div>
<button class="go" id="goBtn" type="button">Strecken berechnen</button>
</div>
</section>
</aside>
<section>
<div class="rhead">
<div class="seg" role="group" aria-label="Ansicht">
<button id="tabPlans" type="button" aria-pressed="true">Vorschläge</button>
<button id="tabEdit" type="button" aria-pressed="false">Eigener Plan</button>
</div>
<span class="count" id="resCount"></span>
<span class="spacer"></span>
<div class="seg" role="group" aria-label="Darstellung">
<button id="viewReal" type="button" aria-pressed="true">Wie gebaut</button>
<button id="viewType" type="button" aria-pressed="false">Nach Teiletyp</button>
</div>
</div>
<div class="bar" id="bar" hidden><i></i></div>
<div id="results"></div>
<div id="editView" hidden>
<div class="edtools">
<button class="ghost" id="edUndo" type="button">Zurück</button>
<button class="ghost" id="edRedo" type="button">Wiederholen</button>
<button class="ghost" id="edClear" type="button">Leeren</button>
<span class="spacer"></span>
<div class="seg" role="group" aria-label="Ausschnitt">
<button id="edZoomOut" type="button" title="Kleiner" aria-label="Kleiner"></button>
<button id="edZoomFit" type="button" title="Ganze Anlage zeigen">Alles zeigen</button>
<button id="edZoomIn" type="button" title="Größer" aria-label="Größer">+</button>
</div>
<button class="ghost" id="edSave" type="button">Speichern</button>
<button class="ghost" id="edLoad" type="button">Gespeicherte Pläne</button>
<button class="ghost" id="edPdf" type="button">Als PDF / drucken</button>
</div>
<div class="edgrid">
<div class="edcanvas" id="edPlan"></div>
<div class="edside">
<section class="panel">
<div class="head"><span class="label">Anbauen am gewählten Ende</span></div>
<div class="panel-body" id="edPalette"></div>
</section>
<section class="panel">
<div class="head"><span class="label">Offene Enden</span></div>
<div class="panel-body" id="edEnds"></div>
</section>
<section class="panel">
<div class="head"><span class="label">Dein Plan</span></div>
<div class="panel-body" id="edInfo"></div>
</section>
</div>
</div>
</div>
</section>
</main>
<dialog id="dlg">
<div class="dhead">
<h2 id="dlgTitle">Vorschlag</h2>
<div class="spacer"></div>
<button class="ghost" id="numsBtn" type="button" aria-pressed="true">Nummern</button>
<button class="ghost" id="toEditBtn" type="button">Selbst weiterbauen</button>
<button class="ghost" id="pdfBtn" type="button">Als PDF / drucken</button>
<button class="ghost" id="closeBtn" type="button">Schließen</button>
</div>
<div class="dbody"><div class="dgrid">
<div class="bigplan" id="dlgPlan"></div>
<div class="side" id="dlgSide"></div>
</div></div>
</dialog>
<dialog id="storeDlg">
<div class="dhead"><h2>Gespeicherte Pläne</h2><div class="spacer"></div>
<button class="ghost" id="storeClose" type="button">Schließen</button></div>
<div class="dbody">
<div id="storeList"></div>
<div style="margin-top:18px">
<span class="label" style="display:block;margin-bottom:6px">Sichern und weitergeben</span>
<p class="note" style="margin:0 0 8px">Der Text unten ist dein Plan. Kopiere ihn als Sicherung — oder füge einen fremden Text ein und drücke Einlesen.</p>
<textarea id="storeText" rows="5" spellcheck="false"></textarea>
<div style="display:flex; gap:8px; margin-top:8px">
<button class="ghost" id="storeCopy" type="button">Text kopieren</button>
<button class="ghost" id="storeImport" type="button">Text einlesen</button>
</div>
</div>
</div>
</dialog>
<div id="printRoot"></div>
<script>
// ==ENGINE-START==
/* ---------------------------------------------------------------
Maße in Millimetern. 1 Noppe = 8 mm.
Alle Richtungen sind Vielfache von 22,5°, gezählt in 16 Schritten.
----------------------------------------------------------------*/
/* Richtungen laufen in 32 Schritten zu 11,25°. Vorher waren es 16 zu
22,5° — das reicht für LEGO-Kurven, aber die weiten Fremdhersteller-
Radien ab R72 sind 11,25°-Stücke (32 je Vollkreis) und fielen damit
durchs Raster. Jede Kurve trägt jetzt ihren Winkel als Anzahl
Schritte (a) bei sich. */
const STUD = 8, U = Math.PI / 16, DIRS = 32, MASK = 31, TRACK_W = 64, GAUGE = 40;
const HALF_TURN = DIRS / 2;
const COS = [], SIN = [];
for (let i = 0; i < DIRS; i++) { COS[i] = Math.cos(i * U); SIN[i] = Math.sin(i * U); }
/** Winkel eines Zuges im Bogenmaß. */
const angOf = m => (m.a || 2) * U;
/* Teilekatalog. k: straight | curve | switch | cross | buffer
len = Länge in mm, r = Radius in mm, s = Abzweigrichtung (1 links, -1 rechts) */
/* Teilekatalog. k: straight | curve | switch | cross | buffer | multi
len = Länge in mm, r = Radius in mm, a = Winkel in 11,25°-Schritten
(2 = 22,5°, 1 = 11,25°), s = Abzweigrichtung (1 links, -1 rechts).
Die Winkel sind nicht geraten: bis R56 gehen 16 Stück auf den Vollkreis,
ab R72 sind es 32 — nachgeschlagen bei TrixBrix. */
const CATALOG = [
{ id:'S16', g:'std', n:'Gerade', note:'Straight · 16 Noppen', k:'straight', len:128, def:8 },
{ id:'S8', g:'std', n:'Halbe Gerade', note:'8 Noppen', k:'straight', len:64, def:0 },
{ id:'C40', g:'std', n:'Kurve R40', note:'Bending · 22,5° · 16 je Kreis', k:'curve', r:320, a:2, def:16 },
{ id:'FLX', g:'std', n:'Flexschiene', note:'Soft track · hier wie 16 gerade', k:'straight', len:128, flex:true, def:0 },
{ id:'WL', g:'std', n:'Weiche links', note:'32 Noppen lang · Abzweig 22,5°', k:'switch', len:256, s:1, def:0 },
{ id:'WR', g:'std', n:'Weiche rechts', note:'32 Noppen lang · Abzweig 22,5°', k:'switch', len:256, s:-1, def:0 },
{ id:'X90', g:'std', n:'Kreuzung 90°', note:'zwei Geraden über Kreuz', k:'cross', len:128, ang:8, def:0 },
{ id:'BUE', g:'std', n:'Bahnübergang', note:'Gerade mit Straße · 16 Noppen', k:'straight', len:128, road:true, def:0 },
{ id:'PRB', g:'std', n:'Prellbock', note:'Gleisende · nur am Stichgleis', k:'buffer', def:0 },
{ id:'DKW', g:'std', n:'Doppelte Kreuzungsweiche', note:'kreuzt unter 45° · vier Zungen', k:'multi', def:0 },
{ id:'S32', g:'alt', n:'Doppelgerade', note:'32 Noppen', k:'straight', len:256, def:0 },
{ id:'S4', g:'alt', n:'Viertelgerade', note:'4 Noppen', k:'straight', len:32, def:0 },
{ id:'S2', g:'alt', n:'Achtelgerade', note:'2 Noppen · zum Ausgleichen', k:'straight', len:16, def:0 },
{ id:'C24', g:'alt', n:'Kurve R24', note:'22,5° · sehr eng · 16 je Kreis', k:'curve', r:192, a:2, def:0 },
{ id:'C32', g:'alt', n:'Kurve R32', note:'22,5° · 16 je Kreis', k:'curve', r:256, a:2, def:0 },
{ id:'C56', g:'alt', n:'Kurve R56', note:'22,5° · weit · 16 je Kreis', k:'curve', r:448, a:2, def:0 },
{ id:'C72', g:'alt', n:'Kurve R72', note:'11,25° · 32 je Kreis', k:'curve', r:576, a:1, def:0 },
{ id:'C88', g:'alt', n:'Kurve R88', note:'11,25° · 32 je Kreis', k:'curve', r:704, a:1, def:0 },
{ id:'C104',g:'alt', n:'Kurve R104', note:'11,25° · 32 je Kreis', k:'curve', r:832, a:1, def:0 },
{ id:'C120',g:'alt', n:'Kurve R120', note:'11,25° · 32 je Kreis', k:'curve', r:960, a:1, def:0 },
{ id:'C136',g:'alt', n:'Kurve R136', note:'11,25° · 32 je Kreis', k:'curve', r:1088, a:1, def:0 },
{ id:'C152',g:'alt', n:'Kurve R152', note:'11,25° · 32 je Kreis', k:'curve', r:1216, a:1, def:0 },
{ id:'C168',g:'alt', n:'Kurve R168', note:'11,25° · 32 je Kreis', k:'curve', r:1344, a:1, def:0 },
{ id:'C184',g:'alt', n:'Kurve R184', note:'11,25° · 32 je Kreis', k:'curve', r:1472, a:1, def:0 },
{ id:'X45', g:'alt', n:'Kreuzung 45°', note:'flache Kreuzung', k:'cross', len:128, ang:4, def:0 },
{ id:'X22', g:'alt', n:'Kreuzung 22,5°', note:'sehr flache Kreuzung', k:'cross', len:128, ang:2, def:0 },
{ id:'WL56',g:'alt', n:'Breite Weiche links', note:'R56 · 32 Noppen', k:'switch', len:256, r:448, s:1, def:0 },
{ id:'WR56',g:'alt', n:'Breite Weiche rechts', note:'R56 · 32 Noppen', k:'switch', len:256, r:448, s:-1, def:0 },
{ id:'SKW', g:'alt', n:'Einfache Kreuzungsweiche', note:'kreuzt unter 45° · zwei Zungen', k:'multi', def:0 },
{ id:'WY', g:'alt', n:'Y-Weiche', note:'teilt sich symmetrisch, je 22,5°', k:'multi', def:0 },
{ id:'W3', g:'alt', n:'Dreiwegweiche', note:'gerade, links und rechts · 32 Noppen', k:'multi', len:256, def:0 }
];
const GROUPS = { std:'LEGO & kompatibel', alt:'Fremdhersteller (Trixbrix & Co.)', own:'Eigene Schienen' };
/* Zugfarben für die Teiletyp-Ansicht — gedeckt, gut unterscheidbar, hell wie dunkel lesbar. */
const TYPE_COLORS = ['#2E7D8F','#C0803A','#6B5CA5','#4C8B4A','#B0566E','#3F6FA8','#8A6A45','#7F8B2E','#B4653A','#4E8C86'];
function partIndex(list) { const m = {}; for (const p of list) m[p.id] = p; return m; }
/* ---- Bewegungen: ein Zug durch ein Teil ---- */
function movesOf(p) {
if (p.k === 'curve') return [{ id:p.id, t:'C', r:p.r, a:p.a, s:1 }, { id:p.id, t:'C', r:p.r, a:p.a, s:-1 }];
if (p.k === 'straight') return [{ id:p.id, t:'S', len:p.len }];
return [];
}
/* Eine Dreiwegweiche hat gerade durch und je einen Abzweig nach beiden
Seiten — ihre Abzweige sind exakt die einer gewöhnlichen Weiche. Sie
kann deshalb überall dort einspringen, wo eine Links- oder eine
Rechtsweiche gebraucht wird. */
const isSwitchLike = p => p.k === 'switch' || (p.k === 'multi' && p.id === 'W3');
/* Eine Y-Weiche taugt nicht als Ersatz für eine gewöhnliche Weiche — sie
hat keinen geraden Strang. Einen Abzweig trägt sie trotzdem. */
const isYSwitch = p => p.k === 'multi' && p.id === 'WY';
const isBranchPart = p => isSwitchLike(p) || isYSwitch(p);
const switchSideOK = (p, side) => p.k === 'switch' ? p.s === side : isSwitchLike(p);
/* Doppelte Kreuzungsweiche: zwei Routen kreuzen sich unter 45°, dazu in
zwei Feldern ein Verbindungsbogen. Die Bögen entsprechen laut Hersteller
je zwei Standardkurven, also 45° bei Radius 40. Damit ein solcher Bogen
tangential an beiden Geraden liegt, misst jede Route 2·R·tan(22,5°) =
265,1 mm. Daraus folgen alle vier Anschlüsse. */
const DKW_R = 320, DKW_ANG = Math.PI / 4; // Ablenkung 45°
const DKW_LEN = 2 * DKW_R * Math.tan(DKW_ANG / 2); // Tangente ab Kreuzungsmitte
const DKW_F = DKW_R * Math.sin(DKW_ANG), DKW_L = DKW_R * (1 - Math.cos(DKW_ANG));
function multiOf(part) {
if (!part || part.k !== 'multi') return null;
const arc45 = s2 => {
const arcs = [{ r:DKW_R, deg:45, s:s2 }];
const c = composeArcs(arcs);
return { id:part.id, t:'T', arcs, fwd:c.fwd, lat:c.lat, dh:(s2 > 0 ? 4 : -4), s:s2 };
};
const straight = len => ({ id:part.id, t:'S', len });
if (part.id === 'DKW' || part.id === 'SKW') {
// Zwei Routen kreuzen sich unter 45°; in einem Feld (SKW) oder in
// zweien (DKW) liegt ein Verbindungsbogen aus zwei Standardkurven.
const routes = [
{ dx:0, dy:0, dh:0, m:straight(DKW_LEN) },
{ dx:DKW_LEN - DKW_F, dy:-DKW_L, dh:4, m:straight(DKW_LEN) },
{ dx:0, dy:0, dh:0, m:arc45(1) }
];
if (part.id === 'DKW') routes.push({ dx:DKW_LEN - DKW_F, dy:-DKW_L, dh:4, m:arc45(-1) });
return { len:DKW_LEN, routes, cons:[
{ dx:0, dy:0, dh:16 },
{ dx:DKW_LEN, dy:0, dh:0 },
{ dx:DKW_LEN - DKW_F, dy:-DKW_L, dh:20 },
{ dx:DKW_F, dy:DKW_L, dh:4 }
] };
}
if (part.id === 'WY') {
// Y-Weiche: beide Stränge biegen symmetrisch ab, je eine R40-Kurve.
const cl = { id:part.id, t:'C', r:320, a:2, s:1 };
const cr = { id:part.id, t:'C', r:320, a:2, s:-1 };
const e = go({ x:0, y:0, h:0 }, cl);
return { len:e.x, routes:[
{ dx:0, dy:0, dh:0, m:cl },
{ dx:0, dy:0, dh:0, m:cr }
], cons:[
{ dx:0, dy:0, dh:16 },
{ dx:e.x, dy:e.y, dh:2 },
{ dx:e.x, dy:-e.y, dh:30 }
] };
}
if (part.id === 'W3') {
// Dreiwegweiche: gerade durch, dazu je ein Abzweig nach beiden Seiten.
const bl = switchBranch(part.id, 1), br = switchBranch(part.id, -1);
return { len:256, routes:[
{ dx:0, dy:0, dh:0, m:straight(256) },
{ dx:0, dy:0, dh:0, m:bl },
{ dx:0, dy:0, dh:0, m:br }
], cons:[
{ dx:0, dy:0, dh:16 },
{ dx:256, dy:0, dh:0 },
{ dx:bl.fwd, dy:bl.lat, dh:2 },
{ dx:br.fwd, dy:br.lat, dh:30 }
] };
}
return null;
}
/** Lokale Lage in Weltkoordinaten umrechnen. */
const localTo = (base, dx, dy, dh) => ({
x: base.x + COS[base.h] * dx - SIN[base.h] * dy,
y: base.y + SIN[base.h] * dx + COS[base.h] * dy,
h: (base.h + dh) & MASK
});
/** Der abzweigende Strang einer Weiche. */
const branchOf = p => switchBranch(p.id, p.s === -1 ? -1 : 1);
/* Derselbe Zug rückwärts befahren. Bei einer einfachen Kurve genügt es,
die Richtung zu spiegeln — bei einem zusammengesetzten Zug wie dem
Weichenabzweig stimmt das nicht: dort muss die Verschiebung in das
umgedrehte Zielkoordinatensystem gerechnet werden. */
function reverseMove(m) {
if (m.t === 'S') return m;
if (m.t === 'C') return { ...m, s:-m.s };
const th = m.dh * U, c = Math.cos(th), sn = Math.sin(th);
return {
id:m.id, t:'T', dh:-m.dh, s:-m.s,
fwd: m.fwd * c + m.lat * sn,
lat: -m.fwd * sn + m.lat * c,
arcs: m.arcs.slice().reverse().map(a => ({ r:a.r, deg:a.deg, s:-a.s }))
};
}
/* Der Abzweig einer Weiche ist kein einfacher Bogen, sondern zwei
R40-Bögen hintereinander: erst 36,87° heraus, dann 14,37° zurück.
Netto 22,5°. Das Ergebnis ist der Grund, warum LEGO-Gleise aufgehen:
Weiche + Gegenkurve landen auf 48 Noppen längs und 16 Noppen quer —
beides glatte Rastermaße. Solche Züge bekommen den Typ 'T'. */
function composeArcs(arcs) {
let x = 0, y = 0, a = 0;
for (const c of arcs) {
const ang = c.deg * Math.PI / 180;
const f = c.r * Math.sin(ang), l = c.r * (1 - Math.cos(ang)) * c.s;
x += Math.cos(a) * f - Math.sin(a) * l;
y += Math.sin(a) * f + Math.cos(a) * l;
a += ang * c.s;
}
return { fwd:x, lat:y, rot:a };
}
function switchBranch(id, s) {
/* Ohne klare Seitenangabe käme aus composeArcs stillschweigend NaN —
und NaN besteht jede Abstandsprüfung. Deshalb hier hart abbrechen. */
if (s !== 1 && s !== -1) throw new Error('switchBranch: Seite fehlt für ' + id);
const arcs = [{ r:320, deg:36.87, s }, { r:320, deg:14.37, s:-s }];
const c = composeArcs(arcs);
return { id, t:'T', arcs, fwd:c.fwd, lat:c.lat, dh:(s > 0 ? 2 : -2), s };
}
/** Weltposen an den Nahtstellen der Teilbögen. */
function subPoses(p, m) {
let x = p.x, y = p.y, a = p.h * U;
const out = [{ x, y, a }];
for (const c of m.arcs) {
const ang = c.deg * Math.PI / 180;
const f = c.r * Math.sin(ang), l = c.r * (1 - Math.cos(ang)) * c.s;
x += Math.cos(a) * f - Math.sin(a) * l;
y += Math.sin(a) * f + Math.cos(a) * l;
a += ang * c.s;
out.push({ x, y, a });
}
return out;
}
function go(p, m) {
if (m.t === 'S') return { x: p.x + COS[p.h] * m.len, y: p.y + SIN[p.h] * m.len, h: p.h };
if (m.t === 'T') return {
x: p.x + COS[p.h] * m.fwd - SIN[p.h] * m.lat,
y: p.y + SIN[p.h] * m.fwd + COS[p.h] * m.lat,
h: (p.h + m.dh + DIRS) & MASK
};
const ang = angOf(m);
const fwd = m.r * Math.sin(ang), lat = m.r * (1 - Math.cos(ang)) * m.s;
return {
x: p.x + COS[p.h] * fwd - SIN[p.h] * lat,
y: p.y + SIN[p.h] * fwd + COS[p.h] * lat,
h: (p.h + (m.a || 2) * (m.s > 0 ? 1 : -1) + DIRS) & MASK
};
}
function shapeOf(p, m) {
if (m.t === 'S') { const q = go(p, m); return [[p.x, p.y], [q.x, q.y]]; }
if (m.t === 'T') {
const out = [[p.x, p.y]];
let x = p.x, y = p.y, a = p.h * U;
for (const c of m.arcs) {
const ang = c.deg * Math.PI / 180;
for (let i = 1; i <= 2; i++) {
const t = ang * i / 2, f = c.r * Math.sin(t), l = c.r * (1 - Math.cos(t)) * c.s;
out.push([x + Math.cos(a) * f - Math.sin(a) * l, y + Math.sin(a) * f + Math.cos(a) * l]);
}
const f = c.r * Math.sin(ang), l = c.r * (1 - Math.cos(ang)) * c.s;
x += Math.cos(a) * f - Math.sin(a) * l;
y += Math.sin(a) * f + Math.cos(a) * l;
a += ang * c.s;
}
return out;
}
const out = [[p.x, p.y]], ang = angOf(m);
for (let i = 1; i <= 3; i++) {
const a = ang * i / 3, f = m.r * Math.sin(a), t = m.r * (1 - Math.cos(a)) * m.s;
out.push([p.x + COS[p.h] * f - SIN[p.h] * t, p.y + SIN[p.h] * f + COS[p.h] * t]);
}
return out;
}
const reachOf = m => m.t === 'S' ? m.len
: m.t === 'T' ? Math.hypot(m.fwd, m.lat)
: 2 * m.r * Math.sin(angOf(m) / 2);
const midOf = (p, m) => { const q = go(p, m); return [(p.x + q.x) / 2, (p.y + q.y) / 2]; };
function segDist(ax, ay, bx, by, cx, cy, dx, dy) {
const ux = bx - ax, uy = by - ay, vx = dx - cx, vy = dy - cy, wx = ax - cx, wy = ay - cy;
const a = ux * ux + uy * uy, b = ux * vx + uy * vy, c = vx * vx + vy * vy;
const d = ux * wx + uy * wy, e = vx * wx + vy * wy, den = a * c - b * b;
let s, t;
if (den < 1e-9) { s = 0; t = c > 1e-9 ? e / c : 0; }
else { s = (b * e - c * d) / den; t = (a * e - b * d) / den; }
s = Math.max(0, Math.min(1, s));
t = c > 1e-9 ? Math.max(0, Math.min(1, (e + b * s) / c)) : 0;
s = a > 1e-9 ? Math.max(0, Math.min(1, (b * t - d) / a)) : 0;
return Math.hypot(ax + ux * s - (cx + vx * t), ay + uy * s - (cy + vy * t));
}
function boxOf(pts) {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const [x, y] of pts) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; }
return [x0, y0, x1, y1];
}
function tooClose(a, b, ba, bb, min) {
if (ba[0] - bb[2] > min || bb[0] - ba[2] > min || ba[1] - bb[3] > min || bb[1] - ba[3] > min) return false;
for (let i = 0; i < a.length - 1; i++)
for (let j = 0; j < b.length - 1; j++)
if (segDist(a[i][0], a[i][1], a[i+1][0], a[i+1][1], b[j][0], b[j][1], b[j+1][0], b[j+1][1]) < min) return true;
return false;
}
/* ---- Kreuzung: dürfen sich diese beiden Teile überschneiden? ---- */
function crossFit(A, B, cross) {
if (A.m.t !== 'S' || B.m.t !== 'S') return false;
if (Math.abs(A.m.len - cross.len) > 1 || Math.abs(B.m.len - cross.len) > 1) return false;
const d = (A.p.h - B.p.h + DIRS) & MASK;
if (d !== cross.ang && d !== ((DIRS - cross.ang) & MASK)) return false;
return Math.hypot(A.mid[0] - B.mid[0], A.mid[1] - B.mid[1]) < 2;
}
/* ---- Kennzahlen ----
Eine Weiche ersetzt die Gerade, an der sie sitzt — die zählt also nicht
mit. Ein Ausweichgleis verbraucht zwei Weichen und die Teile dazwischen. */
function branchRun(b) {
const head = { id:b.switchId, m:b.switchMove, p:b.switchStart,
pts:shapeOf(b.switchStart, b.switchMove), branchHead:true, branchLeg:true };
head.box = boxOf(head.pts);
const out = [head, ...b.pieces];
if (b.kind === 'loop') {
const tail = { id:b.switchId2, m:b.tailMove, p:b.tailStart,
pts:shapeOf(b.tailStart, b.tailMove), branchTail:true, branchLeg:true };
tail.box = boxOf(tail.pts);
out.push(tail);
}
return out;
}
function measure(loop, branches, rings) {
branches = branches || [];
rings = rings || [];
const replaced = new Set();
for (const b of branches) {
for (const k of (b.span || [b.at])) replaced.add(k);
for (const k of (b.span2 || (b.at2 !== undefined ? [b.at2] : []))) replaced.add(k);
}
let pts = [];
for (const q of loop) pts = pts.concat(q.pts);
for (const b of branches) for (const q of branchRun(b)) pts = pts.concat(q.pts);
for (const r of rings) for (const q of r) pts = pts.concat(q.pts);
const box = boxOf(pts);
const use = {};
const bump = id => use[id] = (use[id] || 0) + 1;
for (let i = 0; i < loop.length; i++) {
const q = loop[i];
if (replaced.has(i)) continue;
if (q.crossWith === undefined || q.crossHost) bump(q.crossHost ? q.crossId : q.id);
}
for (const b of branches) {
bump(b.switchId);
if (b.switchId2) bump(b.switchId2);
for (const q of b.pieces) bump(q.id);
if (b.buffer) bump(b.buffer);
}
for (let ri = 0; ri < rings.length; ri++) {
const skip = new Set(branches.filter(b => b.ringIdx === ri).map(b => b.atRing));
const ring = rings[ri];
for (let i = 0; i < ring.length; i++) {
if (skip.has(i)) continue;
const q = ring[i];
if (q.crossWith !== undefined && !q.crossHost) continue;
bump(q.crossHost ? q.crossId : q.id);
}
}
let len = 0;
const arcLen = m => m.t === 'S' ? m.len
: m.t === 'T' ? m.arcs.reduce((a, c) => a + c.r * c.deg * Math.PI / 180, 0)
: m.r * angOf(m);
const addLen = q => len += arcLen(q.m);
for (const q of loop) addLen(q);
for (const b of branches) for (const q of branchRun(b)) addLen(q);
for (const r of rings) for (const q of r) addLen(q);
const w = box[2] - box[0] + TRACK_W, h = box[3] - box[1] + TRACK_W;
const n = Object.values(use).reduce((a, b2) => a + b2, 0);
return { loop, branches, rings, box, w, h, use, n, area: w * h, len };
}
/* ---- Vergleichsschlüssel ----
Zwei Pläne sind dieselbe Runde, wenn sie sich nur im Startpunkt, in der
Fahrtrichtung oder in der Spiegelung unterscheiden. Jede Teileart wird auf
ein Zeichen abgebildet; die kleinste Drehung findet Booths Algorithmus in
linearer Zeit — bei 56 Teilen ist das der Unterschied zwischen brauchbar
und unbenutzbar. */
const _code = new Map(), _key = [], _flip = new Map();
function codeChar(k) {
let c = _code.get(k);
if (c === undefined) { c = String.fromCharCode(48 + _key.length); _code.set(k, c); _key.push(k); }
return c;
}
function flipChar(ch) {
let f = _flip.get(ch);
if (f === undefined) {
const k = _key[ch.charCodeAt(0) - 48];
f = codeChar(k.endsWith('<') ? k.slice(0, -1) + '>' : k.endsWith('>') ? k.slice(0, -1) + '<' : k);
_flip.set(ch, f);
}
return f;
}
function tokens(loop) {
let s = '';
for (const q of loop) s += codeChar(q.m.t === 'S' ? q.id : q.id + (q.m.s > 0 ? '<' : '>'));
return s;
}
/** Kleinste Rotation einer Zeichenkette (Booth). */
function leastRotation(s) {
const n = s.length, t = s + s, f = new Int32Array(2 * n).fill(-1);
let k = 0;
for (let j = 1; j < 2 * n; j++) {
const sj = t[j];
let i = f[j - k - 1];
while (i !== -1 && sj !== t[k + i + 1]) { if (sj < t[k + i + 1]) k = j - i - 1; i = f[i]; }
if (sj !== t[k + i + 1]) { if (sj < t[k]) k = j; f[j - k] = -1; }
else f[j - k] = i + 1;
}
return k;
}
function keyOfTokens(t) {
const flip = s => { let o = ''; for (const ch of s) o += flipChar(ch); return o; };
const rev = s => s.split('').reverse().join('');
let best = null;
for (const v of [t, flip(t), rev(t), flip(rev(t))]) {
const k = leastRotation(v);
const r = v.slice(k) + v.slice(0, k);
if (best === null || r < best) best = r;
}
return best;
}
function canon(loop) { return keyOfTokens(tokens(loop)); }
/* ---------------------------------------------------------------
Wegsuche von einer Pose zu einer anderen.
Dieselbe Maschine trägt zwei Dinge: die Schleifen einer Achterstrecke
zwischen den Armen einer Kreuzung, und das Ausweichgleis zwischen zwei
Weichen. turnTarget gibt vor, wie weit sich die Fahrtrichtung insgesamt
drehen soll — ohne diese Vorgabe steuert die Suche stur aufs Ziel zu,
während eine Schleife erst weit ausholen muss.
----------------------------------------------------------------*/
function searchPath(cfg) {
const { rem, from, to, obstacles, budget, maxLen, wantN, turnTarget,
moves, minGap, maxReach, curveIds } = cfg;
// Wieviel Versatz beim Anschluss durchgehen darf. Steckverbindungen haben
// Spiel; ohne diese Toleranz lässt sich an ein Weichenpaar gar kein
// Ausweichgleis anschließen, weil 122,46 mm Kurvensehne nie im 128er
// Raster aufgehen.
const tol = cfg.tol !== undefined ? cfg.tol : 1;
// Harte Zeitgrenze. Ohne sie kann eine einzige Wegsuche bei großen
// Anlagen minutenlang laufen — jeder Knoten prüft gegen alle Gleisteile.
const deadline = cfg.deadline || 0;
const seq = [], hits = [];
let nodes = 0, done = false;
const usableIds = [...new Set(moves.map(m => m.id))];
const left = () => { let s = 0; for (const id of usableIds) s += rem[id] || 0; return s; };
const steps = cfg.curveSteps || {};
const remCurves = () => { let c = 0; for (const id of curveIds) c += (rem[id] || 0) * (steps[id] || 2); return c; };
/* Wieviele Teile mindestens noch fehlen. Der Drehbedarf zählt in
Schritten, die Vorratszahl in Stücken — deshalb wird durch den
größten Kurvenwinkel geteilt, den der Vorrat hergibt. */
const maxStep = curveIds.reduce((a, id) => Math.max(a, steps[id] || 2), 1);
const need = (q, tn) => Math.max(Math.ceil(Math.abs(turnTarget - tn) / maxStep),
Math.ceil(Math.hypot(q.x - to.x, q.y - to.y) / maxReach - 1e-6));
const turnOf = m => m.t === 'C' ? (m.a || 2) * (m.s > 0 ? 1 : -1) : m.t === 'T' ? m.dh : 0;
// Wie viel Umweg zugelassen wird. Je Lauf zufällig, damit wiederholte
// Läufe unterschiedliche Wege finden.
const greed = 0.6 + Math.random() * 2.2;
const w = {};
for (const m of moves) w[m.id + (m.t === 'C' ? (m.s > 0 ? '<' : '>') : '')] = 0.3 + Math.random() * 1.6;
function order() {
const out = [];
for (const m of moves) {
if ((rem[m.id] || 0) <= 0) continue;
out.push([-Math.log(Math.random() + 1e-9) / w[m.id + (m.t === 'C' ? (m.s > 0 ? '<' : '>') : '')], m]);
}
return out.sort((a, b) => a[0] - b[0]).map(e => e[1]);
}
function dfs(cur, tn) {
if (done || ++nodes > budget || seq.length >= maxLen) return;
if (deadline && (nodes & 255) === 0 && now() > deadline) { done = true; return; }
const tight = left() - need(cur, tn) <= 2;
const list = order()
.map(m => [need(go(cur, m), tn + turnOf(m)) + (tight ? 0 : Math.random() * greed), m])
.sort((a, b) => a[0] - b[0])
.map(e => e[1]);
for (const m of list) {
if (done) return;
const nx = go(cur, m), ntn = tn + turnOf(m);
const arrive = ntn === turnTarget && Math.hypot(nx.x - to.x, nx.y - to.y) < tol;
const piece = { id:m.id, m, p:cur, pts:shapeOf(cur, m), mid:midOf(cur, m) };
piece.box = boxOf(piece.pts);
let bad = false;
for (const o of obstacles) {
if (o.skipFirst && seq.length === 0) continue;
if (o.skipLast && arrive) continue;
const g = o.gap !== undefined ? o.gap : (o.soft ? minGap - 6 : minGap);
if (tooClose(piece.pts, o.pts, piece.box, o.box, g)) { bad = true; break; }
}
if (!bad) for (let j = 0; j <= seq.length - 2; j++)
if (tooClose(piece.pts, seq[j].pts, piece.box, seq[j].box, minGap)) { bad = true; break; }
if (bad) continue;
rem[m.id]--; seq.push(piece);
if (arrive) { hits.push(seq.slice()); if (hits.length >= (wantN || 1)) done = true; }
else if (need(nx, ntn) <= left() && Math.abs(turnTarget - ntn) <= remCurves()) dfs(nx, ntn);
seq.pop(); rem[m.id]++;
}
}
dfs(from, 0);
return hits;
}
/* ---------------------------------------------------------------
Suche nach geschlossenen Runden.
1. punktsymmetrisch: eine halbe Strecke mit genau 180° Wende ergibt
verdoppelt zwingend eine geschlossene Runde.
2. frei: Tiefensuche, die den Startpunkt punktgenau treffen muss.
----------------------------------------------------------------*/
function createSolver(stock, parts, opt) {
const minGap = opt.gap;
const idx = partIndex(parts);
const moves = [];
for (const p of parts) if (stock[p.id] > 0) for (const m of movesOf(p)) moves.push(m);
/* Für eine Achterstrecke zählt an einer Kreuzungsweiche nur die
Kreuzung selbst: zwei Gleise unter 45°. Wer eine DKW besitzt, aber
keine einfache Kreuzung, bekommt trotzdem Achterstrecken. */
const crossable = p => p.k === 'cross' ? { id:p.id, len:p.len, ang:p.ang }
: (p.k === 'multi' && (p.id === 'DKW' || p.id === 'SKW'))
? { id:p.id, len:DKW_LEN, ang:4 } : null;
const crosses = opt.useCross
? parts.map(p => (stock[p.id] > 0 ? crossable(p) : null)).filter(Boolean) : [];
const curveIds = parts.filter(p => p.k === 'curve').map(p => p.id);
const maxReach = moves.reduce((a, m) => Math.max(a, reachOf(m)), 1);
const totalPieces = Object.entries(stock).reduce((a, [id, c]) => a + (idx[id] && idx[id].k !== 'buffer' ? c : 0), 0);
// Obergrenze für die Teilezahl einer Strecke. Wer 500 Schienen hat, will
// keine 70-Teile-Runde — lange Strecken werden nur seltener gefunden.
const maxN = Math.min(totalPieces + (crosses.length ? 8 : 0), 120);
const found = new Map(), seen = new Set(), STOP = {};
let restarts = 0, gains = 0, capRun = 8, turn = 0;
/* Drehvermögen: eine 11,25°-Kurve dreht einen Schritt, eine 22,5°-Kurve
zwei. Gezählt werden Schritte, nicht Teile. */
const curveSteps = {};
for (const q of parts) if (q.k === 'curve') curveSteps[q.id] = q.a || 2;
const remCurves = rem => { let c = 0; for (const id of curveIds) c += (rem[id] || 0) * (curveSteps[id] || 2); return c; };
const turnsTo = (h, t) => { const d = (t - h + DIRS) & MASK; return Math.min(d, DIRS - d); };
function allUsed(rem) {
for (const p of parts) if (p.k !== 'buffer' && p.k !== 'switch' && (rem[p.id] || 0) > 0) return false;
return true;
}
/* Prüft die fertige Runde vollständig: geschlossen, nirgends zu nah
außer dort, wo eine Kreuzung liegt oder liegen kann. */
function validate(loop) {
const e = go(loop[loop.length - 1].p, loop[loop.length - 1].m), s = loop[0].p;
if (Math.abs(e.x - s.x) > 1 || Math.abs(e.y - s.y) > 1 || e.h !== s.h) return false;
const avail = {};
for (const c of crosses) avail[c.id] = stock[c.id] || 0;
for (const q of loop) if (q.crossHost) avail[q.crossId]--;
const n = loop.length;
for (let i = 0; i < n; i++)
for (let j = i + 2; j < n; j++) {
if (i === 0 && j === n - 1) continue;
// An den Armen einer Kreuzung liegen benachbarte Gleise bauartbedingt
// genau auf Grenzabstand — dort etwas Toleranz geben.
const gap = (loop[i].crossId || loop[j].crossId) ? minGap - 6 : minGap;
if (!tooClose(loop[i].pts, loop[j].pts, loop[i].box, loop[j].box, gap)) continue;
if (loop[i].crossWith === j || loop[j].crossWith === i) continue;
if (loop[i].crossWith !== undefined || loop[j].crossWith !== undefined) return false;
const c = crosses.find(cp => avail[cp.id] > 0 && crossFit(loop[j], loop[i], cp));
if (!c) return false;
avail[c.id]--;
loop[j].crossWith = i; loop[j].crossId = c.id; loop[j].crossHost = true;
loop[i].crossWith = j; loop[i].crossId = c.id;
}
return true;
}
/* Erst den Schlüssel prüfen, dann erst die Geometrie bauen — beim
Verdoppeln spart das den Großteil der Arbeit. */
function keepKeyed(tk, build) {
const k = keyOfTokens(tk);
if (seen.has(k)) return;
seen.add(k);
// Auf einer Kopie prüfen — validate() setzt Kreuzungsmarken,
// die die laufende Suche nicht sehen darf.
const copy = build().map(q => ({ ...q }));
if (!validate(copy)) return;
found.set(k, copy);
if (++gains >= capRun) throw STOP;
}
const keep = loop => keepKeyed(tokens(loop), () => loop);
function weights() {
const w = {}, sideBias = 0.55 + Math.random() * 0.44;
const left = Math.random() < 0.5;
// Jeder dritte Lauf bevorzugt Geraden stark. Nur so entstehen lange
// gerade Abschnitte — und nur dort passen zwei Weichen mit einem
// Ausweichgleis dazwischen hinein.
const straightRun = Math.random() < 0.35;
const curveBias = straightRun ? 0.12 + Math.random() * 0.25 : 0.4 + Math.random() * 1.8;
for (const m of moves) {
const key = m.id + (m.t === 'C' ? (m.s > 0 ? '<' : '>') : '');
w[key] = m.t === 'S'
? (straightRun ? 2.5 + Math.random() * 3 : 0.25 + Math.random() * 1.5)
: curveBias * ((m.s > 0) === left ? sideBias : 1 - sideBias);
}
return w;
}
function order(rem, w) {
const out = [];
for (const m of moves) {
if ((rem[m.id] || 0) <= 0) continue;
const key = m.id + (m.t === 'C' ? (m.s > 0 ? '<' : '>') : '');
out.push([-Math.log(Math.random() + 1e-9) / w[key], m]);
}
return out.sort((a, b) => a[0] - b[0]).map(e => e[1]);
}
/* Gemeinsamer Kern beider Verfahren.
stopAt: 'origin' = Runde schließen, 'half' = 180° gedreht, dann verdoppeln. */
function walk(budget, mode) {
const rem = {};
for (const p of parts) rem[p.id] = mode === 'origin' ? (stock[p.id] || 0) : (stock[p.id] || 0) >> 1;
const seq = [], w = weights();
let nodes = 0;
/* Für Bahnhöfe braucht die Runde eine lange gerade Strecke. Die wird
zu Beginn jeder Hälfte fest gelegt, sonst entsteht sie kaum. */
let forced = null;
if (opt.needRun && mode === 'half') {
const st = parts.find(x => x.k === 'straight' && Math.abs(x.len - 128) < 1 && rem[x.id] >= opt.needRun);
if (st) forced = { id:st.id, t:'S', len:128 };
}
/* Spiegelmodus: die Strecke startet senkrecht zur Spiegelachse und
muss senkrecht auf sie zurückkommen — Achse ist y = 0. Das ist
eine Bedingung mehr als beim Punktsymmetrie-Modus und deshalb
seltener, aber weit häufiger als das blinde Treffen des
Ursprungs. Dafür entstehen ganz andere Umrisse. */
const MIRROR_IN = DIRS / 4, MIRROR_OUT = (MIRROR_IN + HALF_TURN) & MASK;
const startPose = mode === 'mirror' ? { x:0, y:0, h:MIRROR_IN } : { x:0, y:0, h:0 };
const maxStep = curveIds.reduce((a, id) => Math.max(a, curveSteps[id] || 2), 1);
const need = q => Math.max(Math.ceil(turnsTo(q.h, 0) / maxStep),
Math.ceil(Math.hypot(q.x, q.y) / maxReach - 1e-6));
// Nur Teile zählen, die in der Runde überhaupt verlegt werden können.
const usableIds = [...new Set(moves.map(m => m.id))];
const usableLeft = () => { let s = 0; for (const id of usableIds) s += rem[id] || 0; return s; };
function dfs(cur) {
if (++nodes > budget) throw STOP;
let list = order(rem, w);
if (forced && seq.length < opt.needRun) list = [forced];
if (mode === 'origin') {
const left = usableLeft();
if (left - need(cur) <= 2) {
list = list.map(m => [need(go(cur, m)), m]).sort((a, b) => a[0] - b[0]).map(e => e[1]);
}
}
for (const m of list) {
const nx = go(cur, m);
const closing = mode === 'origin'
? (nx.h === 0 && Math.abs(nx.x) < 1 && Math.abs(nx.y) < 1)
: false;
const piece = { id:m.id, m, p:cur, pts:shapeOf(cur, m), mid:midOf(cur, m) };
piece.box = boxOf(piece.pts);
// Abstände zu den bisherigen Teilen; Kreuzungen sind die Ausnahme.
let blocked = false, pair = null;
for (let j = closing ? 1 : 0; j <= seq.length - 2; j++) {
if (!tooClose(piece.pts, seq[j].pts, piece.box, seq[j].box, minGap)) continue;
if (pair || seq[j].crossWith !== undefined) { blocked = true; break; }
const c = crosses.find(cp => (rem[cp.id] || 0) > 0 && crossFit(piece, seq[j], cp));
if (!c) { blocked = true; break; }
pair = { j, id:c.id };
}
if (blocked) continue;
rem[m.id]--;
if (pair) {
rem[pair.id]--; rem[m.id]++; rem[seq[pair.j].id]++;
piece.crossWith = pair.j; piece.crossId = pair.id; piece.crossHost = true;
seq[pair.j].crossWith = seq.length; seq[pair.j].crossId = pair.id;
}
seq.push(piece);
if (mode === 'origin' && closing) {
if (seq.length >= 12 && (!opt.useAll || allUsed(rem))) keep(seq);
} else if (mode === 'half' && nx.h === HALF_TURN && seq.length >= 6 && (!opt.useAll || allUsed(rem))) {
const tk = tokens(seq);
keepKeyed(tk + tk, () => doubleUp(seq));
} else if (mode === 'mirror' && nx.h === MIRROR_OUT && Math.abs(nx.y) < 0.5 &&
seq.length >= 4 && (!opt.useAll || allUsed(rem))) {
const tk = tokens(seq);
keepKeyed(tk + tk.split('').reverse().join(''), () => mirrorUp(seq));
}
const target = mode === 'half' ? HALF_TURN : mode === 'mirror' ? MIRROR_OUT : 0;
if (!closing && seq.length < (mode === 'origin' ? maxN : Math.ceil(maxN / 2)) &&
turnsTo(nx.h, target) <= remCurves(rem) &&
(mode !== 'origin' || need(nx) <= usableLeft()) &&
(mode !== 'mirror' || Math.abs(nx.y) <= usableLeft() * maxReach))
dfs(nx);
seq.pop();
if (pair) {
delete seq[pair.j].crossWith; delete seq[pair.j].crossId;
rem[pair.id]++; rem[m.id]--; rem[seq[pair.j].id]--;
}
rem[m.id]++;
}
}
try { dfs(startPose); } catch (e) { if (e !== STOP) throw e; }
restarts++;
}
/* Halbe Strecke an der Achse spiegeln: die zweite Hälfte ist dieselbe
Folge rückwärts befahren. Das ergibt Tropfen, U- und Hantelformen —
Umrisse, die punktsymmetrisch gar nicht entstehen können. */
function mirrorUp(half) {
const out = half.map(q => ({ id:q.id, m:q.m, p:q.p, pts:q.pts, mid:q.mid, box:q.box }));
let cur = go(half[half.length - 1].p, half[half.length - 1].m);
for (let i = half.length - 1; i >= 0; i--) {
const m = half[i].m;
const piece = { id:m.id, m, p:cur, pts:shapeOf(cur, m), mid:midOf(cur, m) };
piece.box = boxOf(piece.pts);
out.push(piece);
cur = go(cur, m);
}
return out;
}
/* Halbe Strecke zweimal legen: die zweite Hälfte ist die um 180° gedrehte erste. */
function doubleUp(half) {
const out = half.map(q => ({ id:q.id, m:q.m, p:q.p, pts:q.pts, mid:q.mid, box:q.box }));
let cur = go(half[half.length - 1].p, half[half.length - 1].m);
for (const q of half) {
const piece = { id:q.id, m:q.m, p:cur, pts:shapeOf(cur, q.m), mid:midOf(cur, q.m) };
piece.box = boxOf(piece.pts);
out.push(piece);
cur = go(cur, q.m);
}
return out;
}
/* ---- Weg von einer Pose zu einer Zielpose ---- */
const findPath = (rem, from, to, obstacles, budget, maxLen, wantN, turnTarget) =>
searchPath({ rem, from, to, obstacles, budget, maxLen, wantN, turnTarget,
moves, minGap, maxReach, curveIds, curveSteps });
/* ---- Runde mit Kreuzung: Achterstrecken und sich kreuzende Gleise ---- */
function crossRun(budget) {
const avail = crosses.filter(c => (stock[c.id] || 0) > 0);
if (!avail.length) return;
const c = avail[(Math.random() * avail.length) | 0];
const rem = {};
for (const p of parts) rem[p.id] = (stock[p.id] || 0) >> 1;
rem[c.id] = 0;
const half = c.len / 2, a = c.ang;
// Route 1 liegt waagerecht, Route 2 um den Kreuzungswinkel gedreht.
const A0 = { x:-half, y:0, h:0 }, A1 = { x:half, y:0, h:0 };
const B0 = { x:-half * COS[a], y:-half * SIN[a], h:a };
const B1 = { x: half * COS[a], y: half * SIN[a], h:a };
const mk = p => { const m = { id:c.id, t:'S', len:c.len };
const q = { id:c.id, m, p, pts:shapeOf(p, m), mid:midOf(p, m) }; q.box = boxOf(q.pts); return q; };
const X1 = mk(A0), X2 = mk(B0);
const obs = [{ ...X1, soft:true, skipFirst:true }, { ...X2, soft:true, skipLast:true }];
// Wie weit sich die Schleife insgesamt drehen soll: alles, was auf die
// Zielrichtung passt und mit den vorhandenen Kurven aufgeht.
const budgetC = remCurves(rem);
const wanted = (B0.h - A1.h + DIRS) & MASK;
const turns = [];
for (let t = -DIRS * 2; t <= DIRS * 2; t++)
if (((t % DIRS) + DIRS) % DIRS === wanted && Math.abs(t) <= budgetC) turns.push(t);
if (!turns.length) return;
const turnTarget = turns[(Math.random() * turns.length) | 0];
// Der Suche wird bewusst wenig Material gegeben: genau so viele Kurven,
// wie die Drehung braucht, und eine begrenzte Zahl Geraden. Mit vollem
// Vorrat ist der Spielraum so groß, dass sie den Anschluss nie trifft.
/* Kurven werden nach Drehschritten gedeckelt, nicht nach Stückzahl:
eine 22,5°-Kurve dreht zwei Schritte, eine 11,25°-Kurve einen. */
const capSteps = (ids, totalSteps) => {
let rest = totalSteps;
for (const id of ids.slice().sort(() => Math.random() - 0.5)) {
const st = curveSteps[id] || 2;
const take = Math.min(rem[id], Math.floor(rest / st));
rem[id] = take; rest -= take * st;
}
};
const capCount = (ids, total) => {
let rest = total;
for (const id of ids.slice().sort(() => Math.random() - 0.5)) {
const take = Math.min(rem[id], rest); rem[id] = take; rest -= take;
}
};
capSteps(curveIds, Math.abs(turnTarget) + (Math.random() < 0.25 ? 4 : 0));
const straightIds = [...new Set(moves.filter(m => m.t === 'S').map(m => m.id))];
const haveS = straightIds.reduce((a, id) => a + rem[id], 0);
capCount(straightIds, Math.min(haveS, 2 + ((Math.random() * 7) | 0)));
// Nur die erste Schleife wird gesucht — deshalb steht auch nur die
// halbe Sammlung zur Verfügung.
const found1 = findPath(rem, A1, B0, obs, budget, Math.floor(maxN / 2), 6 + ((Math.random() * 10) | 0), turnTarget);
restarts++;
for (const pathA of found1) {
const left = { ...rem };
for (const q of pathA) left[q.id]--;
// Zweite Schleife: dieselbe Strecke rückwärts und um 180° gedreht.
// Sie trifft den Ausgangspunkt zwangsläufig wieder.
const pathB = [];
let cur = B1;
for (let i = pathA.length - 1; i >= 0; i--) {
const src = pathA[i].m;
const m = src.t === 'S' ? src : { ...src, s:-src.s };
const q = { id:m.id, m, p:cur, pts:shapeOf(cur, m), mid:midOf(cur, m) };
q.box = boxOf(q.pts);
pathB.push(q);
cur = go(cur, m);
}
const a1 = mk(A0), a2 = mk(B0);
a1.crossHost = true; a1.crossId = c.id; a1.crossWith = 1 + pathA.length;
a2.crossId = c.id; a2.crossWith = 0;
if (!opt.useAll || allUsed(left)) keep([a1, ...pathA, a2, ...pathB]);
}
}
/* ---- Unsymmetrische Runden ----
Der Symmetriemodus liefert schnell und zuverlässig geschlossene
Strecken — aber jede davon ist punktsymmetrisch. Deshalb sahen sich
die Vorschläge trotz unterschiedlicher Teilefolgen alle ähnlich:
immer derselbe Klecks mit 180°-Symmetrie. Hier wird stattdessen ein
zufälliges Anfangsstück gelegt und der Rest mit der Wegsuche zum
Ausgangspunkt zurückgeführt. So entstehen lange Geraden mit enger
Kehre, S-Bögen und einseitige Ausbuchtungen — Formen, die
punktsymmetrisch gar nicht vorkommen können. */
const turnOfMove = m => m.t === 'C' ? (m.a || 2) * (m.s > 0 ? 1 : -1) : m.t === 'T' ? m.dh : 0;
function freeRun(budget) {
const rem = {};
for (const p of parts) rem[p.id] = stock[p.id] || 0;
const start = { x:0, y:0, h:0 };
const head = [], w = weights();
let pose = start, turn = 0;
const wantHead = 2 + ((Math.random() * 7) | 0);
for (let i = 0; i < wantHead; i++) {
const opts = order(rem, w);
if (!opts.length) break;
const m = opts[0];
const q = { id:m.id, m, p:pose, pts:shapeOf(pose, m), mid:midOf(pose, m) };
q.box = boxOf(q.pts);
if (head.some((o, k) => k < head.length - 1 && tooClose(q.pts, o.pts, q.box, o.box, minGap))) break;
head.push(q); rem[m.id]--; turn += turnOfMove(m); pose = go(pose, m);
}
if (head.length < 2) return;
/* Das Anfangsstück ist für die Rückführung ein Hindernis — außer an
den beiden Nahtstellen, wo sie bauartbedingt anliegen muss. */
const obs = head.map((q, i) => ({ pts:q.pts, box:q.box,
soft:i === 0 || i === head.length - 1,
skipFirst:i === head.length - 1,
skipLast:i === 0 }));
// Eine geschlossene Runde dreht sich insgesamt einmal ganz herum.
const dir = Math.random() < 0.5 ? DIRS : -DIRS;
const turnTarget = dir - turn;
if (Math.abs(turnTarget) > remCurves(rem)) return;
const tail = findPath(rem, pose, start, obs, budget, maxN - head.length,
1 + ((Math.random() * 3) | 0), turnTarget);
restarts++;
for (const t of tail) {
const left = { ...rem };
for (const q of t) left[q.id]--;
if (!opt.useAll || allUsed(left)) keep([...head, ...t]);
}
}
const symOK = (() => {
let c = 0; for (const id of curveIds) c += (stock[id] || 0) >> 1;
if (c < 8) return false;
if (!opt.useAll) return true;
return parts.every(p => p.k === 'buffer' || p.k === 'multi' || isSwitchLike(p)
|| ((stock[p.id] || 0) % 2 === 0));
})();
return {
slice(ms) {
const t0 = now();
while (now() - t0 < ms && found.size < 40000) {
const budget = 4000 + Math.floor(Math.random() * 16000);
gains = 0; capRun = 3 + Math.floor(Math.random() * 12);
// Kreuzungsläufe bekommen ein eigenes, kleines Budget und einen festen
// Turnus — sonst kommen sie bei großen Sammlungen nie an die Reihe.
turn++;
try {
/* Ein Fünftel der Läufe sucht unsymmetrische Runden. Die sind
deutlich seltener erfolgreich als der Symmetriemodus, aber
ohne sie gäbe es nur eine einzige Sorte Bild. Der freie
Tiefensuchmodus 'origin' brachte messbar gar nichts — er
muss den Ursprung blind treffen — und weicht deshalb. */
/* Die freie Suche ist teuer und trifft selten — sie bekommt
nur jeden zwölften Lauf. Den Großteil der Abwechslung liefert
inzwischen der Spiegelmodus zum halben Preis. */
if (crosses.length && turn % 6 === 0) crossRun(Math.min(budget, 6000));
/* Beide Symmetriemodi legen jedes Teil zweimal. Wer restlos
alles verbauen will und ungerade Stückzahlen hat, kommt
damit nie hin — dann bleiben nur die unsymmetrischen. */
else if (!symOK) { if (turn % 2 === 0) freeRun(20000); else walk(budget, 'origin'); }
else if (turn % 12 === 1) freeRun(20000);
else if (turn % 2 === 0) walk(budget, 'mirror');
else walk(budget, 'half');
} catch (e) { if (e !== STOP) throw e; }
}
return found.size;
},
results() { return [...found.values()]; },
restarts() { return restarts; }
};
}
const now = (typeof performance !== 'undefined' && performance.now) ? () => performance.now() : () => Date.now();
/* ---------------------------------------------------------------
Weichen anbauen: Abstellgleis (Stichgleis) und Ausweichgleis.
Eine Weiche ersetzt eine Gerade der Runde — Hauptgleis gleiche Länge,
also bleibt die Runde unverändert. Vom Weichenanfang zweigt eine
22,5°-Kurve im Weichenradius ab.
----------------------------------------------------------------*/
/* ---------------------------------------------------------------
Weichen anbauen.
Eine Weiche ersetzt eine Gerade der Runde: das Hauptgleis ist genauso
lang, die Runde bleibt also unverändert. Vom Anfang der Weiche zweigt
eine 22,5°-Kurve im Weichenradius ab.
Daraus entstehen zwei Dinge:
· Abstellgleis — eine Weiche, ein Stichgleis, am Ende ein Prellbock.
· Ausweichgleis — zwei Weichen, dazwischen ein zweites Gleis parallel
zur Runde. Mehrere davon ergeben drei- und mehrgleisige Abschnitte.
----------------------------------------------------------------*/
function addSidings(loop, stock, parts, opt) {
const until = now() + (opt.msPerPlan || 140);
const rem = { ...stock };
for (const q of loop) if (rem[q.id] !== undefined && q.crossWith === undefined) rem[q.id]--;
for (const q of loop) if (q.crossHost) rem[q.crossId]--;
const allSwitches = parts.filter(p => isSwitchLike(p) && (rem[p.id] || 0) > 0);
if (!parts.some(p => isBranchPart(p) && (rem[p.id] || 0) > 0)) return [];
const moves = [];
for (const p of parts) if ((rem[p.id] || 0) > 0 && !isSwitchLike(p)) for (const m of movesOf(p)) moves.push(m);
if (!moves.length) return [];
const curveIds = parts.filter(p => p.k === 'curve').map(p => p.id);
const curveSteps = {};
for (const q of parts) if (q.k === 'curve') curveSteps[q.id] = q.a || 2;
const maxReach = moves.reduce((a, m) => Math.max(a, reachOf(m)), 1);
const gap = opt.gap;
const branches = [];
const usedLoop = new Set();
const own = [];
const near = (i, j) => { const d = Math.abs(i - j); return Math.min(d, loop.length - d) <= 3; };
/* Hindernisse für die Wegsuche: die ganze Runde und alle bisherigen
Abzweige. An den Weichen selbst liegen Abzweig und Hauptgleis
bauartbedingt eng beieinander — dort wird nicht geprüft. */
/* Nur Teile in der Nähe des Korridors zwischen beiden Weichen prüfen.
Alles andere kann der Abzweig gar nicht erreichen — und die Prüfung
gegen jedes einzelne Gleisteil ist der teuerste Teil der Suche. */
function obstaclesFor(i, j, region) {
const out = [];
for (let k = 0; k < loop.length; k++) {
const atI = near(i, k), atJ = j !== undefined && near(j, k);
if (atI && atJ) continue;
const b = loop[k].box;
if (region && (b[0] > region[2] || b[2] < region[0] || b[1] > region[3] || b[3] < region[1])) continue;
const o = { pts:loop[k].pts, box:b, loopIdx:k };
if (atI) { o.soft = true; o.skipFirst = true; }
if (atJ) { o.soft = true; o.skipLast = true; }
out.push(o);
}
return out.concat(own);
}
const regionOf = (a, b, pad) => [Math.min(a.x, b.x) - pad, Math.min(a.y, b.y) - pad,
Math.max(a.x, b.x) + pad, Math.max(a.y, b.y) + pad];
/* Eine Weiche ist 32 Noppen lang und belegt damit zwei Geraden.
Gesucht sind also Stellen, an denen ab i genügend gerade Teile
hintereinanderliegen, um die Weichenlänge auszufüllen. */
function runAt(i, need) {
let sum = 0, k = 0;
while (sum < need - 0.5 && k < loop.length) {
const q = loop[(i + k) % loop.length];
if (q.m.t !== 'S' || q.crossWith !== undefined) return null;
sum += q.m.len; k++;
}
return Math.abs(sum - need) < 0.5 ? k : null;
}
const swLen = (allSwitches[0] || { len:256 }).len;
const spots = [];
for (let i = 0; i < loop.length; i++) if (runAt(i, swLen)) spots.push(i);
const spanOf = i => runAt(i, swLen) || 1;
const shuffled = a => {
const b = a.slice();
for (let i = b.length - 1; i > 0; i--) { const j = (Math.random() * (i + 1)) | 0; [b[i], b[j]] = [b[j], b[i]]; }
return b;
};
/* Erst eine passende einfache Weiche, dann erst die Dreiwegweiche —
die ist zu schade, um eine gewöhnliche Weiche zu ersetzen. */
const takeSwitch = side => parts.find(p => p.k === 'switch' && p.s === side && (rem[p.id] || 0) > 0)
|| parts.find(p => isSwitchLike(p) && p.k !== 'switch' && (rem[p.id] || 0) > 0);
const pieceAt = (pose, m) => {
const q = { id:m.id, m, p:pose, pts:shapeOf(pose, m), mid:midOf(pose, m) };
q.box = boxOf(q.pts); return q;
};
/* ---- Bahnhöfe ----
Ein Bahnsteiggleis läuft im festen LEGO-Abstand von 16 Noppen neben
dem Hauptgleis. Das ist nichts Gesuchtes, sondern gerechnet: Weiche
plus Gegenkurve ergeben 48 Noppen längs und 16 quer, am anderen Ende
dasselbe spiegelverkehrt. Ein Bahnsteig aus N Geraden braucht deshalb
genau N+6 Geraden am Stück im Hauptgleis. */
function buildStation(i, sA, platform, asStation) {
const need = platform + 6;
if (runAt(i, need * 128) !== need) return null;
const swA = takeSwitch(sA), swB = takeSwitch(-sA);
if (!swA || !swB) return null;
if (swA.id === swB.id && (rem[swA.id] || 0) < 2) return null;
const curve = parts.find(x => x.k === 'curve' && Math.abs(x.r - 320) < 1 && (rem[x.id] || 0) > 0);
const str = parts.find(x => x.k === 'straight' && Math.abs(x.len - 128) < 1 && (rem[x.id] || 0) > 0);
if (!curve) return null;
if (platform > 0 && (!str || (rem[str.id] || 0) < platform)) return null;
if ((rem[curve.id] || 0) < 2) return null;
const j = (i + platform + 4) % loop.length;
/* Belegt sind nur die vier Weichenplätze, nicht die ganze Strecke —
sonst passt kein zweiter Bahnsteig auf die andere Seite. */
const slots = [i, (i + 1) % loop.length, j, (j + 1) % loop.length];
for (const k of slots) if (usedLoop.has(k)) return null;
const headMove = switchBranch(swA.id, sA);
const cv = { id:curve.id, t:'C', r:320, s:-sA };
let pose = go(loop[i].p, headMove);
const pieces = [];
const add = m => { pieces.push(pieceAt(pose, m)); pose = go(pose, m); };
add(cv);
for (let k = 0; k < platform; k++) add({ id:str.id, t:'S', len:128 }); // bei 0 bleibt es beim reinen Bogen
add(cv);
// Gegenprobe: trifft das Bahnsteiggleis die Ausfahrtweiche?
const jEnd = (j + 1) % loop.length;
const E = go(loop[jEnd].p, loop[jEnd].m);
const RB = { x:E.x, y:E.y, h:(E.h + HALF_TURN) & MASK };
const backMove = switchBranch(swB.id, -sA);
const PB = go(RB, backMove);
const target = { x:PB.x, y:PB.y, h:(PB.h + HALF_TURN) & MASK };
if (Math.hypot(pose.x - target.x, pose.y - target.y) > 1.5 || pose.h !== target.h) return null;
// Platz prüfen: das Bahnsteiggleis darf nichts anderes berühren
const tailMove = reverseMove(backMove);
const tail = pieceAt(target, tailMove);
const head = pieceAt(loop[i].p, headMove);
/* Geprüft wird gegen alles außerhalb der Bahnhofsstrecke — samt einem
Teil davor und dahinter, denn dort stoßen Weiche und Hauptgleis
bauartbedingt aneinander. */
const skip = new Set();
for (let t = -1; t <= need; t++) skip.add(((i + t) % loop.length + loop.length) % loop.length);
for (const q of [head, ...pieces, tail]) {
for (let k = 0; k < loop.length; k++) {
if (skip.has(k)) continue;
if (tooClose(q.pts, loop[k].pts, q.box, loop[k].box, gap - 6)) return null;
}
for (const o of own) if (tooClose(q.pts, o.pts, q.box, o.box, gap - 6)) return null;
}
for (const q of pieces) rem[q.id]--;
rem[swA.id]--; rem[swB.id]--;
const spanA = [i, (i + 1) % loop.length], spanB = [j, jEnd];
for (const k of slots) usedLoop.add(k);
for (const q of [head, ...pieces, tail]) own.push({ pts:q.pts, box:q.box });
return { kind:'loop', station:asStation, at:i, at2:j, span:spanA, span2:spanB,
switchId:swA.id, switchId2:swB.id, switchMove:headMove, switchStart:loop[i].p,
tailMove, tailStart:target, pieces, end:E, buffer:null };
}
if (opt.stations) {
const platform = opt.platform || 2;
let built = 0;
for (const i of shuffled(spots)) {
if (built >= opt.stations) break;
for (const sA of shuffled([1, -1])) {
const b = buildStation(i, sA, platform, true);
if (!b) continue;
branches.push(b);
built++;
// Drittes Gleis: zweiter Bahnsteig auf der anderen Seite, zwei
// Geraden versetzt, damit sich die Weichen nicht überlappen.
if (opt.stationTracks >= 3) {
const b2 = buildStation((i + 2) % loop.length, -sA, platform, true);
if (b2) branches.push(b2);
}
break;
}
}
}
/* ---- Ausweichgleise ----
Geometrisch dasselbe wie ein Bahnsteiggleis: zwischen zwei Weichen
läuft ein Gleis im festen Abstand von 16 Noppen neben dem Hauptgleis.
Deshalb wird es gerechnet statt gesucht — die frühere Zufallssuche
fand kaum je eines. */
if (opt.sidings) {
let gebaut = 0;
for (const i of shuffled(spots)) {
if (gebaut >= 2) break;
/* Von der gewünschten Länge abwärts, bis eine passt. Ein
Ausweichgleis ganz ohne Bahnsteigteil ist auch eines: zwei
Weichen und vier Gegenkurven, zusammen 96 Noppen. Vorher
wurden nur Längen ab zwei versucht — dafür braucht es acht
Geraden am Stück, die es bei kleinen Beständen nie gibt. */
const want = 1 + ((Math.random() * 4) | 0);
let done = false;
for (const sA of shuffled([1, -1])) {
for (let len = want; len >= 0 && !done; len--) {
const b = buildStation(i, sA, len, false);
if (b) { branches.push(b); gebaut++; done = true; }
}
if (done) break;
}
}
}
/* ---- Abkürzungen: ein Gleis quer durch die Runde. Der Zug kann die
große Schleife fahren oder abkürzen — wie in echten Gleisplänen. ---- */
const pairs = [];
for (const i of spots) for (const j of spots) {
if (j === i) continue;
const span = (j - i + loop.length) % loop.length;
if (span < 2) continue;
// Abkürzung: die beiden Weichen müssen weit auseinander in der Runde,
// aber räumlich noch erreichbar sein.
if (span < 6 || span > loop.length - 6) continue;
const d = Math.hypot(loop[j].p.x - loop[i].p.x, loop[j].p.y - loop[i].p.y);
if (d > 1100) continue;
pairs.push([i, j, 'cut']);
}
// Abkürzungen zuerst versuchen — sie machen den Plan interessanter.
pairs.sort((a, b) => (a[2] === 'cut' ? 0 : 1) - (b[2] === 'cut' ? 0 : 1));
const byKind = { cut:shuffled(pairs.filter(x => x[2] === 'cut')).slice(0, 10), par:[] };
// Harte Obergrenze: sonst frisst die Abkürzungssuche bei vielen geraden
// Stellen beliebig viel Zeit.
let searches = 0;
for (const [i, j, kind] of byKind.cut.concat(byKind.par)) {
if (searches > 14) break;
if (usedLoop.has(i) || usedLoop.has(j)) continue;
for (const sA of shuffled([1, -1])) {
const swA = takeSwitch(sA), swB = takeSwitch(-sA);
if (!swA || !swB) continue;
if (swA.id === swB.id && (rem[swA.id] || 0) < 2) continue;
const headMove = switchBranch(swA.id, sA);
const head = pieceAt(loop[i].p, headMove);
const PA = go(loop[i].p, headMove);
const jEnd = (j + spanOf(j) - 1) % loop.length;
const E = go(loop[jEnd].p, loop[jEnd].m);
const RB = { x:E.x, y:E.y, h:(E.h + HALF_TURN) & MASK };
// Von der zweiten Weiche aus rückwärts gerechnet: so liegt ihr
// Abzweigende. Befahren wird es später in Gegenrichtung, und dabei
// dreht sich die Kurvenrichtung um.
const backMove = switchBranch(swB.id, -sA);
const PB = go(RB, backMove);
const target = { x:PB.x, y:PB.y, h:(PB.h + HALF_TURN) & MASK };
const tailMove = reverseMove(backMove);
const wanted = (target.h - PA.h + DIRS) & MASK;
const budgetC = curveIds.reduce((a, id) => a + (rem[id] || 0), 0);
const turns = [];
for (let t = -DIRS; t <= DIRS; t++)
if (((t % DIRS) + DIRS) % DIRS === wanted && Math.abs(t) <= budgetC) turns.push(t);
turns.sort((a, b) => Math.abs(a) - Math.abs(b));
if (!turns.length) continue;
const tries = kind === 'cut' ? 4 : 3;
const maxLen = kind === 'cut' ? 18 : 12;
/* Wie weit ein Ausweichgleis neben dem Hauptgleis liegt, ist keine
freie Wahl: Abzweigkurve plus Gegenkurve ergeben genau diesen
Versatz. Auf dem parallelen Stück gilt deshalb dieser Abstand
statt des allgemeinen Mindestabstands. */
const spacing = 128; // Weiche plus Gegenkurve ergeben 16 Noppen
const sideGap = Math.min(gap, spacing - 2);
const between = k => {
const d1 = (k - i + loop.length) % loop.length;
return d1 <= (j - i + loop.length) % loop.length;
};
const tailPts = shapeOf(RB, backMove);
const obs = obstaclesFor(i, j, regionOf(PA, target, 520)).map((o, k) => o.loopIdx !== undefined && between(o.loopIdx)
? { ...o, gap:sideGap } : o).concat([
{ pts:head.pts, box:head.box, gap:sideGap, skipFirst:true },
{ pts:tailPts, box:boxOf(tailPts), gap:sideGap, skipLast:true }
]);
let hit = null;
if (now() > until) break;
for (const turnTarget of turns.slice(0, tries)) {
searches++;
if (now() > until) break;
const found = searchPath({ rem, from:PA, to:target, obstacles:obs, budget:3500,
maxLen, wantN:1, turnTarget, moves,
minGap:kind === 'cut' ? gap - 4 : sideGap,
maxReach, curveIds, curveSteps, tol:1.5, deadline:until });
if (found.length) { hit = found[0]; break; }
}
if (!hit) continue;
/* Die Wegsuche prüft aus Geschwindigkeitsgründen nur Gleisteile im
Suchkorridor. Ein fertiger Abzweig wird deshalb noch einmal gegen
die ganze Runde geprüft — sonst schneidet eine weit ausholende
Abkürzung durch Teile, die außerhalb des Korridors lagen. */
const tailPiece = pieceAt(target, tailMove);
const full = [head, ...hit, tailPiece];
let crossesLoop = false;
for (const q of full) {
for (let k = 0; k < loop.length && !crossesLoop; k++) {
if (near(i, k) || near(j, k)) continue;
if (tooClose(q.pts, loop[k].pts, q.box, loop[k].box, gap - 6)) crossesLoop = true;
}
for (const o of own) if (!crossesLoop && tooClose(q.pts, o.pts, q.box, o.box, gap - 6)) crossesLoop = true;
if (crossesLoop) break;
}
if (crossesLoop) continue;
for (const q of hit) rem[q.id]--;
rem[swA.id]--; rem[swB.id]--;
for (let k = 0; k < spanOf(i); k++) usedLoop.add((i + k) % loop.length);
for (let k = 0; k < spanOf(j); k++) usedLoop.add((j + k) % loop.length);
for (const q of full) own.push({ pts:q.pts, box:q.box });
const spanA = [], spanB = [];
for (let k = 0; k < spanOf(i); k++) spanA.push((i + k) % loop.length);
for (let k = 0; k < spanOf(j); k++) spanB.push((j + k) % loop.length);
branches.push({ kind:'loop', cut:kind === 'cut', at:i, at2:j, span:spanA, span2:spanB, spacing,
switchId:swA.id, switchId2:swB.id,
switchMove:headMove, switchStart:loop[i].p,
tailMove, tailStart:target, pieces:hit, end:E, buffer:null });
break;
}
}
/* ---- Abstellgleise ----
Wo ein zweiter Strang abgehen kann, hängt vom Teil ab: eine
gewöhnliche und eine Dreiwegweiche brauchen 32 Noppen Gerade im
Rundkurs, eine Y-Weiche dagegen tritt an die Stelle einer
R40-Kurve und lenkt den zweiten Strang zur Gegenseite. */
const curveSpots = [];
for (let i = 0; i < loop.length; i++) {
const m = loop[i].m;
if (m.t === 'C' && Math.abs(m.r - 320) < 1 && (m.a || 2) === 2) curveSpots.push(i);
}
const stubHosts = [];
for (const p of parts) {
if ((rem[p.id] || 0) <= 0) continue;
if (isSwitchLike(p))
stubHosts.push({ part:p, at:spots, span:spanOf, head:() => {
// Die Dreiwegweiche legt sich nicht auf eine Seite fest.
const side = p.s === 1 || p.s === -1 ? p.s : (Math.random() < 0.5 ? 1 : -1);
return switchBranch(p.id, side);
} });
else if (isYSwitch(p))
stubHosts.push({ part:p, at:curveSpots, span:() => 1,
head:i => ({ id:p.id, t:'C', r:320, a:2, s:-loop[i].m.s }) });
}
for (const host of shuffled(stubHosts)) {
const sw = host.part;
if ((rem[sw.id] || 0) <= 0) continue;
if (now() > until + 60) break;
for (const i of shuffled(host.at)) {
if (usedLoop.has(i)) continue;
if (now() > until + 60) break;
const headMove = host.head(i);
const head = pieceAt(loop[i].p, headMove);
const obs = obstaclesFor(i, undefined, regionOf(loop[i].p, loop[i].p, 900));
if (obs.some(o => !o.skipFirst && tooClose(head.pts, o.pts, head.box, o.box, o.soft ? gap - 6 : gap))) continue;
let pose = go(loop[i].p, headMove);
const pieces = [], mine = [{ pts:head.pts, box:head.box }];
const wantLen = 2 + ((Math.random() * 4) | 0);
for (let step = 0; step < wantLen; step++) {
const opts2 = moves.filter(m => (rem[m.id] || 0) > 0);
if (!opts2.length) break;
let placed = false;
for (let t = 0; t < opts2.length * 3 && !placed; t++) {
const m = opts2[(Math.random() * opts2.length) | 0];
const q = pieceAt(pose, m);
const clash = obs.some(o => !(o.skipFirst && step === 0) &&
tooClose(q.pts, o.pts, q.box, o.box, o.soft ? gap - 6 : gap))
|| mine.slice(0, -1).some(o => tooClose(q.pts, o.pts, q.box, o.box, gap));
if (clash) continue;
pieces.push(q); mine.push({ pts:q.pts, box:q.box });
rem[m.id]--; pose = go(pose, m); placed = true;
}
if (!placed) break;
}
if (!pieces.length) continue;
let clash2 = false;
for (const q of [head, ...pieces]) {
for (let k = 0; k < loop.length && !clash2; k++) {
if (near(i, k)) continue;
if (tooClose(q.pts, loop[k].pts, q.box, loop[k].box, gap - 6)) clash2 = true;
}
if (clash2) break;
}
if (clash2) { for (const q of pieces) rem[q.id]++; continue; }
rem[sw.id]--;
const wide = host.span(i);
for (let k = 0; k < wide; k++) usedLoop.add((i + k) % loop.length);
for (const o of mine) own.push(o);
const buffer = parts.find(p => p.k === 'buffer' && (rem[p.id] || 0) > 0);
if (buffer) rem[buffer.id]--;
const spanS = [];
for (let k = 0; k < wide; k++) spanS.push((i + k) % loop.length);
branches.push({ kind:'stub', at:i, span:spanS, switchId:sw.id, switchMove:headMove, switchStart:loop[i].p,
pieces, end:pose, buffer: buffer ? buffer.id : null });
break;
}
}
return branches;
}
/* ---------------------------------------------------------------
Rücklage für die Weichen.
Der Rundkurs würde sonst den ganzen Bestand aufbrauchen und für die
Abzweige bliebe nichts übrig. Pro Weichenpaar werden zwei Kurven und
drei Geraden zurückgelegt, für eine einzelne Weiche zwei Geraden —
aber nie so viel, dass der Rundkurs selbst nicht mehr zustande kommt.
----------------------------------------------------------------*/
function loopStockFor(stock, parts, opt) {
if (!opt.sidings) return { ...stock };
const sw = parts.filter(isSwitchLike).reduce((a, p) => a + (stock[p.id] || 0), 0);
if (!sw) return { ...stock };
const pairs = Math.min(Math.floor(sw / 2), 3), singles = Math.min(sw - pairs * 2, 2);
// Ein Ausweichgleis besteht aus zwei Gegenkurven und ein paar Geraden.
// Die Geraden bleiben besser im Rundkurs — dort bilden sie den geraden
// Abschnitt, an dem die Weichen überhaupt erst sitzen können.
let wantC = pairs * 5 + singles * 2, wantS = pairs * 2 + singles * 2;
const out = { ...stock };
const curveIds = parts.filter(p => p.k === 'curve').map(p => p.id);
const straightIds = parts.filter(p => p.k === 'straight').map(p => p.id);
const total = ids => ids.reduce((a, id) => a + (out[id] || 0), 0);
// Für einen geschlossenen Kreis müssen 16 Kurven im Spiel bleiben.
wantC = Math.max(0, Math.min(wantC, total(curveIds) - 16));
wantS = Math.max(0, Math.min(wantS, total(straightIds) - 2));
const take = (ids, n) => {
for (const id of ids) {
if (n <= 0) break;
const t = Math.min(out[id] || 0, n);
out[id] -= t; n -= t;
}
};
take(curveIds, wantC);
take(straightIds, wantS);
return out;
}
// ==ENGINE-END==
/* ---------------------------------------------------------------
Auswahl: aus vielen Treffern bewusst unterschiedliche zeigen.
----------------------------------------------------------------*/
/* ---- Formkennung ----
Wie ein Plan AUSSIEHT, nicht woraus er besteht. Zwei Ovale mit anders
verteilten Geraden sind unterschiedliche Teilefolgen, ergeben aber
dasselbe Bild — und genau das hat die alte Auswahl nicht getrennt.
Die Strecke wird auf ein 8×8-Raster über ihr eigenes Rechteck gelegt;
gezählt wird, welche Felder sie berührt. Gedrehte und gespiegelte
Fassungen ergeben dieselbe Kennung, denn auf dem Teppich ist das
derselbe Plan. */
const SHAPE_N = 8;
const SHAPE_SYM = [
(x, y, N) => [x, y], (x, y, N) => [N - 1 - y, x],
(x, y, N) => [N - 1 - x, N - 1 - y], (x, y, N) => [y, N - 1 - x],
(x, y, N) => [N - 1 - x, y], (x, y, N) => [y, x],
(x, y, N) => [x, N - 1 - y], (x, y, N) => [N - 1 - y, N - 1 - x]
];
function shapeGrid(r) {
const N = SHAPE_N, g = new Uint8Array(N * N), b = r.box;
const w = Math.max(b[2] - b[0], 1), h = Math.max(b[3] - b[1], 1);
const eat = q => {
for (const pt of q.pts) {
const cx = Math.min(N - 1, Math.max(0, ((pt[0] - b[0]) / w * N) | 0));
const cy = Math.min(N - 1, Math.max(0, ((pt[1] - b[1]) / h * N) | 0));
g[cy * N + cx] = 1;
}
};
for (const q of r.loop) eat(q);
for (const br of r.branches || []) for (const q of branchRun(br)) eat(q);
for (const ring of r.rings || []) for (const q of ring) eat(q);
return g;
}
function shapeKey(g) {
const N = SHAPE_N;
let best = null;
for (const f of SHAPE_SYM) {
let str = '';
for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
const [u, v] = f(x, y, N);
str += g[v * N + u] ? '1' : '0';
}
if (best === null || str < best) best = str;
}
return best;
}
/* Punktsymmetrisch heißt: die zweite Hälfte ist die um 180° gedrehte
erste. Solche Runden sehen einander zwangsläufig ähnlich, deshalb
trennt die Auswahl sie hart von den unsymmetrischen. */
function punktsym(r) {
const n = r.loop.length;
if (n % 2) return false;
for (let i = 0; i < n / 2; i++) {
const a = r.loop[i].m, b = r.loop[i + n / 2].m;
if (a.t !== b.t) return false;
if (a.t === 'S' && Math.abs(a.len - b.len) > 0.5) return false;
if (a.t === 'C' && (Math.abs(a.r - b.r) > 0.5 || a.s !== b.s || (a.a || 2) !== (b.a || 2))) return false;
}
return true;
}
function traits(r) {
const s = r.loop, n = s.length;
let flips = 0, maxRun = 1, run = 0, straightRuns = 0, radii = new Set();
for (let i = 0; i < n; i++) {
const c = s[i], p = s[(i + n - 1) % n];
const cu = c.m.t === 'C', pu = p.m.t === 'C';
if (cu) radii.add(c.m.r);
if (cu && pu && c.m.s !== p.m.s) flips++;
run = (cu === pu && (!cu || c.m.s === p.m.s)) ? run + 1 : 1;
if (cu && run > maxRun) maxRun = run;
if (!cu && pu) straightRuns++;
}
const long = Math.max(r.w, r.h), short = Math.min(r.w, r.h);
const hasCross = s.some(q => q.crossWith !== undefined) ? 1 : 0;
const stationCount = (r.branches || []).filter(b => b.station).length;
const ringCount = (r.rings ? r.rings.length : 0) / 2;
if (r._sym === undefined) r._sym = punktsym(r);
return [r.n / 24, long / short / 2.5, Math.sqrt(r.area) / 1200, flips / 5, maxRun / 10,
straightRuns / 5, radii.size / 2, (r.branches.length || 0) / 2, hasCross * 1.6,
ringCount, stationCount * 1.4, r._sym ? 0 : 2.2];
}
function pick(list, want) {
if (list.length <= want) return list.slice().sort((a, b) => (b.n - a.n) || (a.area - b.area));
/* Erst nach Bild entdoppeln. Von mehreren Plänen mit demselben Umriss
bleibt der mit dem höchsten Teileverbrauch übrig — die anderen sind
für das Auge dieselbe Strecke. */
const byShape = new Map();
for (const r of list) {
r._grid = r._grid || shapeGrid(r);
const k = shapeKey(r._grid);
const had = byShape.get(k);
if (!had || r.n > had.n || (r.n === had.n && r.area < had.area)) byShape.set(k, r);
}
const uniq = [...byShape.values()];
const maxUsed = uniq.reduce((m, r) => Math.max(m, r.n), 0);
/* Der Verbrauchskorridor war zu eng: übrig blieben fast nur die
größten Runden, und die ähneln einander zwangsläufig. Lieber weit
fassen und die Auswahl darunter nach Verschiedenheit treffen —
wer wirklich jedes Teil verbauen will, hat dafür das Häkchen. */
const band = Math.max(10, Math.round(maxUsed * 0.34));
let pool = uniq.filter(r => r.n >= maxUsed - band).sort((a, b) => (b.n - a.n) || (a.area - b.area));
if (pool.length > 900) {
/* Unsymmetrische Runden sind selten und deshalb wertvoll — sie
dürfen beim Ausdünnen nicht wegfallen, sonst bleiben nur die
Kleckse übrig, die alle gleich aussehen. */
const kostbar = pool.filter(r => { if (r._sym === undefined) r._sym = punktsym(r); return !r._sym; });
const rest = pool.filter(r => r._sym).slice(0);
const head = rest.slice(0, 60), tail = rest.slice(60);
for (let i = tail.length - 1; i > 0; i--) { const j = (Math.random() * (i + 1)) | 0; [tail[i], tail[j]] = [tail[j], tail[i]]; }
pool = kostbar.slice(0, 300)
.concat(head, tail.slice(0, Math.max(0, 900 - Math.min(kostbar.length, 300) - head.length)))
.sort((a, b) => (b.n - a.n) || (a.area - b.area));
}
if (pool.length <= want) return pool;
/* Merkmalsvektor = Kennzahlen und obendrein die 64 Rasterfelder. Ohne
die Felder galten zwei Pläne mit gleicher Teilezahl und gleichem
Seitenverhältnis als gleichwertig, auch wenn der eine ein Oval und
der andere eine Acht war. */
const f = pool.map(r => {
const t = traits(r), g = r._grid;
for (let i = 0; i < g.length; i++) t.push(g[i] ? 0.28 : 0);
return t;
});
const taken = new Uint8Array(pool.length);
// Nicht immer mit dem größten Plan anfangen — sonst ist Vorschlag 1
// bei jedem Durchlauf derselbe.
const seed = (Math.random() * Math.min(pool.length, 10)) | 0;
const chosen = [seed];
taken[seed] = 1;
while (chosen.length < want && chosen.length < pool.length) {
let best = -1, bestScore = -1;
for (let i = 0; i < pool.length; i++) {
if (taken[i]) continue;
let d = Infinity;
for (const j of chosen) {
let s = 0;
for (let k = 0; k < f[i].length; k++) { const t = f[i][k] - f[j][k]; s += t * t; }
if (s < d) d = s;
}
const sc = d * (1 + pool[i].n / 400);
if (sc > bestScore) { bestScore = sc; best = i; }
}
if (best < 0) break;
taken[best] = 1; chosen.push(best);
}
return chosen.map(i => pool[i]).sort((a, b) => (b.n - a.n) || (a.area - b.area));
}
/* ---------------------------------------------------------------
Zeichnen.
So sieht eine echte Schiene von oben aus: einzelne Schwellen mit
Lücken dazwischen, darauf zwei durchgehende helle Schienenstränge.
Wichtig ist außerdem, dass ein Gleiszug als EIN Pfad gezeichnet wird:
zeichnet man jedes Teil einzeln mit dicker Linie, klafft an jedem
Stoß außen ein Keil auf und der Kreis wirkt eckig.
----------------------------------------------------------------*/
const fx = v => v.toFixed(2);
/** Punkt auf einem Teil beim Anteil f, seitlich um offset versetzt. */
function atFraction(p, m, f, offset) {
if (m.t === 'S') {
const l = m.len * f;
return { x:p.x + COS[p.h] * l - SIN[p.h] * offset, y:p.y + SIN[p.h] * l + COS[p.h] * offset };
}
const a = angOf(m) * f, fw = m.r * Math.sin(a), lt = m.r * (1 - Math.cos(a)) * m.s;
const hh = p.h * U + a * m.s;
return { x:p.x + COS[p.h] * fw - SIN[p.h] * lt - Math.sin(hh) * offset,
y:p.y + SIN[p.h] * fw + COS[p.h] * lt + Math.cos(hh) * offset };
}
/** Ein einzelnes Teil als Pfadstück, seitlich versetzt um offset.
from = ab welchem Anteil gezeichnet wird; der Abzweig einer Weiche
beginnt erst dort, wo er aus dem Hauptgleis heraustritt. */
function segOf(q, offset, withMove, from) {
const p = q.p, m = q.m, e = go(p, m);
const f0 = from || 0;
if (m.t === 'T') {
// Zusammengesetzter Zug: jeder Teilbogen einzeln, seitlich versetzt.
const ps = subPoses(p, m);
const off = k => ({ x:ps[k].x - Math.sin(ps[k].a) * offset, y:ps[k].y + Math.cos(ps[k].a) * offset });
let d = '', started = false;
for (let i = 0; i < m.arcs.length; i++) {
const c = m.arcs[i];
// Der Abzweig soll erst dort sichtbar werden, wo er das Hauptgleis verlässt
let a0 = off(i), share = 0;
if (f0 && i === 0) {
const ang = c.deg * Math.PI / 180 * f0;
const fw = c.r * Math.sin(ang), lt = c.r * (1 - Math.cos(ang)) * c.s;
const aa = ps[0].a + ang * c.s;
a0 = { x:ps[0].x + Math.cos(ps[0].a) * fw - Math.sin(ps[0].a) * lt - Math.sin(aa) * offset,
y:ps[0].y + Math.sin(ps[0].a) * fw + Math.cos(ps[0].a) * lt + Math.cos(aa) * offset };
share = f0;
}
if (!started) { d += `M${fx(a0.x)} ${fx(-a0.y)}`; started = true; }
const b = off(i + 1), r = c.s > 0 ? c.r - offset : c.r + offset;
d += `A${fx(r)} ${fx(r)} 0 0 ${c.s > 0 ? 0 : 1} ${fx(b.x)} ${fx(-b.y)}`;
}
return withMove || f0 ? d : d.replace(/^M[^A]*/, '');
}
const a0 = f0 ? atFraction(p, m, f0, offset) : null;
const ax = f0 ? a0.x : p.x - SIN[p.h] * offset, ay = f0 ? a0.y : p.y + COS[p.h] * offset;
const bx = e.x - SIN[e.h] * offset, by = e.y + COS[e.h] * offset;
const head = withMove || f0 ? `M${fx(ax)} ${fx(-ay)}` : '';
if (m.t === 'S') return head + `L${fx(bx)} ${fx(-by)}`;
const r = m.s > 0 ? m.r - offset : m.r + offset;
// Die y-Achse ist für SVG gespiegelt, deshalb dreht sich auch der
// Drehsinn um: eine Linkskurve braucht sweep-flag 0, nicht 1. Mit dem
// falschen Flag setzt SVG den Bogenmittelpunkt auf die andere Seite und
// die Kurve wölbt sich spiegelverkehrt.
return head + `A${fx(r)} ${fx(r)} 0 0 ${m.s > 0 ? 0 : 1} ${fx(bx)} ${fx(-by)}`;
}
const pieceSeg = (q, offset) => segOf(q, offset, true, q.branchLeg && q.m.t !== 'T' ? BRANCH_FROM : 0);
const BRANCH_FROM = 0.32; // ab hier tritt der Abzweig aus dem Hauptgleis
/** Ein ganzer Gleiszug als ein einziger Pfad — keine Kerben an den Stößen. */
function runPath(run, offset, closed) {
let d = '';
for (let i = 0; i < run.length; i++)
d += segOf(run[i], offset, i === 0, run[i].branchLeg ? BRANCH_FROM : 0);
return d + (closed ? 'Z' : '');
}
/** Schwellen eines Teils: einzelne Querbalken mit Lücke, wie am echten Teil. */
function tiesOf(q, len) {
const m = q.m, p = q.p, out = [];
if (m.t === 'T') return ''; // der Abzweig bekommt keine eigenen Schwellen
const from = q.branchLeg ? BRANCH_FROM : 0;
const total = (m.t === 'S' ? m.len : m.r * angOf(m)) * (1 - from);
const n = Math.max(2, Math.round(total / 16));
for (let k = 0; k < n; k++) {
const f = from + (1 - from) * (k + 0.5) / n;
let x, y, h;
if (m.t === 'S') { const l = m.len * f; x = p.x + COS[p.h] * l; y = p.y + SIN[p.h] * l; h = p.h * U; }
else {
const a = angOf(m) * f, fw = m.r * Math.sin(a), lt = m.r * (1 - Math.cos(a)) * m.s;
x = p.x + COS[p.h] * fw - SIN[p.h] * lt; y = p.y + SIN[p.h] * fw + COS[p.h] * lt; h = p.h * U + a * m.s;
}
const nx = -Math.sin(h) * len, ny = Math.cos(h) * len;
out.push(`M${fx(x - nx)} ${fx(-(y - ny))}L${fx(x + nx)} ${fx(-(y + ny))}`);
}
return out.join('');
}
/** Alle zusammenhängenden Gleiszüge eines Plans. */
function runsOf(r) {
const runs = [];
if (r.loop && r.loop.length) runs.push({ pieces:r.loop, closed:true });
for (const ch of (r.chains || [])) if (ch.length) runs.push({ pieces:ch, closed:false });
for (const b of (r.branches || [])) runs.push({ pieces:branchRun(b), closed:false, branch:b });
return runs;
}
function typeColor(id) {
const all = CATALOG.concat(customParts).filter(p => p.k !== 'buffer');
const i = all.findIndex(p => p.id === id);
return TYPE_COLORS[(i < 0 ? 0 : i) % TYPE_COLORS.length];
}
function planSVG(r, o) {
const opt = Object.assign({ detail:false, colored:false, numbers:false, pad:110, width:300 }, o || {});
const parts = partIndex(CATALOG.concat(customParts));
const [x0, y0, x1, y1] = r.box;
/* Die Beschriftungen ragen über den Gleiskörper hinaus — der Rand muss
sie mitfassen, sonst schneidet der Ausschnitt sie ab. */
const nBranch = (r.branches || []).length;
const fsRaw = Math.max(26, ((x1 - x0) + opt.pad * 2) / 38);
const pad = opt.pad + (opt.marks !== false ? fsRaw * 4.5 : 0);
let vx = x0 - pad, vy = -y1 - pad, vw = (x1 - x0) + pad * 2, vh = (y1 - y0) + pad * 2;
// Beim Ziehen bleibt der Ausschnitt stehen, sonst springt der Plan unter der Maus weg.
if (opt.viewBox) { const v = opt.viewBox.split(/\s+/).map(Number); vx = v[0]; vy = v[1]; vw = v[2]; vh = v[3]; }
const gid = 'g' + Math.random().toString(36).slice(2, 8);
const runs = runsOf(r);
/* Ein Schild darf kein Gleis verdecken. Deshalb werden mehrere Stellen
durchprobiert und die erste genommen, an der weder ein Gleis noch ein
schon gesetztes Schild im Weg ist. */
const allPieces = [];
for (const run of runs) for (const q of run.pieces) allPieces.push(q);
const placedLabels = [];
function rectFree(cx, cy, w, h) {
const m = TRACK_W / 2;
const ax0 = cx - w / 2 - m, ax1 = cx + w / 2 + m;
const ay0 = cy - h / 2 - m, ay1 = cy + h / 2 + m;
for (const q of allPieces) {
const b = q.box;
if (b[2] < ax0 || b[0] > ax1 || b[3] < ay0 || b[1] > ay1) continue;
for (let i = 0; i < q.pts.length - 1; i++) {
const p0 = q.pts[i], p1 = q.pts[i + 1];
for (let t = 0; t <= 4; t++) {
const x = p0[0] + (p1[0] - p0[0]) * t / 4, y = p0[1] + (p1[1] - p0[1]) * t / 4;
if (x > ax0 && x < ax1 && y > ay0 && y < ay1) return false;
}
}
}
for (const l of placedLabels)
if (Math.abs(l.x - cx) < (l.w + w) / 2 + 14 && Math.abs(l.y - cy) < (l.h + h) / 2 + 14) return false;
return true;
}
/* Zwei Strecken kreuzen sich? Führungslinien, die übereinander
hinweglaufen, machen einen Plan unlesbar: man sieht nicht mehr,
welches Schild zu welchem Punkt gehört. */
const segCross = (a, b, c, d) => {
const side = (p, q, t) => Math.sign((q.x - p.x) * (t.y - p.y) - (q.y - p.y) * (t.x - p.x));
return side(a, b, c) !== side(a, b, d) && side(c, d, a) !== side(c, d, b);
};
const leaders = [];
/** Freie Stelle für ein Schild: erst nah am Gleis, dann weiter weg —
und zuletzt außerhalb der Anlage, wo garantiert kein Gleis liegt.
Unter den freien Plätzen gewinnt der, dessen Führungslinie keine
andere kreuzt. */
function findSpot(ax, ay, hd, w, h, step) {
const nx = -SIN[hd], ny = COS[hd]; // quer zur Fahrtrichtung
const tx = COS[hd], ty = SIN[hd]; // längs
const cands = [];
for (const d of [2.1, 3.0, 4.0, 5.2, 6.6, 8.2, 10.5, 13.5, 17])
for (const sg of [1, -1])
for (const al of [0, 1.8, -1.8, 3.6, -3.6, 6, -6])
cands.push({ x:ax + nx * d * step * sg + tx * al * step,
y:ay + ny * d * step * sg + ty * al * step,
cost:d + Math.abs(al) * 0.6 });
cands.sort((a, b) => a.cost - b.cost);
// Notplätze am Rand der Anlage: dort steht nie ein Gleis im Weg.
// Ihr Aufwand wird in dieselbe Einheit gebracht wie oben (Vielfache
// von step) und liegt bewusst über allem, was nahe am Gleis geht.
const edge = step * 0.9;
const outs = [
{ x:ax, y:y1 + h / 2 + edge }, { x:ax, y:y0 - h / 2 - edge },
{ x:x1 + w / 2 + edge, y:ay }, { x:x0 - w / 2 - edge, y:ay }
].map(o => ({ ...o, cost:24 + Math.hypot(o.x - ax, o.y - ay) / step }))
.sort((a, b) => a.cost - b.cost);
const anchor = { x:ax, y:ay };
let best = null;
for (const c of cands.concat(outs)) {
if (!rectFree(c.x, c.y, w, h)) continue;
let pen = 0;
for (const l of leaders) if (segCross(anchor, c, l.a, l.b)) pen += 7;
if (best === null || c.cost + pen < best.cost + best.pen) best = { x:c.x, y:c.y, cost:c.cost, pen };
// Der erste kreuzungsfreie Platz ist zugleich der günstigste,
// weil die Liste nach Aufwand sortiert ist.
if (pen === 0) break;
}
return best || outs[0];
}
/* Einzelne Schwellen lohnen nur, wenn sie auch sichtbar werden. Der
Schwellenabstand liegt bei rund 16 mm; kommen davon weniger als
etwa zweieinhalb Bildpunkte an, wird daraus kein Muster mehr,
sondern ein grauer Schleier, der die Farbe des Gleises auswäscht.
Die alte Grenze von 0,055 ließ sie praktisch immer zeichnen — genau
deshalb sahen große Pläne blass und undeutlich aus. */
const pxPerMm = opt.width / vw;
const showTies = opt.detail || pxPerMm * 16 > 2.5;
/* Strichstärken in Millimetern werden bei kleinem Maßstab dünner als
ein Bildpunkt. Deshalb bekommt jede Linie eine Mindestbreite in
Bildpunkten, zurückgerechnet in Millimeter. */
const mind = px => px / pxPerMm;
/* Ohne Schwellen und sichtbare Schienen bleibt vom Gleis nur ein
farbiges Band ohne Kante — nebeneinanderliegende Gleise verschmelzen
dann zu einer Fläche. Ein dunkler Rand gibt jedem Gleis wieder eine
Kontur. Er liegt unter allen Gleiskörpern, sonst würde der Rand eines
Abzweigs das Gleis daneben überdecken. */
const rimW = TRACK_W + Math.max(6, mind(3));
let outline = '', base = '', ties = '', joints = '', rails = '', extras = '';
for (const run of runs) {
const tint = q => opt.colored ? typeColor(q.crossHost ? q.crossId : q.id) : 'var(--tie)';
/* Der Gleiskörper ist eine durchgehende Fläche — nur so bleibt der
Umriss glatt. Der Abzweig einer Weiche gehört aber nicht als zweites
volles Gleis daneben: er liegt IM selben Körper, nur mit eigenem
Schienenpaar. Deshalb wird er schmaler und ohne eigene Schwellen
gezeichnet, sonst sieht die Weiche aus wie zwei Gleise übereinander. */
const solid = run.pieces.filter(q => !q.branchLeg);
if (solid.length) {
if (!showTies)
outline += `<path d="${runPath(solid, 0, run.closed)}" fill="none" stroke="var(--tie-dark)" stroke-width="${fx(rimW)}" stroke-linejoin="round"/>`;
base += `<path d="${runPath(solid, 0, run.closed)}" fill="none" stroke="var(--tie)" stroke-width="${TRACK_W}" stroke-linejoin="round"/>`;
}
for (const q of run.pieces) if (q.branchLeg)
base += `<path d="${pieceSeg(q, 0)}" fill="none" stroke="var(--tie)" stroke-width="${q.m.t === 'T' ? GAUGE + 8 : TRACK_W}" stroke-linecap="butt"/>`;
if (opt.colored) for (const q of run.pieces)
base += `<path d="${pieceSeg(q, 0)}" fill="none" stroke="${tint(q)}" stroke-width="${TRACK_W - 10}"/>`;
// Schwellen liegen als Linien auf der Fläche, nicht daneben.
if (showTies) for (const q of run.pieces) if (!q.branchLeg)
ties += `<path d="${tiesOf(q, TRACK_W / 2 - 3)}" fill="none" stroke="var(--tie-dark)" stroke-width="5" stroke-linecap="butt" opacity=".8"/>`;
/* Schienen nur zeichnen, wenn sie auch als Strich ankommen. Zu dünn
gezeichnet werden sie zu einem hellen Schleier, der die Farbe des
Gleises auswäscht, statt etwas zu zeigen. */
/* Stoßmarken an jeder Teilegrenze. Ohne sie ist das Gleis ein
durchgehendes Band, in dem sich nichts abzählen lässt — man sieht
zwar an der Farbe, welche Sorte da liegt, aber nicht, wie viele
Teile eine Strecke lang ist. Genau das braucht man beim Bauen. */
for (const q of run.pieces) {
const e = go(q.p, q.m);
if (Math.hypot(e.x - q.p.x, e.y - q.p.y) * pxPerMm < 3) continue; // zu dicht zum Abzählen
const hw = (q.branchLeg ? GAUGE + 8 : TRACK_W) / 2 - 1;
const nx = -SIN[q.p.h] * hw, ny = COS[q.p.h] * hw;
/* Deutlich kräftiger als die Schwellen — sonst geht die Stoßmarke
im Schwellenmuster unter und man kann wieder nichts abzählen. */
const jw = Math.max(showTies ? 10 : 3, mind(1.3));
joints += `<path d="M${fx(q.p.x - nx)} ${fx(-(q.p.y - ny))}L${fx(q.p.x + nx)} ${fx(-(q.p.y + ny))}"
stroke="var(--ink-3)" stroke-width="${fx(jw)}" stroke-linecap="butt" opacity=".9"/>`;
}
const rw = showTies ? 6 : Math.max(7, mind(1.1));
if (showTies || mind(0.9) <= GAUGE / 2)
for (const off of [GAUGE / 2, -GAUGE / 2])
rails += `<path d="${runPath(run.pieces, off, run.closed)}" fill="none" stroke="var(--rail)" stroke-width="${fx(rw)}" stroke-linejoin="round"/>`;
/* Eine Kreuzungsweiche ist mehr als eine Kreuzung: durch ihre Felder
führen zusätzlich Verbindungsbögen. Ohne die sähe sie im Plan aus
wie eine gewöhnliche Kreuzung — und genau das soll man ja
unterscheiden können. */
for (const q of run.pieces) {
if (!q.crossHost) continue;
const cp = parts[q.crossId];
if (!cp || cp.k !== 'multi') continue;
const mu = multiOf(cp);
if (!mu) continue;
for (const rt of mu.routes) {
if (rt.m.t === 'S') continue; // die Geraden liegen schon da
const seg = { p:localTo(q.p, rt.dx, rt.dy, rt.dh), m:rt.m };
extras += `<path d="${pieceSeg(seg, 0)}" fill="none" stroke="var(--tie)" stroke-width="${GAUGE + 6}" stroke-linecap="butt" opacity=".85"/>`;
for (const off of [GAUGE / 2, -GAUGE / 2])
extras += `<path d="${pieceSeg(seg, off)}" fill="none" stroke="var(--rail)" stroke-width="5" stroke-linecap="butt"/>`;
}
}
for (const q of run.pieces) {
const pd = parts[q.id];
if (pd && pd.road) {
const e = go(q.p, q.m), mx = (q.p.x + e.x) / 2, my = (q.p.y + e.y) / 2;
const dx = COS[q.p.h] * 68, dy = SIN[q.p.h] * 68;
extras += `<path d="M${fx(mx - dx)} ${fx(-(my - dy))}L${fx(mx + dx)} ${fx(-(my + dy))}" stroke="var(--road)" stroke-width="104" opacity=".45" stroke-linecap="butt"/>`;
}
}
if (run.branch && run.branch.buffer && run.branch.kind !== 'loop') {
const p = run.branch.end, nx = -SIN[p.h] * 30, ny = COS[p.h] * 30;
extras += `<path d="M${fx(p.x - nx)} ${fx(-(p.y - ny))}L${fx(p.x + nx)} ${fx(-(p.y + ny))}" stroke="var(--buffer)" stroke-width="13" stroke-linecap="round"/>`;
}
}
// Anfassflächen je Teil — nur im Editor
let hits = '';
if (opt.pick) for (const run of runs) for (const q of run.pieces) {
if (q.uid === undefined) continue;
// Angefasst werden kann jede Route, hervorgehoben wird nur die erste —
// sonst überlagern sich bei mehrteiligen Stücken vier Markierungen.
const sel = opt.selUid === q.uid && !q.ghost;
hits += `<path d="${pieceSeg(q, 0)}" fill="none" stroke="${sel ? 'var(--accent)' : 'transparent'}"
stroke-width="${TRACK_W}" opacity="${sel ? '.32' : '1'}" data-uid="${q.uid}"
style="cursor:grab; pointer-events:stroke"/>`;
}
// Startpunkt und, im Editor, die offenen Enden
const sp = (r.loop && r.loop[0]) ? r.loop[0].p : null;
/* Der Bauanfang muss ins Auge springen und die Baurichtung zeigen —
die Bauanleitung sagt „links/rechts in Fahrtrichtung ab hier". */
let startDot = '';
if (sp) {
const R0 = fsRaw * 0.95, a = sp.h * U;
const cs = Math.cos(a), sn = Math.sin(a);
const px = -sn, py = cs; // Normale nach links
const pt = (f, l) => `${fx(sp.x + cs * f + px * l)} ${fx(-(sp.y + sn * f + py * l))}`;
const w = fsRaw * 5.4, h = fsRaw * 1.55;
const spot = findSpot(sp.x, sp.y, sp.h, w, h, fsRaw);
const lx = spot.x, ly = spot.y;
placedLabels.push({ x:lx, y:ly, w, h });
startDot = `<g>
<path d="M${fx(sp.x)} ${fx(-sp.y)}L${fx(lx)} ${fx(-ly)}" stroke="var(--accent)" stroke-width="${fx(fsRaw * 0.14)}" opacity=".8"/>
<circle cx="${fx(sp.x)}" cy="${fx(-sp.y)}" r="${fx(R0)}" fill="var(--accent)"
stroke="var(--plate)" stroke-width="${fx(fsRaw * 0.2)}"/>
<path d="M${pt(R0 * 0.55, 0)}L${pt(-R0 * 0.3, R0 * 0.42)}L${pt(-R0 * 0.3, -R0 * 0.42)}Z" fill="var(--plate)"/>
<rect x="${fx(lx - w / 2)}" y="${fx(-ly - h / 2)}" width="${fx(w)}" height="${fx(h)}"
rx="${fx(h / 2)}" fill="var(--accent)"/>
<text x="${fx(lx)}" y="${fx(-ly + fsRaw * 0.36)}" text-anchor="middle"
font-family="Archivo, sans-serif" font-weight="600" font-size="${fx(fsRaw)}"
fill="var(--plate)">Bauanfang</text></g>`;
}
let endMarks = '';
for (const e of (opt.ends || [])) {
const a = e.pose.h * U, tipx = e.pose.x + Math.cos(a) * 46, tipy = e.pose.y + Math.sin(a) * 46;
endMarks += `<circle cx="${fx(e.pose.x)}" cy="${fx(-e.pose.y)}" r="${e.active ? 26 : 18}" fill="none"
stroke="${e.active ? 'var(--accent)' : 'var(--ink-3)'}" stroke-width="${e.active ? 8 : 5}"/>`
+ `<path d="M${fx(e.pose.x)} ${fx(-e.pose.y)}L${fx(tipx)} ${fx(-tipy)}" stroke="${e.active ? 'var(--accent)' : 'var(--ink-3)'}" stroke-width="${e.active ? 8 : 5}"/>`;
}
/* Beschriftung: welches Gleis wofür da ist. Ohne sie ist ein Plan mit
Abzweigen kaum zu lesen — alles sieht gleich aus. */
let marks = '';
if (opt.marks !== false && r.branches && r.branches.length) {
const fs = fsRaw;
/* Die Nummer am Schild ist die des Bauschritts. Erst dadurch lassen
sich zwei gleichnamige Schilder überhaupt auseinanderhalten — und
man findet das Gleis aus dem Plan in der Bauanleitung wieder. */
const stepNo = new Map();
for (const st of stepList(r)) if (st.branch) stepNo.set(st.branch, st.no);
const midOfBranch = b => {
const run = branchRun(b);
return run[Math.floor(run.length / 2)];
};
for (const b of r.branches) {
const mid = midOfBranch(b);
const [mx, my] = mid.mid;
const no = stepNo.get(b);
const wort = b.station ? 'Bahnhof'
: b.kind === 'stub' ? 'Abstellgleis'
: b.cut ? 'Abkürzung' : 'Ausweichgleis';
const label = no ? `${wort} ${no}` : wort;
const col = b.station ? 'var(--ok)' : 'var(--accent)';
const w = label.length * fs * 0.56 + fs * 0.7, h = fs * 1.55;
const spot = findSpot(mx, my, mid.p.h, w, h, fs);
const lx = spot.x, ly = spot.y;
placedLabels.push({ x:lx, y:ly, w, h });
leaders.push({ a:{ x:mx, y:my }, b:{ x:lx, y:ly } });
marks += `<g>
<path d="M${fx(mx)} ${fx(-my)}L${fx(lx)} ${fx(-ly)}" stroke="${col}" stroke-width="${fs * 0.12}" opacity=".8"/>
<rect x="${fx(lx - w / 2)}" y="${fx(-ly - h / 2)}" width="${fx(w)}" height="${fx(h)}"
rx="${fx(h / 2)}" fill="${col}"/>
<text x="${fx(lx)}" y="${fx(-ly + fs * 0.36)}" text-anchor="middle"
font-family="Archivo, sans-serif" font-weight="600" font-size="${fx(fs)}"
fill="var(--plate)">${label}</text></g>`;
/* Die Weichen des Abzweigs tragen dieselbe Nummer wie ihr Schild —
aber NEBEN dem Gleis, nicht darauf. Ein Ring mit zwei Ziffern ist
breiter als das Gleis selbst; mitten daraufgesetzt verdeckt er
genau das, was der Plan zeigen soll. Auf dem Gleis bleibt nur ein
kleiner Punkt, der die Stelle genau angibt. */
const rad = no ? fs * 0.52 : fs * 0.34;
for (const sp of [b.switchStart, b.tailStart]) {
if (!sp) continue;
const nx = -SIN[sp.h], ny = COS[sp.h], tx = COS[sp.h], ty = SIN[sp.h];
const weg = TRACK_W / 2 + rad + fs * 0.28;
let ring = null;
for (const d of [weg, weg * 1.5, weg * 2.1, weg * 2.9]) {
for (const al of [0, fs * 1.0, -fs * 1.0, fs * 2.1, -fs * 2.1]) {
for (const sg of [1, -1]) {
const x = sp.x + nx * d * sg + tx * al, y = sp.y + ny * d * sg + ty * al;
if (rectFree(x, y, rad * 2, rad * 2)) { ring = { x, y }; break; }
}
if (ring) break;
}
if (ring) break;
}
if (!ring) ring = { x:sp.x + nx * weg, y:sp.y + ny * weg };
placedLabels.push({ x:ring.x, y:ring.y, w:rad * 2, h:rad * 2 });
marks += `<path d="M${fx(sp.x)} ${fx(-sp.y)}L${fx(ring.x)} ${fx(-ring.y)}"
stroke="${col}" stroke-width="${fx(fs * 0.09)}" opacity=".75"/>`
+ `<circle cx="${fx(sp.x)}" cy="${fx(-sp.y)}" r="${fx(fs * 0.13)}" fill="${col}"/>`
+ `<circle cx="${fx(ring.x)}" cy="${fx(-ring.y)}" r="${fx(rad)}"
fill="var(--plate)" stroke="${col}" stroke-width="${fx(fs * 0.16)}"/>`;
if (no) marks += `<text x="${fx(ring.x)}" y="${fx(-ring.y + fs * 0.2)}" text-anchor="middle"
font-family="IBM Plex Mono, monospace" font-weight="600" font-size="${fx(fs * 0.56)}"
fill="${col}">${no}</text>`;
}
}
}
let labels = '';
if (opt.numbers) {
const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
for (const st of stepList(r)) {
const p = st.piece.p;
const nx = -SIN[p.h], ny = COS[p.h];
const side = ((p.x - cx) * nx + (p.y - cy) * ny) >= 0 ? 1 : -1;
const lx = p.x + nx * 62 * side, ly = p.y + ny * 62 * side;
labels += `<circle cx="${fx(lx)}" cy="${fx(-ly)}" r="26" fill="var(--plate)" stroke="var(--ink-3)" stroke-width="2.5"/>`
+ `<text x="${fx(lx)}" y="${fx(-ly + 11)}" text-anchor="middle" font-family="IBM Plex Mono, monospace" font-size="30" fill="var(--ink)">${st.no}</text>`;
}
}
const viewBox = `${vx.toFixed(0)} ${vy.toFixed(0)} ${vw.toFixed(0)} ${vh.toFixed(0)}`;
const inner = `
<defs><pattern id="${gid}" width="256" height="256" patternUnits="userSpaceOnUse">
<path d="M0 0H256M0 64H256M0 128H256M0 192H256M0 0V256M64 0V256M128 0V256M192 0V256" fill="none" stroke="var(--grid)" stroke-width="1.5"/>
<path d="M0 0H256M0 0V256" fill="none" stroke="var(--grid-major)" stroke-width="3"/>
</pattern></defs>
<rect x="${vx.toFixed(0)}" y="${vy.toFixed(0)}" width="${vw.toFixed(0)}" height="${vh.toFixed(0)}" fill="url(#${gid})"/>
${extras}${outline}${base}${ties}${joints}${rails}${hits}
${startDot}${endMarks}${marks}
${labels}`;
// Der Editor braucht Inhalt und Ausschnitt getrennt, weil seine
// Zeichenfläche bestehen bleibt.
return opt.parts ? { viewBox, inner }
: `<svg viewBox="${viewBox}" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Gleisplan">${inner}</svg>`;
}
/** Ein Teil allein zeichnen — für Bestandsliste und Legende. */
function pieceIcon(p, w, h) {
const wrap = (vb, inner) =>
`<svg width="${w}" height="${h}" viewBox="${vb}" preserveAspectRatio="xMidYMid meet">${inner}</svg>`;
const tie = (x, y, vert) => vert
? `M${x - 30} ${y}H${x + 30}` : `M${x} ${y - 30}V${y + 30}`;
if (p.k === 'buffer') {
let t = '';
for (let x = 8; x < 108; x += 21) t += `M${x} -30V30`;
return wrap('-8 -40 140 80',
`<path d="M0 0H108" stroke="var(--tie)" stroke-width="${GAUGE + 12}" opacity=".9"/>`
+ `<path d="${t}" stroke="var(--tie)" stroke-width="10"/>`
+ `<path d="M0 -20H108M0 20H108" stroke="var(--rail)" stroke-width="6"/>`
+ `<path d="M108 -30V30" stroke="var(--buffer)" stroke-width="14" stroke-linecap="round"/>`);
}
if (p.k === 'cross') {
let t = '';
for (let d = -54; d <= 54; d += 21) { t += `M${d} -30V30`; t += `M-30 ${d}H30`; }
return wrap('-72 -72 144 144',
`<path d="M-64 0H64M0 -64V64" stroke="var(--tie)" stroke-width="${GAUGE + 12}" opacity=".9"/>`
+ `<path d="${t}" stroke="var(--tie)" stroke-width="9"/>`
+ `<path d="M-64 -20H64M-64 20H64M-20 -64V64M20 -64V64" stroke="var(--rail)" stroke-width="5.5"/>`);
}
if (p.k === 'multi') {
const mu = multiOf(p), base = { x:0, y:0, h:0 };
let body = '', rail = '';
for (const r of mu.routes) {
const pose = localTo(base, r.dx, r.dy, r.dh);
const q = { id:p.id, m:r.m, p:pose };
body += `<path d="${pieceSeg(q, 0)}" fill="none" stroke="var(--tie)" stroke-width="${TRACK_W}"/>`;
rail += `<path d="${pieceSeg(q, GAUGE / 2)}" fill="none" stroke="var(--rail)" stroke-width="6"/>`
+ `<path d="${pieceSeg(q, -GAUGE / 2)}" fill="none" stroke="var(--rail)" stroke-width="6"/>`;
}
let pts = [];
for (const r of mu.routes) pts = pts.concat(shapeOf(localTo(base, r.dx, r.dy, r.dh), r.m));
const b = boxOf(pts), m = 40;
return wrap(`${(b[0] - m).toFixed(0)} ${(-b[3] - m).toFixed(0)} ${(b[2] - b[0] + m * 2).toFixed(0)} ${(b[3] - b[1] + m * 2).toFixed(0)}`,
body + rail);
}
const pieces = [{ id:p.id, m:(p.k === 'curve' ? { t:'C', r:p.r, s:1 } : { t:'S', len:p.len || 128 }), p:{ x:0, y:0, h:0 } }];
if (p.k === 'switch') pieces.push({ id:p.id, m:branchOf(p), p:{ x:0, y:0, h:0 }, branchLeg:true });
let body = '', ties = '', rail = '';
for (const pc of pieces) {
body += `<path d="${pieceSeg(pc, 0)}" fill="none" stroke="var(--tie)" stroke-width="${TRACK_W}"/>`;
ties += `<path d="${tiesOf(pc, TRACK_W / 2 - 3)}" fill="none" stroke="var(--tie-dark)" stroke-width="6" opacity=".8"/>`;
rail += `<path d="${pieceSeg(pc, GAUGE / 2)}" fill="none" stroke="var(--rail)" stroke-width="6"/>`
+ `<path d="${pieceSeg(pc, -GAUGE / 2)}" fill="none" stroke="var(--rail)" stroke-width="6"/>`;
}
let pts = [];
for (const pc of pieces) pts = pts.concat(shapeOf(pc.p, pc.m));
const b = boxOf(pts), m = 38;
const vw = Math.max(b[2] - b[0] + m * 2, 70), vh = Math.max(b[3] - b[1] + m * 2, 70);
return wrap(`${(b[0] - m).toFixed(0)} ${(-b[3] - m).toFixed(0)} ${vw.toFixed(0)} ${vh.toFixed(0)}`,
body + ties + rail);
}
/* ---------------------------------------------------------------
Bauliste: gleiche Nummern wie die Kreise im Plan.
----------------------------------------------------------------*/
function stepList(r) {
const parts = partIndex(CATALOG.concat(customParts));
const out = [];
let no = 0, cur = null;
const key = q => q.id + (q.m.t === 'C' ? (q.m.s > 0 ? 'L' : 'R') : '');
const isX = q => q.crossWith !== undefined;
// Wo eine Weiche sitzt, wird nicht die Gerade gelegt, sondern die Weiche.
const swapAt = new Map();
for (const b of r.branches) {
// Eine Weiche belegt mehrere Geraden — alle davon markieren, sonst
// stehen die übrigen weiter als eigene Geraden in der Liste.
for (const k of (b.span || [b.at])) swapAt.set(k, { id:b.switchId, s:b.switchMove.s, branch:b });
if (b.at2 !== undefined) {
// Die Richtung steht am Teil selbst, nicht an der Fahrtrichtung durch es.
const p2 = parts[b.switchId2];
for (const k of (b.span2 || [b.at2]))
swapAt.set(k, { id:b.switchId2, s:p2 ? p2.s : b.tailMove.s, branch:b, second:true });
}
}
for (let i = 0; i < r.loop.length; i++) {
const q = r.loop[i];
const sw = swapAt.get(i);
if (!sw && cur && !isX(q) && !isX(cur.piece) && !cur.swap && key(cur.piece) === key(q)) {
cur.count++; cur.to = i; continue;
}
// Eine Weiche belegt mehrere Geraden — die werden zu einem Schritt.
if (sw && cur && cur.swapInfo && cur.swapInfo.id === sw.id && cur.to === i - 1) {
cur.count++; cur.to = i; continue;
}
cur = { no:++no, piece:q, count:1, from:i, to:i, swap:!!sw,
part:sw ? parts[sw.id] : parts[isX(q) ? q.crossId : q.id],
dir:sw ? (sw.s > 0 ? 'links' : 'rechts')
: (q.m.t === 'C' ? (q.m.s > 0 ? 'links' : 'rechts') : null),
again:isX(q) && !q.crossHost, branch:null, swapInfo:sw || null };
out.push(cur);
}
/* Eigene Pläne bestehen aus lose gelegten Teilen statt aus einer Runde —
die werden hier der Reihe nach aufgeführt. */
for (const ch of (r.chains || [])) {
let rc = null;
for (const q of ch) {
if (q.ghost) continue;
if (rc && key(rc.piece) === key(q)) { rc.count++; continue; }
rc = { no:++no, piece:q, count:1, part:parts[q.id],
dir:q.m.t === 'C' ? (q.m.s > 0 ? 'links' : 'rechts') : null, branch:null };
out.push(rc);
}
}
const stepAt = i => out.find(st => i >= st.from && i <= st.to);
for (const b of r.branches) {
const a1 = stepAt(b.at), a2 = b.at2 !== undefined ? stepAt(b.at2) : null;
out.push({ no:++no, piece:{ m:b.switchMove, p:b.switchStart }, count:1,
part:parts[b.switchId], part2:b.switchId2 ? parts[b.switchId2] : null,
dir:b.switchMove.s > 0 ? 'links' : 'rechts', branch:b,
anchorNo:a1 ? a1.no : 1, anchorNo2:a2 ? a2.no : null });
}
return out;
}
function branchSteps(b) {
const parts = partIndex(CATALOG.concat(customParts));
const runs = [];
for (const q of b.pieces) {
const k = q.id + (q.m.t === 'C' ? (q.m.s > 0 ? 'L' : 'R') : '');
const last = runs[runs.length - 1];
if (last && last.k === k) last.count++;
else runs.push({ k, count:1, part:parts[q.id], dir:q.m.t === 'C' ? (q.m.s > 0 ? 'links' : 'rechts') : null });
}
return runs;
}
</script>
<script>
/* ---------------------------------------------------------------
Bedienung
----------------------------------------------------------------*/
const $ = s => document.querySelector(s);
let customParts = [];
let stock = {};
let current = []; // gerade angezeigte Vorschläge
let pool = []; // alle berechneten Strecken
let poolTotal = 0;
let shown = new Set();
let colored = false;
const STORE = 'gleisplaner.v2';
function save() {
try { localStorage.setItem(STORE, JSON.stringify({ stock, customParts })); } catch (e) {}
}
function load() {
try {
const d = JSON.parse(localStorage.getItem(STORE) || 'null');
if (d && d.stock) { stock = d.stock; customParts = d.customParts || []; return true; }
} catch (e) {}
return false;
}
function allParts() { return CATALOG.concat(customParts); }
function defaults() { const s = {}; for (const p of CATALOG) s[p.id] = p.def || 0; return s; }
function renderStock() {
const host = $('#groups');
host.innerHTML = '';
const groups = { std:[], alt:[], own:[] };
for (const p of CATALOG) groups[p.g].push(p);
for (const p of customParts) groups.own.push(p);
for (const g of ['std', 'alt', 'own']) {
if (!groups[g].length) continue;
const count = groups[g].reduce((a, p) => a + (stock[p.id] || 0), 0);
const det = document.createElement('details');
det.className = 'group';
det.open = g === 'std' || count > 0;
det.innerHTML = `<summary>${GROUPS[g]}<span class="n">${count}</span></summary>`;
const rows = document.createElement('div');
rows.className = 'rows';
for (const p of groups[g]) {
const row = document.createElement('div');
row.className = 'row' + ((stock[p.id] || 0) > 0 ? ' on' : '');
row.innerHTML = `<div class="ic">${pieceIcon(p, 40, 26)}</div>
<div class="nm">${p.n}<small>${p.note}</small></div>
<div class="stepper">
<button type="button" data-d="-1" aria-label="weniger ${p.n}"></button>
<input type="number" min="0" max="99" value="${stock[p.id] || 0}" aria-label="Anzahl ${p.n}">
<button type="button" data-d="1" aria-label="mehr ${p.n}">+</button>
</div>`;
const inp = row.querySelector('input');
row.querySelectorAll('.stepper button').forEach(b => b.onclick = () => {
stock[p.id] = Math.max(0, Math.min(99, (stock[p.id] || 0) + (+b.dataset.d)));
inp.value = stock[p.id]; row.classList.toggle('on', stock[p.id] > 0); refreshTotals(); save();
});
inp.oninput = () => { stock[p.id] = Math.max(0, +inp.value || 0); row.classList.toggle('on', stock[p.id] > 0); refreshTotals(); save(); };
if (p.custom) {
const del = document.createElement('button');
del.className = 'del'; del.type = 'button'; del.textContent = '×'; del.title = 'Schiene entfernen';
del.onclick = () => { customParts = customParts.filter(c => c.id !== p.id); delete stock[p.id]; renderStock(); save(); };
row.appendChild(del);
}
rows.appendChild(row);
}
det.appendChild(rows);
host.appendChild(det);
}
refreshTotals();
}
function refreshTotals() {
let t = 0;
for (const p of allParts()) t += stock[p.id] || 0;
$('#totalCount').textContent = t;
document.querySelectorAll('.group').forEach(det => {
const sum = det.querySelector('summary .n');
if (!sum) return;
const name = det.querySelector('summary').firstChild.textContent;
const g = Object.keys(GROUPS).find(k => GROUPS[k] === name);
if (g) sum.textContent = allParts().filter(p => p.g === g).reduce((a, p) => a + (stock[p.id] || 0), 0);
});
}
$('#addBtn').onclick = () => {
const kind = $('#addKind').value, v = Math.max(1, +$('#addVal').value || 0);
const id = 'U' + (customParts.length + 1) + kind[0] + v;
if (allParts().some(p => p.id === id)) return;
customParts.push(kind === 'curve'
? { id, g:'own', n:`Kurve R${v}`, note:`22,5° · Radius ${v} Noppen`, k:'curve', r:v * STUD, custom:true }
: { id, g:'own', n:`Gerade ${v}`, note:`${v} Noppen · ${(v * 0.8).toFixed(1)} cm`, k:'straight', len:v * STUD, custom:true });
stock[id] = 2;
renderStock(); save();
};
$('#resetBtn').onclick = () => { for (const p of allParts()) stock[p.id] = 0; renderStock(); save(); };
const PRESETS = {
startset: { S16:4, C40:16 },
mittel: { S16:12, S8:2, C40:24, WL:1, WR:1, PRB:1 },
gross: { S16:20, S8:4, C40:32, FLX:2, WL:2, WR:2, X90:1, BUE:1, PRB:2 }
};
document.querySelectorAll('[data-preset]').forEach(b => b.onclick = () => {
for (const p of allParts()) stock[p.id] = 0;
Object.assign(stock, PRESETS[b.dataset.preset]);
renderStock(); save();
});
$('#themeBtn').onclick = () => {
const dark = matchMedia('(prefers-color-scheme: dark)').matches;
const cur = document.documentElement.getAttribute('data-theme') || (dark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', cur === 'dark' ? 'light' : 'dark');
};
function updateStationHint() {
const n = +$('#platform').value + 6 + (+$('#stationTracks').value >= 3 ? 2 : 0);
$('#needRunHint').textContent = n;
}
['stations', 'stationTracks', 'platform'].forEach(id => $('#' + id).onchange = updateStationHint);
updateStationHint();
$('#useTable').onchange = () => { $('#tableFields').hidden = !$('#useTable').checked; };
$('#viewReal').onclick = () => setView(false);
$('#viewType').onclick = () => setView(true);
function setView(c) {
colored = c;
$('#viewReal').setAttribute('aria-pressed', String(!c));
$('#viewType').setAttribute('aria-pressed', String(c));
if (current.length) drawCards();
}
const cm = mm => (mm / 10).toFixed(0);
const studs = mm => Math.round(mm / STUD);
/* Dieselbe Farbe wie im Plan — deshalb hier nur weitergereicht und
nicht ein zweites Mal berechnet. Prellböcke haben im Plan keine
Typfarbe, sie werden rot gezeichnet. */
const BUFFER_COLOR = '#B2231F';
function colorFor(id) {
const p = allParts().find(q => q.id === id);
return p && p.k === 'buffer' ? BUFFER_COLOR : typeColor(id);
}
function usageTags(r) {
const parts = partIndex(allParts());
const out = [];
for (const [id, c] of Object.entries(r.use).sort((a, b) => b[1] - a[1])) {
const p = parts[id]; if (!p) continue;
out.push(`<span class="tag">${colored ? `<i class="sw" style="background:${colorFor(id)}"></i>` : ''}${c}× ${p.n}</span>`);
}
const st = r.branches.filter(b => b.station).length;
if (st) out.unshift(`<span class="tag" style="background:var(--ok-soft);border-color:transparent;color:var(--ok)">${st === 1 ? 'Bahnhof' : st + ' Bahnhöfe'}</span>`);
let rest = 0;
for (const p of allParts()) rest += Math.max(0, (stock[p.id] || 0) - (r.use[p.id] || 0));
out.push(rest === 0 ? `<span class="tag full">alle Teile verbaut</span>` : `<span class="tag rest">${rest} übrig</span>`);
return out.join('');
}
function drawCards() {
const box = $('#results');
const rest = pool.length - current.length;
box.innerHTML = `<div class="grid">${current.map((r, i) => `
<button class="card" data-i="${i}" type="button">
<div class="plan">${planSVG(r, { colored })}</div>
<div class="meta">
<div class="r1"><h3>Vorschlag ${i + 1}</h3><span class="dim">${cm(r.w)} × ${cm(r.h)} cm</span></div>
<div class="tags">${usageTags(r)}</div>
</div>
</button>`).join('')}</div>
${rest > 0 ? `<div class="more"><button class="ghost" id="moreBtn" type="button">Weitere Strecken zeigen</button>
<span class="note">${rest} weitere berechnet</span></div>` : ''}`;
box.querySelectorAll('.card').forEach(c => c.onclick = () => openDetail(+c.dataset.i));
const mb = $('#moreBtn');
if (mb) mb.onclick = () => {
const restList = pool.filter(r => !shown.has(r));
const add = pick(restList, +$('#want').value);
for (const r of add) shown.add(r);
current = current.concat(add);
drawCards();
window.scrollBy({ top: 320, behavior:'smooth' });
};
}
function stepsHTML(r) {
const steps = stepList(r);
const dot = id => `<span class="n" style="background:${colorFor(id)}">`;
const runText = runs => runs.map(x => `${x.count}× ${x.part ? x.part.n : '?'}${x.dir ? ' ' + x.dir : ''}`).join(', ');
let lastRing = 0;
return steps.map(s => {
let head = '';
if ((s.ringNo || 0) !== lastRing) {
lastRing = s.ringNo || 0;
if (lastRing) head = `<li class="ringhead">${lastRing === 1 ? 'Zweiter' : 'Dritter'} Rundkurs — liegt innen, getrennt vom ersten</li>`;
}
if (s.branch) {
const b = s.branch, runs = branchSteps(b);
if (b.kind === 'loop' && b.station) {
return head + `<li class="branch">${dot(b.switchId)}${s.no}</span>
<span><b>Bahnhofsgleis</b> zwischen den Weichen aus Schritt ${s.anchorNo} und ${s.anchorNo2}:
${runText(runs)} — es läuft 16 Noppen neben dem Hauptgleis, dort hält der Zug.</span></li>`;
}
if (b.kind === 'loop') {
return head + `<li class="branch">${dot(b.switchId)}${s.no}</span>
<span><b>Ausweichgleis</b> zwischen den Weichen aus Schritt ${s.anchorNo} und ${s.anchorNo2}:
von der ersten Weiche abzweigen, dann ${runText(runs)} — das Gleis läuft neben der Runde her
und mündet in der zweiten Weiche.</span></li>`;
}
return head + `<li class="branch">${dot(b.switchId)}${s.no}</span>
<span><b>Abstellgleis</b> ab der Weiche aus Schritt ${s.anchorNo}: ${runText(runs)}${b.buffer ? ', Prellbock ans Ende' : ''}</span></li>`;
}
if (s.again) return head + `<li>${dot(s.part.id)}${s.no}</span>
<span>ein zweites Mal durch die <b>${s.part.n}</b> — quer zur ersten Durchfahrt</span></li>`;
if (s.swap) return head + `<li>${dot(s.part.id)}${s.no}</span>
<span><b>1× ${s.part.n}</b> — sie ersetzt ${s.count > 1 ? s.count + ' Geraden' : 'eine Gerade'};
das Abzweiggleis folgt weiter unten</span></li>`;
return head + `<li>${dot(s.part ? s.part.id : s.piece.id)}${s.no}</span>
<span><b>${s.count}×</b> ${s.part ? s.part.n : s.piece.id}${s.dir ? ` <span class="dir">${s.dir}</span>` : ''}</span></li>`;
}).join('');
}
function legendHTML(r) {
const parts = partIndex(allParts());
return Object.entries(r.use).sort((a, b) => b[1] - a[1]).map(([id, c]) => {
const p = parts[id]; if (!p) return '';
return `<div>${colored ? `<i class="sw" style="background:${colorFor(id)};width:12px;height:12px"></i>` : ''}
<span class="mini">${pieceIcon(p, 30, 20)}</span>
<span>${p.n}</span><span class="cnt">${c}×</span></div>`;
}).join('');
}
let showNums = true;
function openDetail(i) {
const r = current[i];
$('#dlgTitle').textContent = `Vorschlag ${i + 1}`;
// Bei großen Anlagen wären 50 Nummernkreise nur noch Gewusel.
showNums = stepList(r).length <= 24;
const drawPlan = () => {
$('#dlgPlan').innerHTML = planSVG(r, { detail:true, colored, numbers:showNums, pad:150, width:700 });
$('#numsBtn').setAttribute('aria-pressed', String(showNums));
$('#numsBtn').style.opacity = showNums ? '1' : '.6';
};
$('#numsBtn').onclick = () => { showNums = !showNums; drawPlan(); };
drawPlan();
let rest = [];
for (const p of allParts()) {
const left = (stock[p.id] || 0) - (r.use[p.id] || 0);
if (left > 0) rest.push(`${left}× ${p.n}`);
}
$('#dlgSide').innerHTML = `
<div><span class="label" style="display:block;margin-bottom:7px">Maße</span>
<dl class="facts">
<dt>Platzbedarf</dt><dd>${cm(r.w)} × ${cm(r.h)} cm</dd>
<dt>in Noppen</dt><dd>${studs(r.w)} × ${studs(r.h)}</dd>
<dt>Bauplatten 32×32</dt><dd>${Math.ceil(r.w / 256)} × ${Math.ceil(r.h / 256)}</dd>
<dt>Streckenlänge</dt><dd>${cm(r.len)} cm</dd>
<dt>Teile im Plan</dt><dd>${r.n}</dd>
</dl></div>
<div><span class="label" style="display:block;margin-bottom:7px">Teileliste</span>
<div class="legend${colored ? ' tinted' : ''}">${legendHTML(r)}</div>
${rest.length ? `<p class="note" style="margin:9px 0 0">Bleibt im Kasten: ${rest.join(', ')}</p>` : ''}</div>
<div><span class="label" style="display:block;margin-bottom:7px">Bauanleitung ab der Marke</span>
<ol class="steps">${stepsHTML(r)}</ol></div>
<p class="note">Die rote Marke <b>Bauanfang</b> zeigt, wo du anfängst; der Pfeil darin gibt die Baurichtung. „Links“ und „rechts“ gelten in dieser Richtung. Kurvenschienen sind symmetrisch — die Richtung entsteht erst beim Anlegen.<br><br>
<b>PDF:</b> „Als PDF / drucken“ öffnet den Druckdialog — dort als Drucker <b>„Als PDF speichern“</b> wählen. Der Plan kommt formatfüllend auf eine A4-Seite, Bauanleitung und Teileliste darunter.</p>`;
$('#pdfBtn').onclick = () => printPlans([r], [i + 1]);
$('#toEditBtn').onclick = () => { $('#dlg').close(); edFromResult(r); };
$('#dlg').showModal();
}
$('#closeBtn').onclick = () => $('#dlg').close();
/* ---- Druck / PDF ----
Zwei Seiten je Plan: erst der Gleisplan quer und formatfüllend, nach
Teiletyp eingefärbt und mit Schrittnummern; dann Teileliste und
Bauanleitung auf eigener Seite, damit man beim Bauen blättern kann. */
function printPlans(list, nums) {
const parts = partIndex(allParts());
/* Als SVG-Fläche, nicht als CSS-Hintergrund: Hintergrundfarben lässt
der Browser beim Drucken standardmäßig weg, Füllfarben in einer
Grafik nicht. Deshalb fehlten die Farben bisher auf dem Papier. */
const swatch = id => `<svg class="psw" viewBox="0 0 12 12" aria-hidden="true"><rect width="12" height="12" rx="3" fill="${colorFor(id)}"/></svg>`;
const stepColor = st => st.branch ? st.branch.switchId
: st.part ? st.part.id : st.piece ? st.piece.id : null;
$('#printRoot').innerHTML = list.map((r, k) => {
const steps = stepList(r);
const rest = [];
for (const q of allParts()) {
const left = (stock[q.id] || 0) - (r.use[q.id] || 0);
if (left > 0) rest.push(`${left}× ${q.n}`);
}
const head = `<h1>Vorschlag ${nums[k]}</h1>
<span class="d">${cm(r.w)} × ${cm(r.h)} cm · ${studs(r.w)} × ${studs(r.h)} Noppen · ${r.n} Teile</span>`;
const stepText = st => {
const rt = b => branchSteps(b).map(x => `${x.count}× ${x.part ? x.part.n : ''}${x.dir ? ' ' + x.dir : ''}`).join(', ');
if (st.branch && st.branch.station)
return `<b>Bahnhofsgleis</b> zwischen Schritt ${st.anchorNo} und ${st.anchorNo2}: ${rt(st.branch)}`;
if (st.branch && st.branch.kind === 'loop')
return `<b>${st.branch.cut ? 'Abkürzung' : 'Ausweichgleis'}</b> zwischen Schritt ${st.anchorNo} und ${st.anchorNo2}: ${rt(st.branch)}`;
if (st.branch)
return `<b>Abstellgleis</b> ab Schritt ${st.anchorNo}: ${rt(st.branch)}${st.branch.buffer ? ', Prellbock' : ''}`;
if (st.again) return `ein zweites Mal durch die ${st.part.n}`;
if (st.swap) return `1× <b>${st.part.n}</b> (ersetzt ${st.count > 1 ? st.count + ' Geraden' : 'eine Gerade'})`;
return `${st.count}× ${st.part ? st.part.n : ''}${st.dir ? ' ' + st.dir : ''}`;
};
return `<section class="sheet plansheet">
<div class="ph">${head}</div>
<div class="planwrap">${planSVG(r, { detail:true, colored:true, numbers:steps.length <= 40, pad:150, width:1000 })}</div>
</section>
<section class="sheet listsheet">
<div class="ph"><h1>Teileliste — Vorschlag ${nums[k]}</h1>
<span class="d">${r.n} Teile · ${cm(r.len)} Gleis</span></div>
<div class="pcols">
<div>
<h3>Was du brauchst</h3>
<div class="plist">${Object.entries(r.use).sort((a, b) => b[1] - a[1])
.map(([id, c]) => parts[id]
? `${swatch(id)}<span class="pn">${c}×</span><span>${parts[id].n}</span><span class="pd">${parts[id].note || ''}</span>` : '').join('')}</div>
${rest.length ? `<p class="prest">Bleibt im Kasten: ${rest.join(', ')}</p>` : ''}
<p class="prest">Die Farben entsprechen den Gleisen auf der Planseite.</p>
</div>
<div>
<h3>Bauanleitung ab der Marke „Bauanfang“</h3>
<ol class="psteps">${steps.map(st => {
const cid = stepColor(st);
return `<li>${cid ? swatch(cid) : ''}<span>${stepText(st)}</span></li>`;
}).join('')}</ol>
</div>
</div>
</section>`;
}).join('');
print();
}
$('#printAllBtn').onclick = () => {
if (!current.length) return;
printPlans(current, current.map((_, i) => i + 1));
};
/* ---- Rechnen ---- */
const hasSwitches = () => allParts().some(p => isBranchPart(p) && (stock[p.id] || 0) > 0);
function emptyState(useAll) {
const parts = allParts();
let curves = 0, straights = 0;
for (const p of parts) { if (p.k === 'curve') curves += stock[p.id] || 0; if (p.k === 'straight') straights += stock[p.id] || 0; }
const tips = [];
if (curves < 16) tips.push(`Ein geschlossener Rundkurs braucht mindestens <b>16 Kurven</b> — im Bestand sind ${curves}.`);
if (curves % 2 === 1 && useAll) tips.push('Bei einer <b>ungeraden</b> Kurvenzahl kann keine geschlossene Strecke alle Kurven aufbrauchen.');
if (useAll) tips.push('Schalte <b>„Alle Teile verbauen“</b> aus — dann darf etwas im Kasten bleiben.');
tips.push('Mehr <b>Rechenzeit</b> wählen, oder den Abstand paralleler Gleise kleiner setzen.');
$('#results').innerHTML = `<div class="empty"><h3>Damit schließt sich keine Strecke</h3>
<p>Aus diesem Bestand ließ sich kein Gleis finden, das punktgenau zum Ausgangspunkt zurückkommt.</p>
<ul>${tips.map(t => `<li>${t}</li>`).join('')}</ul></div>`;
}
$('#goBtn').onclick = () => {
const parts = allParts();
const stations = +$('#stations').value;
const stationTracks = +$('#stationTracks').value;
const platform = +$('#platform').value;
const opt = {
useAll: $('#useAll').checked,
gap: +$('#gap').value,
useCross: $('#wantCross').checked,
sidings: $('#wantSidings').checked,
stations, stationTracks, platform,
// Für einen Bahnhof braucht die Runde eine gerade Strecke dieser Länge
needRun: stations ? platform + 6 + (stationTracks >= 3 ? 2 : 0) : 0
};
const want = +$('#want').value, budget = +$('#effort').value;
// Tischmaß: ein Plan passt, wenn er längs oder quer hineingeht.
const tbl = $('#useTable').checked
? { w:Math.max(40, +$('#tableW').value || 0) * 10, h:Math.max(40, +$('#tableH').value || 0) * 10 }
: null;
const fitsTable = r => !tbl
|| (r.w <= tbl.w + 1 && r.h <= tbl.h + 1)
|| (r.w <= tbl.h + 1 && r.h <= tbl.w + 1);
const btn = $('#goBtn'), bar = $('#bar');
btn.disabled = true; btn.textContent = 'Rechnet …';
bar.hidden = false; bar.firstElementChild.style.width = '0%';
$('#results').innerHTML = ''; $('#resCount').textContent = 'Strecken werden gesucht …';
const solver = createSolver(loopStockFor(stock, parts, opt), parts, opt);
/* Gezählt wird die tatsächlich gerechnete Zeit, nicht die Uhrzeit: sonst
hängt das Ergebnis daran, wie oft der Browser die Seite zeichnet — im
Hintergrund oder gedrosselt kämen nur ein Bruchteil der Strecken heraus. */
let spent = 0;
(function tick() {
const t1 = now();
solver.slice(90);
spent += now() - t1;
bar.firstElementChild.style.width = Math.min(100, spent / budget * 100) + '%';
const hits = solver.results().length;
$('#resCount').textContent = hits ? `${hits} Strecken gefunden` : 'Strecken werden gesucht …';
if (spent < budget) { setTimeout(tick, 0); return; }
const raw = solver.results();
if (!raw.length) {
bar.hidden = true; btn.disabled = false; btn.textContent = 'Strecken berechnen';
$('#resCount').textContent = ''; emptyState(opt.useAll); return;
}
let measured;
if ((opt.sidings || opt.stations) && hasSwitches()) {
/* Weichen anzubauen kostet Rechenzeit. Deshalb erst eine Vorauswahl
treffen und die Abzweige danach häppchenweise suchen, damit die
Seite bedienbar bleibt. */
/* Erst am Tischmaß messen, dann vorauswählen: sonst landen in der
Vorauswahl nur die größten Strecken und der Filter wirft danach
alles weg. */
const roh = raw.map(loop => measure(loop, []));
const passend = roh.filter(fitsTable);
if (!passend.length) { finish(roh, raw.length); return; }
const rough = pick(passend, Math.min(60, want * 4 + 12));
$('#resCount').textContent = 'Weichen werden eingeplant …';
bar.firstElementChild.style.width = '0%';
measured = [];
let k = 0;
let sideSpent = 0;
(function sideTick() {
const t1 = now();
while (k < rough.length && now() - t1 < 120) {
const withSid = sideSpent < 9000
? addSidings(rough[k].loop, stock, parts, Object.assign({ msPerPlan:130 }, opt))
: [];
measured.push(measure(rough[k].loop, withSid));
k++;
}
sideSpent += now() - t1;
bar.firstElementChild.style.width = (k / rough.length * 100) + '%';
if (k < rough.length) { setTimeout(sideTick, 0); return; }
finish(measured, raw.length);
})();
return;
}
measured = raw.map(loop => measure(loop, []));
const pass2 = measured.filter(fitsTable);
finish(pass2.length ? pass2 : measured, raw.length);
function finish(list, total) {
bar.hidden = true; btn.disabled = false; btn.textContent = 'Strecken berechnen';
const allList = list;
const all = list.length;
list = list.filter(fitsTable);
if (!list.length) {
const kleinste = allList.reduce((m, x) => Math.min(m, Math.max(x.w, x.h)), Infinity);
current = []; pool = []; $('#resCount').textContent = '';
$('#results').innerHTML = `<div class="empty"><h3>Nichts passt auf den Tisch</h3>
<p>Von ${all} gerechneten Strecken bleibt keine unter ${tbl.w / 10} × ${tbl.h / 10} cm.
Die kleinste misst ${cm(kleinste)} cm in der längsten Kante — nimm weniger Kurven,
oder gib dem Tisch mehr Maß.</p>
<ul><li>Weniger <b>Kurven</b> eintragen — die bestimmen den Durchmesser.</li>
<li><b>Enge Radien</b> aus dem Fremdhersteller-Bereich verwenden.</li>
<li>Tischmaß abschalten und schauen, was der Bestand hergibt.</li></ul></div>`;
return;
}
pool = list; poolTotal = total;
current = pick(pool, want);
shown = new Set(current);
const tblNote = tbl ? ` · auf ${tbl.w / 10}×${tbl.h / 10} cm passend` : '';
$('#resCount').textContent = (pool.length < total
? `${current.length} von ${pool.length} ausgearbeiteten · ${total} Strecken gefunden`
: `${current.length} von ${total} gefundenen Strecken`) + tblNote;
drawCards();
}
})();
};
/* ---------------------------------------------------------------
Eigener Gleisplan.
Jedes Teil liegt für sich, mit eigener Lage. Man zieht es mit der Maus
dorthin, wo es hin soll; beim Loslassen rastet es an einem freien Ende
ein, wenn eines in der Nähe ist — wie beim Zusammenstecken echter
Schienen. Gedreht wird in 22,5°-Schritten.
----------------------------------------------------------------*/
const ed = { items: [], sel: null, uid: 1, undo: [] };
const SNAP = 90; // Fangbereich in mm
const edMove = (part, dir) => part.k === 'curve'
? { id:part.id, t:'C', r:part.r, s:dir || 1 }
: part.k === 'multi'
? { id:part.id, t:'S', len:multiOf(part).len }
: { id:part.id, t:'S', len:part.len || 128 };
/** Alle Anschlüsse eines Teils. h zeigt jeweils nach außen. */
function connectorsOf(it) {
if (it.multi) {
const mu = multiOf(allParts().find(x => x.id === it.id));
return mu.cons.map((c, i) => {
const q = localTo(it.p, c.dx, c.dy, c.dh);
return { x:q.x, y:q.y, h:q.h, k:'c' + i };
});
}
const e = go(it.p, it.m);
const out = [{ x:it.p.x, y:it.p.y, h:(it.p.h + HALF_TURN) & MASK, k:'in' },
{ x:e.x, y:e.y, h:e.h, k:'out' }];
if (it.branch) { const b = go(it.p, it.branch); out.push({ x:b.x, y:b.y, h:b.h, k:'branch' }); }
return out;
}
const conMatch = (a, b) => Math.hypot(a.x - b.x, a.y - b.y) < 4 && ((a.h + HALF_TURN) & MASK) === b.h;
/** Anschlüsse, an denen nichts steckt. */
function openConnectors(items) {
const all = [];
items.forEach(it => connectorsOf(it).forEach(c => all.push({ ...c, uid:it.uid })));
return all.filter(c => !all.some(o => o.uid !== c.uid && conMatch(c, o)));
}
function edPieces(it) {
if (it.multi) {
const mu = multiOf(allParts().find(x => x.id === it.id));
return mu.routes.map((r, i) => {
const pose = localTo(it.p, r.dx, r.dy, r.dh);
// Die beiden durchgehenden Routen bilden den Körper, die zwei
// Verbindungsbögen liegen darin — schmal und ohne eigene Schwellen.
const o = { id:it.id, m:r.m, p:pose, uid:it.uid, ghost:i > 0, branchLeg:i >= 2,
pts:shapeOf(pose, r.m), mid:midOf(pose, r.m) };
o.box = boxOf(o.pts);
return o;
});
}
const list = [{ id:it.id, m:it.m, p:it.p, uid:it.uid }];
if (it.branch) list.push({ id:it.id, m:it.branch, p:it.p, uid:it.uid, ghost:true, branchLeg:true });
return list.map(q => {
const o = { ...q, pts:shapeOf(q.p, q.m), mid:midOf(q.p, q.m) };
o.box = boxOf(o.pts);
return o;
});
}
function edSnapshot() {
ed.undo.push(JSON.stringify(ed.items));
if (ed.undo.length > 80) ed.undo.shift();
ed.redo = []; // ein neuer Schritt verwirft die Vorwärtsspur
}
function edResult() {
const chains = ed.items.map(it => edPieces(it));
let pts = [];
for (const c of chains) for (const q of c) pts = pts.concat(q.pts);
const box = pts.length ? boxOf(pts) : [-300, -300, 300, 300];
const use = {};
for (const it of ed.items) use[it.id] = (use[it.id] || 0) + 1;
for (const it of ed.items) if (it.buffer) {
const b = allParts().find(p => p.k === 'buffer');
if (b) use[b.id] = (use[b.id] || 0) + 1;
}
const arcLenOf = m => m.t === 'S' ? m.len
: m.t === 'T' ? m.arcs.reduce((a, c) => a + c.r * c.deg * Math.PI / 180, 0)
: m.r * angOf(m);
let len = 0;
for (const it of ed.items) {
if (it.multi) {
const mu = multiOf(allParts().find(x => x.id === it.id));
if (mu) for (const rt of mu.routes) len += arcLenOf(rt.m);
continue;
}
len += arcLenOf(it.m);
if (it.branch) len += arcLenOf(it.branch);
}
return { loop:[], branches:[], rings:[], chains, box,
w:box[2] - box[0] + TRACK_W, h:box[3] - box[1] + TRACK_W,
use, n:ed.items.length, area:0, len };
}
/** Teil so drehen und schieben, dass sein Anschluss auf ein freies Ende passt. */
function edSnap(it) {
const others = ed.items.filter(o => o.uid !== it.uid);
if (!others.length) return false;
const free = openConnectors(others);
let best = null;
for (const mine of connectorsOf(it)) {
for (const target of free) {
const d = Math.hypot(mine.x - target.x, mine.y - target.y);
if (d > SNAP) continue;
if (!best || d < best.d) best = { d, mine, target };
}
}
if (!best) return false;
// Erst drehen, bis der eigene Anschluss dem fremden entgegenzeigt,
// dann so verschieben, dass beide aufeinanderliegen.
const rot = (((best.target.h + HALF_TURN) - best.mine.h) + DIRS * 2) & MASK;
if (rot) {
const c = COS[rot], s = SIN[rot], o = it.p;
it.p = { x:o.x * c - o.y * s, y:o.x * s + o.y * c, h:(o.h + rot) & MASK };
}
const now2 = connectorsOf(it).find(c => c.k === best.mine.k);
it.p = { x:it.p.x + (best.target.x - now2.x), y:it.p.y + (best.target.y - now2.y), h:it.p.h };
return true;
}
/** Liegt das Teil auf einem anderen? Anschlüsse dürfen sich berühren. */
function edClashes(it) {
const gap = +$('#gap').value - 8;
const mine = edPieces(it);
for (const other of ed.items) {
if (other.uid === it.uid) continue;
const touching = connectorsOf(it).some(a => connectorsOf(other).some(b => conMatch(a, b)));
if (touching) continue;
for (const a of mine) for (const b of edPieces(other))
if (tooClose(a.pts, b.pts, a.box, b.box, gap)) return true;
}
return false;
}
/* ---- Zwischen zwei Teile einfügen ----
Alles, was hinter der Naht hängt, rückt starr um das neue Teil weiter.
In einer geschlossenen Runde geht das nicht auf: dort öffnet sich am
anderen Ende zwangsläufig eine Lücke. Das ist keine Panne, sondern
dasselbe wie auf dem Teppich — ein Teil mehr passt nur, wenn man den
Kreis irgendwo aufmacht. */
function edNeighbourAt(con, skipUid) {
return ed.items.find(q => q.uid !== skipUid && connectorsOf(q).some(d => conMatch(con, d)));
}
/** Alle Teile, die von start aus erreichbar sind, ohne über via zu laufen. */
function edChain(start, viaUid) {
const seen = new Set([viaUid, start.uid]);
const queue = [start], out = [start];
while (queue.length) {
const cur = queue.shift();
for (const c of connectorsOf(cur))
for (const it of ed.items) {
if (seen.has(it.uid)) continue;
if (!connectorsOf(it).some(d => conMatch(c, d))) continue;
seen.add(it.uid); queue.push(it); out.push(it);
}
}
return out;
}
function edInsert(part, dir, host, con, nb) {
edSnapshot();
const it = { uid:ed.uid++, id:part.id, m:edMove(part, dir), p:{ x:con.x, y:con.y, h:con.h }, buffer:false };
if (part.k === 'switch') it.branch = branchOf(part);
if (part.k === 'multi') it.multi = true;
const after = go(it.p, it.m);
const dh = (after.h - con.h + DIRS) & MASK, c = COS[dh], sn = SIN[dh];
const shift = q => {
const dx = q.x - con.x, dy = q.y - con.y;
return { x:after.x + dx * c - dy * sn, y:after.y + dx * sn + dy * c, h:(q.h + dh) & MASK };
};
const chain = edChain(nb, host.uid);
const closed = chain.some(q => connectorsOf(q).some(d => conMatch(d, connectorsOf(host)[0])));
for (const q of chain) q.p = shift(q.p);
ed.items.push(it);
ed.sel = it;
edDraw(closed
? 'Eingefügt — die Runde war geschlossen, deshalb ist am anderen Ende eine Lücke entstanden.'
: `Eingefügt · ${chain.length} ${chain.length === 1 ? 'Teil' : 'Teile'} nachgerückt.`);
}
function edAdd(part, dir) {
/* Ist der Ausgang des gewählten Teils schon belegt, wird nicht
irgendwo anders angebaut, sondern genau hier eingefügt. */
if (ed.sel && part.k !== 'buffer') {
const cons = connectorsOf(ed.sel);
const exit = cons.find(c => c.k === 'out') || cons.find(c => c.k === 'branch');
if (exit) {
const nb = edNeighbourAt(exit, ed.sel.uid);
if (nb) { edInsert(part, dir, ed.sel, exit, nb); return; }
}
}
edSnapshot();
const it = { uid:ed.uid++, id:part.id, m:edMove(part, dir), p:{ x:0, y:0, h:0 }, buffer:false };
if (part.k === 'switch') it.branch = branchOf(part);
if (part.k === 'multi') it.multi = true;
/* Angehängt wird bevorzugt am Ausgang des zuletzt angefassten Teils —
so wächst die Strecke in Fahrtrichtung weiter, statt am erstbesten
freien Anschluss zu springen. */
const free = openConnectors(ed.items);
const su = (ed.sel || {}).uid;
const pref = free.find(c => c.uid === su && c.k === 'out')
|| free.find(c => c.uid === su && c.k === 'branch')
|| free.find(c => c.uid === su)
|| free.find(c => c.k === 'out') || free[0];
if (pref) {
it.p = { x:pref.x, y:pref.y, h:pref.h };
edSnap(it);
} else if (ed.items.length) {
const b = edResult().box;
it.p = { x:(b[0] + b[2]) / 2, y:b[1] - 260, h:0 };
}
ed.items.push(it);
ed.sel = it;
edDraw(edClashes(it) ? 'Dieses Teil liegt auf einem anderen — zieh es beiseite.' : '');
}
function edRotate(step) {
if (!ed.sel) return;
edSnapshot();
const it = ed.sel, o = it.p;
// um den Eingang drehen, damit das Teil an Ort und Stelle bleibt
it.p = { x:o.x, y:o.y, h:(o.h + step + DIRS) & MASK };
edDraw();
}
function edFlip() {
if (!ed.sel || ed.sel.m.t !== 'C') return;
edSnapshot();
ed.sel.m = { ...ed.sel.m, s:-ed.sel.m.s };
edDraw();
}
function edDelete() {
if (!ed.sel) return;
edSnapshot();
ed.items = ed.items.filter(i => i.uid !== ed.sel.uid);
ed.sel = null;
edDraw();
}
function edUndoStep() {
if (!ed.undo.length) return;
(ed.redo = ed.redo || []).push(JSON.stringify(ed.items));
ed.items = JSON.parse(ed.undo.pop());
ed.sel = null;
edDraw();
}
function edRedoStep() {
if (!ed.redo || !ed.redo.length) return;
ed.undo.push(JSON.stringify(ed.items));
ed.items = JSON.parse(ed.redo.pop());
ed.sel = null;
edDraw();
}
/* ---- Zeichnen und Maus ----
Die Zeichenfläche bleibt bestehen; nur ihr Inhalt wird ausgetauscht.
Sonst verschwände mitten im Ziehen das Element unter dem Mauszeiger,
und der Zeiger verlöre sein Ziel. */
let edMsg = '';
let edSvg = null, edDragging = null, edFrozen = null, edPanning = null;
/* Zoomen und Schieben verändern nur den Ausschnitt. Sobald der Ausschnitt
von Hand gewählt wurde, springt die Ansicht nicht mehr bei jedem Teil
auf die ganze Anlage zurück — das war beim Bauen im Detail lästig. */
let edViewLocked = false;
const edView = () => (edFrozen || '0 0 2000 1400').split(/\s+/).map(Number);
function edSetView(v) {
edFrozen = v.map(n => n.toFixed(0)).join(' ');
edViewLocked = true;
edEnsureSvg().setAttribute('viewBox', edFrozen);
}
function edZoom(f, fx, fy) {
const [x, y, w, h] = edView();
const nw = Math.min(Math.max(w * f, 600), 120000), k = nw / w, nh = h * k;
const cx = fx === undefined ? x + w / 2 : fx;
const cy = fy === undefined ? y + h / 2 : fy;
edSetView([cx - (cx - x) * k, cy - (cy - y) * k, nw, nh]);
}
function edFitView() { edViewLocked = false; edDraw(); }
function edStatus(m) { edDraw(m); }
function edEnsureSvg() {
if (edSvg && edSvg.isConnected) return edSvg;
$('#edPlan').innerHTML = '<svg id="edSvg" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Eigener Gleisplan"></svg>';
edSvg = $('#edSvg');
edBindPointer(edSvg);
return edSvg;
}
function edPaint(keepView) {
const svg = edEnsureSvg();
const r = edResult();
const open = openConnectors(ed.items);
const out = planSVG(r, {
detail:true, colored, numbers:false, pad:200, width:820, pick:true, parts:true,
selUid:ed.sel ? ed.sel.uid : null,
viewBox:keepView ? edFrozen : null,
ends:open.map(c => ({ pose:c, active:ed.sel && c.uid === ed.sel.uid }))
});
svg.setAttribute('viewBox', out.viewBox);
svg.innerHTML = out.inner;
if (!keepView) edFrozen = out.viewBox;
return { r, open };
}
function edDraw(msg) {
edMsg = msg || '';
const { r, open } = edPaint(edViewLocked);
edPalette(r);
edSide(r, open);
}
function edPalette(r) {
const parts = allParts().filter(p => (stock[p.id] || 0) > 0 && p.k !== 'cross');
const left = {};
for (const p of parts) left[p.id] = (stock[p.id] || 0) - (r.use[p.id] || 0);
$('#edPalette').innerHTML = parts.length ? `<div class="pal">${parts.map(p => {
const n = left[p.id], dis = n <= 0 ? ' disabled' : '';
const btns = p.k === 'curve'
? `<button data-id="${p.id}" data-dir="1"${dis} title="Bogen nach links">↰</button>
<button data-id="${p.id}" data-dir="-1"${dis} title="Bogen nach rechts">↱</button>`
: `<button data-id="${p.id}" data-dir="0"${dis}>legen</button>`;
return `<div class="palrow"><div class="ic">${pieceIcon(p, 38, 26)}</div>
<div class="nm2">${p.n}<small>noch ${n}</small></div><div class="btns">${btns}</div></div>`;
}).join('')}</div>` : '<p class="note">Trag links erst deine Schienen ein.</p>';
$('#edPalette').querySelectorAll('button[data-id]').forEach(b => b.onclick = () => {
const part = allParts().find(x => x.id === b.dataset.id);
if (part.k === 'buffer') {
if (!ed.sel) { edStatus('Erst ein Gleis anklicken, an das der Prellbock soll.'); return; }
edSnapshot(); ed.sel.buffer = !ed.sel.buffer; edDraw();
return;
}
edAdd(part, +b.dataset.dir || 1);
});
}
function edSide(r, open) {
$('#edEnds').innerHTML = `
<div class="endlist">
<button type="button" id="edRotL" ${ed.sel ? '' : 'disabled'} title="Mit Umschalttaste in 11,25°-Schritten">↺ 22,5°</button>
<button type="button" id="edRotR" ${ed.sel ? '' : 'disabled'} title="Mit Umschalttaste in 11,25°-Schritten">↻ 22,5°</button>
${ed.sel && ed.sel.m.t === 'C' ? '<button type="button" id="edFlip">Bogen spiegeln</button>' : ''}
<button type="button" id="edDel" ${ed.sel ? '' : 'disabled'}>Entfernen</button>
</div>
<p class="note" style="margin:9px 0 0">${ed.sel
? 'Ausgewähltes Teil: mit der Maus ziehen, beim Loslassen rastet es an einem freien Ende ein.'
: 'Ein Gleis im Plan anklicken, um es zu ziehen, zu drehen oder zu entfernen.'}</p>`;
const rl = $('#edRotL'), rr = $('#edRotR'), fl = $('#edFlip'), dl = $('#edDel');
// Ein Richtungsschritt sind 11,25° — die Schaltfläche dreht zwei davon.
if (rl) rl.onclick = ev => edRotate(ev.shiftKey ? 1 : 2);
if (rr) rr.onclick = ev => edRotate(ev.shiftKey ? -1 : -2);
if (fl) fl.onclick = edFlip;
if (dl) dl.onclick = edDelete;
const over = [];
for (const p of allParts()) {
const l = (stock[p.id] || 0) - (r.use[p.id] || 0);
if (l < 0) over.push(`${-l}× ${p.n} zu viel`);
}
$('#edInfo').innerHTML = `
<dl class="facts">
<dt>Teile</dt><dd>${r.n}</dd>
<dt>Platzbedarf</dt><dd>${cm(r.w)} × ${cm(r.h)} cm</dd>
<dt>Streckenlänge</dt><dd>${cm(r.len)} cm</dd>
<dt>Offene Enden</dt><dd>${open.length}</dd>
</dl>
${r.n ? `<div style="margin-top:12px"><span class="label" style="display:block;margin-bottom:6px">Teileliste</span>
<div class="legend${colored ? ' tinted' : ''}">${legendHTML(r)}</div></div>` : ''}
${over.length ? `<p class="warn" style="margin:10px 0 0">${over.join(', ')} — mehr, als im Bestand steht.</p>` : ''}
${edMsg ? `<p class="warn" style="margin:10px 0 0">${edMsg}</p>` : ''}
${r.n && !open.length ? `<p class="note" style="margin:10px 0 0;color:var(--ok)">Alles verbunden — ein Zug kann durchfahren.</p>` : ''}`;
}
/* Maus: anklicken, ziehen, loslassen. Die SVG-Fläche rechnet Bildpunkte
selbst in Millimeter um, damit das Ziehen bei jedem Zoom stimmt. */
function edBindPointer(svg) {
const toMm = ev => {
const m = svg.getScreenCTM();
if (!m) return null;
const pt = svg.createSVGPoint();
pt.x = ev.clientX; pt.y = ev.clientY;
const u = pt.matrixTransform(m.inverse());
return { x:u.x, y:-u.y };
};
/* Am Rad zoomen — und zwar auf die Stelle unter dem Zeiger, nicht
auf die Bildmitte. */
svg.addEventListener('wheel', ev => {
ev.preventDefault();
const m = svg.getScreenCTM();
if (!m) return;
const pt = svg.createSVGPoint();
pt.x = ev.clientX; pt.y = ev.clientY;
const u = pt.matrixTransform(m.inverse());
edZoom(ev.deltaY > 0 ? 1.15 : 1 / 1.15, u.x, u.y);
}, { passive:false });
svg.addEventListener('pointerdown', ev => {
const el = ev.target.closest('path[data-uid]');
/* Auf freier Fläche wird der Ausschnitt geschoben. Ein Klick ohne
Bewegung hebt weiterhin die Auswahl auf. */
if (!el) {
edPanning = { px:ev.clientX, py:ev.clientY, view:edView(), moved:false };
try { svg.setPointerCapture(ev.pointerId); } catch (e) {}
return;
}
const it = ed.items.find(i => i.uid === +el.dataset.uid);
const start = toMm(ev);
if (!it || !start) return;
ed.sel = it;
edSnapshot();
edDragging = { start, from:{ ...it.p }, moved:false, it };
try { svg.setPointerCapture(ev.pointerId); } catch (e) {}
ev.preventDefault();
const { r, open } = edPaint(true);
edSide(r, open);
});
svg.addEventListener('pointermove', ev => {
if (edPanning) {
const box = svg.getBoundingClientRect();
if (!box.width) return;
const k = edPanning.view[2] / box.width;
const dx = (ev.clientX - edPanning.px) * k, dy = (ev.clientY - edPanning.py) * k;
if (!edPanning.moved && Math.hypot(dx, dy) < 4 * k) return;
edPanning.moved = true;
const v = edPanning.view;
edSetView([v[0] - dx, v[1] - dy, v[2], v[3]]);
return;
}
if (!edDragging) return;
const m = toMm(ev);
if (!m) return;
const dx = m.x - edDragging.start.x, dy = m.y - edDragging.start.y;
if (!edDragging.moved && Math.hypot(dx, dy) < 8) return;
edDragging.moved = true;
edDragging.it.p = { x:edDragging.from.x + dx, y:edDragging.from.y + dy, h:edDragging.from.h };
edPaint(true);
});
const finish = () => {
if (edPanning) {
const moved = edPanning.moved;
edPanning = null;
if (!moved) { ed.sel = null; edDraw(); }
return;
}
if (!edDragging) return;
const it = edDragging.it, moved = edDragging.moved;
edDragging = null;
if (moved) {
const snapped = edSnap(it);
edDraw(edClashes(it) ? 'Dieses Teil liegt auf einem anderen — noch etwas verschieben.'
: snapped ? '' : 'Frei abgelegt — es hängt an keinem Gleis.');
} else {
ed.undo.pop();
edDraw();
}
};
svg.addEventListener('pointerup', finish);
svg.addEventListener('pointercancel', finish);
svg.addEventListener('lostpointercapture', finish);
}
/* ---- Speichern ---- */
const STORE_PLANS = 'gleisplaner.eigene';
const loadPlans = () => { try { return JSON.parse(localStorage.getItem(STORE_PLANS) || '[]'); } catch (e) { return []; } };
const savePlans = a => { try { localStorage.setItem(STORE_PLANS, JSON.stringify(a)); } catch (e) {} };
const edSerialize = () => ed.items.map(it => ({
i:it.id, t:it.m.t, v:it.m.t === 'S' ? it.m.len : it.m.r, s:it.m.s || 0,
x:Math.round(it.p.x), y:Math.round(it.p.y), h:it.p.h,
b:it.buffer ? 1 : 0, w:it.branch ? [it.branch.r, it.branch.s] : 0, u:it.multi ? 1 : 0
}));
function edDeserialize(list) {
ed.items = list.map((a, k) => {
const it = { uid:k + 1, id:a.i, p:{ x:a.x, y:a.y, h:a.h }, buffer:!!a.b,
m:a.t === 'S' ? { id:a.i, t:'S', len:a.v } : { id:a.i, t:'C', r:a.v, s:a.s } };
if (a.w) it.branch = switchBranch(a.i, a.w[1]);
if (a.u) it.multi = true;
return it;
});
ed.uid = ed.items.length + 1;
ed.sel = null; ed.undo = []; ed.redo = []; edViewLocked = false;
}
$('#edUndo').onclick = edUndoStep;
$('#edRedo').onclick = edRedoStep;
$('#edZoomIn').onclick = () => edZoom(1 / 1.3);
$('#edZoomOut').onclick = () => edZoom(1.3);
$('#edZoomFit').onclick = edFitView;
$('#edClear').onclick = () => { edSnapshot(); ed.items = []; ed.sel = null; edDraw(); };
$('#edPdf').onclick = () => printPlans([edResult()], ['Eigener Plan']);
$('#edSave').onclick = () => {
const r = edResult();
if (!r.n) { edStatus('Erst ein paar Schienen legen.'); return; }
const name = prompt('Name für den Plan:', 'Mein Gleisplan ' + new Date().toLocaleDateString('de-DE'));
if (!name) return;
const list = loadPlans();
list.unshift({ name, date:Date.now(), n:r.n, items:edSerialize() });
savePlans(list.slice(0, 40));
edStatus('Gespeichert: ' + name);
};
$('#edLoad').onclick = () => { showStore(); $('#storeDlg').showModal(); };
$('#storeClose').onclick = () => $('#storeDlg').close();
function showStore() {
const list = loadPlans();
$('#storeList').innerHTML = list.length ? list.map((e, i) => `
<div class="storerow"><div>${e.name}<small>${e.n} Teile · ${new Date(e.date).toLocaleString('de-DE')}</small></div>
<button class="ghost" data-load="${i}" type="button">Laden</button>
<button class="ghost" data-del="${i}" type="button">Löschen</button></div>`).join('')
: '<p class="note">Noch nichts gespeichert.</p>';
$('#storeList').querySelectorAll('[data-load]').forEach(b => b.onclick = () => {
edDeserialize(loadPlans()[+b.dataset.load].items);
$('#storeDlg').close(); showEditor();
});
$('#storeList').querySelectorAll('[data-del]').forEach(b => b.onclick = () => {
const l = loadPlans(); l.splice(+b.dataset.del, 1); savePlans(l); showStore();
});
$('#storeText').value = JSON.stringify({ gleisplan:2, items:edSerialize() });
}
$('#storeCopy').onclick = () => {
$('#storeText').select();
navigator.clipboard.writeText($('#storeText').value).then(() => {
$('#storeCopy').textContent = 'Kopiert'; setTimeout(() => $('#storeCopy').textContent = 'Text kopieren', 1500);
});
};
$('#storeImport').onclick = () => {
try {
const d = JSON.parse($('#storeText').value);
if (!d.items) throw new Error('kein Plan erkennbar');
edDeserialize(d.items);
$('#storeDlg').close(); showEditor();
} catch (e) { alert('Der Text ließ sich nicht lesen: ' + e.message); }
};
/* ---- Umschalten ---- */
function showEditor() {
$('#tabPlans').setAttribute('aria-pressed', 'false');
$('#tabEdit').setAttribute('aria-pressed', 'true');
$('#results').hidden = true; $('#bar').hidden = true;
$('#editView').hidden = false;
$('#resCount').textContent = 'Teil legen, mit der Maus schieben — es rastet ein';
edDraw();
}
function showPlans() {
$('#tabPlans').setAttribute('aria-pressed', 'true');
$('#tabEdit').setAttribute('aria-pressed', 'false');
$('#results').hidden = false;
$('#editView').hidden = true;
$('#resCount').textContent = current.length
? `${current.length} von ${pool.length} ausgearbeiteten · ${poolTotal} Strecken gefunden` : '';
}
$('#tabEdit').onclick = showEditor;
$('#tabPlans').onclick = showPlans;
/** Einen berechneten Vorschlag zum Weiterbauen übernehmen. */
function edFromResult(r) {
ed.items = []; ed.uid = 1; ed.undo = []; ed.redo = []; ed.sel = null; edViewLocked = false;
const add = (id, m, p) => ed.items.push({ uid:ed.uid++, id, m, p:{ ...p }, buffer:false });
for (const q of r.loop) add(q.crossHost ? q.crossId : q.id, q.m, q.p);
for (const b of r.branches) {
const sw = { uid:ed.uid++, id:b.switchId, m:{ id:b.switchId, t:'S', len:128 },
p:{ ...b.switchStart }, branch:{ ...b.switchMove }, buffer:false };
ed.items.push(sw);
for (const q of b.pieces) add(q.id, q.m, q.p);
}
showEditor();
}
if (!load()) stock = defaults();
renderStock();
$('#goBtn').click();
</script>