Files
cnx_vinyl/docs/plant-operation.html
T
grabowski d7380919e4
Deploy test site / deploy (push) Successful in 43s
Steam and cooling, plant operation: show the launch plant (1 Duplex + 1 Classic)
User (October 2026): "launch plant should be 1x duplex system and 1x
classic". The calculator already did; these two pages did not.

- steam-cooling.html: default case is now "Launch plant" (D1-A, D1-B,
  Classic; B-1 and B-2; CH-1 and CH-2: 450 to 540 kg/h on 1,000 kg/h,
  391.5 kW on 500 kW). Other cases: Duplex first (commissioning), Second
  Duplex (growth option, five presses), One module down (launch plant
  with B-2 and CH-2 out). Idle boxes say "reserved position" / "growth
  option". Phasing table: launch plant, second Duplex, options;
  galvanics cooling is launch scope. Five-press figures labelled as the
  growth case or backbone sizing; chiller cost per the CSV 2-module row.
- plant-operation.html: the launch plant: Classic C2 removed everywhere
  (press, trimmer, routes, conveyor, lamps, texts), Classic re-centred;
  one PDPU (2 cells) as in total-investment; boilers 2 x 500 kg/h,
  chillers 2 x 250 kW; title arrivals scaled to three presses; phone
  legend in one column.
- Landing entries and README bullets for both pages.

Verified headless (Edge): skill checks pw_sc_check and pw_plant_check
with launch-plant expectations, 90-day runs, perf unchanged; 390 / 1024
/ 1440 px dark and light without page errors or overflow.
2026-10-16 16:58:37 +07:00

