3641 lines
175 KiB
HTML
3641 lines
175 KiB
HTML
<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> <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>
|