1112 lines
104 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CNX Vinyl · Plant overview (simulated SCADA)</title>
<script>
(function(){var t=null;try{t=localStorage.getItem("cnx-theme")}catch(e){}
if(t!=="light"&&t!=="dark")t=window.matchMedia&&matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";
document.documentElement.setAttribute("data-theme",t);})();
</script>
<style>
:root{
--bg:#0a0f1a;--panel:#0f1626;--box:#121a2b;--stroke:#2a3a56;--head:#1a2640;
--fg:#d6deeb;--mute:#8a97b0;--run:#3ddc84;--idle:#6b7a90;--warn:#ffbf1f;--alarm:#ff3b3b;
--ink:#e6ecf7;--z0:#1b2438;--z1:#3a2a1c;--z2:#4a2e1a;--z3:#5a301a;
--p-steam:#ff5a3c;--p-cond:#c96a4e;--p-cw:#3aa0ff;--p-cwr:#7fc0ff;--p-hyd:#f5a623;--p-chw:#2e7fd0;--p-di:#9ad7ff;--p-mat:#b9c2d0;--p-reg:#8ea36f;--p-col:#c86bff;--p-metal:#c0c8d6;--p-scrap:#c2b280;--p-route:#3a4d70;--p-lbl:#e6ecf7;
--mono:ui-monospace,"Cascadia Mono",Consolas,"Liberation Mono",monospace;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
html{-webkit-text-size-adjust:100%;text-size-adjust:100%}
body{background:var(--bg);color:var(--fg);font:13px/1.4 -apple-system,"Segoe UI",Roboto,Arial,sans-serif;display:flex;flex-direction:column;height:100vh;height:100dvh;overflow:hidden}
header{display:flex;flex-wrap:wrap;align-items:center;gap:6px 16px;padding:7px 14px;background:var(--panel);border-bottom:1px solid var(--stroke);flex:0 0 auto}
header h1{font-size:15px;font-weight:600;letter-spacing:.02em;white-space:nowrap}
header h1 span{color:var(--mute);font-weight:400}
.kpis{display:flex;gap:12px;margin-left:auto;min-width:0;max-width:100%;overflow-x:auto;scrollbar-width:none}
.kpis::-webkit-scrollbar{display:none}
.kpi{display:flex;flex-direction:column;min-width:62px;flex:0 0 auto}
.kpi b{font:600 15px var(--mono);white-space:nowrap}
.kpi small{color:var(--mute);font-size:10px;text-transform:uppercase;letter-spacing:.06em;white-space:nowrap}
.ctl{display:flex;flex-wrap:wrap;gap:6px;align-items:center;flex:0 1 auto}
button{background:#182238;color:var(--fg);border:1px solid var(--stroke);border-radius:4px;padding:4px 9px;font-size:12px;cursor:pointer;white-space:nowrap}
button:hover{background:#1f2c47}
button.on{background:#244a7a;border-color:#3b74b8}
button.demo{border-color:#5a4a1f;color:#ffd777}
main{flex:1 1 auto;min-height:0;display:flex;align-items:center;justify-content:center;padding:4px 10px;position:relative}
svg{width:100%;height:100%;touch-action:none;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent}
svg.zoomed{cursor:grab}
svg.dragging{cursor:grabbing}
/* motion layers: pipes and flow under the drawing, tokens over it (see "effects" in the script) */
.fx{position:absolute;left:0;top:0;pointer-events:none}
#fxUnder{z-index:0}
svg{position:relative;z-index:1}
#fxOver{z-index:2}
.mapctl{position:absolute;right:12px;bottom:8px;display:flex;gap:2px;z-index:3}
.mapctl button{background:rgba(24,34,56,.88);min-width:30px}
#btnInfo,footer .tabs{display:none}
#hint{display:none;position:absolute;top:8px;left:50%;transform:translateX(-50%);background:var(--box);border:1px solid var(--stroke);border-radius:6px;padding:6px 10px;color:var(--mute);font-size:12px;white-space:nowrap;z-index:3}
footer .tabs button.close{margin-left:auto;min-width:32px}
footer{flex:0 0 clamp(150px,26vh,210px);display:grid;grid-template-columns:1.1fr 1fr 0.9fr;gap:10px;padding:8px 14px 10px;background:var(--panel);border-top:1px solid var(--stroke);min-height:0}
.panel{background:var(--box);border:1px solid var(--stroke);border-radius:6px;padding:8px 12px;overflow:auto;min-height:0}
.panel h2{font-size:13px;margin-bottom:4px}
.panel h3{font-size:11px;color:var(--mute);text-transform:uppercase;letter-spacing:.06em;margin:6px 0 2px}
.panel p{font-size:12px;color:#c8d2e4}
.panel ul{font-size:12px;padding-left:16px;color:#c8d2e4}
.panel li{margin:1px 0}
.log{font:11px/1.45 var(--mono)}
.log div{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.log .t{color:var(--mute)}
.log .ok{color:var(--run)}
.log .warn{color:var(--warn)}
.log .alarm{color:var(--alarm)}
.legend{display:grid;grid-template-columns:1fr 1fr;gap:2px 12px;font-size:11px;color:#c8d2e4}
.legend i{display:inline-block;width:22px;height:0;border-top:3px solid;margin-right:6px;vertical-align:middle}
.legend i.d{border-top-style:dashed}
.legend .note{grid-column:1/3;color:var(--mute);margin-top:6px;font-size:10.5px}
/* Short screens (phones held sideways): one-row header with a scrolling KPI
strip, the drawing takes the rest, panels open as a sheet on the right. */
@media (max-height:560px){
header{flex-wrap:nowrap;gap:8px;padding:4px 8px}
header h1{font-size:13px}
header h1 span{display:none}
.ctl{gap:4px;flex-wrap:nowrap}
button{padding:4px 7px;font-size:11px;min-height:28px}
.kpis{flex:1 1 0;gap:10px}
.kpi{min-width:56px}
.kpi b{font-size:13px}
.kpi small{font-size:9px}
main{padding:2px 4px}
.mapctl{right:8px;bottom:4px}
body.sheet .mapctl{right:calc(min(420px,55vw) + 8px)}
#btnInfo{display:inline-block}
footer{position:fixed;z-index:5;top:var(--hdrH,37px);right:0;bottom:0;width:min(420px,55vw);display:none;flex-direction:column;gap:6px;padding:6px;border-top:0;border-left:1px solid var(--stroke);box-shadow:-8px 0 24px rgba(0,0,0,.45)}
body.sheet footer{display:flex}
footer .tabs{display:flex;gap:4px;flex:0 0 auto}
footer .panel{display:none;flex:1 1 auto}
body[data-panel=detail] #detail,body[data-panel=log] #logPanel,body[data-panel=legend] #legendPanel{display:block}
}
/* Phones held upright: the plant is too wide, ask for landscape. */
@media (orientation:portrait) and (max-width:600px){
#hint{display:block}
header h1{white-space:normal}
.ctl{flex-wrap:wrap}
.legend{grid-template-columns:minmax(0,1fr)}
.legend div{overflow-wrap:anywhere}
.legend .note{grid-column:auto}
}
/* SVG */
.mbox{fill:#121a2b;stroke:#2a3a56;stroke-width:1}
.machine{cursor:pointer}
.machine:hover .mbox,.machine:hover .frame{stroke:#4f7ac7}
.machine.sel .mbox,.machine.sel .frame{stroke:#7fb2ff;stroke-width:1.5}
.mhead{fill:#1a2640}
.mtitle{fill:#e6ecf7;font-size:12px;font-weight:600}
.msub{fill:#8a97b0;font-size:9.5px}
.mtag{fill:#c8d2e4;font-family:var(--mono);font-size:10.5px}
.ctxt{fill:#c8d2e4;font-family:var(--mono);font-size:9.5px}
.small{fill:#8a97b0;font-size:9px}
.lane{fill:#5f6f8c;font-size:10px;letter-spacing:.14em;text-transform:uppercase;font-weight:600}
.roomlbl{fill:#6f84a8;font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;font-weight:600}
.room{fill:rgba(40,60,90,.08);stroke:#2f4566;stroke-dasharray:6 5;stroke-width:1.2}
.lamp{fill:#6b7a90}
.lamp.run{fill:#3ddc84}
.lamp.warn{fill:#ffbf1f}
.lamp.alarm{fill:#ff3b3b;animation:blink .6s steps(2,start) infinite}
@keyframes blink{to{visibility:hidden}}
.pipe{fill:none;stroke-width:5;stroke-linejoin:round;stroke-linecap:round;opacity:.28}
.flow{fill:none;stroke-width:2.2;stroke-linejoin:round;stroke-linecap:round;stroke-dasharray:7 9;animation:dash 1.2s linear infinite}
.thin{stroke-width:1.4;stroke-dasharray:3 7}
@keyframes dash{to{stroke-dashoffset:-16}}
.paused .flow,.paused .bub,.paused .convd,.paused .screw{animation-play-state:paused}
.steam{stroke:var(--p-steam)}.cond{stroke:var(--p-cond)}.cw{stroke:var(--p-cw)}.cwr{stroke:var(--p-cwr)}.hyd{stroke:var(--p-hyd)}.chw{stroke:var(--p-chw)}.di{stroke:var(--p-di)}
.mat{stroke:var(--p-mat)}.reg{stroke:var(--p-reg)}.col{stroke:var(--p-col)}.metal{stroke:var(--p-metal)}.scrap{stroke:var(--p-scrap)}.route{stroke:var(--p-route)}.lbl{stroke:var(--p-lbl)}
.conv{stroke:#1c2740;stroke-width:14;fill:none}
.convd{stroke:#2e4066;stroke-width:14;stroke-dasharray:3 9;fill:none;animation:dash 1.6s linear infinite}
.gate polygon{fill:#1a2640;stroke:#ffbf1f;stroke-width:1.5}
.gate.ok polygon{stroke:#3ddc84;fill:#143324}
.gate text{fill:#8a97b0;font-size:9px;text-anchor:middle}
.disc{fill:#0b0b0d;stroke:#3a3f4a;stroke-width:1}
.rec.rej .disc{stroke:#ff3b3b;stroke-width:2}
.rec.tp .disc{stroke:#ffbf1f;stroke-width:1.5}
.cellr{fill:#0e1a2a;stroke:#3a5a7a}
.cell.on .cellr{stroke:#3ddc84}
.cell.done .cellr{stroke:#ffbf1f}
.liquid{fill:#12405a;opacity:.9}
.cell.on .liquid{fill:#1a6a4a}
.bub{fill:#8fd3ff;opacity:0;animation:rise 2.2s linear infinite}
.cell.on .bub{opacity:.7}
@keyframes rise{from{transform:translateY(0)}to{transform:translateY(-30px)}}
.pos{fill:#0e1a2a;stroke:#3a5a7a}
.pos.on{stroke:#3ddc84;fill:#123326}
.thk{fill:#1a2640}
.thkv{fill:#9fb3c8}
.pbar{fill:#1a2640}
.pbarv{fill:#3ddc84}
.platen{fill:#2a3a56;stroke:#4a5f85;transition:fill .5s}
.platen.heat{fill:#b3402c}
.platen.cool{fill:#2c5b8f}
.frame{fill:#0f1626;stroke:#3d5077;stroke-width:2}
.silo{fill:#121a2b;stroke:#2a3a56}
.fill{fill:#3b4658}
.fill.reg{fill:#4e5a3a}
.fill.col{fill:#5a3b6a}
.hopper{fill:#121a2b;stroke:#2a3a56}
.zone{stroke:#2a3a56;stroke-width:1}
.screw{stroke:#6d7d99;stroke-width:2;stroke-dasharray:6 6;animation:dash 0.9s linear infinite;fill:none}
/* Light mode: same drawing, light paper. Toggle in the header; the choice is
shared with the other cnxvinyl.lol pages (localStorage "cnx-theme"). */
:root{color-scheme:dark}
html[data-theme=light]{color-scheme:light;
--bg:#eef2f7;--panel:#ffffff;--box:#ffffff;--stroke:#c3cede;--head:#e3e9f3;
--fg:#1b2533;--mute:#5b6880;--run:#16a35a;--idle:#8a97a8;--warn:#c98a00;--alarm:#e0352b;
--ink:#1b2533;--z0:#e9eef5;--z1:#f6dcc8;--z2:#f2c6a5;--z3:#eeae85;--p-steam:#e2462a;--p-cond:#b0573c;--p-cw:#1f84e0;--p-cwr:#4f9be0;--p-hyd:#d48600;--p-chw:#2468b0;--p-di:#2fa8d8;--p-mat:#7d8aa0;--p-reg:#6a8740;--p-col:#a24de0;--p-metal:#7f8ea6;--p-scrap:#97864a;--p-route:#a9b8cf;--p-lbl:#33415a;}
html[data-theme=light] button{background:#f3f6fb}
html[data-theme=light] button:hover{background:#e4ebf6}
html[data-theme=light] button.on{background:#d6e6fb;border-color:#3b74b8}
html[data-theme=light] button.demo{border-color:#d9b44a;color:#8a5a00}
html[data-theme=light] .mapctl button{background:rgba(255,255,255,.92)}
html[data-theme=light] .panel p,html[data-theme=light] .panel ul,html[data-theme=light] .legend{color:#33415a}
html[data-theme=light] footer{box-shadow:none}
html[data-theme=light] .mbox{fill:#ffffff;stroke:#c3cede}
html[data-theme=light] .machine:hover .mbox,html[data-theme=light] .machine:hover .frame{stroke:#3b74b8}
html[data-theme=light] .machine.sel .mbox,html[data-theme=light] .machine.sel .frame{stroke:#1f5fbf}
html[data-theme=light] .mhead{fill:#e3e9f3}
html[data-theme=light] .mtitle{fill:#1b2533}
html[data-theme=light] .msub,html[data-theme=light] .small{fill:#5b6880}
html[data-theme=light] .mtag,html[data-theme=light] .ctxt{fill:#2c3a52}
html[data-theme=light] .lane{fill:#7a879c}
html[data-theme=light] .roomlbl{fill:#5f78a0}
html[data-theme=light] .room{fill:rgba(60,90,140,.05);stroke:#a9bbd6}
html[data-theme=light] .lamp{fill:#a3adbb}
html[data-theme=light] .lamp.run{fill:#16a35a}
html[data-theme=light] .lamp.warn{fill:#e8a400}
html[data-theme=light] .lamp.alarm{fill:#e0352b}
html[data-theme=light] .pipe{opacity:.32}
html[data-theme=light] .conv{stroke:#dfe5ee}
html[data-theme=light] .convd{stroke:#b5c1d4}
html[data-theme=light] .gate polygon{fill:#fff6dc;stroke:#d99a00}
html[data-theme=light] .gate.ok polygon{stroke:#16a35a;fill:#e2f5e9}
html[data-theme=light] .gate text{fill:#5b6880}
html[data-theme=light] .cellr,html[data-theme=light] .pos{fill:#eef4fa;stroke:#8fa9c6}
html[data-theme=light] .cell.on .cellr{stroke:#16a35a}
html[data-theme=light] .cell.done .cellr{stroke:#d99a00}
html[data-theme=light] .liquid{fill:#bfe0f2}
html[data-theme=light] .cell.on .liquid{fill:#bde8d1}
html[data-theme=light] .bub{fill:#3f97cc}
html[data-theme=light] .pos.on{stroke:#16a35a;fill:#e2f5e9}
html[data-theme=light] .thk,html[data-theme=light] .pbar{fill:#e3e9f3}
html[data-theme=light] .thkv{fill:#4d6a8a}
html[data-theme=light] .pbarv{fill:#16a35a}
html[data-theme=light] .platen{fill:#c7d1df;stroke:#8e9db4}
html[data-theme=light] .platen.heat{fill:#e0704f}
html[data-theme=light] .platen.cool{fill:#6fa3d8}
html[data-theme=light] .frame{fill:#f7f9fc;stroke:#8e9db4}
html[data-theme=light] .silo,html[data-theme=light] .hopper{fill:#ffffff;stroke:#c3cede}
html[data-theme=light] .fill{fill:#c9d0db}
html[data-theme=light] .fill.reg{fill:#c5d3a8}
html[data-theme=light] .fill.col{fill:#dcc2ea}
html[data-theme=light] .zone{stroke:#c3cede}
html[data-theme=light] .screw{stroke:#7d8aa0}
</style>
</head>
<body>
<header>
<h1>CNX Vinyl <span>· Poy Records · Plant overview · launch plant</span></h1>
<div class="ctl">
<button id="btnPlay" class="on">Pause</button>
<button data-speed="1" class="on" title="Real time: every cycle at its actual duration">1×</button>
<button data-speed="10">10×</button>
<button data-speed="60" title="One minute per second">60×</button>
<button data-speed="600" title="Ten minutes per second">600×</button>
<button data-speed="3600" title="One hour per second: a day in 24 s">3600×</button>
<button id="btnSteam" class="demo" title="Steam pressure dip: the presses hold in STEAM until the boiler recovers (about 2 min at 1×)">Steam dip</button>
<button id="btnLot" class="demo" title="A new PVC lot arrives: the lab quarantines it until its tests pass (about 2 h; watch at 60×)">New PVC lot</button>
<button id="btnRepress" class="demo" title="Repress order: stampers from the archived mothers, no artist gates (watch at 60× to 600×)">Repress</button>
<button id="btnInfo" title="Station detail, event log and legend">Info</button>
<button id="themeBtn" title="Switch light / dark mode">☀</button>
</div>
<div class="kpis">
<div class="kpi"><b id="kClock">D1 08:00:00</b><small>sim clock</small></div>
<div class="kpi"><b id="kShift">--</b><small>shift</small></div>
<div class="kpi"><b id="kPress">0/3</b><small>presses</small></div>
<div class="kpi"><b id="kRate">--</b><small>cycles/h</small></div>
<div class="kpi"><b id="kGood">0</b><small>good today</small></div>
<div class="kpi"><b id="kRej">0</b><small>rejects</small></div>
<div class="kpi"><b id="kYield">--</b><small>yield</small></div>
<div class="kpi"><b id="kGalv">0</b><small>metal in work</small></div>
<div class="kpi"><b id="kRack">0</b><small>sets ready</small></div>
<div class="kpi"><b id="kSteam">-- bar</b><small>steam</small></div>
<div class="kpi"><b id="kLoad">-- kW</b><small>plant load</small></div>
<div class="kpi"><b id="kPV">-- kW</b><small>solar</small></div>
</div>
</header>
<main>
<canvas id="fxUnder" class="fx"></canvas>
<svg id="svg" viewBox="0 0 2100 820" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg">
<g id="lPipes"></g>
<g id="lMach"></g>
<g id="lTok"></g>
<g id="lTop"></g>
</svg>
<canvas id="fxOver" class="fx"></canvas>
<div class="mapctl" id="zoom">
<button id="zOut" title="Zoom out (-)">−</button>
<button id="zIn" title="Zoom in (+). Pinch, wheel and drag also work">+</button>
<button id="zFit" title="Whole plant (0)">Fit</button>
</div>
<div id="hint">Turn the phone sideways for the plant view</div>
</main>
<footer>
<div class="tabs" id="views">
<button data-view="detail">Detail</button>
<button data-view="log">Log</button>
<button data-view="legend">Legend</button>
<button class="close" id="sheetClose" title="Close">×</button>
</div>
<div class="panel" id="detail">
<h2>Click any station</h2>
<p>The launch plant decided in October 2026: one Newbilt Duplex system (two HRP 12 presses on one VEX 50 extruder, with an EX 35 for colour effects), one Newbilt Classic automatic press, the Pheenix Alpha electroforming line with one PDPU and Sibert stamper prep, on two boiler and two chiller modules. A second Duplex system is a growth option and is not shown. At 1× everything runs at its real duration: press cycles of 28 s (Classic) and about 30 s (Duplex), 45 to 75 minutes of plating per part, artist approvals in days. Speed up to watch whole days (3600× runs a day in 24 s). Click a station for what happens there, its checks, and where its figures come from; durations without a published source are marked as estimates. Simulated from this repository (finance/total-investment.md, equipment/galvanics.md, equipment/pressing-line.md, production-timeline.md, scale.md); nothing here is live.</p>
</div>
<div class="panel" id="logPanel"><h2>Event log</h2><div class="log" id="log"></div></div>
<div class="panel" id="legendPanel"><h2>Legend</h2>
<div class="legend">
<div><i style="border-color:var(--p-steam)"></i>Saturated steam 10 bar</div>
<div><i class="d" style="border-color:var(--p-cond)"></i>Condensate return</div>
<div><i style="border-color:var(--p-cw)"></i>Press cooling water</div>
<div><i class="d" style="border-color:var(--p-cwr)"></i>Cooling water return</div>
<div><i style="border-color:var(--p-hyd)"></i>Hydraulic oil 180 bar</div>
<div><i class="d" style="border-color:var(--p-chw)"></i>Galvanics cooling 12 to 20 °C</div>
<div><i class="d" style="border-color:var(--p-di)"></i>DI water</div>
<div><i class="d" style="border-color:var(--p-mat)"></i>PVC granulate</div>
<div><i class="d" style="border-color:var(--p-reg)"></i>Regrind</div>
<div><i class="d" style="border-color:var(--p-col)"></i>Colour compound</div>
<div><i class="d" style="border-color:var(--p-metal)"></i>Stamper sets to presses</div>
<div><i class="d" style="border-color:var(--p-scrap)"></i>Edge trim and rejects</div>
<div class="note">Lamps: green run · grey idle · amber hold, change or waiting · red alarm. Yellow diamonds are the three artist approval gates, with the number of titles waiting at each. Two shifts, 06:00 to 22:00: presses and operator steps stop at night, plating already in the bath finishes. Discs on the metal path: gold lacquer, silver silvered lacquer, grey father or mother, ringed stamper.</div>
</div>
</div>
</footer>
<script>
(function(){
'use strict';
const NS='http://www.w3.org/2000/svg';
const $=id=>document.getElementById(id);
const COMPACT=matchMedia('(max-height:560px)');
const L={pipes:$('lPipes'),mach:$('lMach'),tok:$('lTok'),top:$('lTop')};
function el(tag,attrs,parent,text){const e=document.createElementNS(NS,tag);for(const k in attrs)e.setAttribute(k,attrs[k]);if(text!=null)e.textContent=text;(parent||L.mach).appendChild(e);return e;}
// Machine header band: top corners rounded concentric with the box (rx 4, band inset 1, so radius 3).
// A square band paints over the inside of the curved border at both top corners.
const headD=(x,y,w)=>`M${x+1},${y+19}V${y+4}a3,3 0 0 1 3,-3H${x+w-4}a3,3 0 0 1 3,3V${y+19}Z`;
const rnd=(a,b)=>a+Math.random()*(b-a);
const jit=(v,a)=>v+(Math.random()-.5)*a;
const pick=a=>a[Math.floor(Math.random()*a.length)];
const f0=v=>Math.round(v).toString(),f1=v=>v.toFixed(1),f2=v=>v.toFixed(2);
// first-order lag, stable for any step size
const lag=(v,t,k,ds)=>v+(t-v)*(1-Math.exp(-k*ds));
// Change-only writes: re-writing an unchanged text or attribute still makes the browser re-lay-out the SVG.
const setT=(e,v)=>{v=String(v);if(e.__t!==v){e.__t=v;e.textContent=v;}};
const setA=(e,k,v)=>{v=String(v);const c=e.__a||(e.__a={});if(c[k]!==v){c[k]=v;e.setAttribute(k,v);}};
const setS=(e,k,v)=>{const c=e.__s||(e.__s={});if(c[k]!==v){c[k]=v;e.style[k]=v;}};
// Everything that moves every frame (pipe flow, tokens, records, screw, bubbles) is drawn on two canvases,
// one under and one over the SVG; the SVG keeps machines and labels. See "effects" further down.
const FX={flows:[],screws:[],bubbles:[]},SPR=[];let fxDirty=true;
function spr(kind,o){const t=Object.assign({kind,x:0,y:0,dead:false,cls:{}},o);
t.remove=()=>{t.dead=true;};t.classList={add:c=>{t.cls[c]=true;}};SPR.push(t);return t;}
const MIN=60,HOUR=3600,DAY=86400;
function dur(s){s=Math.max(0,s);if(s<100)return f0(s)+' s';if(s<6000)return f0(s/60)+' min';if(s<2*DAY)return f1(s/HOUR)+' h';return f1(s/DAY)+' d';}
const PALETTE=['#e0552f','#3aa0ff','#ffbf1f','#3ddc84','#c86bff','#ff7ab6','#59d9c9'];
const TP_N=10; // test pressings per new title
const STEAM_KG=1.3; // steam per press cycle: 150 to 180 kg/h at ~30 s
/* ================================================================ state */
const S={t:0,speed:1,running:true,quiet:true,clock0:8*HOUR-2*DAY,
day:{n:null,good:0,rej:0,tp:0,stampers:0},cyc:[],events:0,logged:0,samples:0,audioLast:'-',opticalLast:'-',lastXRF:'-',lastConc:0,
trimmed:0,sleeved:0,assembled:0,pallet:0,parcels:0,mismatch:0,punched:0,punchUntil:0,tpToArtist:0,pulled:0,
boiler:{p:10.05,F:0.17,faultUntil:-1},
chiller:{sup:22,ret:30,load:280,flow:230},
comp:{p:7.6},hyd:{p:30,oil:44},vex:{rpm:0,melt:150},ex35:false,
lab:{lot:'26-0912',test:null,queue:[],nextLot:913},
silo:{v:2900,vcap:4000,r:240,rcap:600,c:700,ccap:1000},
gran:{bin:4,run:0,end:0,total:0},
labels:{pairs:1800,lot:'L-77'},
hvac:{t:23.2,rh:52,pm:8},energy:{pv:0,load:0,grid:0,soc:70},
diro:{tank:0.72,cond:0.6},zld:{tank:0.35},galvKW:0,
archive:{F:96,M:104},nextArrival:0,
};
const clk=t=>S.clock0+(t==null?S.t:t);
function hourOf(t){const c=clk(t);return (((c%DAY)+DAY)%DAY)/HOUR;}
function shiftOn(t){const h=hourOf(t);return h>=6&&h<22;}
const dayN=t=>Math.floor(clk(t)/DAY)+1;
function clockStr(){const c=clk();const s=((c%DAY)+DAY)%DAY;const hh=Math.floor(s/3600),mi=Math.floor(s/60)%60,ss=Math.floor(s)%60;return 'D'+dayN()+' '+[hh,mi,ss].map(v=>String(v).padStart(2,'0')).join(':');}
// per-record tokens only up to 10x; part hops and feed dots up to 600x
const vis=()=>!S.quiet&&S.speed<=10;
const visHop=()=>!S.quiet&&S.speed<=600;
/* ================================================================ titles and upstream stages */
const UP=[['intake',1,2],['gate1',2,10],['master',2,4],['gate2',2,10],['cut',1,2]]; // days, min to max
// a new title every 0.75 to 1.15 days: ~380 a year, between the revenue model's 300 and 400 titles for the launch plant
// (the four-press rate scaled by press capacity; presses run ~64 to 73 % of shift time over 90 simulated days)
const ARRIVE=[0.75,1.15];
const UPI={};UP.forEach((u,i)=>UPI[u[0]]=i);
const QTY=[300,500,1000,1000,2000,3000,3000,5000,5000,10000]; // mean ~3,100
const TITLES=[];let titleN=40;
function mkTitle(o){o=o||{};titleN++;const r=Math.random();
const kind=o.kind||(r<0.6?'black':r<0.85?'colour':'splatter');
const qty=o.qty||pick(QTY);const sid=String(titleN).padStart(3,'0');
const T={id:'PR-26-'+sid,sid,kind,qty,wt:Math.random()<0.55?180:140,
label:PALETTE[titleN%PALETTE.length],vinyl:kind==='black'?'#0b0b0d':PALETTE[(titleN+3)%PALETTE.length],
stage:o.stage||'intake',tEnd:0,setsTarget:Math.max(1,Math.ceil(qty/1500)),made:{A:0,B:0},ready:{},pairs:0,
cycles:0,good:0,target:Math.ceil(qty*1.04),approved:!!o.approved,tp:o.tp||'none',tpBusy:false,g3due:0,
repress:!!o.repress,faDone:false,mothers:{A:null,B:null},mBusy:{A:false,B:false},done:false,remakes:0};
TITLES.push(T);return T;}
const cntStage=st=>TITLES.reduce((n,T)=>n+(T.stage===st?1:0),0);
function nextUp(st){let m=Infinity;for(const T of TITLES)if(T.stage===st&&T.tEnd<m)m=T.tEnd;return m===Infinity?'-':dur(m-S.t);}
function upTick(){
if(S.t>=S.nextArrival){const T=mkTitle();T.tEnd=S.t+rnd(1,2)*DAY;S.nextArrival=S.t+rnd(ARRIVE[0],ARRIVE[1])*DAY;
log(T.id+' received: '+T.qty+' × 12" '+T.wt+' g '+T.kind+', checksums verified','info');}
for(const T of TITLES){
const i=UPI[T.stage];
if(i!==undefined&&S.t>=T.tEnd){
if(i<UP.length-1){const nx=UP[i+1];const was=UP[i][0];T.stage=nx[0];T.tEnd=S.t+rnd(nx[1],nx[2])*DAY;
if(was==='intake')log(T.id+': technical QA passed, master plan sent to the artist','info');
else if(was==='gate1'){log(T.id+': artist approved the master plan (gate 1)','ok');flash('g1');}
else if(was==='master')log(T.id+': vinyl master done, sent to the artist','info');
else if(was==='gate2'){log(T.id+': artist approved the vinyl master (gate 2), to the lathe','ok');flash('g2');}
}else{T.stage='galv';log(T.id+': lacquers A and B cut and scribed, to silvering (plate within 72 h)','info');startGalv(T);}
}
if(T.tp==='sent'&&S.t>=T.g3due){T.tp='ok';T.approved=true;log(T.id+': artist approved the test pressing (gate 3), production released','ok');flash('g3');}
}
}
/* ================================================================ metal path: operator stations */
const ST={};
function mkStation(id,cap,durFn,next){ST[id]={id,cap,slots:Array(cap).fill(null),q:[],durFn,next,done:0};}
function enq(id,it,from){ST[id].q.push(it);if(from)hop(from,id,it);}
const pl=it=>it.gen+' '+it.T.sid+it.side+(it.gen==='S'?'-'+it.n:'');
const jl=j=>j.grow==='S'&&j.st?pl(j.st):j.grow+' '+j.T.sid+j.side;
function startGalv(T){T.inGalv=true;['A','B'].forEach(s=>enq('pasu',{T,side:s,gen:'L'},'cut'));}
mkStation('pasu',1,()=>15*MIN,it=>{it.silvered=true;enq('pppu',it,'pasu');});
mkStation('pppu',4,()=>20*MIN,it=>plateEnq({mand:it,grow:'F',T:it.T,side:it.side},'pppu'));
mkStation('pmpb',1,j=>j.grow==='S'?10*MIN:12*MIN,j=>sepDone(j));
mkStation('qc',1,it=>it.gen==='M'?25*MIN:3*MIN,it=>qcDone(it));
mkStation('vsf',1,()=>8*MIN,it=>enq('vsp',it,'vsf'));
mkStation('vsp',1,()=>4*MIN,it=>{it.conc=Math.max(3,jit(11,10));S.lastConc=it.conc;enq('vsc',it,'vsp');});
mkStation('vsc',1,()=>2*MIN,it=>stamperReady(it));
function sepDone(j){const T=j.T,s=j.side;S.diro.tank=Math.max(0.05,S.diro.tank-0.03);S.zld.tank=Math.min(1,S.zld.tank+0.01);
if(j.grow==='F')enq('qc',{T,side:s,gen:'F'},'pmpb');
else if(j.grow==='M'){S.archive.F++;enq('qc',{T,side:s,gen:'M'},'pmpb');log(T.sid+s+': mother separated, father '+T.sid+s+' to the archive','info');}
else{enq('qc',j.st,'pmpb');
if(T.made[s]<T.setsTarget)plateEnq({mand:j.mand,grow:'S',T,side:s,remake:j.remake},null);
else{T.mothers[s]=j.mand;T.mBusy[s]=false;S.archive.M++;}}
}
function qcDone(it){const th=it.gen==='S'?jit(0.20,0.012):jit(0.25,0.015);S.lastXRF=pl(it).replace(' ','')+' '+f2(th);
if(it.gen==='F')plateEnq({mand:it,grow:'M',T:it.T,side:it.side},'qc');
else if(it.gen==='M'){it.T.mBusy[it.side]=true;log(pl(it)+': XRF '+f2(th)+' mm, mother played through, no defects','ok');plateEnq({mand:it,grow:'S',T:it.T,side:it.side},'qc');}
else enq('vsf',it,'qc');}
function stamperReady(it){const T=it.T,k=it.n;S.day.stampers++;
if(T.done){log(pl(it)+': run already complete, stamper retired unused','info');return;}
(T.ready[k]=T.ready[k]||{})[it.side]=it;
if(T.ready[k].A&&T.ready[k].B){RACK.push({T,k,used:0});T.pairs++;hop('vsc','rack',it);
log('Set '+T.sid+'-'+k+' press-ready: A and B back-finished, punched (concentricity '+f0(S.lastConc)+' µm), coined','ok');
if(T.stage==='galv')T.stage='press';}}
function stTick(st){
for(let i=0;i<st.cap;i++){const w=st.slots[i];
if(w&&S.t>=w.end){st.slots[i]=null;st.done++;st.next(w.it);}
if(!st.slots[i]&&st.q.length&&shiftOn()){const it=st.q.shift();st.slots[i]={it,t0:S.t,end:S.t+st.durFn(it)};}}
}
/* ================================================================ metal path: PDPU plating cells
Nickel is deposited by Faraday's law, so each part's time follows from its current. */
const GROW={F:{th:0.25,ramp:25*MIN,Imax:185},M:{th:0.25,ramp:8*MIN,Imax:200},S:{th:0.20,ramp:5*MIN,Imax:200}};
const AREA=9.6,GPAH=1.095*0.97; // dm² for a ~Ø350 mm part; g Ni per A·h at 97 % efficiency
const massFor=th=>th*AREA*8.9*10; // g for a thickness in mm
const NOM={};for(const g in GROW){const G=GROW[g];let m=0,t=0;const tgt=massFor(G.th);while(m<tgt){const I=Math.min(G.Imax,20+(G.Imax-20)*t/G.ramp);m+=I*10/HOUR*GPAH;t+=10;}NOM[g]=t;}
const PLATEQ=[];
const PRIO={F:0,R:1,M:2,S:3}; // lacquers first (they age), then remakes
function plateEnq(j,from){j.prio=j.grow==='S'&&j.remake?PRIO.R:PRIO[j.grow];j.from=from;j.tq=S.t;PLATEQ.push(j);PLATEQ.sort((a,b)=>a.prio-b.prio||a.tq-b.tq);}
// launch plant: one PDPU with two cells (total-investment.md; one covers full-capacity demand, galvanics.md)
const CELLS=[0,1].map(i=>({i,u:i>>1,c:i&1,job:null,ph:'idle',tIn:0,I:0,V:0,mass:0,target:1,tPlate:0}));
function cellTick(c,ds){
if(c.ph==='idle'){
if(shiftOn()&&PLATEQ.length){const j=PLATEQ.shift();c.job=j;c.ph='load';c.tIn=0;c.mass=0;c.target=massFor(GROW[j.grow].th);
if(j.grow==='S'){j.T.made[j.side]++;j.st={T:j.T,side:j.side,gen:'S',n:j.T.made[j.side]};}
if(j.from)hop(j.from,'pdpu'+(c.u+1),j.mand);
log('PDPU cell '+(c.c+1)+': '+(j.grow==='F'?'father on lacquer ':j.grow==='M'?'mother on father ':'stamper on mother ')+j.T.sid+j.side+(j.remake?' (from the archive)':''),'info');}
return;}
c.tIn+=ds;
if(c.ph==='load'){if(c.tIn>=5*MIN){c.ph='plate';c.tIn=0;}return;}
if(c.ph==='plate'){const g=GROW[c.job.grow];c.I=Math.min(g.Imax,20+(g.Imax-20)*c.tIn/g.ramp);c.V=8.5+c.I*0.016;c.mass+=c.I*ds/HOUR*GPAH;
if(c.mass>=c.target){c.ph='done';c.tPlate=c.tIn;c.tIn=0;c.I=0;c.V=0;log(jl(c.job)+': plated to '+f2(g.th)+' mm in '+dur(c.tPlate)+', PDPU cell '+(c.c+1),'ok');}
return;}
if(c.ph==='done'){if(shiftOn()){c.ph='unload';c.tIn=0;}return;}
if(c.ph==='unload'&&c.tIn>=10*MIN){const j=c.job;c.job=null;c.ph='idle';c.tIn=0;enq('pmpb',j,'pdpu'+(c.u+1));}
}
function cellLeft(c){if(!c.job)return 0;const g=GROW[c.job.grow];
if(c.ph==='load')return 5*MIN-c.tIn+NOM[c.job.grow]+10*MIN;
if(c.ph==='plate'){const Ieff=c.tIn<g.ramp?(c.I+g.Imax)/2:g.Imax;return (c.target-c.mass)/(Ieff*GPAH)*HOUR+10*MIN;}
if(c.ph==='unload')return 10*MIN-c.tIn;return 0;}
function galvCount(){let n=PLATEQ.length;for(const k in ST)n+=ST[k].q.length+ST[k].slots.filter(Boolean).length;return n+CELLS.filter(c=>c.job).length;}
/* repress and stamper remakes come from the archived mothers */
function requestRemake(T){T.setsTarget++;T.remakes++;
['A','B'].forEach(s=>{
if(T.mothers[s]){const mo=T.mothers[s];T.mothers[s]=null;S.archive.M=Math.max(0,S.archive.M-1);S.pulled++;T.mBusy[s]=true;plateEnq({mand:mo,grow:'S',T,side:s,remake:true},'archive');}
else if(!T.mBusy[s]){T.mBusy[s]=true;S.pulled++;plateEnq({mand:{T,side:s,gen:'M'},grow:'S',T,side:s,remake:true},'archive');}});
log(T.id+': stampers worn before the run is done, a fresh set from the archived mothers is on its way','warn');}
function repress(){
const cands=TITLES.filter(T=>T.done&&T.mothers.A&&T.mothers.B);const O=cands.length?pick(cands):null;
const R=mkTitle({qty:pick([1000,2000,3000]),approved:true,tp:'ok',repress:true,kind:O?O.kind:'black',stage:'galv'});
R.origin=O?O.id:'PR-25-'+f0(rnd(100,180));if(O){R.label=O.label;R.vinyl=O.vinyl;R.wt=O.wt;O.mothers={A:null,B:null};}
['A','B'].forEach(s=>{S.archive.M=Math.max(0,S.archive.M-1);S.pulled++;R.mBusy[s]=true;
plateEnq({mand:{T:R,side:s,gen:'M'},grow:'S',T:R,side:s,remake:true},'archive');});
log('Repress '+R.id+': '+R.qty+' more of '+R.origin+', mothers pulled from the archive, no artist gates','warn');}
/* ================================================================ presses */
const RACK=[];
// Duplex ~30 s average (Newbilt), Classic 28 s at 180 g (2024 flyer); split between states is an estimate
const DSEQ=[['LOAD',5],['CLOSE',2],['STEAM',8],['COOL',10],['OPEN',2],['EJECT',3]];
const CSEQ=[['LOAD',3],['CLOSE',2],['STEAM',7],['COOL',11],['OPEN',2],['EJECT',3]];
const PRESS=[
{id:'pDA',name:'Duplex A',type:'duplex',seq:DSEQ},
{id:'pDB',name:'Duplex B',type:'duplex',seq:DSEQ},
{id:'pC1',name:'Classic',type:'classic',seq:CSEQ}];
PRESS.forEach(P=>Object.assign(P,{state:'OFF',tIn:0,set:null,mode:null,run:0,life:0,tpLeft:0,open:1,force:0,moldT:58,peakT:0,lastCycle:0,cycleStart:0,prevStart:0,waiting:false,changeDur:0,lastT:null,cyc:0,after:null,restart:false,cake:false,cakeKg:0}));
const RUNSTATES=['LOAD','CLOSE','STEAM','COOL','OPEN','EJECT'];
// Unplanned stops, about one per 4 running hours, 15 to 45 min each (estimate): with stamper changes and
// test pressings this lands near the 60 to 70 % overall effectiveness scale.md applies to the fleet.
const MTBF=4*HOUR,STOPS={duplex:['cake stuck','mould temp drift','hydraulic alarm','label misplaced'],classic:['label feed jam','cake loader jam','cooling flow low','vacuum fault']};
const running=P=>RUNSTATES.includes(P.state);
function seqDur(P,st){const s=P.seq.find(x=>x[0]===st);let d=s?s[1]:0;
// Duplex loading is by hand, so it varies a little from cycle to cycle (average stays ~30 s)
if(P.type==='duplex'&&st==='LOAD')d=P.loadD||d;
// Classic: cooling by delivered water volume stretches when the supply water runs warm
if(P.type==='classic'&&st==='COOL')d*=1+0.05*Math.max(0,S.chiller.sup-22);return d;}
function materialOk(T){return T.kind==='black'?S.silo.v+S.silo.r>5:S.silo.c>2;}
function consume(T,kg){if(T.kind==='black'){const rg=Math.min(S.silo.r,kg*0.2);S.silo.r-=rg;S.silo.v=Math.max(0,S.silo.v-(kg-rg));}else S.silo.c=Math.max(0,S.silo.c-kg);}
function pickJob(P){let best=-1,score=-1;
RACK.forEach((r,i)=>{const T=r.T;if(P.type==='classic'&&T.kind==='splatter')return;if(!materialOk(T))return;let s;
if(T.approved)s=10;else if(T.tp==='none'&&!T.tpBusy&&r.k===1)s=14;else return; // test pressings first: the artist is waiting
if(P.type==='duplex'&&T.kind!=='black')s+=2;if(P.type==='classic'&&T.kind==='black')s+=2;if(T.repress)s+=3;if(P.lastT===T)s+=1;
if(s>score){score=s;best=i;}});
return best<0?null:RACK.splice(best,1)[0];}
function mount(P,r){const T=r.T;P.set=r;
P.mode=T.approved?(T.repress&&!T.faDone?'fa':'prod'):'tp';if(P.mode==='fa')T.faDone=true;
if(P.mode==='tp'){T.tpBusy=true;P.tpLeft=TP_N;}
P.life=r.life||(r.life=Math.round(rnd(1000,2000)));P.changeDur=(P.lastT===T?rnd(18,24):rnd(36,46))*MIN;P.lastT=T;P.state='CHANGE';P.tIn=0;P.run=0;
if(visHop())routeHop('st_'+P.id,'S');
log(P.name+': mounting set '+T.sid+'-'+r.k+' ('+T.id+', '+T.kind+') for '+(P.mode==='tp'?'test pressings':P.mode==='fa'?'the repress':'production')+', '+dur(P.changeDur)+' change','warn');}
function pressEnter(P,st){P.state=st;P.tIn=0;
if(st==='LOAD'&&Math.random()<30/MTBF){P.state='STOP';P.stopDur=rnd(15,45)*MIN;P.stopWhy=pick(STOPS[P.type]);P.cake=false;
log(P.name+': STOP, '+P.stopWhy+', maintenance called','alarm');return;}
if(st==='LOAD'){const T=P.set.T;if(!materialOk(T)){P.state='NOPVC';log(P.name+': no released '+(T.kind==='black'?'black':'colour')+' compound, holding','alarm');return;}
P.cakeKg=T.wt/1000+rnd(0.02,0.04);consume(T,P.cakeKg);P.cake=true;P.peakT=0;P.loadD=rnd(4,6.5);S.labels.pairs--;
if(S.labels.pairs<=0){S.labels.pairs=1800;S.labels.lot='L-'+(parseInt(S.labels.lot.slice(2))+1);log('Label oven: new pre-dried stack, lot '+S.labels.lot,'info');}
if(vis()){const lb=spr('dot',{r:3,fill:T.label,stroke:'#e6ecf7',sw:1});mover('l_'+P.id,lb,120,m=>m.node.remove());}}
if(st==='STEAM')P.cake=false;
}
function pressTick(P,ds){
if(P.state==='OFF'){P.force=lag(P.force,0,2,ds);P.moldT=lag(P.moldT,35,0.0003,ds);
if(shiftOn()){if(P.set){P.state='CHANGE';P.tIn=0;P.changeDur=rnd(13,18)*MIN;P.restart=true;log(P.name+': shift start, moulds heating','info');}else P.state='IDLE';}return;}
if(P.state==='IDLE'){P.force=lag(P.force,0,2,ds);P.moldT=lag(P.moldT,58,0.01,ds);
if(!shiftOn()){P.state='OFF';return;}const r=pickJob(P);if(r)mount(P,r);return;}
P.tIn+=ds;
if(P.state==='CHANGE'){P.force=lag(P.force,0,2,ds);P.moldT=lag(P.moldT,120,0.003,ds);if(P.tIn>=P.changeDur){P.restart=false;P.prevStart=S.t;pressEnter(P,'LOAD');}return;}
if(P.state==='UNMOUNT'){P.force=lag(P.force,0,2,ds);if(P.tIn>=10*MIN)unmountDone(P);return;}
if(P.state==='STOP'){P.force=lag(P.force,0,2,ds);P.open=lag(P.open,1,1,ds);if(P.tIn>=P.stopDur){log(P.name+': '+P.stopWhy+' cleared after '+dur(P.tIn)+', restarting','ok');P.prevStart=S.t;pressEnter(P,'LOAD');}return;}
if(P.state==='NOPVC'){if(materialOk(P.set.T))pressEnter(P,'LOAD');return;}
const d=seqDur(P,P.state);P.waiting=false;
if(P.state==='STEAM'&&S.boiler.p<9.5){P.waiting=true;P.tIn=Math.min(P.tIn,d-0.01);}
const k=Math.min(1,P.tIn/d);
if(P.state==='CLOSE')P.open=1-k;else if(P.state==='OPEN')P.open=k;else if(P.state==='LOAD'||P.state==='EJECT')P.open=1;else P.open=0;
if(P.state==='STEAM'){P.force=lag(P.force,150,1.5,ds);P.moldT=lag(P.moldT,165,0.45,ds);P.peakT=Math.max(P.peakT,P.moldT);}
else if(P.state==='COOL'){P.force=lag(P.force,150,1,ds);P.moldT=lag(P.moldT,42,0.35,ds);}
else{P.force=lag(P.force,P.state==='CLOSE'?120:0,2,ds);P.moldT=lag(P.moldT,58,0.2,ds);}
if(P.tIn>=d&&!P.waiting){
// carry the overshoot into the next state, so cycle times stay exact at any step size
const extra=P.tIn-d;
if(P.state==='EJECT'){P.cycleStart=S.t-extra;eject(P);afterEject(P);}
else{const i=P.seq.findIndex(x=>x[0]===P.state);pressEnter(P,P.seq[i+1][0]);}
if(running(P))P.tIn=extra;}
}
function eject(P){const T=P.set.T,tp=P.mode==='tp';P.cyc++;P.lastCycle=P.cycleStart-P.prevStart;P.prevStart=P.cycleStart;P.set.used++;
S.gran.bin+=P.cakeKg-T.wt/1000;S.logged++;
if(tp)S.day.tp++;else{T.cycles++;S.cyc.push(S.t);}
if(vis()){mkRec(P,tp);
log(P.name+' #'+P.cyc+(tp?' TEST PRESSING':'')+' · '+f1(P.lastCycle)+' s · set '+T.sid+'-'+P.set.k+' · cake '+f0(P.cakeKg*1000)+' g · mould '+f0(P.peakT)+' °C',tp?'warn':'info');}
else outcome(T,tp);}
function afterEject(P){const T=P.set.T;
if(P.mode==='tp'){P.tpLeft--;if(P.tpLeft<=0){T.tp='sent';T.tpBusy=false;T.g3due=S.t+rnd(5,15)*DAY;
log(P.name+': '+TP_N+' test pressings of '+T.id+' to the artist (gate 3, 5 to 15 days), stampers unmounted','warn');P.after='rack';P.state='UNMOUNT';P.tIn=0;return;}}
else{P.run++;if(P.mode==='fa'&&P.run>=3){P.mode='prod';log(P.name+': first-article check of repress '+T.id+' passed','ok');}
if(T.cycles>=T.target){T.done=true;T.stage='done';log(P.name+': '+T.id+' complete, '+T.target+' pressed, stampers retired','ok');P.after='retire';P.state='UNMOUNT';P.tIn=0;
for(let i=RACK.length-1;i>=0;i--)if(RACK[i].T===T)RACK.splice(i,1);return;}
if(P.set.used>=P.life){log(P.name+': stamper set '+T.sid+'-'+P.set.k+' worn after '+P.set.used+' pressings','warn');P.after='retire';P.state='UNMOUNT';P.tIn=0;
const more=RACK.some(r=>r.T===T)||T.pairs<T.setsTarget||PRESS.some(Q=>Q!==P&&Q.set&&Q.set.T===T);if(!more)requestRemake(T);return;}}
if(!shiftOn()){P.state='OFF';log(P.name+': shift end, stopped with set '+T.sid+'-'+P.set.k+' mounted','info');return;}
pressEnter(P,'LOAD');}
function unmountDone(P){if(P.after==='rack')RACK.unshift(P.set);P.set=null;P.mode=null;P.state=shiftOn()?'IDLE':'OFF';P.tIn=0;P.cake=false;}
/* records above 10x: counted, not drawn */
function outcome(T,tp){
if(tp){S.samples++;S.tpToArtist++;S.audioLast=f1(jit(98.2,1.2));return;}
if(Math.random()<0.035){S.day.rej++;S.punched++;S.gran.bin+=T.wt/1000;S.punchUntil=S.t+10;return;}
S.day.good++;T.good++;S.trimmed++;S.sleeved++;S.assembled++;S.logged+=2;
if(Math.random()<0.02){S.samples++;S.audioLast=f1(jit(98.2,1.2));}
if(Math.random()<0.33)S.parcels++;else if(++S.pallet>=300)S.pallet=0;}
/* ================================================================ layout */
const MACH=[
// metal path
{id:'audio',x:20,y:56,w:110,h:120,t:'Audio intake',s:'checksums · tech QA',tags:[()=>'titles '+cntStage('intake'),()=>'next '+nextUp('intake'),()=>'~1 new/day']},
{id:'master',x:190,y:56,w:110,h:120,t:'Mastering',s:'vinyl master',tags:[()=>'titles '+cntStage('master'),()=>'next '+nextUp('master'),()=>'2 to 4 days']},
{id:'cut',x:360,y:56,w:110,h:120,t:'Lathe cutting',s:'lacquer · scribe',tags:[()=>'titles '+cntStage('cut'),()=>'next '+nextUp('cut'),()=>'plate < 72 h']},
{id:'pasu',x:488,y:46,w:112,h:150,t:'PASU silver',s:'clean · spray Ag',st:'pasu',bar:true,tags:[()=>stTxt('pasu'),()=>stLeft('pasu'),()=>'queue '+ST.pasu.q.length,()=>'15 min (est.)']},
{id:'pppu',x:610,y:46,w:146,h:150,t:'PPPU pre-plate',s:'4 positions · 20 A',custom:'pppu',tags:[()=>'queue '+ST.pppu.q.length+' · 35 °C',()=>'20 min each']},
{id:'pdpu1',x:766,y:46,w:158,h:150,t:'PDPU plating',s:'2 cells · up to 200 A',custom:'pdpu',u:0,det:'pdpu',tags:[()=>pdpuT1(0),()=>pdpuT2(0)]},
{id:'pmpb',x:934,y:46,w:120,h:150,t:'PMPB bench',s:'separate · passivate',st:'pmpb',bar:true,tags:[()=>stTxt('pmpb'),()=>stLeft('pmpb'),()=>'queue '+ST.pmpb.q.length,()=>'DI tank '+f0(S.diro.tank*100)+' %']},
{id:'qc',x:1064,y:46,w:108,h:150,t:'Part QC',s:'XRF · listening',st:'qc',bar:true,tags:[()=>stTxt('qc'),()=>stLeft('qc'),()=>'queue '+ST.qc.q.length,()=>S.lastXRF]},
{id:'vsf',x:1200,y:46,w:188,h:150,t:'VSF finisher',s:'Sibert · back-finish',st:'vsf',bar:true,tags:[()=>stTxt('vsf'),()=>stLeft('vsf'),()=>'queue '+ST.vsf.q.length,()=>'8 min (est.)']},
{id:'vsp',x:1398,y:46,w:188,h:150,t:'VSP punch',s:'Sibert · ×100 optics',st:'vsp',bar:true,tags:[()=>stTxt('vsp'),()=>stLeft('vsp'),()=>'queue '+ST.vsp.q.length,()=>'conc. '+(S.lastConc?f0(S.lastConc)+' µm':'-')]},
{id:'vsc',x:1596,y:46,w:188,h:150,t:'VSC coiner',s:'Sibert · centre + edge',st:'vsc',bar:true,tags:[()=>stTxt('vsc'),()=>stLeft('vsc'),()=>'queue '+ST.vsc.q.length,()=>'today '+S.day.stampers+' done']},
{id:'archive',x:1812,y:46,w:130,h:150,t:'Archive',s:'fathers · mothers',tags:[()=>'fathers '+S.archive.F,()=>'mothers '+S.archive.M,()=>'pulled '+S.pulled,()=>'3-step plating']},
{id:'rack',x:1952,y:46,w:130,h:150,t:'Stamper rack',s:'press-ready sets',tags:[()=>'ready '+rackReady(),()=>'at gate 3 '+rackWait(),()=>'for TP '+rackTP(),()=>'next '+rackNext()]},
// pressing hall
{id:'silo_v',x:20,y:316,w:54,h:120,custom:'silo',lbl:'Virgin',sub:'black',kind:'v'},
{id:'silo_r',x:80,y:316,w:54,h:120,custom:'silo',lbl:'Regrind',sub:'≤20 %',kind:'r'},
{id:'silo_c',x:140,y:316,w:54,h:120,custom:'silo',lbl:'Colour',sub:'compound',kind:'c'},
{id:'lab',x:208,y:316,w:128,h:112,t:'Material lab',s:'per-lot release gate',tags:[()=>'lot '+S.lab.lot,()=>'H2O '+f2(jit(0.03,0.004))+' % ok',()=>S.lab.test?'test '+S.lab.test.lot+' '+dur(S.lab.test.until-S.t):'no lot in test',()=>'queue '+S.lab.queue.length]},
{id:'oven',x:20,y:456,w:174,h:52,t:'Label dry oven',s:'pre-dried · lot per cycle',tags:[()=>S.labels.lot+' · '+S.labels.pairs+' pairs']},
{id:'pDA',x:350,y:316,w:124,h:196,custom:'press',t:'HRP 12 · A',s:'Duplex · semi-auto',det:'duplex'},
{id:'vex',x:484,y:316,w:140,h:128,custom:'vex'},
{id:'ex35',x:484,y:452,w:140,h:58,t:'EX 35 colour',s:'auxiliary extruder',tags:[()=>S.ex35?'strands ON':'strands idle']},
{id:'pDB',x:634,y:316,w:124,h:196,custom:'press',t:'HRP 12 · B',s:'Duplex · semi-auto',det:'duplex'},
{id:'pC1',x:864,y:316,w:150,h:196,custom:'press',t:'Classic',s:'automatic · 3-column',det:'classic',c3:true},
{id:'tA',x:350,y:522,w:84,h:44,t:'Trimmer A',det:'trim',tags:[()=>trimTag('pDA')]},
{id:'tB',x:634,y:522,w:84,h:44,t:'Trimmer B',det:'trim',tags:[()=>trimTag('pDB')]},
{id:'tC1',x:864,y:522,w:110,h:44,t:'Trimmer C',det:'trim',tags:[()=>trimTag('pC1')]},
{id:'audioqc',x:1120,y:338,w:130,h:70,t:'Audio compare',s:'all TPs + 1 in 50',tags:[()=>'n '+S.samples+' · '+S.audioLast]},
{id:'punch',x:1264,y:338,w:130,h:70,t:'Label punch',s:'106 mm · rejects',tags:[()=>'punched '+S.punched]},
{id:'gran',x:1408,y:320,w:164,h:104,t:'Granulator 400',s:'edge trim + rejects',tags:[()=>'bin '+f1(S.gran.bin)+' kg',()=>S.gran.run?'BATCH '+f1(S.gran.run)+' kg':'waits for 12 kg',()=>'ground '+f0(S.gran.total)+' kg',()=>'200 to 250 kg/h']},
{id:'carton',x:1600,y:338,w:170,h:70,t:'Carton partner',s:'incoming QC per delivery',tags:[()=>'CP-0311 · ΔE 0.8 OK']},
{id:'optical',x:1120,y:452,w:130,h:96,t:'Optical',s:'100 % · darkfield',tags:[()=>'yield '+yieldStr(),()=>'reject '+S.day.rej,()=>'last '+S.opticalLast]},
{id:'finish',x:1264,y:452,w:130,h:96,t:'Finishing',s:'sleeve · shrink',tags:[()=>'sleeved '+S.sleeved,()=>'sleeve IS-204',()=>'shrink sampled']},
{id:'pack',x:1408,y:452,w:150,h:96,t:'Packaging',s:'matrix vs barcode',tags:[()=>'assembled '+S.assembled,()=>'mismatch '+S.mismatch,()=>'barcode 100 %']},
{id:'ship',x:1572,y:452,w:200,h:96,t:'Fulfillment',s:'bulk pallet · dropship',tags:[()=>'pallet '+S.pallet+' / 300',()=>'dropship '+S.parcels+' parcels',()=>'TPs to artists '+S.tpToArtist]},
// utilities
{id:'boiler',x:20,y:676,w:160,h:132,t:'Steam boilers',s:'B-1 + B-2 · 10 bar · 180 °C',tags:[()=>'P '+f2(S.boiler.p)+' bar',()=>'flow '+f0(S.boiler.F*HOUR)+' kg/h',()=>'cap 2 × 500 kg/h',()=>'press 150-180 kg/h',()=>'fuel: open']},
{id:'chiller',x:192,y:676,w:176,h:132,t:'Process cooling',s:'CH-1 + CH-2 · air-cooled',tags:[()=>'supply '+f1(S.chiller.sup)+' °C',()=>'return '+f1(S.chiller.ret)+' °C',()=>'flow '+f0(S.chiller.flow)+' l/min',()=>'load '+f0(S.chiller.load)+' kW',()=>'cap 2 × 250 kW']},
{id:'comp',x:380,y:676,w:120,h:132,t:'Compressed air',s:'6 to 10 bar, dry',tags:[()=>'P '+f1(S.comp.p)+' bar',()=>'dew '+f1(jit(2.5,.4))+' °C',()=>'presses',()=>'Sibert, PASU']},
{id:'hyd',x:512,y:676,w:124,h:132,t:'Hydraulics',s:'Duplex aggregate',tags:[()=>'P '+f0(S.hyd.p)+' bar',()=>'oil '+f1(S.hyd.oil)+' °C',()=>'HLP 46 · 200 l',()=>'presses A + B']},
{id:'diro',x:648,y:676,w:136,h:132,t:'RO / DI water',s:'galvanics rinse',tags:[()=>'tank '+f0(S.diro.tank*100)+' %',()=>'cond '+f1(S.diro.cond)+' µS/cm',()=>'to PMPB, PASU',()=>'0.2 m³/h est.']},
{id:'zld',x:796,y:676,w:160,h:132,t:'Water treatment',s:'zero discharge',tags:[()=>'buffer '+f0(S.zld.tank*100)+' %',()=>'rinse + VSF loop',()=>'metal recovery',()=>'discharge 0 l']},
{id:'energy',x:968,y:676,w:210,h:132,t:'Energy',s:'247 kWp PV · 271 kWh BESS · PEA MV',tags:[()=>'PV '+f0(S.energy.pv)+' kW',()=>'load '+f0(S.energy.load)+' kW',()=>'grid '+f0(S.energy.grid)+' kW',()=>'BESS '+f0(S.energy.soc)+' % SoC',()=>'galvanics '+f0(S.galvKW)+' kW']},
{id:'trace',x:1190,y:676,w:196,h:132,t:'Traceability',s:'log at source, append-only',tags:[()=>'records '+S.logged,()=>'events '+S.events,()=>'stamper+press+cycle',()=>'genealogy per part']},
{id:'hvac',x:1398,y:676,w:196,h:132,t:'Plant HVAC',s:'20 to 26 °C · 35 to 60 % RH',tags:[()=>'T '+f1(S.hvac.t)+' °C',()=>'RH '+f0(S.hvac.rh)+' %',()=>'PM2.5 '+f0(S.hvac.pm)+' µg/m³',()=>'zones press, galv']},
{id:'exh',x:1606,y:676,w:160,h:132,t:'Galv extraction',s:'hoods · scrubber',tags:[()=>'fans '+(CELLS.some(c=>c.job)||ST.pasu.slots[0]?'running':'standby'),()=>'Ø63 per tank unit',()=>'PASU Ø125 mm',()=>'interlock OK']},
];
const M={};MACH.forEach(m=>M[m.id]=m);
const yieldStr=()=>{const n=S.day.good+S.day.rej;return n?f1(100*S.day.good/n)+' %':'--';};
function stTxt(id){const w=ST[id].slots[0];if(!w)return 'idle';const fr=(S.t-w.t0)/(w.end-w.t0);const it=w.it;
const lbl=id==='pmpb'?jl(it):pl(it);let ph;
if(id==='pasu')ph=fr<0.2?'clean':fr<0.35?'sens.':fr<0.8?'spray':'rinse';
else if(id==='pmpb')ph=fr<0.3?'sep.':fr<0.75?'passiv.':'dry';
else if(id==='qc')ph=it.gen==='M'&&fr>0.12?'listen':'XRF';
else if(id==='vsf')ph=['P240','P600','P1200','P2500','clean'][Math.min(4,Math.floor(fr*5))];
else if(id==='vsp')ph=fr<0.8?'centre':'punch';
else ph='form';
return lbl.replace(' ','')+' '+ph;}
function stLeft(id){const w=ST[id].slots[0];return w?'left '+dur(w.end-S.t):'left -';}
function stFrac(id){const w=ST[id].slots[0];return w?Math.min(1,(S.t-w.t0)/(w.end-w.t0)):0;}
function pdpuT1(u){const V=Math.max(...CELLS.filter(c=>c.u===u).map(c=>c.V));return 'bath 55 °C · '+(V>0?f1(V)+' V':'0 V');}
function pdpuT2(u){const n=CELLS.filter(c=>c.u===u&&c.ph==='plate').length;return 'coil '+(n?f0(jit(16,0.6)):'15')+' °C · '+(n?'10':'0')+' l/min';}
const rackReady=()=>RACK.filter(r=>r.T.approved).length;
const rackWait=()=>RACK.filter(r=>!r.T.approved&&r.T.tp==='sent').length;
const rackTP=()=>RACK.filter(r=>r.T.tp==='none').length;
function rackNext(){const r=RACK.find(r=>r.T.approved)||RACK.find(r=>r.T.tp==='none');return r?r.T.sid+'-'+r.k:'-';}
function trimTag(pid){return running(PRESS.find(p=>p.id===pid))?'trim · 6 s':'idle';}
/* ---------------- drawing */
function lane(x,y,txt){el('text',{x,y,class:'lane'},L.mach,txt);}
lane(20,24,'Metal path · audio file to stamper');
lane(20,300,'Pressing hall · Newbilt · material and record path');
lane(20,668,'Utilities and plant systems');
el('rect',{x:478,y:30,width:702,height:176,rx:6,class:'room'},L.pipes);
el('text',{x:488,y:24,class:'roomlbl'},L.mach,'Galvanics clean room · Pheenix Alpha');
el('rect',{x:1190,y:30,width:604,height:176,rx:6,class:'room'},L.pipes);
el('text',{x:1200,y:24,class:'roomlbl'},L.mach,'Stamper prep · Sibert');
function boxBase(m){const g=el('g',{class:'machine',id:'m_'+m.id});
el('rect',{x:m.x,y:m.y,width:m.w,height:m.h,rx:4,class:'mbox'},g);
el('path',{d:headD(m.x,m.y,m.w),class:'mhead'},g);
el('text',{x:m.x+7,y:m.y+14,class:'mtitle'},g,m.t);
m.lamp=el('circle',{cx:m.x+m.w-10,cy:m.y+10,r:4,class:'lamp'},g);
if(m.s)el('text',{x:m.x+7,y:m.y+31,class:'msub'},g,m.s);
m.g=g;g.addEventListener('click',()=>select(m.id));return g;}
function drawBox(m){const g=boxBase(m);const y0=m.tagY!=null?m.tagY:m.y+(m.h<=60&&!m.s?34:46);
m.tagEls=(m.tags||[]).map((t,i)=>el('text',{x:m.x+7,y:y0+i*13,class:'mtag'},g,''));
if(m.bar){el('rect',{x:m.x+7,y:m.y+m.h-16,width:m.w-14,height:6,rx:2,class:'pbar'},g);m.barEl=el('rect',{x:m.x+7,y:m.y+m.h-16,width:0,height:6,rx:2,class:'pbarv'},g);}
return g;}
function drawSilo(m){const g=el('g',{class:'machine',id:'m_'+m.id});const {x,y,w,h}=m;
el('path',{d:`M${x},${y} H${x+w} V${y+h-28} L${x+w/2+7},${y+h} H${x+w/2-7} L${x},${y+h-28} Z`,class:'silo'},g);
m.fillEl=el('rect',{x:x+2,y:y+h-30,width:w-4,height:0,class:'fill'+(m.kind==='r'?' reg':m.kind==='c'?' col':'')},g);
el('text',{x:x+w/2,y:y+13,class:'msub','text-anchor':'middle'},g,m.lbl);
el('text',{x:x+w/2,y:y+25,class:'small','text-anchor':'middle'},g,m.sub);
m.lvlEl=el('text',{x:x+w/2,y:y+h-36,class:'mtag','text-anchor':'middle'},g,'');
m.lamp=el('circle',{cx:x+w-7,cy:y+7,r:3.5,class:'lamp run'},g);
m.g=g;g.addEventListener('click',()=>select(m.id));}
function drawPress(m){const g=el('g',{class:'machine',id:'m_'+m.id});const {x,y,w,h}=m;
el('rect',{x,y,width:w,height:h,rx:4,class:'frame'},g);
el('path',{d:headD(x,y,w),class:'mhead'},g);
el('text',{x:x+7,y:y+14,class:'mtitle'},g,m.t);
m.lamp=el('circle',{cx:x+w-10,cy:y+10,r:4,class:'lamp'},g);
el('text',{x:x+7,y:y+31,class:'msub'},g,m.s);
if(m.c3)el('rect',{x:x+w/2-2,y:y+38,width:4,height:84,fill:'#1d2942'},g); // third column, behind the platens
el('rect',{x:x+8,y:y+38,width:6,height:84,fill:'#2a3a56'},g);
el('rect',{x:x+w-14,y:y+38,width:6,height:84,fill:'#2a3a56'},g);
const px=x+20,pw=w-40;
el('rect',{x:px+8,y:y+97,width:pw-16,height:3,fill:'#b9c2cc'},g);
// platens, top stamper and cake move every cycle: drawn on the overlay canvas (drawFx), not in the SVG
m.plat={px,pw,yb:y+100,cx:x+w/2,cy:y+90};
m.pY=[y+38,y+80];
m.stateEl=el('text',{x:x+w/2,y:y+138,class:'mtag','text-anchor':'middle',style:'font-weight:700;fill:var(--ink)'},g,'OFF');
m.tagEls=[0,1,2].map(i=>el('text',{x:x+7,y:y+154+i*13,class:'mtag'},g,''));
m.g=g;g.addEventListener('click',()=>select(m.id));}
function drawVex(m){const g=el('g',{class:'machine',id:'m_'+m.id});const {x,y,w,h}=m;
el('rect',{x,y,width:w,height:h,rx:4,class:'mbox'},g);
el('path',{d:headD(x,y,w),class:'mhead'},g);
el('text',{x:x+7,y:y+14,class:'mtitle'},g,'VEX 50 extruder');
m.lamp=el('circle',{cx:x+w-10,cy:y+10,r:4,class:'lamp'},g);
m.tagEls=[el('text',{x:x+7,y:y+31,class:'msub'},g,''),el('text',{x:x+7,y:y+43,class:'msub'},g,'')];
m.tags=[()=>'screw '+f0(S.vex.rpm)+' rpm · melt '+f0(S.vex.melt)+' °C',()=>'50 mm · 3 zones · 7.5 kW'];
el('path',{d:`M${x+56},${y+50} H${x+84} V${y+60} L${x+76},${y+74} H${x+64} L${x+56},${y+60} Z`,class:'hopper'},g);
el('text',{x:x+8,y:y+82,class:'small'},g,'◀ cake A');
el('text',{x:x+w-8,y:y+82,class:'small','text-anchor':'end'},g,'cake B ▶');
m.zoneEls=[0,1,2].map(i=>el('rect',{x:x+10+i*40,y:y+88,width:38,height:26,class:'zone',style:'fill:var(--z0)'},g));
m.zoneT=[0,1,2].map(i=>el('text',{x:x+29+i*40,y:y+105,class:'ctxt','text-anchor':'middle'},g,''));
FX.screws.push({x1:x+10,x2:x+w-10,y:y+120});
m.g=g;g.addEventListener('click',()=>select(m.id));}
function drawPppu(m){m.tagY=m.y+126;const g=drawBox(m);
m.pos=[0,1,2,3].map(i=>{const px=m.x+7+(i&1)*68,py=m.y+36+(i>>1)*40;const r=el('rect',{x:px,y:py,width:64,height:36,rx:2,class:'pos'},g);
return {r,a:el('text',{x:px+5,y:py+14,class:'ctxt'},g,''),b:el('text',{x:px+5,y:py+28,class:'ctxt'},g,'')};});}
function drawPdpu(m){m.tagY=m.y+126;const g=drawBox(m);
m.cells=[0,1].map(i=>{const cx=m.x+7+i*74,cy=m.y+38,cw=70,ch=76;const cg=el('g',{class:'cell'},g);
el('rect',{x:cx,y:cy,width:cw,height:ch,rx:2,class:'cellr'},cg);
el('rect',{x:cx+3,y:cy+20,width:cw-6,height:ch-23,class:'liquid'},cg);
for(let b=0;b<3;b++)FX.bubbles.push({x:cx+10+b*18,y:cy+ch-5,d:rnd(0,2.2),cell:i});
el('rect',{x:cx+cw-11,y:cy+22,width:6,height:50,class:'thk'},cg);
const tv=el('rect',{x:cx+cw-11,y:cy+72,width:6,height:0,class:'thkv'},cg);
const t=[0,1,2,3].map(k=>el('text',{x:cx+4,y:cy+12+k*15,class:'ctxt'},cg,''));
return {g:cg,tv,t,cy};});}
MACH.forEach(m=>{
if(m.custom==='silo')drawSilo(m);else if(m.custom==='press')drawPress(m);else if(m.custom==='vex')drawVex(m);
else if(m.custom==='pppu')drawPppu(m);else if(m.custom==='pdpu')drawPdpu(m);else drawBox(m);});
/* gates */
function gate(id,x,y,l1){const g=el('g',{class:'gate',id:'gate_'+id},L.top);
el('polygon',{points:`${x},${y-13} ${x+13},${y} ${x},${y+13} ${x-13},${y}`},g);
el('text',{x,y:y+26},g,l1);const c=el('text',{x,y:y+36},g,'');return {g,c,flash:0};}
const GATES={g1:gate('g1',160,116,'gate 1'),g2:gate('g2',330,116,'gate 2'),g3:gate('g3',1880,232,'gate 3 · TP')};
function flash(id){if(S.quiet)return;GATES[id].flash=2;GATES[id].g.classList.add('ok');}
/* pipes and routes */
// Routes are polylines (M/H/V/L only) measured here, so a token never asks the DOM for a point on a
// path: getPointAtLength after a transform write forces a layout of the whole SVG on every call.
const PATHS={};
function poly(d){if(/[^MHVL\d\s,.\-]/.test(d))throw new Error('route needs M/H/V/L only: '+d);
const segs=[],re=/([MHVL])\s*(-?[\d.]+)(?:\s*,\s*(-?[\d.]+))?/g;let x=0,y=0,len=0,sx=0,sy=0,m;
while((m=re.exec(d))){const c=m[1],a=+m[2],b=m[3]==null?0:+m[3];
if(c==='M'){x=sx=a;y=sy=b;continue;}const nx=c==='V'?x:a,ny=c==='H'?y:c==='V'?a:b,l=Math.hypot(nx-x,ny-y);
segs.push({x0:x,y0:y,x1:nx,y1:ny,l0:len,l});len+=l;x=nx;y=ny;}
return {segs,len,sx,sy};}
function ptAt(p,pos){const g0=p.segs;if(!g0.length)return {x:p.sx,y:p.sy};
for(const g of g0)if(pos<=g.l0+g.l){const k=g.l?Math.max(0,pos-g.l0)/g.l:0;return {x:g.x0+(g.x1-g.x0)*k,y:g.y0+(g.y1-g.y0)*k};}
const g=g0[g0.length-1];return {x:g.x1,y:g.y1};}
function flow(d,cls){const c=cls.split(' ');FX.flows.push({k:'flow',c:c[0],thin:c.includes('thin'),p2:new Path2D(d)});}
function route(id,d,cls){PATHS[id]=poly(d);flow(d,cls);}
function pipe(d,cls,thin){FX.flows.push({k:'pipe',c:cls,p2:new Path2D(d)});flow(d,cls+(thin?' thin':''));}
// pipe rack y 616 to 650, drops into each press from below
pipe('M100,676 V616 H992','steam');['456','740','992'].forEach(x=>pipe(`M${x},616 V512`,'steam'));
pipe('M998,621 H112 V676','cond',true);
pipe('M262,676 V626 H1004','cw');['466','750','1004'].forEach(x=>pipe(`M${x},626 V512`,'cw'));
pipe('M1010,631 H282 V676','cwr',true);
pipe('M574,676 V636 H444 V512','hyd');pipe('M574,636 H728 V512','hyd');
// galvanics services: up the right-hand side to headers over the clean room
pipe('M300,676 V644 H2088 V34 H845 V46','chw',true);
pipe('M716,676 V650 H2094 V40 H544 V46','di',true);pipe('M994,40 V46','di',true);
// record conveyor and edge-trim collection
FX.flows.push({k:'conv',p2:new Path2D('M370,590 H1772')},{k:'convd',p2:new Path2D('M370,590 H1772')});
flow('M392,574 H1104','scrap thin');
// material
route('s_v','M47,436 V444 H272 V428','mat thin');route('s_r','M107,436 V444 H272 V428','reg thin');route('s_c','M167,436 V444 H272 V428','col thin');
route('f_vex','M272,316 V308 H554 V366','mat thin');route('f_c1','M272,316 V308 H939 V316','mat thin');
route('f_ex35','M272,316 V308 H629 V481 H624','col thin');route('ex2vex','M554,452 V444','col thin');
route('l_pDA','M194,482 H350','lbl thin');route('l_pDB','M194,500 H340 V517 H646 V512','lbl thin');
route('l_pC1','M194,500 H340 V517 H876 V512','lbl thin');
route('trimcart','M1104,574 V444 H1440 V424','scrap thin');
route('reject','M1200,590 V570 H1257 V432 H1329 V408','scrap thin');route('sample','M1170,590 V570 H1112 V373 H1120','route thin');
route('p2g','M1394,371 H1408','scrap thin');route('regrind','M1490,320 V284 H107 V316','reg thin');
route('carton','M1685,408 V430 H1483 V452','route thin');
['pDA:412','pDB:696','pC1:939'].forEach(s=>{const [id,x]=s.split(':');route('st_'+id,`M2017,196 V278 H${x} V316`,'metal thin');});
[[180,612,'steam 10 bar'],[1250,640,'galvanics services: cooling water 12 to 20 °C · DI water'],[1500,274,'stamper sets to presses'],[130,280,'regrind to silo'],
[1120,438,'edge trim, in batches'],[1262,428,'rejects'],[1116,332,'samples'],[200,478,'labels']].forEach(a=>el('text',{x:a[0],y:a[1],class:'small'},L.top,a[2]));
/* ================================================================ tokens */
const movers=[];
function mover(pid,node,v,onEnd){const p=PATHS[pid];const m={p,node,pos:0,v,onEnd};movers.push(m);place(node,ptAt(p,0));return m;}
function place(node,pt){node.x=pt.x;node.y=pt.y;}
function dot(fill,r){return spr('dot',{r:r||2.4,fill});}
function shape(kind){return spr('shape',{k:kind});}
const hops=[];
function ctr(id){const m=M[id];return m?{x:m.x+m.w/2,y:m.y+m.h/2}:null;}
function hop(from,to,it){if(!visHop()||!from)return;const a=ctr(from),b=ctr(to);if(!a||!b)return;
const kind=it.grow?it.grow:it.gen==='L'?(it.silvered?'Ls':'L'):it.gen;const n=shape(kind);hops.push({n,a,b,t:0,d:1.1});place(n,a);}
function routeHop(pid,kind){mover(pid,shape(kind),320,m=>m.node.remove());}
const RECS=[];const CONV0=370,CONVEND=1772,CONVY=590;const DROPX={pDA:392,pDB:676,pC1:919};
const STATIONS=[{id:'optical',at:1185,dwell:2.2},{id:'finish',at:1329,dwell:2.5},{id:'pack',at:1483,dwell:3},{id:'ship',at:1672,dwell:0.5}];
function mkRec(P,tp){const T=P.set.T;const g=spr('rec',{r:9,lr:3.2,vinyl:T.vinyl,label:T.label});if(tp)g.classList.add('tp');
const r={node:g,T,tp,ph:'trim',dwell:6,x:DROPX[P.id],y:544,pos:0,visited:{},n:P.cyc,pname:P.name};RECS.push(r);place(g,r);return r;}
function recTick(ds){
const onC=RECS.filter(r=>r.ph==='conv'&&!r.gone).sort((a,b)=>b.pos-a.pos);
for(const r of RECS){
if(r.ph==='trim'){r.dwell-=ds;if(r.dwell<=0){r.ph='drop';S.trimmed++;}}
else if(r.ph==='drop'){r.y=Math.min(CONVY,r.y+60*ds);if(r.y>=CONVY){const p=r.x-CONV0;if(!onC.some(o=>Math.abs(o.pos-p)<24)){r.ph='conv';r.pos=p;r.dwell=0;onC.push(r);onC.sort((a,b)=>b.pos-a.pos);}}}}
for(let i=0;i<onC.length;i++){const r=onC[i];if(r.gone)continue;if(r.dwell>0){r.dwell-=ds;continue;}
let max=CONVEND-CONV0;for(let j=i-1;j>=0;j--){if(!onC[j].gone){max=Math.min(max,onC[j].pos-24);break;}}
let np=Math.min(r.pos+32*ds,Math.max(r.pos,max));
for(const st of STATIONS){const at=st.at-CONV0;if(!r.visited[st.id]&&np>=at){np=at;r.visited[st.id]=true;r.dwell=st.dwell;r.pos=np;recStation(st,r);break;}}
if(!r.gone)r.pos=np;}
for(let i=RECS.length-1;i>=0;i--)if(RECS[i].gone)RECS.splice(i,1);
}
function recStation(st,r){const T=r.T;
if(st.id==='optical'){S.logged++;
if(!r.tp&&Math.random()<0.035){const d=pick(['non-fill','stitching','off-centre hole','debris','label off-centre','warp']);S.day.rej++;S.opticalLast='REJ '+d.slice(0,8);r.node.classList.add('rej');r.gone=true;
log('Optical: '+r.pname+' record #'+r.n+' rejected ('+d+'), label punched, to the granulator','alarm');
mover('reject',r.node,90,m=>{S.punched++;S.punchUntil=S.t+8;m.node.remove();mover('p2g',dot('#c2b280',2.6),40,mm=>{mm.node.remove();S.gran.bin+=T.wt/1000;});});return;}
S.opticalLast='pass #'+r.n;
if(r.tp||Math.random()<0.02){const g=spr('rec',{r:7,lr:2.4,vinyl:T.vinyl,label:T.label});if(r.tp)g.classList.add('tp');
const tp=r.tp;mover('sample',g,60,m=>{m.node.remove();S.samples++;const sc=jit(98.2,1.2);S.audioLast=f1(sc);if(tp)S.tpToArtist++;
log('Audio compare: '+T.sid+(tp?' test pressing':' record #'+r.n)+' score '+f1(sc)+', clicks '+f0(rnd(0,3))+'/side, noise floor OK'+(tp?', to the artist':''),tp?'warn':'ok');});
if(r.tp){r.gone=true;r.node.remove();}}
return;}
if(st.id==='finish')S.sleeved++;
if(st.id==='pack'){S.assembled++;S.day.good++;T.good++;S.logged++;}
if(st.id==='ship'){r.gone=true;r.node.remove();if(Math.random()<0.33)S.parcels++;else if(++S.pallet>=300){S.pallet=0;log('Fulfillment: pallet of 300 closed, booked with the carrier','ok');}}
}
/* ================================================================ materials, lab, granulator */
function newLot(silo){const lot='26-0'+(S.lab.nextLot++);S.lab.queue.push({lot,silo,kg:silo==='v'?3000:600});
log('PVC lot '+lot+' ('+(silo==='v'?'black, 3 t':'colour, 600 kg')+') delivered, quarantined at the material lab','warn');}
function matTick(){
const LB=S.lab,pend=s=>LB.queue.some(q=>q.silo===s)||(LB.test&&LB.test.silo===s);
if(S.silo.v<0.25*S.silo.vcap&&!pend('v'))newLot('v');
if(S.silo.c<0.4*S.silo.ccap&&!pend('c'))newLot('c');
if(!LB.test&&LB.queue.length&&shiftOn()){LB.test=LB.queue.shift();LB.test.until=S.t+2*HOUR;log('Material lab: lot '+LB.test.lot+' in test: moisture, heat stability, colour, density, then a release pressing','info');}
if(LB.test&&S.t>=LB.test.until){const t=LB.test;LB.test=null;if(t.silo==='v')S.silo.v=Math.min(S.silo.vcap,S.silo.v+t.kg);else S.silo.c=Math.min(S.silo.ccap,S.silo.c+t.kg);LB.lot=t.lot;
log('Material lab: lot '+t.lot+' released (moisture '+f2(jit(0.03,0.01))+' %, heat stability OK, ΔE 0.3), retained sample archived','ok');}
const G=S.gran;
if(G.run&&S.t>=G.end){S.silo.r=Math.min(S.silo.rcap,S.silo.r+G.run*0.99);G.total+=G.run;log('Granulator 400: '+f1(G.run)+' kg ground to regrind, to the silo','info');
if(visHop())for(let i=0;i<3;i++)setTimeout(()=>mover('regrind',dot('#8ea36f',2.6),220,m=>m.node.remove()),i*250);G.run=0;}
if(!G.run&&G.bin>=12&&shiftOn()){G.run=G.bin;G.bin=0;G.end=S.t+G.run/225*HOUR;
if(visHop())mover('trimcart',spr('rect',{w:14,h:10,fill:'#5a4f33',stroke:'#c2b280'}),160,m=>m.node.remove());}
}
/* ================================================================ utilities */
function utilTick(ds){
let draw=0;PRESS.forEach(P=>{if(P.state==='STEAM'&&!P.waiting)draw+=STEAM_KG/seqDur(P,'STEAM');});
// header holds ~10 kg of steam per bar; boiler bank B-1 + B-2 at 2 x 500 kg/h (scale.md, steam-cooling.html)
const B=S.boiler,fault=S.t<B.faultUntil,Fmax=fault?0:1000/HOUR;
const F=B.p<10.05?Fmax:Math.min(Fmax,draw);B.p=Math.min(10.12,B.p+(F-draw)/10*ds);B.F=lag(B.F,F,0.02,ds);
if(B.faultUntil>0&&!fault&&B.p>9.6){B.faultUntil=-1;log('Boiler: feedwater pump back, '+f2(B.p)+' bar, presses resume','ok');}
let heat=0,flow=20;PRESS.forEach(P=>{if(running(P)){heat+=72;flow+=53;}}); // average per running press; CH-1 + CH-2 = 500 kW below
heat+=CELLS.filter(c=>c.ph==='plate').length*1.5;
const C=S.chiller;C.load=lag(C.load,heat,1/600,ds);C.flow=lag(C.flow,flow,0.05,ds);C.sup=lag(C.sup,22+Math.max(0,C.load-500)/40,1/300,ds);C.ret=C.sup+C.load/(C.flow/60*4.186);
S.comp.p=lag(S.comp.p,PRESS.some(P=>P.state==='LOAD'||P.state==='EJECT')?7.2:7.7,0.9,ds);
const D=PRESS.slice(0,2),dRun=D.some(running);
S.hyd.p=lag(S.hyd.p,D.some(P=>['CLOSE','STEAM','COOL'].includes(P.state))?180:30,2,ds);S.hyd.oil=lag(S.hyd.oil,dRun?46:38,0.002,ds);
S.vex.rpm=lag(S.vex.rpm,D.some(P=>P.state==='LOAD')?42:dRun?28:0,1.5,ds);S.vex.melt=lag(S.vex.melt,dRun?158:90,0.01,ds);
S.ex35=D.some(P=>running(P)&&P.set&&P.set.T.kind==='splatter');
const w=Math.min(ds,1);S.hvac.t=lag(S.hvac.t,23.2,0.001,ds)+(Math.random()-.5)*0.004*w;S.hvac.rh=lag(S.hvac.rh,52,0.001,ds)+(Math.random()-.5)*0.02*w;
S.diro.tank=Math.min(0.95,S.diro.tank+0.2/HOUR*ds);S.diro.cond=lag(S.diro.cond,0.6+Math.random()*0.3,0.01,ds);
S.zld.tank=Math.max(0.15,S.zld.tank-0.05/HOUR*ds);
const h=hourOf(),sun=Math.max(0,Math.sin(Math.PI*(h-6.5)/12));S.energy.pv=lag(S.energy.pv,247*0.85*sun,0.01,ds);
let load=160+40*sun;PRESS.forEach(P=>{if(running(P))load+=P.type==='duplex'?10:15;});load+=C.load/3.5;
let rect=0;CELLS.forEach(c=>{rect+=c.I*c.V;});rect+=ST.pppu.slots.filter(Boolean).length*20*9;
S.galvKW=rect/1000/0.85+(shiftOn()?6:3);load+=S.galvKW;if(S.gran.run)load+=7.5;load+=shiftOn()?14:3;
const E=S.energy;E.load=lag(E.load,load,0.2,ds);const def=E.load-E.pv;
if(def>0){const dis=E.soc>20?Math.min(def,135):0;E.soc=Math.max(20,E.soc-dis*ds/(271*36));E.grid=lag(E.grid,def-dis,1,ds);}
else{const ch=E.soc<100?Math.min(-def,135):0;E.soc=Math.min(100,E.soc+ch*ds/(271*36));E.grid=lag(E.grid,0,1,ds);}
}
/* ================================================================ sim step */
function tick(ds){S.t+=ds;
const dn=dayN();if(S.day.n!==dn){if(S.day.n!==null)log('Day '+dn+' starts: '+S.day.good+' good records yesterday, '+S.day.rej+' rejects','ok');S.day={n:dn,good:0,rej:0,tp:0,stampers:0};}
upTick();for(const k in ST)stTick(ST[k]);CELLS.forEach(c=>cellTick(c,ds));PRESS.forEach(P=>pressTick(P,ds));
recTick(ds);matTick();utilTick(ds);
if(S.cyc.length&&S.cyc[0]<S.t-HOUR){let i=0;while(i<S.cyc.length&&S.cyc[i]<S.t-HOUR)i++;S.cyc.splice(0,i);}
if(TITLES.length>150){for(let i=0;i<TITLES.length&&TITLES.length>120;i++){if(TITLES[i].done){TITLES.splice(i,1);i--;}}}
}
/* ================================================================ render */
let feedAcc=0,cartonAcc=0;
function animate(dt){
for(let i=movers.length-1;i>=0;i--){const m=movers[i];m.pos+=m.v*dt;if(m.pos>=m.p.len){place(m.node,ptAt(m.p,m.p.len));movers.splice(i,1);m.onEnd&&m.onEnd(m);}else place(m.node,ptAt(m.p,m.pos));}
for(let i=hops.length-1;i>=0;i--){const h=hops[i];h.t+=dt;const k=Math.min(1,h.t/h.d);place(h.n,{x:h.a.x+(h.b.x-h.a.x)*k,y:h.a.y+(h.b.y-h.a.y)*k-Math.sin(k*Math.PI)*18});if(k>=1){h.n.remove();hops.splice(i,1);}}
RECS.forEach(r=>{if(!r.gone)place(r.node,r.ph==='conv'?{x:CONV0+r.pos,y:CONVY}:{x:r.x,y:r.y});});
if(S.running&&!S.quiet&&S.speed<=600){feedAcc+=dt;
if(feedAcc>0.7&&PRESS.some(running)){feedAcc=0;const k=pick(['s_v','s_v','s_r','s_c','f_vex','f_vex','f_c1']);
const col=k==='s_r'?'#8ea36f':k==='s_c'?'#c86bff':'#b9c2d0';mover(k,dot(col),110,m=>m.node.remove());
if(S.ex35&&Math.random()<0.5)mover('f_ex35',dot('#c86bff'),110,m=>{m.node.remove();mover('ex2vex',dot('#c86bff'),20,mm=>mm.node.remove());});}
cartonAcc+=dt;if(cartonAcc>7){cartonAcc=0;mover('carton',spr('rect',{w:12,h:8,fill:'#8a6d3b',stroke:'#c9a24a'}),50,m=>m.node.remove());}}
for(const k in GATES){const G=GATES[k];if(G.flash>0){G.flash-=dt;if(G.flash<=0)G.g.classList.remove('ok');}}
PRESS.forEach(P=>{const k=1-Math.exp(-6*dt);P.__h=(P.__h||0)+((P.state==='STEAM'?1:0)-(P.__h||0))*k;P.__c=(P.__c||0)+((P.state==='COOL'?1:0)-(P.__c||0))*k;});
}
const lampSet=(id,cls)=>{const m=M[id];if(m&&m.lamp)setA(m.lamp,'class','lamp '+cls);};
function pressLabel(P){if(P.state==='STOP')return 'STOP';if(P.state==='STEAM'&&P.waiting)return 'STEAM · WAIT';if(P.state==='CHANGE')return P.restart?'WARM-UP':'STAMPER CHANGE';if(P.state==='NOPVC')return 'NO PVC';return P.state;}
function renderTags(){
MACH.forEach(m=>{if(m.tags&&m.tagEls)m.tags.forEach((fn,i)=>{if(m.tagEls[i])setT(m.tagEls[i],fn());});if(m.barEl)setA(m.barEl,'width',((m.w-14)*stFrac(m.st)).toFixed(1));});
[['silo_v',S.silo.v/S.silo.vcap],['silo_r',S.silo.r/S.silo.rcap],['silo_c',S.silo.c/S.silo.ccap]].forEach(([id,l])=>{const m=M[id];l=Math.max(0,Math.min(1,l));const hh=(m.h-32)*l;
setA(m.fillEl,'y',(m.y+m.h-30-hh).toFixed(1));setA(m.fillEl,'height',hh.toFixed(1));setT(m.lvlEl,f0(l*100)+' %');setA(m.lamp,'class','lamp '+(l<0.1?'alarm':l<0.25?'warn':'run'));});
PRESS.forEach(P=>{const m=M[P.id];const T=P.set&&P.set.T;
setT(m.stateEl,pressLabel(P));const amber=P.waiting||['CHANGE','UNMOUNT'].includes(P.state)||P.mode==='tp';
setS(m.stateEl,'fill',P.state==='NOPVC'||P.state==='STOP'?'var(--alarm)':amber?'var(--warn)':(P.state==='IDLE'||P.state==='OFF')?'var(--mute)':'var(--ink)');
setT(m.tagEls[0],running(P)?'F '+f0(P.force)+' t · '+f0(P.moldT)+' °C':P.state==='STOP'?P.stopWhy:P.state==='OFF'?'night: shift 06:00':'mould '+f0(P.moldT)+' °C');
setT(m.tagEls[1],T?T.id+' · '+P.set.k:(P.state==='OFF'?'':'waiting for a set'));
setT(m.tagEls[2],!T?'':P.mode==='tp'?'test press '+(TP_N-P.tpLeft)+'/'+TP_N:P.mode==='fa'?'first article':'run '+T.cycles+'/'+T.target);
lampSet(P.id,P.state==='NOPVC'||P.state==='STOP'?'alarm':amber?'warn':running(P)?'run':'idle');});
['tA','tB','tC1'].forEach((id,i)=>lampSet(id,running(PRESS[i])?'run':'idle'));
const dRun=PRESS.slice(0,2).some(running);
M.vex.zoneT.forEach((t,i)=>setT(t,f0(dRun?jit([132,146,156][i],1):[92,96,98][i])+'°'));
M.vex.zoneEls.forEach((z,i)=>{setS(z,'fill',dRun?'var(--z'+(i+1)+')':'var(--z0)');});lampSet('vex',dRun?'run':'idle');lampSet('ex35',S.ex35?'run':'idle');
M.pppu.pos.forEach((p,i)=>{const w=ST.pppu.slots[i];setA(p.r,'class','pos'+(w?' on':''));setT(p.a,w?w.it.T.sid+w.it.side+' 20 A':'free');setT(p.b,w?dur(w.end-S.t):'');});
lampSet('pppu',ST.pppu.slots.some(Boolean)?'run':ST.pppu.q.length?'warn':'idle');
[0].forEach(u=>{const m=M['pdpu'+(u+1)];let any=false,done=false;
m.cells.forEach((cv,i)=>{const c=CELLS[u*2+i];const j=c.job;setA(cv.g,'class','cell'+(c.ph==='plate'?' on':c.ph==='done'?' done':''));
if(c.ph!=='idle')any=true;if(c.ph==='done')done=true;
const hh=50*(j?Math.min(1,c.mass/c.target):0);setA(cv.tv,'y',(cv.cy+72-hh).toFixed(1));setA(cv.tv,'height',hh.toFixed(1));
setT(cv.t[0],j?jl(j).replace(' ',''):'idle');
setT(cv.t[1],c.ph==='plate'?f0(c.I)+' A':c.ph==='load'?'load':c.ph==='unload'?'unload':c.ph==='done'?'DONE':'');
setT(cv.t[2],j?f2(c.mass/massFor(1))+' mm':'');
setT(cv.t[3],j&&c.ph!=='done'?dur(cellLeft(c)):c.ph==='done'?'waits':'');});
lampSet('pdpu'+(u+1),done?'warn':any?'run':'idle');});
['pasu','pmpb','qc','vsf','vsp','vsc'].forEach(id=>lampSet(id,ST[id].slots[0]?'run':ST[id].q.length?'warn':'idle'));
[['audio','intake'],['master','master'],['cut','cut']].forEach(([id,st])=>lampSet(id,cntStage(st)?'run':'idle'));
lampSet('archive','run');lampSet('rack',rackReady()?'run':'warn');
const anyRun=PRESS.some(running);['optical','finish','pack'].forEach(id=>lampSet(id,anyRun||RECS.length?'run':'idle'));lampSet('ship','run');
lampSet('audioqc',movers.some(mv=>mv.p===PATHS.sample)?'run':'idle');lampSet('punch',S.t<S.punchUntil?'run':'idle');lampSet('gran',S.gran.run?'run':'idle');
lampSet('lab',S.lab.test||S.lab.queue.length?'warn':'run');lampSet('oven',anyRun?'run':'idle');lampSet('carton','run');
lampSet('boiler',S.boiler.p<9.5?'alarm':S.boiler.p<9.65?'warn':'run');lampSet('chiller',S.chiller.sup>24?'warn':'run');
['comp','diro','zld','energy','trace','hvac','exh'].forEach(id=>lampSet(id,'run'));lampSet('hyd',dRun?'run':'idle');
setT(GATES.g1.c,cntStage('gate1')+' waiting');setT(GATES.g2.c,cntStage('gate2')+' waiting');setT(GATES.g3.c,TITLES.filter(T=>T.tp==='sent').length+' waiting');
setT($('kClock'),clockStr());setT($('kShift'),shiftOn()?(hourOf()<14?'1 (06-14)':'2 (14-22)'):'night');
setT($('kPress'),PRESS.filter(running).length+'/'+PRESS.length);
setT($('kRate'),S.cyc.length);setT($('kGood'),S.day.good);setT($('kRej'),S.day.rej);setT($('kYield'),yieldStr());
setT($('kGalv'),galvCount());setT($('kRack'),rackReady());
setT($('kSteam'),f2(S.boiler.p)+' bar');setS($('kSteam'),'color',S.boiler.p<9.5?'#ff3b3b':'');
setT($('kLoad'),f0(S.energy.load)+' kW');setT($('kPV'),f0(S.energy.pv)+' kW');
}
/* ================================================================ log */
const logEl=$('log');
function log(msg,lvl){S.events++;if(S.quiet)return;
const d=document.createElement('div');d.className=lvl||'info';
d.innerHTML='<span class="t">'+clockStr()+'</span> '+msg;logEl.prepend(d);while(logEl.children.length>80)logEl.lastChild.remove();}
/* ================================================================ details */
const DETAILS={
audio:{t:'1 · Audio intake',w:'Files arrive from the label or artist; checksums and format are verified and a technical QA pass checks levels, clipping and side length against the format. Nothing is cut until the artist confirms the master plan (gate 1, 2 to 10 days on the customer side).',c:['Checksums and format','Technical QA: levels, clipping, side length','Artist gate 1: master plan approved'],f:['1 to 2 days (production timeline)','A new title every 0.75 to 1.15 days here, ~380 a year, between the revenue model\'s 300 (central) and 400 (favourable) titles for the launch plant; in 90-day test runs the presses run ~64 to 73 % of shift time, about scale.md\'s 60 to 70 % effectiveness (estimate)','Runs of 300 to 10,000, ~3,100 records per title on average (revenue model: 2,500 to 3,300)']},
master:{t:'2 · Vinyl mastering',w:'The audio is mastered for the physical limits of the groove. The artist approves the vinyl master (gate 2, 2 to 10 days) before anything is cut.',c:['Engineer QA of the vinyl master','Artist gate 2: master approved'],f:['2 to 4 days in-house (production timeline)','Mastering room ~40 to 60 m²','Mastering and lathe cutting are the planned Payap student-programme subjects (payap-partnership.md)']},
cut:{t:'3 · Lathe cutting',w:'The master is cut into a nitrocellulose lacquer per side: grooves 40 to 80 µm wide, ~28 µm deep. The dead-wax matrix code and an angular fiducial are scribed cold (never with a laser: the lacquer is flammable), and the scribe carries through every metal generation onto every record. Both lacquers go to silvering together and must be plated within roughly 24 to 72 hours.',c:['Groove microscope','Test cut','Dead-wax scribe and fiducial'],f:['1 to 2 days','Sillitoe SDMS or Agnew lathe (vendor landscape)']},
pasu:{t:'4a · PASU automatic silvering (Pheenix Alpha)',w:'The lacquer is cleaned on the bench station, sensitised, then turned at 45 rpm under four nozzles that spray the silver in an automatic sequence, making the groove conductive for the nickel. Silver is the first metal to touch the groove.',c:['Silvering chemistry logged per batch','Lacquer inspected before silvering'],f:['~15 min per lacquer (estimate; no published figure)','W 2188 × D 715 × H 1835 mm, single-phase 10 A, air 6 bar, exhaust Ø125 mm at 150 m³/h (2024 flyer)']},
pppu:{t:'4b · PPPU pre-plating (Pheenix Alpha)',w:'A thin first nickel layer over the silver at low current, before production plating. Up to four masters at once, each position with its own control and cathode movement, PID temperature control.',c:['Bath temperature PID-controlled','Current and time logged per position'],f:['15 to 30 min at 10 to 20 A in published practice; 20 min at 20 A and 35 °C here (galvanics.md)','Up to 15 VDC and 30 A per position, 385 L tank, 4.5 kW heater (2024 flyer)']},
pdpu:{t:'4c · PDPU dual plating unit (Pheenix Alpha)',w:'Production electroforming: father on the pre-plated lacquer, mother on the father, stampers on the mother. Each cell has its own rotating cathode and digitally controlled rectifier; the current ramps up and the part grows to its thickness. The simulation deposits nickel by Faraday\'s law (1.095 g per ampere-hour at ~97 % efficiency, ~9.6 dm² for a Ø350 mm part), so the times follow from the current: father 0.25 mm in ~1 h 15 min on a slow ramp to 185 A, mother 0.25 mm in ~1 h and stamper 0.20 mm in ~50 min at 200 A. Loading takes 5 min, rinsing and unloading 10 min per part. A part that finishes after the shift waits in the cell until morning.',c:['Current, voltage and ampere-hours per cell (PLC with network access)','Bath temperature, filters, nickel top-up and anode basket alerts','Extraction interlock: no extraction, no plating'],f:['Up to 15 VDC and 200 A per cell, 40 L per cell, 385 L tank, parts up to Ø355 mm, cooling water 12 to 20 °C at 10 L/min (2024 flyer)','Published practice: father ~1 h 15 min, mother 1 to 1¼ h, stamper ~45 min (galvanics.md)','The launch plant has one PDPU (total-investment.md); it covers full-capacity demand of 20 to 32 parts a day, a second would buy redundancy rather than capacity (galvanics.md)','Bath at 55 °C on screen is an estimate']},
pmpb:{t:'4d · PMPB preparation bench (Pheenix Alpha)',w:'Between generations the new part is separated, cleaned, passivated anodically so the next generation will part from it, and spun dry. The lacquer is consumed when the father comes off; in 3-step plating the father, and later the mother, go to the archive.',c:['Passivation logged per part','Rinse water to treatment, never the drain'],f:['~10 to 15 min per part (estimate)','Spin table up to 120 rpm, DI water G3/4", single-phase 10 A (2024 flyer)']},
qc:{t:'4e · Part QC: XRF and mother listening',w:'Every father, mother and stamper gets an XRF thickness reading against its serial; every mother is played before stampers are grown from it, since the mother is the playable positive.',c:['XRF nickel thickness per part, 0.2 to 0.3 mm','Mother listening check'],f:['~3 min per XRF reading, ~25 min for a mother with listening (estimates)']},
vsf:{t:'5a · VSF back-finish (Sibert)',w:'The stamper back is wet-sanded through a sequence of abrasive grades and cleaned, so it seats evenly on the mould and no back-side defect presses through. First of the three Sibert steps: finish, punch, coin (the order Gotta Groove runs its Sibert machines in).',c:['Back visual check','Closed water loop with filtration; the nickel-bearing water goes to treatment'],f:['~5 to 10 min (estimate; Sibert publishes none), 8 min here','P120 to P2500 in nine grades, 4-station disc head, 150 to 1500 rpm, 1200 VA, 250 kg (2022 spec)']},
vsp:{t:'5b · VSP centre punch (Sibert)',w:'After a pneumatic pre-centring the operator centres the lead-out groove, the one true circle on the stamper, under ×100 optics; then the punch cuts the centre hole. An off-centre hole makes every record wow, so concentricity is the critical stamper check, logged per stamper.',c:['Concentricity of hole to groove per stamper','Hardness on the punched centre slug (quality-automation.md)','Punch-and-die shot count against the service interval'],f:['~3 to 5 min (estimate), 4 min here','Ø25.4 mm punch standard, ±0.002 mm, turntable ±0.0015 mm, 250 VA, 200 kg (2023 spec)']},
vsc:{t:'5c · VSC coiner (Sibert)',w:'Forms the centre hole (pneumatic) and the edge (hydraulic) and punches the outside diameter to a preset size for the press mould, fully automatically, so no trimming is needed. It locates on the punched hole, which is why it comes last.',c:['Fit against the mould type'],f:['~1 to 2 min (estimate), 2 min here','7", 10" and 12" tooling carriages, up to three stations, 1 kVA, 1000 kg, about 2.0 × 0.8 × 1.6 m (2022 spec)','Tooling per mould type is a quote line; USD 250k budget for the three Sibert machines confirmed']},
archive:{t:'Father and mother archive',w:'3-step plating keeps the father and the mother of every side. A repress, or a stamper set that wears out before its run is done, pulls a fresh stamper from the archived mother: no lacquer, no artist gates. Press Repress to watch that path.',c:['Storage conditions and genealogy per the traceability spec','Retention policy to be stated in the customer contract'],f:['Repress ~7 to 11 working days door to door (production timeline)']},
rack:{t:'Stamper rack',w:'Press-ready stamper sets (A and B side). A title gets one set per ~1,500 records, since stampers last 1,000 to 2,000 pressings; a set that wears out early triggers a fresh one from the mothers. A new title\'s first set goes to a press for 10 test pressings, then waits here while the artist listens (gate 3), and the press runs other approved titles meanwhile.',c:['Set identity and pressing count travel with the stampers'],f:['Test pressings with the artist 5 to 15 days at gate 3 (production timeline)']},
silo_v:{t:'Virgin black compound',w:'Record-grade compound, classically a vinyl chloride / vinyl acetate copolymer. A lot reaches the silo only after the material lab releases it, and its ID attaches to every record pressed from it. A new lot is ordered when the silo falls below a quarter.',c:['Certificate of analysis per lot','Retained sample per lot'],f:['Qualifying Thai and regional compound is an explicit goal']},
silo_r:{t:'Regrind',w:'Edge trim and punched rejects, ground in-house, feed back as up to 20 % of the black mix. It is a material source with its own gate.',c:['No labels or paper in the grinder','Contamination check per regrind batch','Ratio verified at the extruder'],f:['Edge trim 20 to 40 g per 12" 180 g record (Granulator 400 flyer)','80 % new, 20 % recycled (Newbilt process schematic)']},
silo_c:{t:'Colour compound',w:'Pre-coloured compound for colour titles and the base colour of splatter; the EX 35 adds the second colour as strands. Colour runs do not take black regrind.',c:['Colour ΔE per lot against the reference'],f:['Masterbatch dosed into a local base compound is the alternative route (Making Vinyl briefing)']},
lab:{t:'M · Material lab gate',w:'Every incoming lot is quarantined and tested before release; production keeps running on released stock meanwhile. New lots and new suppliers are released only after a release pressing passes optical and audio QC.',c:['Moisture (halogen analyser), heat stability, density, colour','Release pressing'],f:['~2 h from start of tests to release here (estimate)','Instruments and costs: quality-automation.md']},
oven:{t:'L · Label dry oven',w:'Centre labels are pressed into the record, not glued on. Moisture in the paper causes bubbles, so labels are pre-dried before they reach a press, and the label lot is logged per press cycle.',c:['Print inspection per label lot','Label identity and centring checked by the optical inspection'],f:['France Etuves XU 112 class oven (Newbilt quote)']},
duplex:{t:'6 · Duplex: HRP 12 presses A and B (Newbilt)',w:'Two semi-automatic presses on one VEX 50 extruder and one hydraulic unit. Each cycle the operator places the bottom label, the cake and the top label; the press closes, steam at 10 bar heats the mould and ~150 t flows the vinyl into the grooves, cooling water sets it, it opens and ejects. Newbilt recommends the Duplex for splatter, so colour and splatter titles come here first; splatter runs the EX 35.',c:['Per cycle: temperatures, pressures, cycle time, cake weight, label lot','10 test pressings for every new title, then gate 3','Record key: stamper + press + cycle timestamp + side'],f:['~30 s average cycle (Newbilt website); here LOAD 4 to 6.5 (by hand), CLOSE 2, STEAM 8, COOL 10, OPEN 2, EJECT 3 s (split is an estimate)','scale.md assumes only 60 to 80 records/h per semi-automatic press with operator handling (estimate); Newbilt\'s 30 s is 120/h','HRP 12: four columns, Ø330 mm cylinder, ~150 t (2024 quote)','Steam 150 to 180 kg/h, cooling up to 100 l/min and 125 kW per press (site requirements)','Stamper change ~20 min, new title ~40 min (estimates)','Unplanned stops about one per 4 running hours, 15 to 45 min each (estimate, sized so overall effectiveness lands near scale.md\'s 60 to 70 %)']},
classic:{t:'6 · Classic automatic press (Newbilt)',w:'Fully automatic, on a 3-column frame derived from the Toolex Alpha: built-in extruder, automatic cake and label loading, cooling-plate handling and spindle stacking. Cooling can end by time or by delivered water volume, and the target volume rises as the cooling water warms, so the cycle stretches when the chiller supply runs above design (modelled at 5 % per °C over 22 °C). Black and solid colour titles; Newbilt sends splatter to the Duplex.',c:['Steam temperature, cooling water temperature and flow monitored','Cycle and trend recording; SCADA interface over OPC UA'],f:['28 s even at 180 g, claimed ≥95 % yield and ≥95 % uptime (2024 flyer); here LOAD 3, CLOSE 2, STEAM 7, COOL 11, OPEN 2, EJECT 3 s','12" at 110 to 180 g, 7" in development, inserts shared with the Duplex (2026 flyer)','Built-in extruder: three zones, 7.5 kW motor (2024 flyer)','Unplanned stops as for the Duplex (estimate)']},
vex:{t:'6a · VEX 50 extruder (Newbilt)',w:'Feeds both Duplex presses: granulate from the hopper melts through three heated zones and leaves as a cake 20 to 40 g heavier than the record, the surplus becoming edge trim. The two presses draw cakes alternately.',c:['Zone temperatures and screw speed logged','Cake weight per cycle','Regrind ratio verified here'],f:['50 mm screw, three zones, 7.5 kW motor, 45° mounting (2024 quote)','15 kVA connected, 8 kW average (site requirements)']},
ex35:{t:'6b · EX 35 auxiliary extruder (Newbilt)',w:'Runs beside the VEX 50 and adds a second or third colour as PVC strands for splatter, marble and other effects. Active here whenever a Duplex press runs a splatter title.',c:['Colour lot logged per cycle'],f:['35 mm screw, three zones, two fans, frequency-controlled screw speed (2026 flyer)','Not in the 2024 quote']},
trim:{t:'7a · Edge trimmers (Newbilt)',w:'A heated blade trims each record to its final diameter straight after the press. The trim drops into bins that go to the granulator in batches.',c:['Trim quality on every record'],f:['~6 s per record (estimate; no published figure)','Heated blade, 7"/10"/12" plates, standalone or in the Duplex emergency-stop circuit (2026 flyer)']},
audioqc:{t:'QC layer 3 · Automated audio comparison',w:'A needle-drop station plays every test pressing and about one record in fifty and compares it with the source master: spectral envelope, click count, noise floor, channel balance.',c:['Numeric score and pass/fail to traceability'],f:['Only this layer catches a wrong master, EQ or pitch problems','100 % audio QC is impossible: one press outruns 80 to 95 listening stations']},
punch:{t:'Label punch (Newbilt)',w:'Rejected records lose their label area here so the remaining PVC can be ground without paper.',c:['No label paper into the grinder'],f:['106 mm punch, 7"/10"/12", manual loading then automatic transport and collection (2026 flyer)']},
gran:{t:'Granulator 400 (Newbilt)',w:'Edge trim and punched rejects are ground to regrind in batches, since the plant makes far less scrap per hour than the granulator takes.',c:['Contamination check per batch','Outlet magnet'],f:['200 to 250 kg/h, 7.5 kW, 12 knives, 8 mm screen, cyclone with ~100 kg storage, 800 kg (2026 flyer)','Batches from 12 kg here (estimate)']},
carton:{t:'Cartons and sleeves from local partners',w:'Jackets and sleeves come from local carton partners (Ploy Printing as the intended partner); every delivery passes an incoming gate before assembly.',c:['Dimensions, print ΔE against the proof, seam strength, inner sleeve scuff'],f:['10 to 20 working days from approved artwork']},
optical:{t:'QC layer 1 · 100 % optical inspection',w:'Machine vision inspects every record at line speed: non-fill, stitching, flow lines, warp, off-centre hole, debris, label errors. Rejects go to the label punch and the granulator; test pressings and samples go to the audio station.',c:['Every record, result to traceability'],f:['Custom Keyence/Cognex-class integration','~3.5 % rejects simulated']},
finish:{t:'7b · Finishing',w:'Inner sleeve and, once assembled, the shrink wrapper. The labels are already on: they were pressed in.',c:['Inner sleeve scuff check per lot','Shrink wrap sampled'],f:['2 to 3 days per order (production timeline)']},
pack:{t:'8 · Packaging and assembly',w:'The runout matrix code is scanned against the jacket barcode, so the wrong record in the right jacket is caught every time.',c:['Matrix code vs jacket barcode on every unit','Barcode verification'],f:['Packaging and finished goods ~300 to 500 m²']},
ship:{t:'9 · Fulfillment',w:'Bulk pallets, or per-parcel dropship for the label; carrier tracking joins the traceability record. Test pressings leave here for the artists.',c:['Consignment vs bill of materials','Tracking per shipment or per end customer'],f:['Quoted lead time 7 to 9 weeks mature, 2 to 3 weeks for a repress']},
boiler:{t:'Steam boilers B-1 and B-2',w:'Saturated steam at 10 bar / 180 °C heats the moulds; each press draws ~1.3 kg per cycle. The launch plant\'s three presses need 450 to 540 kg/h at Newbilt\'s 150 to 180 kg/h per press, on two 500 kg/h boiler modules running lead and lag in a boiler hall built for four (scale.md, steam-cooling.html). Below 9.5 bar the presses hold in STEAM (Steam dip button).',c:['Pressure held between 9.5 and 10.5 bar at the press','Feedwater treatment, condensate return'],f:['Launch plant 450 to 540 kg/h against 1,000 kg/h installed (scale.md)','Fuel decision open: biomass, LPG or electric','Detached boiler hall with a 14 m flue','Second Duplex (growth option, five presses): 750 to 900 kg/h on the same two modules (steam-cooling.html)']},
chiller:{t:'Process cooling CH-1 and CH-2',w:'Cooling water sets the vinyl after the steam phase. The launch plant\'s three presses peak at ~390 kW on a hot afternoon (391.5 kW: up to 125 kW per press, 3 kW per extruder, 2.5 kW per press for hydraulics; scale.md), on two 250 kW air-cooled chiller modules, CH-1 and CH-2, 500 kW in a compound built for four (steam-cooling.html). If the supply water runs warm, the Classic stretches its cooling and its cycle. The galvanics plating unit takes a small branch at 12 to 20 °C.',c:['Supply 20 to 25 °C, closed loop','Up to 100 l/min per press, ~53 l/min average (site requirements)'],f:['~80 to 125 kW electrical per 250 kW of cooling: ~125 to 196 kW at the launch plant\'s peak (scale.md, estimate)','Second Duplex (growth option, five presses): ~650 kW on 3 × 250 kW, CH-3 in its reserved position (scale.md)','PDPU coil: 10 L/min at 12 to 20 °C (2024 flyer)']},
comp:{t:'Compressed air',w:'Dry, filtered air for the press and extruder actuators, the Sibert machines and the silvering unit.',c:['Dew point max 3 °C, impurities max 5 µm'],f:['14 m³/h for a Duplex system (site requirements)','Sibert: 6 to 8 bar, 3.1 to 4.5 L/s per machine in use; PASU 6 bar']},
hyd:{t:'Hydraulic aggregate (Duplex)',w:'One aggregate supplies ~150 t to both Duplex presses at 180 bar from a gas-loaded accumulator. A larger central supply shared by several systems is possible (Duplex flyer); whether the Classic runs from it is a re-quote question.',c:['Oil temperature and pressure logged'],f:['200 l HLP 46, 15 kVA, 12 kW average, 600 kg (site requirements)']},
diro:{t:'RO / DI water',w:'Plating rinses need deionised water: the PMPB bench has a DI connection and the silvering chemistry is made up with it.',c:['Conductivity logged'],f:['The RO skid in the utilities CSV is an option row sized for boiler feed, not for this (galvanics.md)','0.2 m³/h make-up here (estimate)']},
zld:{t:'Water treatment, zero discharge',w:'Galvanics rinse water and the VSF\'s nickel-bearing loop water go through closed-loop treatment and metal recovery with an evaporator; nothing goes to the drain. Neither Pheenix nor Digital Matrix supplies this; it is a separate vendor.',c:['Rinse conductivity, evaporator state'],f:['Zero contaminated water commitment (sustainability.md)','USD 90k to 240k (total investment)']},
energy:{t:'Energy',w:'247 kWp of roof PV, a 271 kWh battery for ride-through, and a medium-voltage PEA connection. Solar follows the clock; the battery covers the evening.',c:['Plant peak ~430 to 700 kW without an electric boiler (scale.md, sized for the five-press layout the backbone is built for)'],f:['Galvanics ~27 kVA connected per line, far less drawn (galvanics.md)','PV covers roughly 28 to 45 % of consumption without electric steam']},
trace:{t:'Traceability',w:'Every step logs at the source: press and rectifier PLCs, the lab, the XRF, the vision station; manual steps check in. Append-only, kept for the life of the customer relationship.',c:['Record key: stamper + press + cycle timestamp + side','Every part knows its parents: stamper, mother, father, lacquer'],f:['The part of the plan the industry responded to at Making Vinyl Europe 2026']},
hvac:{t:'Plant HVAC',w:'The press hall at 20 to 26 °C and 35 to 60 % RH; galvanics, archive and audio rooms in their own zones, logged on the production timeline.',c:['T, RH and PM per zone'],f:['~100 to 200 kW peak (estimate)']},
exh:{t:'Galvanics extraction',w:'Every tank unit is extracted (Ø63 mm per plating unit, Ø125 mm at 150 m³/h on the silvering unit) through a scrubber. The interlock is hard-wired: no extraction, no plating.',c:['Fan status logged, interlock hard-wired'],f:['Facility security: fume-extraction interlocks']},
};
let selected=null;
function select(id,quiet){if(!quiet&&COMPACT.matches)showPanel('detail',true);
if(selected)selected.g.classList.remove('sel');selected=M[id];selected.g.classList.add('sel');
const d=DETAILS[M[id].det||id];if(!d)return;const p=$('detail');p.innerHTML='';
const h=document.createElement('h2');h.textContent=d.t;p.appendChild(h);
const w=document.createElement('p');w.textContent=d.w;p.appendChild(w);
[['Checks and gates',d.c],['Figures and sources',d.f]].forEach(([tt,arr])=>{if(!arr)return;const h3=document.createElement('h3');h3.textContent=tt;p.appendChild(h3);
const ul=document.createElement('ul');arr.forEach(x=>{const li=document.createElement('li');li.textContent=x;ul.appendChild(li);});p.appendChild(ul);});}
/* ================================================================ controls */
$('btnPlay').addEventListener('click',()=>{S.running=!S.running;$('btnPlay').textContent=S.running?'Pause':'Play';$('btnPlay').classList.toggle('on',S.running);$('svg').classList.toggle('paused',!S.running);});
document.querySelectorAll('[data-speed]').forEach(b=>b.addEventListener('click',()=>{S.speed=+b.dataset.speed;document.querySelectorAll('[data-speed]').forEach(x=>x.classList.toggle('on',x===b));}));
$('btnSteam').addEventListener('click',()=>{S.boiler.faultUntil=S.t+120;log('Boiler: feedwater pump fault, steam pressure falling (demo)','alarm');});
$('btnLot').addEventListener('click',()=>newLot(S.silo.v/S.silo.vcap<S.silo.c/S.silo.ccap?'v':'c'));
$('btnRepress').addEventListener('click',()=>repress());
document.addEventListener('keydown',e=>{if(e.code==='Space'&&e.target===document.body){e.preventDefault();$('btnPlay').click();}});
/* ================================================================ touch: pan, zoom, panel sheet
Zoom changes only the viewBox, so sim coordinates never change. */
const SV=$('svg'),BASE={w:2100,h:820},ZMAX=5,VB={x:0,y:0,w:BASE.w};
function applyVB(){VB.w=Math.min(BASE.w,Math.max(BASE.w/ZMAX,VB.w));const h=VB.w*BASE.h/BASE.w;
VB.x=Math.min(BASE.w-VB.w,Math.max(0,VB.x));VB.y=Math.min(BASE.h-h,Math.max(0,VB.y));
SV.setAttribute('viewBox',VB.x.toFixed(1)+' '+VB.y.toFixed(1)+' '+VB.w.toFixed(1)+' '+h.toFixed(1));SV.classList.toggle('zoomed',VB.w<BASE.w-0.5);fxDirty=true;}
function svgPt(cx,cy){const m=SV.getScreenCTM().inverse();return {x:m.a*cx+m.c*cy+m.e,y:m.b*cx+m.d*cy+m.f};}
function zoomAt(cx,cy,f){const p=svgPt(cx,cy),nw=Math.min(BASE.w,Math.max(BASE.w/ZMAX,VB.w/f)),r=nw/VB.w;VB.x=p.x-(p.x-VB.x)*r;VB.y=p.y-(p.y-VB.y)*r;VB.w=nw;applyVB();}
function zoomMid(f){const b=SV.getBoundingClientRect();zoomAt(b.left+b.width/2,b.top+b.height/2,f);}
function panBy(dx,dy){const u=1/SV.getScreenCTM().a;VB.x-=dx*u;VB.y-=dy*u;applyVB();}
const PTR=new Map();let travel=0,pinch=null;
function pinchState(){const [a,b]=[...PTR.values()];return {d:Math.hypot(a.x-b.x,a.y-b.y),x:(a.x+b.x)/2,y:(a.y+b.y)/2};}
SV.addEventListener('pointerdown',e=>{if(e.pointerType==='mouse'&&e.button!==0)return;PTR.set(e.pointerId,{x:e.clientX,y:e.clientY});if(PTR.size===1)travel=0;if(PTR.size===2)pinch=pinchState();});
SV.addEventListener('pointermove',e=>{const prev=PTR.get(e.pointerId);if(!prev)return;const cur={x:e.clientX,y:e.clientY};PTR.set(e.pointerId,cur);
// capture only once it is a drag, so a tap still clicks the station
if(PTR.size===1){travel+=Math.abs(cur.x-prev.x)+Math.abs(cur.y-prev.y);if(travel>8){if(!SV.hasPointerCapture(e.pointerId))SV.setPointerCapture(e.pointerId);SV.classList.add('dragging');panBy(cur.x-prev.x,cur.y-prev.y);}}
else if(PTR.size===2&&pinch){travel=99;const n=pinchState();panBy(n.x-pinch.x,n.y-pinch.y);if(pinch.d>0)zoomAt(n.x,n.y,n.d/pinch.d);pinch=n;}});
function ptrUp(e){PTR.delete(e.pointerId);if(PTR.size<2)pinch=null;if(!PTR.size)SV.classList.remove('dragging');}
['pointerup','pointercancel'].forEach(t=>SV.addEventListener(t,ptrUp));
// a drag or pinch must not also select whatever is under the finger
SV.addEventListener('click',e=>{if(travel>8){e.stopPropagation();e.preventDefault();}},true);
SV.addEventListener('wheel',e=>{e.preventDefault();zoomAt(e.clientX,e.clientY,Math.exp(-e.deltaY*(e.deltaMode?0.05:0.0015)));},{passive:false});
$('zIn').addEventListener('click',()=>zoomMid(1.5));$('zOut').addEventListener('click',()=>zoomMid(1/1.5));
$('zFit').addEventListener('click',()=>{VB.x=0;VB.y=0;VB.w=BASE.w;applyVB();});
document.addEventListener('keydown',e=>{if(e.target!==document.body)return;if(e.key==='+'||e.key==='=')zoomMid(1.5);else if(e.key==='-')zoomMid(1/1.5);else if(e.key==='0')$('zFit').click();});
// short screens: Detail / Log / Legend open one at a time as a sheet
function showPanel(name,keepOpen){const open=!!name&&(keepOpen||!(document.body.dataset.panel===name&&document.body.classList.contains('sheet')));
if(open)document.body.dataset.panel=name;document.body.classList.toggle('sheet',open);
document.querySelectorAll('#views button[data-view]').forEach(b=>b.classList.toggle('on',b.dataset.view===document.body.dataset.panel));$('btnInfo').classList.toggle('on',open);}
document.querySelectorAll('#views button[data-view]').forEach(b=>b.addEventListener('click',()=>showPanel(b.dataset.view,true)));
$('sheetClose').addEventListener('click',()=>showPanel(null));
$('btnInfo').addEventListener('click',()=>showPanel(document.body.dataset.panel||'detail'));
const HDR=document.querySelector('header');
new ResizeObserver(()=>document.documentElement.style.setProperty('--hdrH',HDR.offsetHeight+'px')).observe(HDR);
/* ================================================================ effects
Two canvases sized to the SVG box and drawn in viewBox units with the same xMidYMid-meet transform as
the SVG, so pan and zoom need nothing extra. Under the SVG: pipes, flow dashes and the conveyor (under
the machines, as before). Over it: tokens, records, extruder screw, plating bubbles. Nothing here
touches the DOM per frame, which is what made the page lag (forced layouts of the whole SVG). */
const CU=$('fxUnder'),CO=$('fxOver'),XU=CU.getContext('2d'),XO=CO.getContext('2d'),BOX={w:1,h:1,d:1};
let fxT=0,screwPh=0,COL={};
function fxColors(){const de=document.documentElement,cs=getComputedStyle(de),lt=de.getAttribute('data-theme')==='light';
COL={plat:lt?{b:[199,209,223],h:[224,112,79],c:[111,163,216],s:'#8e9db4'}:{b:[42,58,86],h:[179,64,44],c:[44,91,143],s:'#4a5f85'},pipeA:lt?0.32:0.28,conv:lt?'#dfe5ee':'#1c2740',convd:lt?'#b5c1d4':'#2e4066',screw:lt?'#7d8aa0':'#6d7d99',bub:lt?'#3f97cc':'#8fd3ff'};
['steam','cond','cw','cwr','hyd','chw','di','mat','reg','col','metal','scrap','route','lbl'].forEach(k=>{COL[k]=cs.getPropertyValue('--p-'+k).trim();});fxDirty=true;}
function fxResize(){const mr=SV.parentNode.getBoundingClientRect(),sr=SV.getBoundingClientRect();
BOX.w=Math.max(1,sr.width);BOX.h=Math.max(1,sr.height);BOX.d=Math.min(2,window.devicePixelRatio||1);
for(const c of [CU,CO]){c.style.left=(sr.left-mr.left)+'px';c.style.top=(sr.top-mr.top)+'px';c.style.width=BOX.w+'px';c.style.height=BOX.h+'px';
c.width=Math.round(BOX.w*BOX.d);c.height=Math.round(BOX.h*BOX.d);}fxDirty=true;}
function fxBegin(x){const vh=VB.w*BASE.h/BASE.w,k=Math.min(BOX.w/VB.w,BOX.h/vh),d=BOX.d;
x.setTransform(1,0,0,1,0,0);x.clearRect(0,0,x.canvas.width,x.canvas.height);
x.setTransform(d*k,0,0,d*k,d*((BOX.w-VB.w*k)/2-VB.x*k),d*((BOX.h-vh*k)/2-VB.y*k));}
function circ(x,cx,cy,r,fill,stroke,sw){x.beginPath();x.arc(cx,cy,r,0,6.2832);if(fill){x.fillStyle=fill;x.fill();}if(stroke){x.lineWidth=sw||1;x.strokeStyle=stroke;x.stroke();}}
const SHAPES={L:[[7,'#1a1408','#c9a24a'],[2.4,'#c9a24a']],Ls:[[7,'#d9dee6','#fff'],[2.4,'#8a97b0']],
S:[[7,'#b9c2cc','#e6ecf7'],[4.5,null,'#7d8794'],[1.6,'#3a4658']],M:[[7,'#8e98a6','#c8d2e4'],[2.4,'#c8d2e4']]};
function drawFx(dt){fxT+=dt;screwPh+=dt*(S.vex.rpm>1?17.8:0);
const u=XU;fxBegin(u);u.lineJoin='round';
for(const f of FX.flows){
if(f.k==='pipe'){u.globalAlpha=COL.pipeA;u.strokeStyle=COL[f.c];u.lineWidth=5;u.lineCap='round';u.setLineDash([]);}
else if(f.k==='conv'){u.globalAlpha=1;u.strokeStyle=COL.conv;u.lineWidth=14;u.lineCap='butt';u.setLineDash([]);}
else if(f.k==='convd'){u.globalAlpha=1;u.strokeStyle=COL.convd;u.lineWidth=14;u.lineCap='butt';u.setLineDash([3,9]);u.lineDashOffset=-(fxT*10%12);}
else{u.globalAlpha=1;u.strokeStyle=COL[f.c];u.lineCap='round';
if(f.thin){u.lineWidth=1.4;u.setLineDash([3,7]);u.lineDashOffset=-(fxT*13.3%10);}else{u.lineWidth=2.2;u.setLineDash([7,9]);u.lineDashOffset=-(fxT*13.3%16);}}
u.stroke(f.p2);}
u.globalAlpha=1;
const o=XO;fxBegin(o);o.setLineDash([6,6]);o.lineDashOffset=-(screwPh%12);o.lineWidth=2;o.strokeStyle=COL.screw;o.lineCap='butt';
for(const w of FX.screws){o.beginPath();o.moveTo(w.x1,w.y);o.lineTo(w.x2,w.y);o.stroke();}
o.setLineDash([]);o.globalAlpha=0.7;
for(const b of FX.bubbles){const c=CELLS[b.cell];if(!c||c.ph!=='plate')continue;const k=(((fxT-b.d)%2.2)+2.2)%2.2/2.2;circ(o,b.x,b.y-30*k,1.6,COL.bub);}
o.globalAlpha=1;
// presses: platen colour eases to steam red / cooling blue like the old 0.5 s CSS transition
for(const P of PRESS){const m=M[P.id],q=m.plat;if(!q)continue;const ty=m.pY[0]+(m.pY[1]-m.pY[0])*(1-P.open);
const h=P.__h||0,c=P.__c||0,C=COL.plat,rgb=[0,1,2].map(i=>Math.round(C.b[i]*(1-h-c)+C.h[i]*h+C.c[i]*c));
o.fillStyle='rgb('+rgb+')';o.strokeStyle=C.s;o.lineWidth=1;
o.fillRect(q.px,q.yb,q.pw,16);o.strokeRect(q.px,q.yb,q.pw,16);o.fillRect(q.px,ty,q.pw,16);o.strokeRect(q.px,ty,q.pw,16);
o.fillStyle='#b9c2cc';o.fillRect(q.px+8,ty+16,q.pw-16,3);if(P.cake)circ(o,q.cx,q.cy,7,'#3d3a33','#8a7a55',1);}
let j=0;for(const t of SPR)if(!t.dead)SPR[j++]=t;SPR.length=j;
for(const t of SPR){
if(t.kind==='dot')circ(o,t.x,t.y,t.r,t.fill,t.stroke,t.sw);
else if(t.kind==='rect'){o.fillStyle=t.fill;o.fillRect(t.x-t.w/2,t.y-t.h/2,t.w,t.h);o.lineWidth=1;o.strokeStyle=t.stroke;o.strokeRect(t.x-t.w/2,t.y-t.h/2,t.w,t.h);}
else if(t.kind==='rec'){const st=t.cls.rej?['#ff3b3b',2]:t.cls.tp?['#ffbf1f',1.5]:['#3a3f4a',1];circ(o,t.x,t.y,t.r,t.vinyl,st[0],st[1]);circ(o,t.x,t.y,t.lr,t.label);}
else for(const [r,fl,st] of SHAPES[t.k]||SHAPES.M)circ(o,t.x,t.y,r,fl,st,1);}}
new ResizeObserver(fxResize).observe(SV);
new MutationObserver(fxColors).observe(document.documentElement,{attributes:true,attributeFilter:['data-theme']});
fxResize();fxColors();
/* ================================================================ seed, then two days of warm-up so the page opens mid-shift */
function seedSets(T,used){T.made={A:T.setsTarget,B:T.setsTarget};T.pairs=T.setsTarget;T.mothers={A:{T,side:'A',gen:'M'},B:{T,side:'B',gen:'M'}};
for(let k=1;k<=T.setsTarget;k++)RACK.push({T,k,used:k===1?used:0});}
function seed(){
for(let i=0;i<6;i++){const T=mkTitle({qty:pick([3000,5000,5000,10000]),approved:true,tp:'ok',stage:'press',kind:i<2?pick(['colour','splatter']):'black'});seedSets(T,TP_N);}
for(let i=0;i<12;i++){const T=mkTitle({tp:'sent',stage:'press'});T.g3due=Math.random()*12*DAY;seedSets(T,TP_N);}
for(let i=0;i<2;i++){const T=mkTitle({stage:'press'});seedSets(T,0);}
for(let i=0;i<3;i++){const T=mkTitle({stage:'galv'});startGalv(T);}
[['cut',2],['gate2',8],['master',4],['gate1',8],['intake',2]].forEach(([st,n])=>{const u=UP[UPI[st]];for(let i=0;i<n;i++){const T=mkTitle({stage:st});T.tEnd=Math.random()*rnd(u[1],u[2])*DAY;}});
S.nextArrival=rnd(0.1,0.6)*DAY;}
seed();
// Two days of warm-up, then run on to a mid-shift moment with work on the metal path
// and the presses (at ~25 % plating load an arbitrary 08:00 often shows idle cells).
const busy=()=>{const h=hourOf();return h>=7&&h<19&&CELLS.some(c=>c.ph==='plate')&&galvCount()>=3&&PRESS.filter(running).length>=2;};
const t0=performance.now();while(S.t<2*DAY)tick(2);
while(S.t<6*DAY&&!busy())tick(2);
for(let i=0;i<480;i++)tick(0.25); // last two minutes at the 1x step
S.quiet=false;
log('Launch plant, two shifts 06:00 to 22:00: Pheenix line, Sibert prep and three Newbilt presses at real duration (warm-up '+f0(performance.now()-t0)+' ms)','ok');
log('Material lab: lot '+S.lab.lot+' released, moisture 0.03 %, heat stability OK','ok');
select('pC1',true);renderTags();
let last=performance.now(),tagAcc=0;
function frame(now){const dt=Math.min(0.1,(now-last)/1000);last=now;
if(S.running){const ds=dt*S.speed;const sub=S.speed>=3600?1:S.speed>=600?0.5:0.25;const n=Math.ceil(ds/sub);const h=ds/n;for(let i=0;i<n;i++)tick(h);}
animate(S.running?dt:0);tagAcc+=dt;if(tagAcc>=0.2){tagAcc=0;renderTags();}
if(S.running||fxDirty){fxDirty=false;drawFx(S.running?dt:0);}
requestAnimationFrame(frame);}
requestAnimationFrame(frame);
window.__plant={S,TITLES,RACK,PRESS,CELLS,ST,PLATEQ,M,tick,SPR,FX,PATHS,XU,XO};
})();
(function(){
var b=document.getElementById("themeBtn"),de=document.documentElement;
function set(t,save){de.setAttribute("data-theme",t);if(save){try{localStorage.setItem("cnx-theme",t)}catch(e){}}
b.textContent=t==="light"?"☾":"☀";b.title="Switch to "+(t==="light"?"dark":"light")+" mode";}
set(de.getAttribute("data-theme")||"dark",false);
b.addEventListener("click",function(){set(de.getAttribute("data-theme")==="light"?"dark":"light",true);b.blur();});
var mq=window.matchMedia&&matchMedia("(prefers-color-scheme: light)");
if(mq&&mq.addEventListener)mq.addEventListener("change",function(e){var s=null;try{s=localStorage.getItem("cnx-theme")}catch(_){}if(!s)set(e.matches?"light":"dark",false);});
})();
</script>
</body>
</html>