Deploy test site / deploy (push) Successful in 51s
Three presses for now (D1-A, D1-B on one Newbilt Duplex system, C1 Classic) instead of the plan's two Duplex systems. Order arrivals scaled to the smaller fleet (about 3,000 to 4,600 good records a day); press row fills the width; landing page text updated.
1097 lines
108 KiB
HTML
1097 lines
108 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="robots" content="noindex, nofollow">
|
||
<meta name="description" content="CNX Vinyl factory console and customer order status: a simulated plant day with live press counters, optical and audio inspection, lab and stamper results. Investor demo with simulated data.">
|
||
<title>Factory console and order status · CNX Vinyl (demo)</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: #0c0c0f; --bg2: #111116; --card: #16161c; --card2: #1e1e26; --line: #25252e; --stroke: #363642;
|
||
--ink: #f3f0e8; --mute: #a3a0ad; --faint: #6f6c79;
|
||
--acc: #ff6b3d; --acc-s: rgba(255,107,61,0.13); --onacc: #1d0c04;
|
||
--ok: #3ecf8e; --ok-s: rgba(62,207,142,0.12); --warn: #f5b941; --warn-s: rgba(245,185,65,0.12); --bad: #f2706b; --bad-s: rgba(242,112,107,0.13);
|
||
--inv: #96a2ff; --inv-s: rgba(150,162,255,0.12); --metal: #c39bff; --metal-s: rgba(195,155,255,0.12);
|
||
--glass: rgba(12,12,15,0.78); --shadow: 0 18px 50px rgba(0,0,0,0.45);
|
||
--mono: ui-monospace, "Cascadia Mono", Consolas, "SF Mono", monospace;
|
||
color-scheme: dark;
|
||
}
|
||
html[data-theme=light] {
|
||
--bg: #f6f4ef; --bg2: #efebe3; --card: #ffffff; --card2: #f4f1ea; --line: #e8e3d9; --stroke: #d8d2c5;
|
||
--ink: #16151a; --mute: #65616d; --faint: #9a96a1;
|
||
--acc: #e4521f; --acc-s: rgba(228,82,31,0.09); --onacc: #ffffff;
|
||
--ok: #0f9468; --ok-s: rgba(15,148,104,0.09); --warn: #b5770f; --warn-s: rgba(181,119,15,0.1); --bad: #d0453f; --bad-s: rgba(208,69,63,0.09);
|
||
--inv: #4a55c8; --inv-s: rgba(74,85,200,0.08); --metal: #7c4fc4; --metal-s: rgba(124,79,196,0.08);
|
||
--glass: rgba(246,244,239,0.85); --shadow: 0 18px 44px rgba(60,40,10,0.1);
|
||
color-scheme: light;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
[hidden] { display: none !important; }
|
||
body { margin: 0; background: var(--bg); color: var(--ink); font: 14.5px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; -webkit-font-smoothing: antialiased; }
|
||
button { font: inherit; color: inherit; }
|
||
a { color: var(--acc); text-decoration: none; }
|
||
a:hover { text-decoration: underline; }
|
||
.mute { color: var(--mute); }
|
||
.faint { color: var(--faint); }
|
||
.mono, .num { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||
.num { text-align: right; white-space: nowrap; }
|
||
small { font-size: 12px; }
|
||
h1 { font-size: 28px; line-height: 1.15; letter-spacing: -0.02em; margin: 4px 0 0; font-weight: 740; }
|
||
h2 { font-size: 15px; margin: 0; font-weight: 680; }
|
||
h3 { font-size: 13.5px; margin: 0; font-weight: 680; }
|
||
.eyebrow { font-size: 11.5px; letter-spacing: 0.13em; text-transform: uppercase; color: var(--acc); font-weight: 700; }
|
||
|
||
/* top bar */
|
||
.top { position: sticky; top: 0; z-index: 40; background: var(--glass); backdrop-filter: saturate(1.4) blur(14px); -webkit-backdrop-filter: saturate(1.4) blur(14px); border-bottom: 1px solid var(--line); }
|
||
.top .in { max-width: 1500px; margin: 0 auto; padding: 9px 20px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; }
|
||
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15.5px; white-space: nowrap; }
|
||
.brand svg { width: 26px; height: 26px; flex: none; }
|
||
.pill { font-size: 10.5px; font-weight: 700; color: var(--mute); border: 1px solid var(--stroke); border-radius: 99px; padding: 1px 8px; letter-spacing: 0.08em; text-transform: uppercase; }
|
||
.vsw { display: flex; background: var(--card2); border: 1px solid var(--line); border-radius: 99px; padding: 3px; }
|
||
.vsw button { border: 0; background: transparent; padding: 5px 14px; border-radius: 99px; cursor: pointer; color: var(--mute); font-weight: 650; font-size: 13px; white-space: nowrap; }
|
||
.vsw button.on { background: var(--card); color: var(--ink); box-shadow: 0 1px 4px rgba(0,0,0,0.22); }
|
||
.ctl { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-wrap: wrap; }
|
||
.clock { font-family: var(--mono); font-size: 13px; white-space: nowrap; display: flex; flex-direction: column; line-height: 1.2; text-align: right; }
|
||
.clock small { color: var(--mute); font-family: system-ui, sans-serif; font-size: 11px; }
|
||
.seg { display: inline-flex; border: 1px solid var(--stroke); border-radius: 9px; overflow: hidden; }
|
||
.seg button { border: 0; background: transparent; padding: 4px 9px; font-size: 12.5px; cursor: pointer; color: var(--mute); font-family: var(--mono); }
|
||
.seg button + button { border-left: 1px solid var(--stroke); }
|
||
.seg button.on { background: var(--inv-s); color: var(--ink); font-weight: 700; }
|
||
.ib { width: 32px; height: 32px; border-radius: 50%; border: 1px solid var(--stroke); background: var(--card); cursor: pointer; display: grid; place-items: center; font-size: 13px; flex: none; }
|
||
.ib:hover { border-color: var(--acc); }
|
||
.demobar { font-size: 12px; color: var(--mute); text-align: center; padding: 4px 16px; border-top: 1px solid var(--line); background: var(--bg2); }
|
||
|
||
/* factory frame */
|
||
.fac { max-width: 1500px; margin: 0 auto; display: grid; grid-template-columns: 196px minmax(0, 1fr); gap: 26px; padding: 22px 20px 60px; }
|
||
.side { position: sticky; top: calc(var(--toph, 90px) + 14px); align-self: start; }
|
||
.side nav { display: flex; flex-direction: column; gap: 2px; }
|
||
.side nav a { display: flex; align-items: center; gap: 10px; padding: 8px 11px; border-radius: 9px; color: var(--mute); font-weight: 600; font-size: 14px; text-decoration: none; white-space: nowrap; }
|
||
.side nav a:hover { color: var(--ink); background: var(--card); }
|
||
.side nav a.on { color: var(--ink); background: var(--card2); }
|
||
.side nav a .ic { width: 18px; text-align: center; opacity: 0.85; }
|
||
.side nav a .bd { margin-left: auto; font-size: 11px; font-family: var(--mono); background: var(--card2); border: 1px solid var(--line); border-radius: 99px; padding: 0 7px; color: var(--mute); }
|
||
.side nav a .bd.bad { background: var(--bad-s); color: var(--bad); border-color: transparent; }
|
||
.sidenote { margin-top: 18px; font-size: 12px; color: var(--mute); border-top: 1px solid var(--line); padding-top: 12px; }
|
||
.sidenote b { color: var(--ink); font-weight: 650; }
|
||
main { min-width: 0; }
|
||
.ph { display: flex; align-items: flex-end; justify-content: space-between; gap: 10px 20px; flex-wrap: wrap; margin-bottom: 16px; }
|
||
.ph p { margin: 0; }
|
||
.sh { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; margin: 24px 0 10px; }
|
||
.sh .mute { font-size: 12.5px; }
|
||
|
||
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; min-width: 0; }
|
||
.ch { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
|
||
.ch .mute { font-size: 12px; }
|
||
.ch a { font-size: 12.5px; }
|
||
.cols2 { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 16px; margin-top: 16px; }
|
||
.cols3 { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr)); gap: 16px; }
|
||
.kpis { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(168px, 100%), 1fr)); gap: 12px; }
|
||
.kpi { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 12px 14px; display: flex; flex-direction: column; min-width: 0; }
|
||
.kpi span { font-size: 12px; color: var(--mute); }
|
||
.kpi b { font-size: 25px; font-weight: 720; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; line-height: 1.25; }
|
||
.kpi small { color: var(--faint); }
|
||
.kpi.warn b { color: var(--warn); } .kpi.bad b { color: var(--bad); }
|
||
|
||
.bar { display: block; height: 6px; border-radius: 99px; background: var(--card2); overflow: hidden; }
|
||
.bar i { display: block; height: 100%; background: var(--inv); border-radius: 99px; transition: width .3s; }
|
||
.bar.acc i { background: linear-gradient(90deg, var(--acc), var(--warn)); }
|
||
.bar.ok i { background: var(--ok); } .bar.warn i { background: var(--warn); } .bar.bad i { background: var(--bad); } .bar.metal i { background: var(--metal); }
|
||
|
||
.chip { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 650; border-radius: 99px; padding: 1px 9px; white-space: nowrap; background: var(--card2); color: var(--mute); }
|
||
.chip::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||
.g-pre { background: var(--inv-s); color: var(--inv); }
|
||
.g-gate { background: var(--warn-s); color: var(--warn); }
|
||
.g-metal { background: var(--metal-s); color: var(--metal); }
|
||
.g-queue { background: var(--card2); color: var(--mute); }
|
||
.g-press { background: var(--acc-s); color: var(--acc); }
|
||
.g-out { background: var(--ok-s); color: var(--ok); }
|
||
.g-done { background: var(--card2); color: var(--faint); }
|
||
.ok { color: var(--ok); } .bad { color: var(--bad); } .warn { color: var(--warn); }
|
||
|
||
/* presses */
|
||
.presses { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(262px, 100%), 1fr)); gap: 12px; }
|
||
.pcard { display: flex; flex-direction: column; gap: 9px; background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 12px 14px; color: var(--ink); text-decoration: none; min-width: 0; position: relative; }
|
||
.pcard:hover { border-color: var(--stroke); text-decoration: none; }
|
||
.pc-h { display: flex; align-items: center; gap: 8px; }
|
||
.pc-h b { font-family: var(--mono); font-size: 15px; }
|
||
.pc-h .mute { font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
||
.pst { margin-left: auto; font-size: 11.5px; font-weight: 700; padding: 1px 8px; border-radius: 99px; background: var(--card2); color: var(--mute); white-space: nowrap; }
|
||
.lamp { width: 9px; height: 9px; border-radius: 50%; background: var(--faint); flex: none; }
|
||
.s-run .lamp { background: var(--ok); box-shadow: 0 0 0 3px var(--ok-s); animation: pulse 1.6s ease-in-out infinite; }
|
||
.s-run .pst { background: var(--ok-s); color: var(--ok); }
|
||
.s-change .lamp, .s-warm .lamp { background: var(--warn); }
|
||
.s-change .pst, .s-warm .pst { background: var(--warn-s); color: var(--warn); }
|
||
.s-stop .lamp, .s-nopvc .lamp { background: var(--bad); }
|
||
.s-stop .pst, .s-nopvc .pst { background: var(--bad-s); color: var(--bad); }
|
||
.s-stop, .s-nopvc { border-color: color-mix(in srgb, var(--bad) 45%, var(--line)); }
|
||
@keyframes pulse { 50% { box-shadow: 0 0 0 6px transparent; } }
|
||
.pc-o { display: flex; gap: 10px; align-items: center; min-width: 0; }
|
||
.pc-o > div { min-width: 0; }
|
||
.pc-o b { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.pc-o .mute { font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.pc-n { display: flex; align-items: baseline; gap: 4px; font-variant-numeric: tabular-nums; }
|
||
.pc-n b { font-size: 22px; letter-spacing: -0.02em; }
|
||
.pc-n .r { margin-left: auto; font-size: 12px; color: var(--mute); }
|
||
.pc-m { font-size: 12px; color: var(--mute); display: flex; flex-direction: column; gap: 4px; }
|
||
.pc-t { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 12px; color: var(--mute); font-family: var(--mono); border-top: 1px solid var(--line); padding-top: 8px; min-height: 27px; }
|
||
.pc-empty { font-size: 13px; padding: 14px 0; }
|
||
|
||
/* feed */
|
||
.feed { display: flex; flex-direction: column; font-size: 12.5px; }
|
||
.fr { display: grid; grid-template-columns: 62px minmax(0, 1.5fr) 82px minmax(0, 1fr); gap: 10px; padding: 4px 0; border-bottom: 1px dashed var(--line); align-items: baseline; }
|
||
.fr:last-child { border-bottom: 0; }
|
||
.fr .k { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--mute); }
|
||
.fr .fo { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.fr .v { color: var(--ok); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.fr.bad .v { color: var(--bad); font-weight: 650; }
|
||
|
||
.al { display: flex; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line); color: var(--ink); text-decoration: none; }
|
||
.al:last-child { border-bottom: 0; }
|
||
.al:hover { text-decoration: none; background: var(--card2); }
|
||
.al i { width: 8px; height: 8px; border-radius: 50%; flex: none; margin-top: 6px; background: var(--inv); }
|
||
.al.bad i { background: var(--bad); } .al.warn i { background: var(--warn); }
|
||
.al small { display: block; color: var(--mute); }
|
||
|
||
.stations { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(230px, 100%), 1fr)); gap: 12px; }
|
||
.si { padding: 6px 0; border-bottom: 1px dashed var(--line); font-size: 13px; }
|
||
.si:last-child { border-bottom: 0; }
|
||
.si .r1 { display: flex; justify-content: space-between; gap: 8px; }
|
||
.si .r1 span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.si .r1 > :last-child { flex: none; white-space: nowrap; }
|
||
.live:empty { display: none; }
|
||
.qc.wide { grid-column: 1 / -1; }
|
||
td.nw, .nw { white-space: nowrap; }
|
||
td.mat { min-width: 220px; }
|
||
.si small { color: var(--mute); display: block; margin-bottom: 3px; }
|
||
|
||
.pipe { display: flex; flex-direction: column; gap: 7px; }
|
||
.pipe a { display: grid; grid-template-columns: 150px minmax(0, 1fr) 40px; gap: 10px; align-items: center; color: var(--ink); font-size: 13px; text-decoration: none; }
|
||
.pipe a:hover .bar { outline: 1px solid var(--stroke); }
|
||
|
||
/* tables */
|
||
.tw { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||
table.t { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||
table.t th { text-align: left; font-weight: 600; color: var(--mute); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.05em; padding: 6px 10px; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
||
table.t td { padding: 7px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||
table.t tr:last-child td { border-bottom: 0; }
|
||
table.t tr.lnk { cursor: pointer; }
|
||
table.t tr.lnk:hover td { background: var(--card2); }
|
||
table.t td .mute { font-size: 12px; }
|
||
table.t .lim td { color: var(--faint); font-size: 12px; font-style: italic; }
|
||
.filters { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; align-items: center; }
|
||
.fbtn { border: 1px solid var(--stroke); background: var(--card); border-radius: 99px; padding: 4px 11px; font-size: 12.5px; cursor: pointer; color: var(--mute); white-space: nowrap; }
|
||
.fbtn.on { color: var(--ink); border-color: var(--inv); background: var(--inv-s); }
|
||
.fbtn b { font-family: var(--mono); font-weight: 600; margin-left: 4px; }
|
||
input.q { border: 1px solid var(--stroke); background: var(--card); color: var(--ink); border-radius: 99px; padding: 5px 13px; font: inherit; font-size: 13px; width: 220px; max-width: 100%; }
|
||
.btn { border: 1px solid var(--stroke); background: var(--card); color: var(--ink); border-radius: 99px; padding: 6px 14px; cursor: pointer; font-weight: 650; font-size: 13px; white-space: nowrap; display: inline-flex; align-items: center; gap: 6px; text-decoration: none; }
|
||
.btn:hover { border-color: var(--acc); text-decoration: none; }
|
||
.btn.pri { background: var(--acc); color: var(--onacc); border-color: var(--acc); }
|
||
.btn.pri:hover { filter: brightness(1.07); }
|
||
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||
.back { font-size: 13px; display: inline-block; margin-bottom: 8px; }
|
||
|
||
/* order detail */
|
||
.odh { display: flex; gap: 18px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; }
|
||
.odh .tx { flex: 1; min-width: 220px; }
|
||
.odh .acts { display: flex; gap: 8px; flex-wrap: wrap; }
|
||
.track { display: flex; gap: 4px; overflow-x: auto; padding-bottom: 6px; margin-bottom: 16px; scrollbar-width: thin; }
|
||
.track > div { flex: 1 0 96px; border-top: 3px solid var(--line); padding-top: 6px; font-size: 11.5px; color: var(--faint); min-width: 96px; }
|
||
.track > div.done { border-color: var(--ok); color: var(--mute); }
|
||
.track > div.now { border-color: var(--acc); color: var(--ink); font-weight: 650; }
|
||
.track > div small { display: block; font-weight: 400; color: var(--faint); font-family: var(--mono); }
|
||
dl.kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 14px; margin: 0; font-size: 13px; }
|
||
dl.kv dt { color: var(--mute); }
|
||
dl.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||
.gen { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-family: var(--mono); font-size: 12px; margin: 4px 0 10px; }
|
||
.gen span { background: var(--card2); border: 1px solid var(--line); border-radius: 7px; padding: 2px 7px; white-space: nowrap; }
|
||
.gen em { font-style: normal; color: var(--faint); }
|
||
.pareto { display: flex; flex-direction: column; gap: 6px; font-size: 12.5px; }
|
||
.pareto div { display: grid; grid-template-columns: 150px minmax(0, 1fr) 44px; gap: 8px; align-items: center; }
|
||
.note { font-size: 12px; color: var(--faint); margin: 10px 0 0; }
|
||
.spark { display: block; width: 100%; height: 54px; }
|
||
.spark polyline { fill: none; stroke: var(--inv); stroke-width: 1.6; vector-effect: non-scaling-stroke; }
|
||
.spark .band { fill: var(--inv-s); }
|
||
.daysv { display: flex; align-items: flex-end; gap: 8px; height: 130px; padding-top: 8px; }
|
||
.daysv div { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; height: 100%; gap: 4px; min-width: 0; font-size: 11px; color: var(--mute); }
|
||
.daysv i { display: block; width: 100%; max-width: 46px; background: var(--inv); border-radius: 6px 6px 2px 2px; min-height: 2px; }
|
||
.daysv div.td i { background: linear-gradient(180deg, var(--acc), var(--warn)); }
|
||
.daysv b { font-family: var(--mono); font-weight: 600; color: var(--ink); font-size: 11px; }
|
||
.verdict { font-weight: 650; white-space: nowrap; }
|
||
|
||
/* disc */
|
||
.disc .hole { fill: var(--bg); }
|
||
.disc .rot { animation: rot var(--rpm, 1.8s) linear infinite; }
|
||
@keyframes rot { to { transform: rotate(360deg); } }
|
||
@media (prefers-reduced-motion: reduce) { .disc .rot, .s-run .lamp { animation: none; } }
|
||
|
||
/* customer portal */
|
||
.cus { max-width: 1180px; margin: 0 auto; padding: 18px 20px 70px; }
|
||
.cdemo { display: flex; flex-wrap: wrap; gap: 6px 8px; align-items: center; font-size: 12.5px; color: var(--mute); margin-bottom: 16px; padding: 8px 12px; border: 1px dashed var(--stroke); border-radius: 12px; }
|
||
.cdemo .fbtn { font-size: 12px; }
|
||
.cdemo a.fac-l { margin-left: auto; }
|
||
.chead { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; }
|
||
.chead .who { font-size: 13px; color: var(--mute); }
|
||
.chero { display: grid; grid-template-columns: 250px minmax(0, 1fr); gap: 34px; align-items: center; background: radial-gradient(120% 140% at 0% 0%, var(--acc-s), transparent 55%), var(--card); border: 1px solid var(--line); border-radius: 22px; padding: 28px 32px; box-shadow: var(--shadow); }
|
||
.chero .disc { width: 100%; height: auto; max-width: 250px; filter: drop-shadow(0 14px 26px rgba(0,0,0,0.35)); }
|
||
.chero h1 { font-size: 34px; margin: 6px 0 2px; }
|
||
.chero .art { font-size: 16px; color: var(--mute); }
|
||
.specs { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0 0; }
|
||
.specs span { font-size: 12px; border: 1px solid var(--stroke); border-radius: 99px; padding: 2px 9px; color: var(--mute); white-space: nowrap; }
|
||
.status { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--line); }
|
||
.status .hl { font-size: 13px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
|
||
.status .hl.press { color: var(--acc); } .status .hl.gate { color: var(--warn); } .status .hl.done { color: var(--ok); } .status .hl.prog { color: var(--inv); }
|
||
.big { font-size: 54px; font-weight: 760; letter-spacing: -0.035em; line-height: 1.05; font-variant-numeric: tabular-nums; margin: 6px 0 4px; }
|
||
.big span { font-size: 22px; color: var(--mute); font-weight: 600; letter-spacing: -0.01em; }
|
||
.status .bar { height: 10px; margin: 8px 0 8px; }
|
||
.status p { margin: 6px 0 0; color: var(--mute); }
|
||
.action { margin-top: 16px; background: var(--warn-s); border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); border-radius: 16px; padding: 16px 18px; }
|
||
.action h3 { font-size: 16px; margin-bottom: 4px; }
|
||
.action p { margin: 0 0 12px; color: var(--mute); }
|
||
.action .row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||
.cgrid { display: grid; grid-template-columns: 330px minmax(0, 1fr); gap: 22px; margin-top: 22px; align-items: start; }
|
||
.tl { list-style: none; margin: 0; padding: 0; }
|
||
.tl li { display: flex; gap: 12px; position: relative; padding: 0 0 15px; }
|
||
.tl li::before { content: ""; position: absolute; left: 8px; top: 20px; bottom: -2px; width: 2px; background: var(--line); }
|
||
.tl li:last-child::before { display: none; }
|
||
.tl .dot { width: 18px; height: 18px; border-radius: 50%; border: 2px solid var(--stroke); background: var(--card); flex: none; margin-top: 1px; display: grid; place-items: center; font-size: 10px; z-index: 1; }
|
||
.tl li.done .dot { background: var(--ok); border-color: var(--ok); color: #fff; }
|
||
.tl li.done .dot::after { content: "✓"; font-weight: 800; }
|
||
.tl li.done::before { background: var(--ok); }
|
||
.tl li.now .dot { border-color: var(--acc); box-shadow: 0 0 0 4px var(--acc-s); }
|
||
.tl li.now .dot::after { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--acc); }
|
||
.tl li.now.gate .dot { border-color: var(--warn); box-shadow: 0 0 0 4px var(--warn-s); }
|
||
.tl li.now.gate .dot::after { background: var(--warn); }
|
||
.tl li b { font-weight: 620; font-size: 14px; display: block; line-height: 1.3; }
|
||
.tl li.next b { color: var(--mute); font-weight: 500; }
|
||
.tl li small { color: var(--mute); }
|
||
.tl li.gate:not(.done) b::after { content: "you"; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--warn); border: 1px solid currentColor; border-radius: 99px; padding: 0 5px; margin-left: 6px; vertical-align: 1px; }
|
||
.qgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(330px, 100%), 1fr)); gap: 14px; }
|
||
.qc { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 15px 17px; min-width: 0; }
|
||
.qc h3 { font-size: 15px; display: flex; align-items: center; gap: 8px; margin-bottom: 2px; }
|
||
.qc h3 .ok { font-size: 12px; font-weight: 700; margin-left: auto; white-space: nowrap; }
|
||
.qc > p { margin: 0 0 10px; color: var(--mute); font-size: 13px; }
|
||
.qc table.t td, .qc table.t th { padding: 5px 6px; }
|
||
.live { margin-top: 22px; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 14px; }
|
||
.lc { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 15px 17px; min-width: 0; }
|
||
.lc h3 { font-size: 15px; margin-bottom: 8px; }
|
||
.stats3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
|
||
.stats3 div { background: var(--card2); border-radius: 12px; padding: 10px 12px; }
|
||
.stats3 b { display: block; font-size: 22px; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
|
||
.stats3 span { font-size: 12px; color: var(--mute); }
|
||
.cfoot { margin-top: 26px; font-size: 12px; color: var(--faint); border-top: 1px solid var(--line); padding-top: 14px; }
|
||
.toast { position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%); background: var(--ink); color: var(--bg); padding: 10px 18px; border-radius: 12px; font-size: 13.5px; box-shadow: var(--shadow); z-index: 60; max-width: min(520px, calc(100vw - 32px)); }
|
||
.sh2 { font-size: 17px; font-weight: 700; margin: 26px 0 12px; }
|
||
|
||
@media (max-width: 1100px) {
|
||
.cols2 { grid-template-columns: minmax(0, 1fr); }
|
||
.cgrid { grid-template-columns: minmax(0, 1fr); }
|
||
}
|
||
@media (max-width: 900px) {
|
||
.fac { grid-template-columns: minmax(0, 1fr); gap: 14px; padding-top: 0; }
|
||
.side { top: var(--toph, 90px); z-index: 30; background: var(--bg); margin: 0 -20px; padding: 8px 20px; border-bottom: 1px solid var(--line); }
|
||
.side nav { flex-direction: row; overflow-x: auto; scrollbar-width: none; }
|
||
.side nav::-webkit-scrollbar { display: none; }
|
||
.side nav a { padding: 6px 11px; }
|
||
.sidenote { display: none; }
|
||
.live { grid-template-columns: minmax(0, 1fr); }
|
||
}
|
||
@media (max-width: 720px) {
|
||
.top .in { padding: 8px 14px; }
|
||
.ctl { margin-left: 0; width: 100%; justify-content: space-between; }
|
||
.clock { text-align: left; }
|
||
.fac, .cus { padding-left: 14px; padding-right: 14px; }
|
||
.side { margin: 0 -14px; padding: 8px 14px; }
|
||
h1 { font-size: 23px; }
|
||
.chero { grid-template-columns: minmax(0, 1fr); padding: 20px; gap: 16px; }
|
||
.chero .dw { max-width: 170px; margin: 0 auto; }
|
||
.chero h1 { font-size: 27px; }
|
||
.big { font-size: 42px; }
|
||
.fr { grid-template-columns: 58px minmax(0, 1fr) minmax(0, 0.9fr); }
|
||
.fr .fo { display: none; }
|
||
.pipe a { grid-template-columns: 120px minmax(0, 1fr) 34px; }
|
||
.pareto div { grid-template-columns: 112px minmax(0, 1fr) 40px; }
|
||
.demobar { font-size: 11px; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header class="top" id="top">
|
||
<div class="in">
|
||
<div class="brand"><svg viewBox="0 0 32 32" aria-hidden="true"><circle cx="16" cy="16" r="15" fill="#141414" stroke="var(--stroke)"/><circle cx="16" cy="16" r="11" fill="none" stroke="rgba(255,255,255,0.12)"/><circle cx="16" cy="16" r="7.5" fill="none" stroke="rgba(255,255,255,0.12)"/><circle cx="16" cy="16" r="5" fill="var(--acc)"/><circle cx="16" cy="16" r="1.3" fill="#141414"/></svg><span>CNX Vinyl</span><span class="pill">Demo</span></div>
|
||
<div class="vsw" role="tablist" aria-label="View">
|
||
<button type="button" role="tab" data-act="view" data-arg="factory">Factory console</button>
|
||
<button type="button" role="tab" data-act="view" data-arg="customer">Customer portal</button>
|
||
</div>
|
||
<div class="ctl">
|
||
<div class="clock"><span id="clk"></span><small id="shiftLbl"></small></div>
|
||
<button type="button" class="ib" id="playBtn" data-act="play" title="Pause the simulation" aria-label="Pause the simulation">❚❚</button>
|
||
<div class="seg" id="speedSeg" role="group" aria-label="Simulation speed">
|
||
<button type="button" data-act="speed" data-arg="1" title="Real time">1×</button><button type="button" data-act="speed" data-arg="20" title="20 times real time">20×</button><button type="button" data-act="speed" data-arg="600" title="10 minutes a second">600×</button><button type="button" data-act="speed" data-arg="3600" title="One hour a second">3600×</button>
|
||
</div>
|
||
<button type="button" class="ib" id="themeBtn">☀</button>
|
||
</div>
|
||
</div>
|
||
<div class="demobar">Simulated plant: orders, customers, IDs, measurements and results are invented. Stages, checks, cycle times and shifts follow the plan. CNX Vinyl is a working name.</div>
|
||
</header>
|
||
|
||
<div class="fac" id="factory">
|
||
<aside class="side">
|
||
<nav id="fnav" aria-label="Factory console"></nav>
|
||
<div class="sidenote"><b>What this is.</b> The factory application from the digital factory plan (working name cnx-factory): every machine, instrument and operator check writes to one append-only log, and the customer portal is a scoped view of the same records.<br><br><b>Simulated:</b> all orders, values and IDs. <b>From the plan:</b> stages and the three artist gates, the inspection layers, Newbilt cycle times, two shifts 06:00 to 22:00. The demo runs one Duplex system and one Classic. Limits are illustrative: the acceptance specifications are still to be written.</div>
|
||
</aside>
|
||
<main id="fmain"></main>
|
||
</div>
|
||
|
||
<div class="cus" id="customer" hidden></div>
|
||
<div class="toast" id="toast" hidden></div>
|
||
|
||
<script>
|
||
"use strict";
|
||
/* ================= utilities ================= */
|
||
const $=(s,r=document)=>r.querySelector(s),$$=(s,r=document)=>[...r.querySelectorAll(s)];
|
||
const esc=s=>String(s).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||
let RS=20261016>>>0;
|
||
function rnd(){RS=(RS+0x6D2B79F5)>>>0;let t=RS;t=Math.imul(t^(t>>>15),t|1);t^=t+Math.imul(t^(t>>>7),t|61);return((t^(t>>>14))>>>0)/4294967296;}
|
||
const U=(a,b)=>a+rnd()*(b-a),RI=(a,b)=>Math.floor(U(a,b+1)),pick=a=>a[Math.floor(rnd()*a.length)];
|
||
const gauss=()=>(rnd()+rnd()+rnd()+rnd()-2)/0.5774;
|
||
function wpick(l){let r=rnd()*l.reduce((s,x)=>s+x[1],0);for(const x of l){r-=x[1];if(r<=0)return x[0];}return l[l.length-1][0];}
|
||
const SEC=1e3,MIN=6e4,HR=36e5,DAY=864e5;
|
||
const pad=n=>String(n).padStart(2,"0");
|
||
const fT=t=>{const d=new Date(t);return pad(d.getHours())+":"+pad(d.getMinutes());};
|
||
const fTs=t=>fT(t)+":"+pad(new Date(t).getSeconds());
|
||
const fD=t=>new Date(t).toLocaleDateString("en-GB",{day:"numeric",month:"short"});
|
||
const fDW=t=>new Date(t).toLocaleDateString("en-GB",{weekday:"short",day:"numeric",month:"short"});
|
||
function dayKey(t){const d=new Date(t);return d.getFullYear()*10000+(d.getMonth()+1)*100+d.getDate();}
|
||
const fDT=t=>(dayKey(t)===dayKey(S.t)?"today ":dayKey(t)===dayKey(S.t-DAY)?"yesterday ":fD(t)+", ")+fT(t);
|
||
const fI=n=>Math.round(n).toLocaleString("en-US");
|
||
const f1=n=>n.toFixed(1),f2=n=>n.toFixed(2);
|
||
const pct=(a,b)=>b?100*a/b:0;
|
||
function dur(ms){ms=Math.max(0,ms);if(ms<HR)return Math.max(1,Math.round(ms/MIN))+" min";if(ms<40*HR)return (ms/HR).toFixed(ms<10*HR?1:0)+" h";return Math.round(ms/DAY)+" days";}
|
||
function ago(t){const d=S.t-t;return d<MIN?"just now":dur(d)+" ago";}
|
||
const inShift=t=>{const h=new Date(t).getHours();return h>=6&&h<22;};
|
||
function shiftStart(t){const d=new Date(t);if(d.getHours()>=22)d.setDate(d.getDate()+1);d.setHours(6,0,0,0);return d.getTime();}
|
||
function shiftEnd(t){const d=new Date(t);d.setHours(22,0,0,0);return d.getTime();}
|
||
function addShiftH(t,h){for(let g=0;g<90;g++){if(!inShift(t))t=shiftStart(t);const av=(shiftEnd(t)-t)/HR;if(h<=av)return t+h*HR;h-=av;t=shiftEnd(t);}return t;}
|
||
const code=n=>(Math.imul(n+7,2654435761)>>>0).toString(36).toUpperCase().slice(-4).padStart(4,"0");
|
||
function fnv(s,h){h=h>>>0;for(let i=0;i<s.length;i++){h^=s.charCodeAt(i);h=Math.imul(h,16777619)>>>0;}return h>>>0;}
|
||
const hx=s=>fnv(s,2166136261).toString(16).padStart(8,"0")+fnv(s,0x9747b28c).toString(16).padStart(8,"0");
|
||
function lum(hex){const n=parseInt(hex.slice(1),16);return((n>>16&255)*0.299+(n>>8&255)*0.587+(n&255)*0.114)/255;}
|
||
|
||
/* ================= the plan, as data ================= */
|
||
/* stages and durations (days): the mature column of production-timeline.md, as in the artist demos' STG */
|
||
const STAGES=[
|
||
{id:"intake",f:"Audio intake and QA",c:"Files received and checked",n:"File check",d:[1,2],g:"pre",a:"Intake"},
|
||
{id:"g1",f:"Gate 1: master plan",c:"You approved the master plan",n:"Your approval: master plan",d:[2,10],gate:1,g:"gate"},
|
||
{id:"mast",f:"Vinyl mastering",c:"Mastered for vinyl",n:"Vinyl mastering",d:[2,4],opt:1,g:"pre",a:"Mastering"},
|
||
{id:"g2",f:"Gate 2: vinyl master",c:"You approved the vinyl master",n:"Your approval: vinyl master",d:[2,10],gate:2,opt:1,g:"gate"},
|
||
{id:"cut",f:"Lacquer cutting",c:"Cut to lacquer",n:"Lacquer cutting",d:[1,2],g:"metal",a:"Cutting room"},
|
||
{id:"plate",f:"Electroforming",c:"Plated: father, mother, stampers",n:"Plating",d:[2,4],g:"metal",a:"Galvanics"},
|
||
{id:"prep",f:"Stamper prep",c:"Stampers finished and measured",n:"Stamper finishing",d:[1,1],g:"metal",a:"Stamper prep"},
|
||
{id:"tp",f:"Test pressings",c:"Test pressings made and checked",n:"Test pressings",d:[0.5,1],g:"metal",a:"Press floor"},
|
||
{id:"g3",f:"Gate 3: test pressing",c:"You approved the test pressing",n:"Your approval: test pressing",d:[5,15],gate:3,g:"gate"},
|
||
{id:"queue",f:"Press queue",c:"Pressing slot booked",n:"Pressing slot",d:null,g:"queue",a:"Planning"},
|
||
{id:"run",f:"Pressing",c:"Pressed and inspected",n:"Pressing",d:null,g:"press",a:"Press floor"},
|
||
{id:"fin",f:"Finishing and packing",c:"Packed and checked",n:"Packing",d:[2,3],g:"out",a:"Packing"},
|
||
{id:"ship",f:"Shipping",c:"Shipped",n:"Shipping",d:[1,2],g:"out",a:"Dispatch"},
|
||
{id:"done",f:"Delivered",c:"Delivered",n:"Delivery",d:null,g:"done",a:"Carrier"}
|
||
];
|
||
const SI=Object.fromEntries(STAGES.map((s,i)=>[s.id,i]));
|
||
const GRP=[["pre","Pre-production"],["gate","Waiting for customer"],["metal","Cutting and metal"],["queue","Press queue"],["press","Pressing"],["out","Packing and shipping"],["done","Delivered"]];
|
||
const GATE_T={1:"master plan",2:"vinyl master",3:"test pressing"};
|
||
const REP_SKIP=new Set(["g1","mast","g2","cut","g3"]);
|
||
/* side limits, minutes "at most" (artist-platform.md) */
|
||
const LIM={"12":{33:22,45:15},"10":{33:15,45:12},"7":{33:7,45:5}};
|
||
/* what the inline optical inspection detects (quality-automation.md, layer 1) */
|
||
const REASONS=[["Non-fill",28],["Surface debris",22],["Stitching or flow lines",16],["Label off-centre",11],["Warp",9],["Off-centre hole",7],["Label print defect",7]];
|
||
const PR={black:0.022,solid:0.03,marbled:0.04,splatter:0.05};
|
||
const STOPS=["Label feed jam","Extruder temperature alarm","Trimmer blade change","Hydraulic pressure low","Steam pressure dip","Cake transfer fault"];
|
||
const COLS=[["Bone white","#ece6d6"],["Transparent red","#c8323c"],["Ocean blue","#2f6fb3"],["Mustard","#d9a521"],["Jade","#2f9e7a"],["Smoke","#7d8088"],["Orchid","#a86bc4"],["Tangerine","#f08a24"],["Sea glass","#9fd0c4"],["Oxblood","#6e1f2a"]];
|
||
const ARTISTS=["Lanna Dub Society","Khun Wang Strings","Old City Soundsystem","Monsoon Disco","Nimman Late Club","Doi Saket Brass","Suan Dok Choir","Wat Ket Jazz Trio","Golden Hour Ska","Hang Dong Hi-Fi","Mae Rim Field Recordings","Riverside Tape Club","Kiln Studio Berlin","Nordlicht Schallplatten","Harbour Lights Melbourne","Shimokita Dub Club","Pacific Drift Records","Saigon Tape Club","Manila Late Night","Seoul Basement Records","Rotterdam Rhythm Archive","Cool Season Records"];
|
||
const TITLES=["Rain Season","Somewhere North","Night Bus to Chiang Rai","Slow Fire","Paper Lanterns","River Mouth","Teak and Tin","Highland Fog","Second Moon","Market Day","Kilns","After the Monsoon","Elephant Grass","Sticky Heat","Neon Temple","Overnight Bus","Bamboo Echoes","Cloud Forest","Smoke on the River","Mango Season","Late Ferry","Waterlight","Dust Roads","Floating Market Dub","Three Rivers","North Star Hotel","Hot Season Tapes","Long Weekend"];
|
||
const MAT={black:"Black record compound",clear:"Clear compound for colours"};
|
||
const SUP={A:"Supplier A, EU reference",B:"Supplier B, regional, in qualification"};
|
||
/* illustrative limits: the plant's specifications are still to be written (quality-automation.md) */
|
||
const LIMS={moist:0.20,heat:45,dE:1.0,densLo:1.35,densHi:1.40,gloss:80,fines:0.5,ash:0.8,specks:5,thick:[190,210],ttv:20,ridge:[25,32],hv:200,cen:40,score:96,clicks:4};
|
||
const PST={run:"Running",warm:"Warm-up",change:"Changeover",stop:"Stopped",off:"Off",idle:"Idle",nopvc:"No compound"};
|
||
const NOW0=(()=>{const d=new Date();d.setHours(10,5,0,0);return d.getTime();})();
|
||
|
||
/* ================= state ================= */
|
||
const S={t:NOW0-60*DAY,speed:20,run:true,warm:true,orders:[],byId:{},presses:[],lots:[],use:{black:null,clear:null},pend:{black:0,clear:0},regrind:[],feed:[],aud:[],led:[],ledN:0,day:{k:0},days:[],nextArr:0,seq:{o:1040,p:300,lot:2400,rg:880},
|
||
ui:{view:"factory",fpage:"floor",arg:null,fhash:"floor",filter:"active",q:"",cust:null}};
|
||
function mkPress(id,sys,kind){return {id,sys,kind,st:"off",next:0,until:0,order:null,set:null,since:0,why:"",tel:[],lc:30000,op:"",res:0};}
|
||
/* demo fleet for now: one Duplex system (two presses on one extruder set) and one Classic */
|
||
S.presses=[mkPress("D1-A","Duplex","duplex"),mkPress("D1-B","Duplex","duplex"),mkPress("C1","Classic","classic")];
|
||
const pById=id=>S.presses.find(p=>p.id===id);
|
||
|
||
/* ================= ledger: append-only, hash-chained ================= */
|
||
function log(t,k,txt,actor,oid){
|
||
S.ledN++;const prev=S.led.length?S.led[S.led.length-1].h:"0000000000000000";
|
||
const e={t,k,txt,actor,oid,p:prev};e.h=hx(prev+t+k+txt);S.led.push(e);
|
||
if(oid&&S.byId[oid]){const o=S.byId[oid];o.ev.push(e);if(o.ev.length>60)o.ev.shift();}
|
||
if(!S.warm&&S.led.length>900)S.led.splice(0,S.led.length-800);
|
||
}
|
||
function rechain(){S.led.sort((a,b)=>a.t-b.t);if(S.led.length>800)S.led=S.led.slice(-800);let prev=hx("genesis "+(S.ledN-S.led.length));for(const e of S.led){e.p=prev;e.h=hx(prev+e.t+e.k+e.txt);prev=e.h;}}
|
||
|
||
/* ================= orders ================= */
|
||
const SIDES=["A","B"];
|
||
function initials(a){return a.split(/\s+/).filter(Boolean).map(w=>w[0]).join("").toUpperCase().slice(0,3);}
|
||
function colT(o){return o.colour==="black"?"black":o.colour==="solid"?o.colName.toLowerCase():o.colName.toLowerCase()+" "+o.colour;}
|
||
function fmtS(o){return o.size+'" '+(o.weight?o.weight+" g ":"")+colT(o);}
|
||
function fmtL(o){return (o.size==="12"?'12" LP':o.size==='10'?'10"':'7" single')+" · "+(o.rpm===33?"33⅓":"45")+" rpm"+(o.weight?" · "+o.weight+" g":"")+" · "+(o.colour==="black"?"Black":o.colName+(o.colour==="solid"?"":" "+o.colour));}
|
||
function stageOn(o,st){if(st.opt&&!o.mastering)return false;if(o.repress&&REP_SKIP.has(st.id))return false;return true;}
|
||
function nextIdx(o,i){i=i==null?o.si:i;for(let j=i+1;j<STAGES.length;j++)if(stageOn(o,STAGES[j]))return j;return STAGES.length-1;}
|
||
function newOrder(t,x){
|
||
x=x||{};const n=++S.seq.o;
|
||
const size=x.size||wpick([["12",0.75],["7",0.15],["10",0.10]]);
|
||
const colour=x.colour||wpick([["black",0.58],["solid",0.25],["marbled",0.11],["splatter",0.06]]);
|
||
const c=pick(COLS),c2=pick(COLS.filter(k=>k!==c));
|
||
const o=Object.assign({id:"CNX-"+n,n,artist:pick(ARTISTS),title:pick(TITLES),size,
|
||
rpm:size==="7"?45:size==="10"?pick([33,45]):(rnd()<0.12?45:33),weight:size==="12"?(rnd()<0.35?180:140):null,colour,
|
||
colName:colour==="black"?"Black":colour==="solid"?c[0]:c[0]+" and "+c2[0].toLowerCase(),col:colour==="black"?"#151515":c[1],col2:c2[1],
|
||
qty:wpick([[RI(1,3)*100,0.25],[RI(5,10)*100,0.3],[RI(15,30)*100,0.3],[RI(40,60)*100,0.15]]),
|
||
mastering:rnd()<0.7,repress:rnd()<0.14,intl:rnd()<0.35,shrink:rnd()<0.6,
|
||
created:t,si:0,ss:t,sd:0,hist:[],gates:{},good:0,rej:0,rejBy:{},cycles:0,audio:[],sets:[],setIdx:0,press:null,lots:[],feed:[],ev:[],ta:null,demo:false,prio:0,hold:false},x);
|
||
if(o.repress)o.mastering=false;
|
||
o.catno=o.catno||initials(o.artist)+String(RI(1,40)).padStart(3,"0");
|
||
S.orders.push(o);S.byId[o.id]=o;
|
||
log(t,"order",`${o.id} received: ${o.title}, ${fI(o.qty)} × ${fmtS(o)}${o.repress?", repress":""}`,"Customer portal",o.id);
|
||
enter(o,0,t);
|
||
return o;
|
||
}
|
||
function enter(o,i,t){
|
||
const last=o.hist[o.hist.length-1];if(last&&last.b==null)last.b=t;
|
||
const st=STAGES[i];o.si=i;o.ss=t;
|
||
const d=o.repress&&st.id==="plate"?[1,2]:st.d;
|
||
o.sd=st.gate&&o.demo?Infinity:(d?U(d[0],d[1])*DAY:0);
|
||
o.hist.push({id:st.id,a:t,b:st.id==="done"?t:null});
|
||
if(H[st.id])H[st.id](o,t);
|
||
if(st.id==="done")log(t,"ship",`${o.id} delivered`,"Carrier",o.id);
|
||
else if(i>0)log(t,"order",`${o.id} → ${st.f}`,st.gate?"System":st.a,o.id);
|
||
}
|
||
function approve(o,g,t,by){o.gates[g]={t,by};log(t,"gate",`${o.id} gate ${g} approved: ${GATE_T[g]}`,by,o.id);}
|
||
function mkSet(o,k){return {n:k,st:SIDES.map(s=>({side:s,id:"S-"+code(o.n*97+k*7+s.charCodeAt(0))+"-"+s+k,res:null})),plays:0,life:RI(1000,2000),state:"made",press:null};}
|
||
function measure(set){for(const s of set.st)s.res={thick:U(194,206),ttv:U(7,17),ridge:[0,1,2,3].map(()=>U(26.5,30.5)),ra:U(8,18),hv:U(205,240),cen:U(6,28)};}
|
||
const H={
|
||
intake(o){const lim=LIM[o.size][o.rpm];o.files={lim,len:SIDES.map(()=>lim*U(0.62,0.96)),sr:pick([44.1,48,96]),bits:24,peak:-U(0.3,1.4),fmt:pick(["WAV","FLAC","AIFF"])};},
|
||
cut(o,t){o.lac=SIDES.map(s=>({id:"L-"+code(++S.seq.p),side:s,w:U(48,62),dep:U(26,31),scribe:o.catno+" "+s,stylus:"Stylus "+RI(11,19)}));log(t,"metal",`${o.id}: lacquers ${o.lac.map(l=>l.id).join(", ")} on Lathe 1`,"Cutting room",o.id);},
|
||
plate(o,t){
|
||
if(o.repress){o.fa=null;o.mo=SIDES.map(s=>({id:"M-"+code(o.n*31+s.charCodeAt(0)),side:s,thick:U(246,256),listen:"pass",made:t-U(90,400)*DAY,arch:true}));}
|
||
else{o.fa=SIDES.map(s=>({id:"F-"+code(++S.seq.p),side:s,thick:U(246,256)}));o.mo=SIDES.map(s=>({id:"M-"+code(++S.seq.p),side:s,thick:U(246,256),listen:"pass",made:t+1.2*DAY}));}
|
||
o.cell="PDPU 1, cell "+RI(1,2);
|
||
o.sets=[];const n=Math.max(1,Math.ceil(o.qty*1.08/1000));for(let k=1;k<=n;k++)o.sets.push(mkSet(o,k));
|
||
},
|
||
prep(o,t){for(const s of o.sets)measure(s);log(t,"metal",`${o.id}: ${o.sets.length*2} stampers punched, back-finished, measured`,"Stamper prep",o.id);},
|
||
tp(o){o.tp={n:10,you:5,sides:SIDES.map(s=>({side:s,score:U(97.8,99.5),clicks:RI(0,1),noise:-U(61,66)}))};},
|
||
queue(o,t){for(const p of S.presses)if(p.st==="idle")p.next=Math.min(p.next,t);},
|
||
run(o,t){if(!o.runA)o.runA=t;},
|
||
fin(o,t){o.runB=t;o.pack={units:o.good,mism:rnd()<0.12?1:0,grade:rnd()<0.85?"A":"B",shrink:o.shrink};o.labelLot=o.labelLot||"LB-"+code(o.n*5+1);},
|
||
ship(o,t){o.ship={carrier:o.intl?"DHL Express":"Thailand Post EMS",trk:"SIM-"+code(o.n*7+3)+code(o.n*13+5),boxes:Math.ceil(o.good/(o.size==="7"?150:50))};}
|
||
};
|
||
function advanceOrders(t){
|
||
for(const o of S.orders){
|
||
for(let g=0;g<6;g++){
|
||
const st=STAGES[o.si];if(st.id==="queue"||st.id==="run"||st.id==="done")break;
|
||
const end=o.ss+o.sd;if(!(end<=t))break;
|
||
if(st.gate)approve(o,st.gate,end,"Customer portal");
|
||
enter(o,nextIdx(o),end);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ================= compound lots and the lab gate ================= */
|
||
function labRes(mat,sup){return {moist:U(0.04,0.15),heat:U(55,90),dE:U(0.2,0.9),dens:U(1.365,1.392),gloss:mat==="black"?U(84,92):null,fines:U(0.1,0.45),ash:U(0.2,0.55),specks:RI(0,3),ext:sup==="B"?{k:U(49.6,51.2),stab:"Ca-Zn",lead:"none detected"}:null};}
|
||
function newLot(mat,t,x){
|
||
const sup=x&&x.sup?x.sup:mat==="black"&&rnd()<0.35?"B":"A";
|
||
const L=Object.assign({id:"PVC-"+(++S.seq.lot),mat,sup,arr:t,kg:mat==="black"?1500:1000,st:"lab",labDone:t+U(5,9)*HR,res:labRes(mat,sup)},x||{});
|
||
L.left=L.kg;S.lots.push(L);
|
||
log(t,"lab",`${L.id} received: ${MAT[mat]}, ${L.kg} kg, ${SUP[L.sup]}`,"Goods in",null);
|
||
return L;
|
||
}
|
||
function advanceLots(t){
|
||
for(const L of S.lots){
|
||
if(L.st==="lab"&&t>=L.labDone){
|
||
if(L.res.moist>LIMS.moist){L.st="hold";log(L.labDone,"lab",`${L.id} on hold: moisture ${f2(L.res.moist)} %, limit ${f2(LIMS.moist)} %`,"Lab",null);}
|
||
else{L.st="tp";L.tpDone=L.labDone+U(4,8)*HR;log(L.labDone,"lab",`${L.id} passed the lab checks, release test pressing next`,"Lab",null);}
|
||
}else if(L.st==="tp"&&t>=L.tpDone){L.st="ok";L.rel=L.tpDone;log(L.tpDone,"lab",`${L.id} released: test pressing passed optical and audio checks`,"Lab",null);}
|
||
else if(L.st==="dry"&&t>=L.dryDone){L.res.moist=U(0.06,0.11);L.redried=true;L.st="lab";L.labDone=L.dryDone+U(2,4)*HR;}
|
||
}
|
||
for(const mat of ["black","clear"]){
|
||
if(S.pend[mat]&&t>=S.pend[mat]){newLot(mat,S.pend[mat]);S.pend[mat]=0;}
|
||
if(!S.pend[mat]){const stock=S.lots.filter(L=>L.mat===mat&&["lab","tp","ok","use"].includes(L.st)).reduce((a,L)=>a+L.left,0);
|
||
if(stock<(mat==="black"?1700:900))S.pend[mat]=t+U(0.3,1)*DAY;}
|
||
}
|
||
}
|
||
function lotFor(o,now){
|
||
const mat=o.colour==="black"?"black":"clear";let L=S.use[mat];
|
||
if(!L||L.left<=0){L=S.lots.filter(x=>x.mat===mat&&x.st==="ok").sort((a,b)=>a.arr-b.arr)[0]||null;
|
||
if(L){L.st="use";L.inUse=now;S.use[mat]=L;log(now,"lab",`${L.id} into the ${mat==="black"?"black":"colour"} hopper`,"Operator",null);}else S.use[mat]=null;}
|
||
if(L&&!o.lots.includes(L.id))o.lots.push(L.id);
|
||
return L;
|
||
}
|
||
|
||
/* ================= presses ================= */
|
||
function fits(p,o){if(p.kind!=="duplex"&&(o.colour==="splatter"||o.size!=="12"))return false;return true;}
|
||
function cycMs(p){p.lc=p.kind==="duplex"?(25+U(4,6.5))*SEC:(28+gauss()*0.25)*SEC;return p.lc;}
|
||
function cakeW(o){return o.size==="12"?o.weight+11.5:o.size==="10"?120:45;}
|
||
function assign(p,now){
|
||
if(p.res&&now>=p.res)return false;
|
||
let best=null;
|
||
for(const o of S.orders){
|
||
if(o.press||o.hold||(o.si!==SI.queue&&o.si!==SI.run)||!fits(p,o))continue;
|
||
const k=(o.si===SI.run?2:0)+(o.prio||0);
|
||
if(!best||k>best.k||(k===best.k&&o.ss<best.o.ss))best={o,k};
|
||
}
|
||
if(!best)return false;startRun(p,best.o,now);return true;
|
||
}
|
||
function mount(p,o,now){const set=o.sets[o.setIdx];set.state="mounted";set.press=p.id;p.set=set;if(!o.labelLot)o.labelLot="LB-"+code(o.n*5+1);}
|
||
function startRun(p,o,now){
|
||
const resumed=o.si===SI.run;o.press=p.id;p.order=o;p.since=0;
|
||
if(!resumed)enter(o,SI.run,now);
|
||
mount(p,o,now);
|
||
p.st="change";p.why=resumed?"Resuming "+o.id:"New title: stampers, labels, compound";p.until=now+(resumed?25:40)*MIN;p.next=p.until;
|
||
log(now,"press",`${p.id} set up for ${o.id}, stamper set ${o.setIdx+1}`,p.op||"Operator",o.id);
|
||
}
|
||
function finishRun(p,now){
|
||
const o=p.order;o.press=null;
|
||
for(const s of o.sets)if(s.state==="mounted")s.state="rack";
|
||
log(now,"press",`${p.id} finished ${o.id}: ${fI(o.good)} good, ${fI(o.rej)} rejected`,"PLC",o.id);
|
||
p.order=null;p.set=null;enter(o,SI.fin,now);
|
||
if(!assign(p,now)){p.st="idle";p.next=now+5*MIN;}
|
||
}
|
||
function resume(p,now){
|
||
if(p.order){if(!lotFor(p.order,now)){p.st="nopvc";p.why="Waiting for released compound";p.next=now+15*MIN;return;}p.st="run";p.next=now+cycMs(p);}
|
||
else if(!assign(p,now)){p.st="idle";p.next=now+5*MIN;}
|
||
}
|
||
function cycle(p,now){
|
||
const o=p.order;if(!o){p.st="idle";p.next=now+MIN;return;}
|
||
const set=o.sets[o.setIdx],L=lotFor(o,now);
|
||
if(!L){p.st="nopvc";p.why="Waiting for released compound";p.next=now+15*MIN;return;}
|
||
p.since++;set.plays++;o.cycles++;
|
||
const warm=p.since<=25,tel={t:now,cyc:p.lc/SEC,steam:180+gauss()*0.7,bar:10+gauss()*0.1,cool:18+gauss()*0.5,cake:cakeW(o)+gauss()*1.1};
|
||
p.tel.push(tel);if(p.tel.length>48)p.tel.shift();
|
||
const A=o.ta||(o.ta={n:0,cyc:0,steam:0,cake:0,sMin:999,sMax:0,kMin:999,kMax:0});
|
||
A.n++;A.cyc+=tel.cyc;A.steam+=tel.steam;A.cake+=tel.cake;A.sMin=Math.min(A.sMin,tel.steam);A.sMax=Math.max(A.sMax,tel.steam);A.kMin=Math.min(A.kMin,tel.cake);A.kMax=Math.max(A.kMax,tel.cake);
|
||
L.left-=cakeW(o)*(o.colour==="black"?0.8:1)/1000;
|
||
if(L.left<=0){L.left=0;L.st="empty";L.emptied=now;S.use[L.mat]=null;}
|
||
const pr=PR[o.colour]*(warm?2.5:1)*(set.plays>set.life*0.92?1.6:1)*(L.sup==="B"?1.15:1);
|
||
const bad=rnd()<pr,r=bad?wpick(REASONS):null;
|
||
const bp=S.day.bp[p.id]||(S.day.bp[p.id]={g:0,r:0});
|
||
if(bad){o.rej++;o.rejBy[r]=(o.rejBy[r]||0)+1;S.day.rej++;S.day.by[r]=(S.day.by[r]||0)+1;bp.r++;}
|
||
else{o.good++;S.day.good++;bp.g++;}
|
||
const key=set.st[0].id+" · "+p.id+" · "+fTs(now);
|
||
if(!bad){const first=set.plays===1||o.good===1,every=set.plays>set.life*0.8?25:50;if(first||o.good%every===0)sample(o,p,set,now,key,first);}
|
||
const rec={t:now,o:o.id,title:o.title,p:p.id,key,ok:!bad,r,copy:o.good};
|
||
o.feed.push(rec);if(o.feed.length>10)o.feed.shift();
|
||
if(!S.warm||now>NOW0-25*MIN){S.feed.push(rec);if(S.feed.length>60)S.feed.shift();}
|
||
if(o.good>=o.qty){finishRun(p,now);return;}
|
||
if(set.plays>=set.life){
|
||
set.state="retired";set.retired=now;o.setIdx++;
|
||
if(!o.sets[o.setIdx]){const ns=mkSet(o,o.sets.length+1);measure(ns);o.sets.push(ns);log(now,"metal",`${o.id}: extra stamper set ${ns.n} grown from the archived mothers`,"Galvanics",o.id);}
|
||
mount(p,o,now);p.st="change";p.why="Stamper change to set "+(o.setIdx+1);p.until=now+20*MIN;p.next=p.until;p.since=0;
|
||
log(now,"press",`${p.id}: stamper set ${set.n} retired after ${fI(set.plays)} plays`,p.op||"Operator",o.id);return;
|
||
}
|
||
if(rnd()<1/480){p.st="stop";p.why=pick(STOPS);p.until=now+U(15,45)*MIN;p.next=p.until;log(now,"press",`${p.id} stopped: ${p.why}`,"PLC",o.id);return;}
|
||
p.next=now+cycMs(p);
|
||
}
|
||
function sample(o,p,set,now,key,first){
|
||
const rev=rnd()<0.025;
|
||
const a={t:now,rt:now+U(25,70)*MIN,copy:o.good,set:set.n,plays:set.plays,press:p.id,key,first,score:rev?U(94.5,95.8):U(97.2,99.6),clicks:rev?[RI(5,8),RI(0,2)]:[RI(0,2),RI(0,2)],noise:-U(60,66),bal:gauss()*0.15,rev};
|
||
o.audio.push(a);if(o.audio.length>200)o.audio.shift();
|
||
S.aud.push({o:o.id,title:o.title,a});if(S.aud.length>40)S.aud.shift();
|
||
S.day.samples++;if(rev)S.day.rev++;
|
||
}
|
||
function pressAdvance(p,t){
|
||
for(let g=0;p.next<=t&&g<8000;g++){
|
||
const now=p.next;
|
||
if(!inShift(now)){p.st="off";p.next=shiftStart(now);continue;}
|
||
switch(p.st){
|
||
case "off":p.st="warm";p.op="Operator "+pad(RI(2,14));p.until=now+U(13,18)*MIN;p.next=p.until;break;
|
||
case "warm":case "change":case "stop":case "nopvc":resume(p,now);break;
|
||
case "idle":if(!assign(p,now)){p.next=now+5*MIN;}break;
|
||
case "run":cycle(p,now);break;
|
||
default:p.st="idle";p.next=now+MIN;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ================= the simulation step ================= */
|
||
function dayRoll(t){
|
||
const k=dayKey(t);if(S.day.k===k)return;
|
||
if(S.day.k){S.days.push(S.day);if(S.days.length>10)S.days.shift();
|
||
const kg=Math.round(S.day.rej*0.15+S.day.good*0.012);
|
||
if(kg>0){S.regrind.unshift({id:"RG-"+(++S.seq.rg),t:t,kg,src:S.day.rej});if(S.regrind.length>8)S.regrind.pop();}
|
||
S.orders=S.orders.filter(o=>o.demo||o.si!==SI.done||t-o.ss<30*DAY);S.byId=Object.fromEntries(S.orders.map(o=>[o.id,o]));
|
||
S.lots=S.lots.filter(L=>L.st!=="empty"||t-L.emptied<20*DAY);
|
||
}
|
||
const d=new Date(t);d.setHours(0,0,0,0);
|
||
S.day={k,t0:d.getTime(),good:0,rej:0,by:{},bp:{},samples:0,rev:0};
|
||
}
|
||
function arrivals(t){
|
||
while(S.nextArr<=t){
|
||
const at=S.nextArr;newOrder(at);
|
||
const queued=S.orders.reduce((a,o)=>a+((o.si===SI.queue||o.si===SI.run)?o.qty-o.good:0),0);
|
||
S.nextArr=at-Math.log(1-rnd())*(DAY/2.2)*(queued>11000?2.6:1);
|
||
}
|
||
}
|
||
function step(t){dayRoll(t);advanceLots(t);for(const p of S.presses)pressAdvance(p,t);advanceOrders(t);arrivals(t);S.t=t;}
|
||
function advanceSim(ms){const target=S.t+ms;while(S.t<target)step(Math.min(target,S.t+30*SEC));}
|
||
|
||
/* ================= seed: 60 simulated days, then the demo orders ================= */
|
||
const DEMO={};
|
||
function demoOrder(k,x){const o=newOrder(S.t,Object.assign({demo:true,repress:false,intl:false},x));DEMO[k]=o.id;return o;}
|
||
function backfill(o,untilId,tUntil,fix){
|
||
fix=fix||{};
|
||
const seq=[];for(let i=0;i<=SI[untilId];i++)if(stageOn(o,STAGES[i]))seq.push(i);
|
||
const ds=seq.slice(0,-1).map(i=>{const s=STAGES[i];if(fix[s.id]!=null)return fix[s.id]*DAY;const d=s.d||[0.5,1.5];return U(d[0],d[1])*DAY*(s.gate?0.45:1);});
|
||
let t=tUntil-ds.reduce((a,b)=>a+b,0);
|
||
o.hist=[];o.ev=[];o.created=t;o.ss=t;
|
||
log(t,"order",`${o.id} received: ${o.title}, ${fI(o.qty)} × ${fmtS(o)}`,"Customer portal",o.id);
|
||
seq.forEach((i,k)=>{if(k>0&&STAGES[seq[k-1]].gate)approve(o,STAGES[seq[k-1]].gate,t,"Customer portal");enter(o,i,t);if(k<ds.length)t+=ds[k];});
|
||
}
|
||
function synthRun(o,lotId,pressId){
|
||
const A=o.hist.find(h=>h.id==="run"),a=A.a,b=A.b;
|
||
o.good=o.qty;o.rej=Math.round(o.qty*U(0.025,0.04));o.cycles=o.good+o.rej;o.lots=[lotId];o.ran=pressId;o.runA=a;o.runB=b;
|
||
for(let i=0;i<o.rej;i++){const r=wpick(REASONS);o.rejBy[r]=(o.rejBy[r]||0)+1;}
|
||
o.sets[0].plays=o.cycles;o.sets[0].state="rack";o.sets[0].press=pressId;
|
||
o.ta={n:o.cycles,cyc:o.cycles*30.4,steam:o.cycles*180.1,cake:o.cycles*(cakeW(o)+0.2),sMin:178.2,sMax:182.1,kMin:cakeW(o)-2.9,kMax:cakeW(o)+3.1};
|
||
o.audio=[];for(let c=1;c<=o.qty;c+=(c===1?49:50)){const t=a+(b-a)*c/o.qty;o.audio.push({t,rt:t+40*MIN,copy:c,set:1,plays:c,press:pressId,key:o.sets[0].st[0].id+" · "+pressId+" · "+fTs(t),first:c===1,score:U(97.4,99.5),clicks:[RI(0,2),RI(0,2)],noise:-U(61,66),bal:gauss()*0.15,rev:false});}
|
||
o.feed=[];for(let c=o.qty-7;c<=o.qty;c++){const t=b-(o.qty-c)*31*SEC;o.feed.push({t,o:o.id,title:o.title,p:pressId,key:o.sets[0].st[0].id+" · "+pressId+" · "+fTs(t),ok:true,copy:c});}
|
||
o.pack.units=o.good;o.ship.boxes=Math.ceil(o.good/50);
|
||
}
|
||
function seed(){
|
||
dayRoll(S.t);
|
||
newLot("black",S.t-3*DAY,{st:"ok",rel:S.t-2*DAY,sup:"A"});newLot("black",S.t-2*DAY,{st:"ok",rel:S.t-DAY,sup:"A"});newLot("clear",S.t-3*DAY,{st:"ok",rel:S.t-2*DAY,sup:"A"});
|
||
for(const p of S.presses)p.next=S.t;
|
||
S.nextArr=S.t;
|
||
/* the pressing demo order: approved test pressing two days ago, mounted on D1-A at 08:20 today */
|
||
const tInj=NOW0-105*MIN;
|
||
const dp=demoOrder("press",{artist:"Mae Ping Collective",title:"Night Market Songs",size:"12",rpm:33,weight:140,colour:"marbled",colName:"Jade and bone white",col:"#2f9e7a",col2:"#ece6d6",qty:300,mastering:true,catno:"MPC001",shrink:true});
|
||
backfill(dp,"queue",tInj-U(1.3,1.7)*DAY,{g3:2.2});dp.hold=true;
|
||
const D1=S.presses[0];D1.res=tInj-35*MIN;
|
||
let done=false;
|
||
while(S.t<NOW0){
|
||
const t=Math.min(NOW0,S.t+5*MIN);
|
||
if(!done&&t>=tInj){step(tInj);
|
||
if(D1.order){const o2=D1.order;o2.press=null;for(const s of o2.sets)if(s.state==="mounted")s.state="rack";D1.order=null;D1.set=null;log(tInj,"press",`D1-A: ${o2.id} moved to the next free press`,"Planning",o2.id);}
|
||
D1.res=0;dp.hold=false;D1.op=D1.op||"Operator 07";startRun(D1,dp,tInj);done=true;continue;}
|
||
step(t);
|
||
}
|
||
/* customers waiting on a test pressing, in plating, and delivered */
|
||
const tp=demoOrder("tp",{artist:"Lanna Dub Society",title:"Rain Season",size:"7",rpm:45,weight:null,colour:"splatter",colName:"Smoke and tangerine",col:"#7d8088",col2:"#f08a24",qty:500,mastering:true,catno:"LDS007",shrink:false});
|
||
backfill(tp,"g3",NOW0-2.1*DAY);
|
||
const pl=demoOrder("plate",{artist:"Suan Dok Records",title:"Teak and Tin, a Chiang Mai compilation",size:"12",rpm:33,weight:180,colour:"black",colName:"Black",col:"#151515",qty:1000,mastering:false,catno:"SDR012",shrink:true});
|
||
backfill(pl,"plate",NOW0-1.25*DAY);pl.sd=3.1*DAY;
|
||
const dl=demoOrder("done",{artist:"Ping River Tapes",title:"Cool Season Sessions",size:"12",rpm:33,weight:180,colour:"solid",colName:"Bone white",col:"#ece6d6",col2:"#ece6d6",qty:200,mastering:true,catno:"PRT003",shrink:true,intl:false});
|
||
backfill(dl,"done",NOW0-2.6*DAY,{g3:3,queue:1.2,run:0.12,fin:2.2,ship:1.4});
|
||
const runA=dl.hist.find(h=>h.id==="run").a;
|
||
const lot=S.lots.filter(L=>L.mat==="clear"&&L.rel&&L.rel<runA).sort((a,b)=>b.rel-a.rel)[0]||S.lots.find(L=>L.mat==="clear");
|
||
synthRun(dl,lot.id,"C1");
|
||
/* a lot the lab is holding */
|
||
const hl=newLot("black",NOW0-7*HR,{sup:"B"});hl.res.moist=0.27;hl.res.ext={k:50.4,stab:"Ca-Zn",lead:"none detected"};hl.labDone=NOW0-2.5*HR;hl.st="hold";
|
||
log(hl.labDone,"lab",`${hl.id} on hold: moisture 0.27 %, limit ${f2(LIMS.moist)} %`,"Lab",null);
|
||
for(const o of S.orders)o.ev.sort((a,b)=>a.t-b.t);
|
||
rechain();S.t=NOW0;S.warm=false;
|
||
}
|
||
|
||
/* ================= rendering helpers ================= */
|
||
function discSVG(o,px,spin){
|
||
const id="dk"+o.n+"x"+px,lr=o.size==="7"?54:o.size==="10"?42:34.8,base=o.colour==="black"?"#141414":o.col,light=o.colour!=="black"&&lum(o.col)>0.6;
|
||
let ex="";
|
||
if(o.colour==="marbled")ex=`<g clip-path="url(#${id}c)" fill="none" stroke="${o.col2}" stroke-linecap="round" opacity="0.75">`+[0,1,2,3].map(k=>`<path d="M${-110+k*18} ${-60+k*34} C -30 ${-110+k*52}, 40 ${20-k*30}, 110 ${-40+k*44}" stroke-width="${9+k*4}" transform="rotate(${(o.n*37+k*83)%360})"/>`).join("")+`</g>`;
|
||
if(o.colour==="splatter"){let s=o.n*9301+49297;const r=()=>(s=(s*9301+49297)%233280)/233280;ex=`<g fill="${o.col2}">`+Array.from({length:48},()=>{const a=r()*6.283,d=lr+5+r()*(98-lr-5);return `<circle cx="${(Math.cos(a)*d).toFixed(1)}" cy="${(Math.sin(a)*d).toFixed(1)}" r="${(0.8+r()*3.8).toFixed(1)}"/>`;}).join("")+`</g>`;}
|
||
const gs=light?"rgba(0,0,0,0.10)":"rgba(255,255,255,0.07)";let gr="";for(let r=100;r>lr+7;r-=5)gr+=`<circle r="${r}" fill="none" stroke="${gs}" stroke-width="0.7"/>`;
|
||
const hue=fnv(o.artist,7)%360;
|
||
return `<svg class="disc" viewBox="-106 -106 212 212" width="${px}" height="${px}" aria-hidden="true" style="--rpm:${o.rpm===45?"1.33s":"1.8s"}"><defs><clipPath id="${id}c"><circle r="104"/></clipPath><radialGradient id="${id}g" cx="35%" cy="28%" r="80%"><stop offset="0" stop-color="#fff" stop-opacity="0.18"/><stop offset="0.6" stop-color="#fff" stop-opacity="0"/></radialGradient></defs><g class="${spin?"rot":""}"><circle r="104" fill="${base}"/>${ex}${gr}<circle r="${lr}" fill="hsl(${hue} 42% 50%)"/><circle r="${(lr*0.8).toFixed(1)}" fill="none" stroke="rgba(255,255,255,0.28)" stroke-width="0.8"/><text y="${(lr*0.5).toFixed(1)}" text-anchor="middle" font-size="${(lr*0.2).toFixed(1)}" fill="rgba(255,255,255,0.88)" font-family="ui-monospace,Consolas,monospace">${esc(o.catno)}</text><text y="${(-lr*0.32).toFixed(1)}" text-anchor="middle" font-size="${(lr*0.17).toFixed(1)}" fill="rgba(255,255,255,0.75)" font-family="system-ui,sans-serif">${esc(o.artist.slice(0,18))}</text></g><circle r="104" fill="url(#${id}g)"/><circle r="3.4" class="hole"/></svg>`;
|
||
}
|
||
const bar=(f,cls)=>`<span class="bar ${cls||""}"><i style="width:${(Math.max(0,Math.min(1,f||0))*100).toFixed(1)}%"></i></span>`;
|
||
const chipG=(g,txt)=>`<span class="chip g-${g}">${esc(txt)}</span>`;
|
||
function stLabel(o){const st=STAGES[o.si];if(st.id==="run"){const p=pById(o.press);return p?"Pressing on "+p.id:"Pressing, paused";}return st.f;}
|
||
const chip=o=>chipG(STAGES[o.si].g,stLabel(o));
|
||
function prog(o){const st=STAGES[o.si];if(st.id==="run")return o.good/o.qty;if(st.id==="done")return 1;if(st.gate||st.id==="queue")return null;return o.sd?Math.min(1,(S.t-o.ss)/o.sd):null;}
|
||
function pstate(p){
|
||
switch(p.st){
|
||
case "run":return "Running";
|
||
case "change":return p.why+", "+dur(p.until-S.t)+" left";
|
||
case "warm":return "Shift start warm-up, "+dur(p.until-S.t)+" left";
|
||
case "stop":return "Stopped: "+p.why+", back in about "+dur(p.until-S.t);
|
||
case "off":return "Off until 06:00";
|
||
case "nopvc":return p.why;
|
||
default:return "Idle: no released order fits this press";
|
||
}
|
||
}
|
||
function subStep(o){const st=STAGES[o.si].id,f=prog(o)||0;
|
||
if(st==="plate")return o.repress?(f<0.5?"Stampers growing from the archived mothers":"Stampers separated, rinsing"):f<0.08?"Silvering the lacquers":f<0.42?"Father in the bath":f<0.72?"Mother in the bath":"Stampers in the bath";
|
||
if(st==="cut")return f<0.5?"Cutting side A":"Cutting side B, scribing the run-out";
|
||
if(st==="prep")return "Punch, back-finish, metrology";
|
||
if(st==="tp")return "Pressing and checking 10 test pressings";
|
||
if(st==="fin")return `Sleeving and assembly scans, ${fI(Math.min(o.good,o.good*f))} of ${fI(o.good)}`;
|
||
if(st==="mast")return "Engineer at the mastering desk";
|
||
if(st==="intake")return "Checksums, levels, side lengths";
|
||
return "";}
|
||
function rateH(p){return p&&p.kind==="classic"?3600/28:3600/30.3;}
|
||
function eta(o){
|
||
const st=STAGES[o.si].id,t=S.t,yld=1-PR[o.colour];
|
||
if(st==="done")return {t:o.ss,done:true};
|
||
if(st==="ship")return {t:o.ss+o.sd};
|
||
if(st==="fin")return {t:o.ss+o.sd+1.5*DAY};
|
||
if(st==="run"){const p=pById(o.press);const h=(o.qty-o.good)/(rateH(p)*yld);const start=p&&(p.st==="change"||p.st==="stop"||p.st==="warm")?p.until:t;const end=addShiftH(Math.max(t,start),h);return {t:end+4*DAY,runEnd:end};}
|
||
if(st==="queue"){const s=schedEst(o);if(s)return {t:s.end+4*DAY,start:s.start,runEnd:s.end,press:s.press};}
|
||
return null;
|
||
}
|
||
/* greedy plan of the press queue, the same rule the scheduler uses: resumed runs first, then priority, then first come */
|
||
function schedEst(o){
|
||
const free=S.presses.map(p=>{let t=S.t;if(p.st==="change"||p.st==="warm"||p.st==="stop")t=Math.max(t,p.until);if(p.order&&p.order!==o)t=addShiftH(t,(p.order.qty-p.order.good)/(rateH(p)*(1-PR[p.order.colour])));return {p,t};});
|
||
const key=q=>(q.si===SI.run?2:0)+(q.prio||0);
|
||
const Q=S.orders.filter(q=>(q.si===SI.queue||(q.si===SI.run&&!q.press))&&!q.hold).sort((a,b)=>key(b)-key(a)||a.ss-b.ss);
|
||
for(const q of Q){const c=free.filter(f=>fits(f.p,q)).sort((a,b)=>a.t-b.t)[0];if(!c)continue;
|
||
const start=addShiftH(c.t,40/60),end=addShiftH(start,(q.qty-q.good)/(rateH(c.p)*(1-PR[q.colour])));
|
||
if(q===o)return {start,end,press:c.p.id};c.t=end;}
|
||
return null;
|
||
}
|
||
const cnt=f=>S.orders.reduce((a,o)=>a+(f(o)?1:0),0);
|
||
const grpOf=o=>STAGES[o.si].g;
|
||
function spark(vals,lo,hi){if(vals.length<2)return "";const w=100,h=40,mn=lo,mx=hi;const pts=vals.map((v,i)=>`${(i/(vals.length-1)*w).toFixed(1)},${(h-(v-mn)/(mx-mn)*h).toFixed(1)}`).join(" ");return `<svg class="spark" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" aria-hidden="true"><polyline points="${pts}"/></svg>`;}
|
||
const verdict=(ok,txt)=>`<span class="verdict ${ok?"ok":"bad"}">${ok?"✓":"✕"} ${esc(txt)}</span>`;
|
||
const lim=(ok)=>ok?'<span class="ok">✓</span>':'<span class="bad">✕</span>';
|
||
function lotRow(L){const r=L.res;return [
|
||
["Moisture",f2(r.moist)+" %","≤ "+f2(LIMS.moist)+" %",r.moist<=LIMS.moist],
|
||
["Heat stability, oven strips",Math.round(r.heat)+" min","≥ "+LIMS.heat+" min",r.heat>=LIMS.heat],
|
||
["Colour against the reference","ΔE "+f2(r.dE),"≤ "+f1(LIMS.dE),r.dE<=LIMS.dE],
|
||
["Density",r.dens.toFixed(3)+" g/cm³","1.35 to 1.40",r.dens>=LIMS.densLo&&r.dens<=LIMS.densHi],
|
||
...(r.gloss!=null?[["Gloss, 60°",Math.round(r.gloss)+" GU","≥ "+LIMS.gloss,r.gloss>=LIMS.gloss]]:[]),
|
||
["Fines and dust",f2(r.fines)+" %","≤ "+f1(LIMS.fines)+" %",r.fines<=LIMS.fines],
|
||
["Ash, filler drift",f2(r.ash)+" %","≤ "+f1(LIMS.ash)+" %",r.ash<=LIMS.ash],
|
||
["Specks per plaque",String(r.specks),"≤ "+LIMS.specks,r.specks<=LIMS.specks]];}
|
||
function lotStatus(L){return {lab:["g-pre","In the lab"],tp:["g-metal","Release test pressing"],ok:["g-out","Released"],use:["g-press","In the hopper"],empty:["g-done","Used up"],hold:["g-gate","On hold"],dry:["g-gate","Re-drying"]}[L.st];}
|
||
function stamperTable(o){const rows=o.sets.flatMap(s=>s.st.filter(x=>x.res).map(x=>({s,x})));if(!rows.length)return "";
|
||
return `<div class="tw"><table class="t"><tr><th>Stamper</th><th class="num">Nickel</th><th class="num">Flatness</th><th class="num">Groove depth, 4 zones</th><th class="num">Hardness</th><th class="num">Centring</th></tr>
|
||
<tr class="lim"><td>limit, illustrative</td><td class="num">${LIMS.thick[0]} to ${LIMS.thick[1]} µm</td><td class="num">TTV ≤ ${LIMS.ttv} µm</td><td class="num">${LIMS.ridge[0]} to ${LIMS.ridge[1]} µm</td><td class="num">≥ ${LIMS.hv} HV</td><td class="num">≤ ${LIMS.cen} µm</td></tr>
|
||
${rows.slice(0,8).map(({s,x})=>`<tr><td class="mono">${x.id}</td><td class="num">${Math.round(x.res.thick)} µm</td><td class="num">${f1(x.res.ttv)} µm</td><td class="num">${x.res.ridge.map(v=>f1(v)).join(" / ")}</td><td class="num">${Math.round(x.res.hv)} HV</td><td class="num">${Math.round(x.res.cen)} µm</td></tr>`).join("")}</table></div>`;}
|
||
function audioTable(list,showOrder){const L=list.slice(-10).reverse();if(!L.length)return '<p class="mute">No samples yet.</p>';
|
||
return `<div class="tw"><table class="t"><tr><th>Time</th>${showOrder?"<th>Order</th>":""}<th>Copy</th><th class="num">Match</th><th class="num">Clicks A / B</th><th class="num">Noise floor</th><th>Result</th></tr>
|
||
${L.map(x=>{const a=x.a||x;const pend=a.rt>S.t;return `<tr><td class="mono">${fT(a.t)}</td>${showOrder?`<td><a href="#order=${x.o}">${x.o}</a></td>`:""}<td class="mono">${a.first?"first-off":"#"+fI(a.copy)}${a.plays?`<div class="mute">set ${a.set}, ${fI(a.plays)} plays</div>`:""}</td>${pend?`<td colspan="3" class="mute">at the listening station</td><td class="mute">pending</td>`:`<td class="num">${f1(a.score)} %</td><td class="num">${a.clicks[0]} / ${a.clicks[1]}</td><td class="num">${Math.round(a.noise)} dB</td><td>${a.rev?'<span class="verdict warn">✓ passed after listening</span>':verdict(true,"pass")}</td>`}</tr>`;}).join("")}</table></div>`;}
|
||
function paretoHTML(by){const L=Object.entries(by).sort((a,b)=>b[1]-a[1]);if(!L.length)return '<p class="mute">No rejects yet.</p>';const mx=L[0][1];
|
||
return `<div class="pareto">${L.map(([r,n])=>`<div><span>${esc(r)}</span>${bar(n/mx,"bad")}<span class="num">${fI(n)}</span></div>`).join("")}</div>`;}
|
||
function feedHTML(list,n){if(!list.length)return '<p class="mute">The presses are off for the night. Records appear here as they leave the press.</p>';
|
||
return `<div class="feed">${list.slice(-n).reverse().map(r=>`<div class="fr ${r.ok?"":"bad"}"><span class="mono">${fTs(r.t)}</span><span class="mono k" title="${esc(r.key)}">${esc(r.key)}</span><span class="fo"><a href="#order=${r.o}">${r.o}</a></span><span class="v">${r.ok?"✓ pass":"✕ "+esc(r.r)}</span></div>`).join("")}</div>`;}
|
||
const kpi=(l,v,s,c)=>`<div class="kpi ${c||""}"><span>${l}</span><b>${v}</b><small>${s||" "}</small></div>`;
|
||
|
||
/* ================= factory pages ================= */
|
||
const NAV=[["floor","◉","Floor"],["orders","☰","Orders"],["quality","✓","Quality"],["lab","⚗","Lab"],["metal","◎","Metal"],["ledger","⛓","Ledger"]];
|
||
function navHTML(){const cur=S.ui.fpage==="order"?"orders":S.ui.fpage==="press"?"floor":S.ui.fpage;
|
||
const bd={floor:S.presses.filter(p=>p.st==="stop"||p.st==="nopvc").length,orders:cnt(o=>o.si!==SI.done),lab:S.lots.filter(L=>L.st==="hold").length,quality:S.aud.filter(x=>x.a.rev&&x.a.rt<=S.t&&S.t-x.a.rt<6*HR).length};
|
||
return NAV.map(([k,ic,l])=>{const b=bd[k];const bad=(k==="lab"||k==="floor"||k==="quality")&&b>0;return `<a href="#${k}" class="${cur===k?"on":""}"><span class="ic">${ic}</span>${l}${b?`<span class="bd ${bad?"bad":""}">${fI(b)}</span>`:""}</a>`;}).join("");}
|
||
function pressCard(p){
|
||
const o=p.order,set=p.set,tl=p.tel[p.tel.length-1];
|
||
let body="";
|
||
if(o)body=`<div class="pc-o">${discSVG(o,44,false)}<div><b>${esc(o.title)}</b><div class="mute">${esc(o.artist)} · ${o.id}</div><div class="mute">${esc(fmtS(o))}</div></div></div>
|
||
<div class="pc-n"><b>${fI(o.good)}</b><span class="mute">/ ${fI(o.qty)} good</span><span class="r">${fI(o.rej)} rejected</span></div>${bar(o.good/o.qty,"acc")}
|
||
<div class="pc-m"><span>Stamper set ${set?set.n:"-"}: ${set?fI(set.plays):0} of ${set?fI(set.life):0} plays</span>${bar(set?set.plays/set.life:0,set&&set.plays/set.life>0.9?"warn":"")}</div>`;
|
||
else body=`<div class="pc-empty mute">${p.st==="off"?"Off for the night.":"No order mounted."}</div>`;
|
||
const stats=p.st==="run"&&tl?`<span>${f1(tl.cyc)} s cycle</span><span>${Math.round(3600/tl.cyc)} an hour</span><span>steam ${Math.round(tl.steam)} °C</span>`:`<span>${esc(pstate(p))}</span>`;
|
||
return `<a class="pcard s-${p.st}" href="#press=${p.id}"><div class="pc-h"><span class="lamp"></span><b>${p.id}</b><span class="mute">${p.kind==="duplex"?"Newbilt Duplex, press "+p.id.slice(-1):"Newbilt Classic"}</span><span class="pst">${PST[p.st]}</span></div>${body}<div class="pc-t">${stats}</div></a>`;
|
||
}
|
||
function alerts(){const A=[];
|
||
for(const p of S.presses){if(p.st==="stop")A.push(["bad",`${p.id} stopped: ${p.why}`,`Back in about ${dur(p.until-S.t)}. Logged by the press PLC.`,"press="+p.id]);
|
||
if(p.st==="nopvc")A.push(["bad",`${p.id}: no released compound`,"Waiting for the lab to release a lot.","lab"]);
|
||
if(p.set&&p.set.plays/p.set.life>0.9)A.push(["warn",`${p.id}: stamper set ${p.set.n} at ${Math.round(100*p.set.plays/p.set.life)} % of its life`,"Audio sampling doubled near end of life; next set ready in the rack.","press="+p.id]);}
|
||
for(const L of S.lots){if(L.st==="hold")A.push(["bad",`${L.id} on hold: moisture ${f2(L.res.moist)} %`,`Limit ${f2(LIMS.moist)} %. Re-dry and retest before release.`,"lab"]);if(L.st==="dry")A.push(["warn",`${L.id} re-drying`,`Retest at ${fT(L.dryDone)}.`,"lab"]);}
|
||
for(const x of S.aud)if(x.a.rev&&x.a.rt<=S.t&&S.t-x.a.rt<6*HR)A.push(["warn",`Listening review: ${x.o}, copy ${fI(x.a.copy)}`,`${x.a.clicks[0]} clicks on side A. Engineer listened: passed; press checked for debris.`,"order="+x.o]);
|
||
const g=S.orders.filter(o=>STAGES[o.si].gate&&S.t-o.ss>8*DAY).length;
|
||
if(g)A.push(["info",`${g} orders waiting on a customer approval for more than 8 days`,"The portal sends reminders; nothing proceeds past a gate without an approval.","orders"]);
|
||
return A;}
|
||
function stationsHTML(){
|
||
const L=(f)=>S.orders.filter(f).sort((a,b)=>a.ss-b.ss);
|
||
const box=(name,sub,list,line)=>`<section class="card"><div class="ch"><h3>${name}</h3><span class="mute">${sub}</span></div>${list.length?list.slice(0,3).map(line).join("")+(list.length>3?`<p class="note">+ ${list.length-3} more</p>`:""):'<p class="mute" style="margin:0">Nothing in progress.</p>'}</section>`;
|
||
const ol=o=>`<div class="si"><div class="r1"><span><a href="#order=${o.id}">${o.id}</a> ${esc(o.title)}</span><span class="mono mute">${Math.round(100*(prog(o)||0))} %</span></div><small>${esc(subStep(o))}</small>${bar(prog(o),"metal")}</div>`;
|
||
const lots=S.lots.filter(x=>["lab","tp","hold","dry"].includes(x.st));
|
||
return box("Cutting room","Lathe 1",L(o=>o.si===SI.cut),ol)+box("Galvanics","Pheenix line",L(o=>o.si===SI.plate),ol)+box("Stamper prep","Sibert VSF, VSP, VSC",L(o=>o.si===SI.prep),ol)
|
||
+`<section class="card"><div class="ch"><h3>Material lab</h3><span class="mute">gate per compound lot</span></div>${lots.length?lots.map(x=>{const s=lotStatus(x);return `<div class="si"><div class="r1"><span><a href="#lab">${x.id}</a> ${esc(MAT[x.mat])}</span><span class="chip ${s[0]}">${s[1]}</span></div><small>${esc(SUP[x.sup])}</small></div>`;}).join(""):'<p class="mute" style="margin:0">No lot in testing.</p>'}</section>`
|
||
+box("Packing","assembly scans",L(o=>o.si===SI.fin),o=>`<div class="si"><div class="r1"><span><a href="#order=${o.id}">${o.id}</a> ${esc(o.title)}</span><span class="mono mute">${fI(o.good)}</span></div>${bar(prog(o),"ok")}</div>`);
|
||
}
|
||
const PAGES={};
|
||
PAGES.floor={
|
||
layout:()=>`<div class="ph"><div><div class="eyebrow">Factory console</div><h1>Press floor, live</h1></div><p class="mute" id="fShift"></p></div>
|
||
<div class="kpis" id="fK"></div>
|
||
<div class="sh"><h2>Presses</h2><span class="mute">One Newbilt Duplex system (two HRP 12 presses) and one Newbilt Classic</span></div>
|
||
<div class="presses" id="fP"></div>
|
||
<div class="cols2"><section class="card"><div class="ch"><h3>Optical inspection, every record</h3><span class="mute">record key: stamper · press · cycle time</span></div><div id="fFeed"></div></section>
|
||
<section class="card"><div class="ch"><h3>Needs attention</h3></div><div id="fAl"></div></section></div>
|
||
<div class="sh"><h2>Stations</h2></div><div class="stations" id="fS"></div>
|
||
<div class="cols2"><section class="card"><div class="ch"><h3>Orders by stage</h3><a href="#orders">All orders</a></div><div id="fPipe"></div></section>
|
||
<section class="card"><div class="ch"><h3>Good records per day</h3><span class="mute">shifts 06:00 to 22:00</span></div><div id="fDays"></div></section></div>`,
|
||
blocks(){
|
||
const d=S.day,ins=d.good+d.rej,run=S.presses.filter(p=>p.st==="run").length,hold=S.lots.filter(L=>L.st==="hold").length;
|
||
const A=alerts();
|
||
const days=[...S.days.slice(-6),d],mx=Math.max(1,...days.map(x=>x.good));
|
||
return {
|
||
fShift:esc((inShift(S.t)?(new Date(S.t).getHours()<14?"Shift 1, 06:00 to 14:00":"Shift 2, 14:00 to 22:00"):"Night: presses off, plating baths and lab keep running")),
|
||
fK:kpi("Good records today",fI(d.good),fI(ins)+" inspected optically")+kpi("Rejected today",fI(d.rej),ins?f1(pct(d.rej,ins))+" % of inspected":"none yet",ins&&pct(d.rej,ins)>5?"warn":"")
|
||
+kpi("Presses running",`${run} of ${S.presses.length}`,inShift(S.t)?S.presses.filter(p=>p.st==="change"||p.st==="warm").length+" in changeover":"off until 06:00")
|
||
+kpi("Orders in production",fI(cnt(o=>["metal","queue","press","out"].includes(grpOf(o)))),fI(cnt(o=>o.si===SI.queue))+" waiting for a press")
|
||
+kpi("Waiting for customers",fI(cnt(o=>STAGES[o.si].gate)),"at one of the three gates")+kpi("Compound on hold",fI(hold),"lots held by the lab",hold?"bad":""),
|
||
fP:S.presses.map(pressCard).join(""),
|
||
fFeed:feedHTML(S.feed,12),
|
||
fAl:A.length?A.slice(0,6).map(([c,t,s,h])=>`<a class="al ${c}" href="#${h}"><i></i><div>${esc(t)}<small>${esc(s)}</small></div></a>`).join(""):'<p class="mute">All clear.</p>',
|
||
fS:stationsHTML(),
|
||
fPipe:`<div class="pipe">${GRP.map(([g,l])=>{const n=cnt(o=>grpOf(o)===g);return `<a href="#orders" data-act="filter" data-arg="${g}"><span>${l}</span>${bar(n/Math.max(1,S.orders.length)*2.2,g==="press"?"acc":g==="gate"?"warn":g==="out"?"ok":g==="metal"?"metal":"")}<span class="num">${n}</span></a>`;}).join("")}</div>`,
|
||
fDays:`<div class="daysv">${days.map((x,i)=>`<div class="${i===days.length-1?"td":""}"><b>${x.good>=1000?f1(x.good/1000)+"k":fI(x.good)}</b><i style="height:${(x.good/mx*82).toFixed(1)}%"></i><span>${i===days.length-1?"today":new Date(x.t0).toLocaleDateString("en-GB",{weekday:"short"})}</span></div>`).join("")}</div><p class="note">Rejected records and trim are granulated in-house; ${S.regrind[0]?`yesterday's regrind batch ${S.regrind[0].id}: ${fI(S.regrind[0].kg)} kg`:"regrind batches are logged per day"}.</p>`
|
||
};
|
||
}
|
||
};
|
||
PAGES.orders={
|
||
layout:()=>`<div class="ph"><div><div class="eyebrow">Factory console</div><h1>Orders</h1></div><input class="q" id="oq" type="search" placeholder="Search order, title or artist" value="${esc(S.ui.q)}"></div><div class="filters" id="oF"></div><section class="card"><div class="tw" id="oT"></div></section>`,
|
||
blocks(){
|
||
const f=S.ui.filter,q=S.ui.q.trim().toLowerCase();
|
||
let L=S.orders.filter(o=>f==="active"?o.si!==SI.done:grpOf(o)===f);
|
||
if(q)L=L.filter(o=>(o.id+" "+o.title+" "+o.artist).toLowerCase().includes(q));
|
||
L.sort((a,b)=>b.si-a.si||a.ss-b.ss);
|
||
const fl=[["active","All active",cnt(o=>o.si!==SI.done)],...GRP.map(([g,l])=>[g,l,cnt(o=>grpOf(o)===g)])];
|
||
return {
|
||
oF:fl.map(([k,l,n])=>`<button type="button" class="fbtn ${f===k?"on":""}" data-act="filter" data-arg="${k}">${l}<b>${n}</b></button>`).join(""),
|
||
oT:`<table class="t"><tr><th>Order</th><th>Release</th><th>Format</th><th class="num">Copies</th><th>Stage</th><th>Since</th><th>Estimated delivery</th></tr>${L.slice(0,120).map(o=>{const p=prog(o),e=eta(o);
|
||
return `<tr class="lnk" data-go="order=${o.id}"><td class="mono"><a href="#order=${o.id}">${o.id}</a>${o.demo?'<div class="mute">demo customer</div>':""}</td><td><b>${esc(o.title)}</b><div class="mute">${esc(o.artist)}${o.repress?" · repress":""}</div></td><td>${esc(fmtS(o))}</td><td class="num">${fI(o.qty)}</td><td>${chip(o)}${p!=null&&o.si!==SI.done?`<div style="margin-top:5px;max-width:160px">${bar(p,o.si===SI.run?"acc":"")}</div>`:""}${o.si===SI.run?`<div class="mute mono">${fI(o.good)} of ${fI(o.qty)}</div>`:""}</td><td class="mute">${ago(o.ss)}</td><td class="mute">${e?(e.done?"delivered "+fD(e.t):fD(e.t)):"after gate 3"}</td></tr>`;}).join("")}</table>${L.length>120?`<p class="note">Showing 120 of ${L.length}.</p>`:""}${!L.length?'<p class="mute">No orders match.</p>':""}`
|
||
};
|
||
}
|
||
};
|
||
function orderBlocks(o){
|
||
const st=STAGES[o.si],seq=STAGES.map((s,i)=>i).filter(i=>stageOn(o,STAGES[i]));
|
||
const track=seq.map(i=>{const s=STAGES[i],h=o.hist.filter(x=>x.id===s.id).pop();const cls=i<o.si||o.si===SI.done?"done":i===o.si?"now":"";return `<div class="${cls}">${esc(s.f)}<small>${h?fD(h.a):""}</small></div>`;}).join("");
|
||
const p=pById(o.press);
|
||
const spec=`<dl class="kv"><dt>Format</dt><dd>${esc(fmtL(o))}</dd><dt>Copies</dt><dd>${fI(o.qty)}</dd><dt>Run-out</dt><dd class="mono">${esc(o.catno)} A / ${esc(o.catno)} B</dd><dt>Labels</dt><dd>Printed${o.labelLot?", lot "+o.labelLot+", print-inspected, oven pre-dried":""}</dd><dt>Packing</dt><dd>Inner sleeve, printed jacket${o.shrink?", shrink wrap":""}</dd><dt>Ordered</dt><dd>${fDT(o.created)}</dd>${o.repress?"<dt>Repress</dt><dd>From the archived mothers, no new cut</dd>":""}</dl>`;
|
||
const gates=[1,2,3].filter(g=>!o.repress&&(g!==2||o.mastering)).map(g=>{const a=o.gates[g];const waiting=st.gate===g;return `<dt>Gate ${g}</dt><dd>${a?`<span class="ok">✓</span> ${GATE_T[g]}, ${fDT(a.t)}, ${esc(a.by)}`:waiting?`<span class="warn">waiting ${dur(S.t-o.ss)}</span> for the ${GATE_T[g]}`:`<span class="faint">${GATE_T[g]}</span>`}</dd>`;}).join("");
|
||
let metal="";
|
||
if(o.lac||o.mo){metal=`<div class="gen">${o.lac?`<span>${o.lac[0].id}</span><em>→</em>`:""}${o.fa?`<span>${o.fa[0].id}</span><em>→</em>`:""}${o.mo?`<span>${o.mo[0].id}${o.mo[0].arch?" (archive)":""}</span><em>→</em>`:""}<span>${o.sets.length} stamper set${o.sets.length>1?"s":""}</span></div><p class="note" style="margin:0 0 8px">Side A shown; side B has its own chain. ${o.lac?`Lacquers under the microscope: groove ${Math.round(o.lac[0].w)} µm wide, ${Math.round(o.lac[0].dep)} µm deep; scribed <span class="mono">${esc(o.lac[0].scribe)}</span>, fiducial applied.`:"Repress: grown from the archived mothers."} ${o.mo?"Mothers listened to: pass.":""}</p>${stamperTable(o)}`;}
|
||
const lots=o.lots.map(id=>S.lots.find(L=>L.id===id)).filter(Boolean);
|
||
const comp=lots.length?lots.map(L=>`<p style="margin:0 0 6px"><b class="mono">${L.id}</b> ${esc(MAT[L.mat])}, ${esc(SUP[L.sup])}${L.mat==="black"?". Mixed 80 % virgin / 20 % in-house regrind at the extruder":""}.</p><div class="tw"><table class="t">${lotRow(L).map(([k,v,l,ok])=>`<tr><td>${k}</td><td class="num">${v}</td><td class="num faint">${l}</td><td>${lim(ok)}</td></tr>`).join("")}</table></div>`).join(""):'<p class="mute" style="margin:0">Assigned when the press run starts.</p>';
|
||
const A=o.ta;
|
||
const run=o.si>=SI.run?`<dl class="kv"><dt>Press</dt><dd>${o.press||o.ran||"-"}${p?" · "+esc(pstate(p)):""}</dd><dt>Pressed</dt><dd>${fI(o.good)} good of ${fI(o.qty)}, ${fI(o.rej)} rejected (${f1(pct(o.rej,o.good+o.rej))} %)</dd>${A&&A.n?`<dt>Cycle</dt><dd>${f1(A.cyc/A.n)} s average</dd><dt>Steam</dt><dd>${f1(A.steam/A.n)} °C, ${f1(A.sMin)} to ${f1(A.sMax)}</dd><dt>Cake</dt><dd>${f1(A.cake/A.n)} g, ${f1(A.kMin)} to ${f1(A.kMax)}</dd>`:""}<dt>Stampers</dt><dd>${o.sets.filter(s=>s.plays).map(s=>`set ${s.n}: ${fI(s.plays)} plays (${s.state})`).join("; ")||"-"}</dd></dl>${o.si===SI.run?bar(o.good/o.qty,"acc"):""}`:'<p class="mute" style="margin:0">Not pressed yet.</p>';
|
||
const pack=o.pack?`<dl class="kv"><dt>Assembly scans</dt><dd>${fI(o.si>SI.fin?o.pack.units:Math.min(o.good,o.good*(prog(o)||0)))} of ${fI(o.good)}: run-out code against jacket barcode${o.pack.mism&&o.si>SI.fin?", 1 mismatch caught and corrected":""}</dd><dt>Barcode grade</dt><dd>${o.pack.grade} (ISO/IEC 15416)</dd>${o.ship?`<dt>Carrier</dt><dd>${o.ship.carrier}, ${o.ship.boxes} cartons, tracking <span class="mono">${o.ship.trk}</span> (simulated)</dd>`:""}</dl>`:'<p class="mute" style="margin:0">Not packed yet.</p>';
|
||
return {track,spec,gates,metal,comp,run,pack,p};
|
||
}
|
||
PAGES.order={
|
||
layout:()=>`<a class="back" href="#orders">← Orders</a><div id="odH"></div><div class="track" id="odT"></div><div class="cols3" id="odG"></div>`,
|
||
blocks(){
|
||
const o=S.byId[S.ui.arg];if(!o)return {odH:'<p class="mute">This order is no longer in the active list.</p>',odT:"",odG:""};
|
||
const b=orderBlocks(o),st=STAGES[o.si];
|
||
return {
|
||
odH:`<div class="odh">${discSVG(o,84,false)}<div class="tx"><div class="eyebrow">${o.id}${o.demo?" · demo customer":""}</div><h1>${esc(o.title)}</h1><div class="mute">${esc(o.artist)} · ${esc(fmtL(o))} · ${fI(o.qty)} copies</div><div style="margin-top:6px">${chip(o)} <span class="mute">${esc(subStep(o)||"")}</span></div></div><div class="acts"><a class="btn pri" href="#customer=${o.id}">Customer view →</a></div></div>`,
|
||
odT:b.track,
|
||
odG:`<section class="card"><div class="ch"><h3>Specification</h3></div>${b.spec}</section>
|
||
<section class="card"><div class="ch"><h3>Approvals</h3><span class="mute">nothing passes a gate without one</span></div><dl class="kv">${b.gates||"<dt>Repress</dt><dd>No artist gates: same master, same metal</dd>"}</dl></section>
|
||
<section class="card"><div class="ch"><h3>Optical rejects by reason</h3><span class="mute">100 % inline</span></div>${paretoHTML(o.rejBy)}</section>
|
||
<section class="card" style="grid-column:1/-1"><div class="ch"><h3>Metal genealogy and stamper metrology</h3><span class="mute">XRF, confocal on sampled radial zones, centre slug</span></div>${b.metal||'<p class="mute" style="margin:0">Not cut yet.</p>'}</section>
|
||
<section class="card"><div class="ch"><h3>Press run</h3><span class="mute">PLC telemetry every cycle</span></div>${b.run}</section>
|
||
<section class="card"><div class="ch"><h3>Compound lot</h3><span class="mute">lab gate</span></div>${b.comp}</section>
|
||
<section class="card"><div class="ch"><h3>Packing and shipping</h3></div>${b.pack}</section>
|
||
<section class="card" style="grid-column:1/-1"><div class="ch"><h3>Audio comparison, sampled</h3><span class="mute">first-off per stamper set, about every 50th record, every 25th near end of life</span></div>${audioTable(o.audio,false)}</section>
|
||
<section class="card" style="grid-column:1/-1"><div class="ch"><h3>Events for this order</h3><span class="mute">from the hash-chained log</span></div>${ledgerTable(o.ev.slice(-14).reverse(),false)}</section>`
|
||
};
|
||
}
|
||
};
|
||
PAGES.press={
|
||
layout:()=>`<a class="back" href="#floor">← Floor</a><div id="prH"></div><div class="cols3" id="prG"></div>`,
|
||
blocks(){
|
||
const p=pById(S.ui.arg);if(!p)return {prH:"Unknown press",prG:""};
|
||
const o=p.order,tel=p.tel,last=tel[tel.length-1],bp=S.day.bp[p.id]||{g:0,r:0};
|
||
const tl=(lbl,key,unit,lo,hi,fmt)=>`<div><div class="ch" style="margin:0"><span>${lbl}</span><b class="mono">${last?fmt(last[key])+" "+unit:"-"}</b></div>${spark(tel.map(x=>x[key]),lo,hi)}</div>`;
|
||
return {
|
||
prH:`<div class="ph"><div><div class="eyebrow">${p.kind==="duplex"?"Newbilt Duplex, HRP 12 press "+p.id.slice(-1):"Newbilt Classic, automatic"}</div><h1>${p.id} <span class="chip ${p.st==="run"?"g-out":p.st==="stop"||p.st==="nopvc"?"g-gate":"g-queue"}">${PST[p.st]}</span></h1></div><p class="mute">${esc(pstate(p))}${p.op?" · "+p.op:""}</p></div>`,
|
||
prG:`<section class="card"><div class="ch"><h3>Mounted</h3>${o?`<a href="#order=${o.id}">${o.id}</a>`:""}</div>${o?`<div class="pc-o" style="margin-bottom:10px">${discSVG(o,54,false)}<div><b>${esc(o.title)}</b><div class="mute">${esc(o.artist)}</div><div class="mute">${esc(fmtS(o))}</div></div></div><dl class="kv"><dt>Progress</dt><dd>${fI(o.good)} good of ${fI(o.qty)}, ${fI(o.rej)} rejected</dd><dt>Stampers</dt><dd class="mono">${p.set?p.set.st.map(s=>s.id).join(" / "):"-"}</dd><dt>Plays</dt><dd>${p.set?fI(p.set.plays)+" of "+fI(p.set.life)+" expected life":"-"}</dd><dt>Compound</dt><dd class="mono">${o.lots.slice(-1)[0]||"-"}</dd><dt>Labels</dt><dd class="mono">${o.labelLot||"-"}</dd></dl>`:'<p class="mute">Nothing mounted.</p>'}</section>
|
||
<section class="card"><div class="ch"><h3>Today on this press</h3></div><div class="stats3"><div><b>${fI(bp.g)}</b><span>good</span></div><div><b>${fI(bp.r)}</b><span>rejected</span></div><div><b>${f1(pct(bp.r,bp.g+bp.r))} %</b><span>reject rate</span></div></div><p class="note">${p.kind==="duplex"?"Cycle about 30 s (Newbilt): load by hand 4 to 6.5 s, close, steam, cool, open, eject.":"Cycle 28 s (Newbilt Classic flyer), fully automatic."} Unplanned stops average one per four running hours in this simulation.</p></section>
|
||
<section class="card" style="grid-column:1/-1"><div class="ch"><h3>Telemetry, last ${tel.length} cycles</h3><span class="mute">values simulated around the plan's 10 bar / 180 °C steam</span></div><div class="cols3">${tl("Cycle time","cyc","s",26,34,f1)}${tl("Steam temperature","steam","°C",176,184,f1)}${tl("Steam pressure","bar","bar",9.5,10.5,f2)}${tl("Cooling water supply","cool","°C",15,21,f1)}${tl("Cake weight","cake","g",o?cakeW(o)-5:140,o?cakeW(o)+5:160,f1)}</div></section>
|
||
<section class="card" style="grid-column:1/-1"><div class="ch"><h3>Records from this press</h3></div>${feedHTML(S.feed.filter(r=>r.p===p.id),10)}</section>`
|
||
};
|
||
}
|
||
};
|
||
PAGES.quality={
|
||
layout:()=>`<div class="ph"><div><div class="eyebrow">Factory console</div><h1>Quality</h1></div><p class="mute">Three layers: optical on every record, press telemetry on every cycle, audio on samples</p></div><div class="kpis" id="qK"></div><div class="cols2"><section class="card"><div class="ch"><h3>Rejects today by reason</h3><span class="mute">inline optical inspection</span></div><div id="qP"></div></section><section class="card"><div class="ch"><h3>Yield per press today</h3></div><div id="qY"></div></section></div><section class="card" style="margin-top:16px"><div class="ch"><h3>Audio comparison against the source master</h3><span class="mute">needle-drop, spectral match, clicks, noise floor; limits illustrative</span></div><div id="qA"></div></section><section class="card" style="margin-top:16px"><div class="ch"><h3>Optical inspection, live</h3></div><div id="qF"></div></section>`,
|
||
blocks(){const d=S.day,ins=d.good+d.rej;
|
||
return {qK:kpi("Inspected today",fI(ins),"every record, at the unloader")+kpi("Pass rate",ins?f1(100-pct(d.rej,ins))+" %":"-","Newbilt publishes ≥ 95 % yield")+kpi("Audio samples today",fI(d.samples),"first-off per set, then about 1 in 50")+kpi("Listening reviews",fI(d.rev),"engineer listens to every flag",d.rev?"warn":""),
|
||
qP:paretoHTML(d.by),
|
||
qY:`<div class="tw"><table class="t"><tr><th>Press</th><th class="num">Good</th><th class="num">Rejected</th><th class="num">Rate</th><th>On the press</th></tr>${S.presses.map(p=>{const b=d.bp[p.id]||{g:0,r:0};return `<tr><td class="mono"><a href="#press=${p.id}">${p.id}</a></td><td class="num">${fI(b.g)}</td><td class="num">${fI(b.r)}</td><td class="num">${f1(pct(b.r,b.g+b.r))} %</td><td class="mute">${p.order?esc(p.order.title)+", "+esc(colT(p.order)):PST[p.st]}</td></tr>`;}).join("")}</table></div>`,
|
||
qA:audioTable(S.aud,true),qF:feedHTML(S.feed,20)};}
|
||
};
|
||
PAGES.lab={
|
||
layout:()=>`<div class="ph"><div><div class="eyebrow">Factory console</div><h1>Material lab</h1></div><p class="mute">Every compound lot passes the gate before it reaches an extruder; a new lot is released only by a test pressing</p></div><section class="card"><div class="ch"><h3>Compound lots</h3><span class="mute">day-one instruments: moisture analyzer, lab oven, balance with density kit, spectrophotometer, gloss meter, sieves, muffle furnace, stereo microscope, plaque press</span></div><div class="tw" id="lT"></div><p class="note">Limits are illustrative: the compound specification is still to be written. Supplier B lots also get the external panel during qualification (K-value, XRF stabiliser and heavy metals).</p></section><div class="cols2"><section class="card"><div class="ch"><h3>Regrind batches</h3><span class="mute">rejects and trim, granulated in-house</span></div><div id="lR"></div></section><section class="card"><div class="ch"><h3>Hoppers</h3></div><div id="lH"></div></section></div>`,
|
||
blocks(){
|
||
const all=S.lots.slice().sort((a,b)=>b.arr-a.arr),L=all.filter(x=>x.st!=="empty").concat(all.filter(x=>x.st==="empty").slice(0,3));
|
||
return {
|
||
lT:`<table class="t"><tr><th>Lot</th><th>Material</th><th>Status</th><th class="num">Moisture</th><th class="num">Heat stab.</th><th class="num">ΔE</th><th class="num">Density</th><th class="num">Gloss</th><th class="num">Fines</th><th class="num">Ash</th><th class="num">Specks</th><th class="num">Left</th><th></th></tr>
|
||
<tr class="lim"><td colspan="3">limit, illustrative</td><td class="num">≤ ${f2(LIMS.moist)} %</td><td class="num">≥ ${LIMS.heat} min</td><td class="num">≤ ${f1(LIMS.dE)}</td><td class="num">1.35 to 1.40</td><td class="num">≥ ${LIMS.gloss}</td><td class="num">≤ ${f1(LIMS.fines)} %</td><td class="num">≤ ${f1(LIMS.ash)} %</td><td class="num">≤ ${LIMS.specks}</td><td></td><td></td></tr>
|
||
${L.map(x=>{const r=x.res,s=lotStatus(x),tested=x.st!=="lab";const c=(ok,v)=>`<td class="num ${tested&&!ok?"bad":""}">${tested?v:"…"}</td>`;
|
||
return `<tr><td class="mono nw">${x.id}<div class="mute">${fD(x.arr)}</div></td><td class="mat">${esc(MAT[x.mat])}<div class="mute">${esc(SUP[x.sup])}${r.ext&&tested?`<br>external panel: K-value ${f1(r.ext.k)}, ${r.ext.stab} stabiliser, no lead`:""}</div></td><td class="nw"><span class="chip ${s[0]}">${s[1]}</span>${x.redried?'<div class="mute">re-dried</div>':""}</td>${c(r.moist<=LIMS.moist,f2(r.moist)+" %")}${c(r.heat>=LIMS.heat,Math.round(r.heat)+" min")}${c(r.dE<=LIMS.dE,f2(r.dE))}${c(true,r.dens.toFixed(3))}${c(true,r.gloss!=null?Math.round(r.gloss):"n/a")}${c(true,f2(r.fines)+" %")}${c(true,f2(r.ash)+" %")}${c(true,r.specks)}<td class="num">${fI(x.left)} kg</td><td>${x.st==="hold"?`<button type="button" class="btn" data-act="redry" data-arg="${x.id}">Re-dry and retest</button>`:x.st==="dry"?`<span class="mute">retest ${fT(x.dryDone)}</span>`:""}</td></tr>`;}).join("")}</table>`,
|
||
lR:S.regrind.length?`<table class="t"><tr><th>Batch</th><th>Day</th><th class="num">Weight</th><th>Contamination check</th></tr>${S.regrind.map(r=>`<tr><td class="mono">${r.id}</td><td>${fD(r.t-DAY)}</td><td class="num">${fI(r.kg)} kg</td><td>${verdict(true,"pass, magnets clear")}</td></tr>`).join("")}</table>`:'<p class="mute">First batch at midnight.</p>',
|
||
lH:["black","clear"].map(m=>{const L=S.use[m];return `<div class="si"><div class="r1"><span><b>${m==="black"?"Black":"Colour"} hopper</b></span><span class="mono">${L?L.id:"empty"}</span></div><small>${L?`${fI(L.left)} kg left of ${fI(L.kg)} kg · ${esc(SUP[L.sup])}`:"waiting for a released lot"}</small>${bar(L?L.left/L.kg:0,L&&L.left/L.kg<0.2?"warn":"ok")}</div>`;}).join("")+`<p class="note">Black runs mix 80 % virgin with 20 % regrind at the extruder (Newbilt Duplex specification).</p>`
|
||
};
|
||
}
|
||
};
|
||
PAGES.metal={
|
||
layout:()=>`<div class="ph"><div><div class="eyebrow">Factory console</div><h1>Metal parts</h1></div><p class="mute">Lacquer → father → mother (archived) → stampers; every part has an ID and knows its parents</p></div><div class="cols2"><section class="card"><div class="ch"><h3>Cutting room, baths and prep</h3><span class="mute">Lathe 1 · Pheenix electroforming, 2 cells · Sibert</span></div><div id="mB"></div></section><section class="card"><div class="ch"><h3>Mounted stampers</h3></div><div id="mM"></div></section></div><section class="card" style="margin-top:16px"><div class="ch"><h3>Recent stamper metrology</h3><span class="mute">XRF nickel thickness, flatness, groove depth on four radial zones, hardness on the centre slug, centring after punch</span></div><div id="mR"></div></section><section class="card" style="margin-top:16px"><div class="ch"><h3>Archive</h3></div><div id="mA"></div></section>`,
|
||
blocks(){
|
||
const plating=S.orders.filter(o=>o.si===SI.plate||o.si===SI.prep||o.si===SI.cut).sort((a,b)=>a.si-b.si||a.ss-b.ss);
|
||
const rec=S.orders.filter(o=>o.si>SI.prep&&o.sets.length&&o.sets[0].st[0].res).sort((a,b)=>(b.hist.find(h=>h.id==="prep")||{a:0}).a-(a.hist.find(h=>h.id==="prep")||{a:0}).a).slice(0,3);
|
||
const mothers=S.orders.reduce((a,o)=>a+(o.mo&&!o.repress?o.mo.length:0),0);
|
||
return {
|
||
mB:plating.length?plating.map(o=>`<div class="si"><div class="r1"><span><a href="#order=${o.id}">${o.id}</a> ${esc(o.title)}</span>${chip(o)}</div><small>${esc(subStep(o))}${o.cell&&o.si===SI.plate?" · "+o.cell:""}</small>${bar(prog(o),"metal")}</div>`).join(""):'<p class="mute">Nothing in progress.</p>',
|
||
mM:S.presses.map(p=>`<div class="si"><div class="r1"><span class="mono"><a href="#press=${p.id}">${p.id}</a> ${p.set?p.set.st.map(s=>s.id).join(" / "):""}</span><span class="mono mute">${p.set?fI(p.set.plays)+" / "+fI(p.set.life):PST[p.st]}</span></div>${bar(p.set?p.set.plays/p.set.life:0,p.set&&p.set.plays/p.set.life>0.9?"warn":"metal")}</div>`).join(""),
|
||
mR:rec.length?rec.map(o=>`<p style="margin:10px 0 4px"><a href="#order=${o.id}">${o.id}</a> ${esc(o.title)} <span class="mute">${o.repress?"repress from the archive":""}</span></p>${stamperTable(o)}`).join(""):'<p class="mute">No stampers measured yet.</p>',
|
||
mA:`<div class="stats3"><div><b>${fI(mothers)}</b><span>mothers archived, orders on file</span></div><div><b>${fI(cnt(o=>o.repress))}</b><span>represses in the pipeline</span></div><div><b>${fI(S.orders.reduce((a,o)=>a+o.sets.filter(s=>s.state==="rack").length,0))}</b><span>stamper sets in the rack</span></div></div><p class="note">A repress skips cutting and the father: new stampers grow from the archived mother, which is the cost and lead-time advantage of plating in-house.</p>`
|
||
};
|
||
}
|
||
};
|
||
function ledgerTable(L,showOrder){return `<div class="tw"><table class="t"><tr><th>Time</th><th>Actor</th><th>Event</th><th>Hash</th></tr>${L.map(e=>`<tr><td class="mono" style="white-space:nowrap">${fDT(e.t)}</td><td class="mute" style="white-space:nowrap">${esc(e.actor)}</td><td>${esc(e.txt).replace(/CNX-\d+/,m=>`<a href="#order=${m}">${m}</a>`)}</td><td class="mono mute" title="previous ${e.p}">${e.h.slice(0,10)}</td></tr>`).join("")}</table></div>`;}
|
||
PAGES.ledger={
|
||
layout:()=>`<div class="ph"><div><div class="eyebrow">Factory console</div><h1>Event log</h1></div><p class="mute">Append-only; each entry carries the hash of the one before, so an edit anywhere breaks the chain</p></div><section class="card"><div id="gL"></div></section>`,
|
||
blocks:()=>({gL:ledgerTable(S.led.slice(-60).reverse(),true)+`<p class="note">Showing the latest 60 of ${fI(S.ledN)} events since the simulation started. The demo uses a short hash; the factory application uses SHA-256.</p>`})
|
||
};
|
||
|
||
/* ================= customer portal ================= */
|
||
function cStatus(o){
|
||
const st=STAGES[o.si].id,p=pById(o.press),e=eta(o);
|
||
if(st==="run"){
|
||
const live=p&&p.st==="run";
|
||
const head=!p?"Pressing paused":live?"Pressing now":p.st==="change"?"Setting up the press":p.st==="off"?"Pressing resumes at 06:00":p.st==="warm"?"Press warming up":"Short stop on the press";
|
||
const sub=!p?"Your run continues on the next free press.":live?`On press ${p.id}, about ${Math.round(rateH(p))} records an hour. Pressing finishes about ${fDT(e.runEnd)}.`:p.st==="change"?`Your stampers, labels and compound are going on press ${p.id}. First records in about ${dur(p.until-S.t)}.`:p.st==="off"?"The press floor runs two shifts, 06:00 to 22:00.":p.st==="warm"?`Shift start. First records in about ${dur(p.until-S.t)}.`:`${p.why}. Back in about ${dur(p.until-S.t)}.`;
|
||
return `<div class="hl press">${head}</div><div class="big">${fI(o.good)}<span> of ${fI(o.qty)} pressed</span></div>${bar(o.good/o.qty,"acc")}<p>${esc(sub)}</p><p>Estimated delivery <b style="color:var(--ink)">${fDW(e.t)}</b></p>`;
|
||
}
|
||
if(STAGES[o.si].gate)return `<div class="hl gate">Waiting for you</div><div class="big" style="font-size:34px">Approve the ${GATE_T[STAGES[o.si].gate]}</div><p>Nothing goes ahead without your approval. Waiting since ${fDW(o.ss)}.</p>`;
|
||
if(st==="done")return `<div class="hl done">Delivered</div><div class="big" style="font-size:34px">${fDW(o.ss)}</div><p>${fI(o.good)} copies, every one inspected. Your mothers stay in our archive, so a repress skips cutting and plating the father.</p>`;
|
||
if(st==="queue")return `<div class="hl prog">Pressing slot booked</div><div class="big" style="font-size:34px">Starts about ${fDT(e.start)}</div><p>Planned on press ${e.press}${pById(e.press).kind==="duplex"?", a Newbilt Duplex":", the Newbilt Classic"}.</p><p>Your stampers are measured and in the rack. Estimated delivery <b style="color:var(--ink)">${fDW(e.t)}</b>.</p>`;
|
||
if(st==="ship"||st==="fin")return `<div class="hl done">${st==="ship"?"On the way":"Packing"}</div><div class="big" style="font-size:34px">Arrives about ${fDW(e.t)}</div><p>${st==="ship"?`${o.ship.carrier}, ${o.ship.boxes} cartons, tracking ${o.ship.trk} (simulated).`:`Every copy is scanned into its jacket: the run-out code against the jacket barcode.`}</p>`;
|
||
const f=prog(o)||0;
|
||
return `<div class="hl prog">${esc(STAGES[o.si].n)}</div><div class="big" style="font-size:34px">${esc(subStep(o))}</div>${bar(f)}<p>Step ${STAGES.slice(0,o.si+1).filter(s=>stageOn(o,s)).length} of ${STAGES.filter(s=>stageOn(o,s)).length}. This step usually takes ${STAGES[o.si].d?STAGES[o.si].d.join(" to ")+" days":"a day"}. You approve the test pressing before anything is pressed.</p>`;
|
||
}
|
||
function cSteps(o){
|
||
const seq=STAGES.map((s,i)=>i).filter(i=>stageOn(o,STAGES[i]));
|
||
return `<ol class="tl">${seq.map(i=>{const s=STAGES[i],h=o.hist.filter(x=>x.id===s.id).pop();
|
||
const state=i<o.si||(o.si===SI.done)?"done":i===o.si?"now":"next";
|
||
let when="";if(state==="done")when=fDW(s.id==="done"?h.a:(h.b||h.a));else if(state==="now")when=s.gate?"waiting for you since "+fDW(o.ss):s.id==="run"?fI(o.good)+" of "+fI(o.qty)+" pressed":"in progress, "+Math.round(100*(prog(o)||0))+" %";
|
||
return `<li class="${state}${s.gate?" gate":""}"><span class="dot"></span><div><b>${esc(state==="done"?s.c:s.n.replace(/^Your approval: /,"Approve the "))}</b><small>${esc(when)}</small></div></li>`;}).join("")}</ol>`;
|
||
}
|
||
function cQual(o){
|
||
const out=[],card=(t,ok,sub,body,wide)=>`<div class="qc${wide?" wide":""}"><h3>${t}${ok?`<span class="ok">✓ ${ok}</span>`:""}</h3>${sub?`<p>${sub}</p>`:""}${body||""}</div>`;
|
||
const done=id=>o.hist.some(h=>h.id===id&&h.b!=null);
|
||
if(o.files&&(done("intake")||o.si>0))out.push(card("Your files","checked",`${o.files.fmt}, ${o.files.bits}-bit / ${o.files.sr} kHz, checksums verified at both ends.`,`<table class="t">${o.files.len.map((m,i)=>`<tr><td>Side ${SIDES[i]}</td><td class="num">${Math.floor(Math.round(m*60)/60)}:${pad(Math.round(m*60)%60)}</td><td class="num faint">at most ${o.files.lim} min</td><td>${lim(true)}</td></tr>`).join("")}<tr><td>True peak</td><td class="num">${f1(o.files.peak)} dBTP</td><td></td><td>${lim(true)}</td></tr></table>`));
|
||
if(o.lac&&done("cut"))out.push(card("Your lacquers","inspected",`Cut on Lathe 1 and checked under the microscope. Your run-out text is scribed by hand next to the run-out groove.`,`<table class="t"><tr><th>Lacquer</th><th class="num">Groove width</th><th class="num">Depth</th><th>Run-out</th></tr>${o.lac.map(l=>`<tr><td class="mono">${l.id}</td><td class="num">${Math.round(l.w)} µm</td><td class="num">${Math.round(l.dep)} µm</td><td class="mono">${esc(l.scribe)}</td></tr>`).join("")}</table>`));
|
||
if(o.mo&&(done("plate")||o.si===SI.plate)){const inb=o.si===SI.plate;
|
||
out.push(card("Your metal parts",done("prep")?"measured":"",inb?`In the bath now: ${esc(subStep(o).toLowerCase())}. `+(o.repress?"":"From your lacquer we grow a father, from the father a mother, and from the mother the stampers."):`From your lacquer${o.repress?" (first pressing)":""} we grew a father, a mother and ${o.sets.length*2} stampers. The mother stays in our archive for represses.`,
|
||
`<div class="gen">${o.lac?`<span>${o.lac[0].id}</span><em>→</em>`:""}${o.fa?`<span>${o.fa[0].id}</span><em>→</em>`:""}<span>${o.mo[0].id}</span><em>→</em><span>${o.sets[0].st[0].id}${o.sets.length>1?" +"+(o.sets.length-1):""}</span></div>${inb?bar(prog(o),"metal"):""}${done("prep")?stamperTable(o):""}`,done("prep")));}
|
||
if(o.tp&&done("tp"))out.push(card("Test pressings","checked",`${o.tp.n} pressed, ${o.tp.you} sent to you. Optical inspection passed on both sides; audio compared with your master:`,`<table class="t">${o.tp.sides.map(s=>`<tr><td>Side ${s.side}</td><td class="num">${f1(s.score)} % match</td><td class="num">${s.clicks} clicks</td><td class="num">${Math.round(s.noise)} dB</td></tr>`).join("")}</table>${o.gates[3]?`<p class="note">You approved it ${fDW(o.gates[3].t)}.</p>`:""}`));
|
||
const lots=o.lots.map(id=>S.lots.find(L=>L.id===id)).filter(Boolean);
|
||
for(const L of lots)out.push(card("Your vinyl compound","lab released",`Lot <span class="mono">${L.id}</span>, ${esc(MAT[L.mat].toLowerCase())}. Tested in our lab before release, then released by a test pressing.`,`<table class="t">${lotRow(L).map(([k,v,l,ok])=>`<tr><td>${k}</td><td class="num">${v}</td><td class="num faint">${l}</td><td>${lim(ok)}</td></tr>`).join("")}</table>`));
|
||
if(o.si>=SI.run&&(o.good||o.rej)){const A=o.ta;
|
||
out.push(card("Pressing and inspection",o.si>SI.run?"complete":"",`Every record is photographed and checked at the press. Rejected records are granulated, never shipped.`,`<div class="stats3"><div><b>${fI(o.good)}</b><span>passed</span></div><div><b>${fI(o.rej)}</b><span>rejected</span></div><div><b>${f1(100-pct(o.rej,o.good+o.rej))} %</b><span>pass rate</span></div></div>${paretoHTML(o.rejBy).replace(/<p class="note">.*<\/p>/,"")}${A&&A.n?`<p class="note">Press ${o.press||o.ran}: cycle ${f1(A.cyc/A.n)} s on average, steam ${f1(A.sMin)} to ${f1(A.sMax)} °C, cake ${f1(A.kMin)} to ${f1(A.kMax)} g. Labels lot ${o.labelLot}, print-inspected against your proof and pre-dried.</p>`:""}`));}
|
||
if(o.audio.length)out.push(card("Listening checks",o.audio.some(a=>a.rt<=S.t)?o.audio.filter(a=>a.rt<=S.t).length+" passed":"",`A pulled copy is played and compared with your master: the first copy from each stamper set, then about every 50th.`,audioTable(o.audio,false),true));
|
||
if(o.pack&&o.si>=SI.fin)out.push(card("Packing",o.si>SI.fin?"complete":"",`Each copy is scanned into its jacket: run-out code against the jacket barcode. Barcode graded ${o.pack.grade} (ISO/IEC 15416).`));
|
||
if(!out.length)out.push(card("Quality report","",`Results appear here as each step finishes: file checks, lacquer inspection, metal measurements, test pressings, the lab certificate of your vinyl compound, inspection of every pressed copy.`));
|
||
return out.join("");
|
||
}
|
||
const CUST={
|
||
layout(){const o=S.byId[S.ui.cust];
|
||
return `<div class="cdemo"><span>Demo only, view as:</span>${Object.entries(DEMO).map(([k,id])=>{const d=S.byId[id];return `<a class="fbtn ${id===S.ui.cust?"on":""}" href="#customer=${id}">${esc(d.artist)}</a>`;}).join("")}${o&&!o.demo?`<span class="fbtn on">${esc(o.artist)}</span>`:""}<a class="fac-l" href="#order=${S.ui.cust}">Same order in the factory console →</a></div>
|
||
<div class="chead"><div><div class="eyebrow">Your orders</div></div><div class="who">Signed in as <b>${o?esc(o.artist):""}</b> · CNX Vinyl portal (working name)</div></div>
|
||
<section class="chero"><div class="dw" id="cDisc"></div><div><div class="mute mono" id="cId"></div><h1 id="cT"></h1><div class="art" id="cA"></div><div class="specs" id="cSp"></div><div class="status" id="cSt"></div><div id="cAct"></div></div></section>
|
||
<div class="live" id="cLive"></div>
|
||
<div class="cgrid"><section><div class="sh2">Progress</div><div id="cSteps"></div></section><section><div class="sh2">Quality report</div><div class="qgrid" id="cQ"></div></section></div>
|
||
<div class="cfoot">Demo with simulated data. Limits shown are illustrative: the plant's acceptance specifications are still to be written. In the live portal you see only your own orders, and every value comes from the same records the factory works from. <a href="#customer=${S.ui.cust}" data-act="trace">Download the trace report (PDF)</a></div>`;},
|
||
blocks(){const o=S.byId[S.ui.cust];if(!o)return {cT:"Order not found"};
|
||
const p=pById(o.press),spin=o.si===SI.run&&p&&p.st==="run";
|
||
const g=STAGES[o.si].gate;
|
||
let live="";
|
||
if(o.si===SI.run||(o.si>SI.run&&o.feed.length)){
|
||
live=`<div class="lc"><h3>Your latest copies</h3>${o.feed.length?`<div class="feed">${o.feed.slice(-8).reverse().map(r=>`<div class="fr ${r.ok?"":"bad"}" style="grid-template-columns:62px 74px minmax(0,1fr) minmax(0,0.9fr)"><span class="mono">${fTs(r.t)}</span><span class="mono">${r.ok?"#"+fI(r.copy):"-"}</span><span class="mono k">${esc(r.key.split(" · ").slice(0,2).join(" · "))}</span><span class="v">${r.ok?"✓ passed":"✕ "+esc(r.r)}</span></div>`).join("")}</div>`:'<p class="mute">The first copies appear here as they leave the press.</p>'}<p class="note">Record key: stamper, press and the second it was pressed. It is printed on the sleeve as a code and opens the record passport.</p></div>
|
||
<div class="lc"><h3>Right now</h3><div class="stats3"><div><b>${fI(o.good)}</b><span>pressed and passed</span></div><div><b>${fI(o.rej)}</b><span>caught by the camera</span></div><div><b>${fI(o.audio.filter(a=>a.rt<=S.t).length)}</b><span>listening checks passed</span></div></div>${p&&p.tel.length?`<p class="note">Press ${p.id} last cycle: ${f1(p.tel[p.tel.length-1].cyc)} s, steam ${f1(p.tel[p.tel.length-1].steam)} °C, cake ${f1(p.tel[p.tel.length-1].cake)} g.</p>`:""}${o.audio.some(a=>a.rt>S.t)?`<p class="note">${(n=>n+(n>1?" copies":" copy"))(o.audio.filter(a=>a.rt>S.t).length)} at the listening station now.</p>`:""}</div>`;}
|
||
return {
|
||
cDisc:discSVG(o,250,spin)+"<!--"+(spin?"s":"")+"-->",
|
||
cId:`Order ${o.id} · simulated · ordered ${fDW(o.created)}`,
|
||
cT:esc(o.title),cA:esc(o.artist),
|
||
cSp:[fmtL(o),fI(o.qty)+" copies","Run-out "+o.catno,o.shrink?"Shrink wrap":"No shrink wrap"].map(x=>`<span>${esc(x)}</span>`).join(""),
|
||
cSt:cStatus(o),
|
||
cAct:g?`<div class="action"><h3>${g===3?"Your test pressings arrived. How do they sound?":g===2?"Your vinyl master is ready to hear":"Your master plan is ready"}</h3><p>${g===3?"Play both sides. When you approve, we book your pressing slot and give you a delivery date. If something is wrong, tell us and nothing is pressed.":"Listen and approve, or tell us what to change. Nothing goes ahead without your approval."}</p><div class="row"><button type="button" class="btn pri" data-act="approve" data-arg="${o.id}">Approve the ${GATE_T[g]}</button><button type="button" class="btn" data-act="problem" data-arg="${o.id}">Something is wrong</button></div></div>`:"",
|
||
cLive:live,cSteps:cSteps(o),cQ:cQual(o)
|
||
};}
|
||
};
|
||
|
||
/* ================= render, routing, controls ================= */
|
||
const CACHE={};let freezeUntil=0;
|
||
function put(id,html){if(html==null)return;const el=document.getElementById(id);if(!el)return;if(CACHE[id]===html)return;CACHE[id]=html;el.innerHTML=html;}
|
||
function topRender(){
|
||
const d=new Date(S.t);
|
||
put("clk",fDW(S.t)+" · "+fTs(S.t));
|
||
put("shiftLbl",esc((S.run?(S.speed===1?"live":"simulated, "+S.speed+"× speed"):"paused")+" · "+(inShift(S.t)?(d.getHours()<14?"shift 1":"shift 2"):"night")));
|
||
$$("#speedSeg button").forEach(b=>b.classList.toggle("on",+b.dataset.arg===S.speed&&S.run));
|
||
const pb=$("#playBtn");const lbl=S.run?"Pause the simulation":"Resume the simulation";if(pb.title!==lbl){pb.title=lbl;pb.setAttribute("aria-label",lbl);pb.innerHTML=S.run?"❚❚":"▶";}
|
||
}
|
||
function render(){
|
||
if(S.warm)return;topRender();
|
||
if(S.ui.view==="customer"){const B=CUST.blocks();for(const k in B)put(k,B[k]);}
|
||
else{put("fnav",navHTML());const B=PAGES[S.ui.fpage].blocks();for(const k in B)put(k,B[k]);}
|
||
}
|
||
function layout(){
|
||
const cust=S.ui.view==="customer";
|
||
$("#factory").hidden=cust;$("#customer").hidden=!cust;
|
||
$$(".vsw button").forEach(b=>b.classList.toggle("on",b.dataset.arg===(cust?"customer":"factory")));
|
||
for(const k in CACHE)delete CACHE[k];
|
||
if(cust)$("#customer").innerHTML=CUST.layout();else $("#fmain").innerHTML=PAGES[S.ui.fpage].layout();
|
||
const q=$("#oq");if(q)q.addEventListener("input",()=>{S.ui.q=q.value;render();});
|
||
render();
|
||
}
|
||
function route(){
|
||
const h=decodeURIComponent(location.hash.slice(1)),i=h.indexOf("="),k=i<0?h:h.slice(0,i),v=i<0?null:h.slice(i+1);
|
||
const prev=S.ui.view+S.ui.fpage+S.ui.arg+S.ui.cust;
|
||
if(k==="customer"){S.ui.view="customer";S.ui.cust=v&&S.byId[v]?v:(S.ui.cust&&S.byId[S.ui.cust]?S.ui.cust:DEMO.press);}
|
||
else{S.ui.view="factory";S.ui.fpage=PAGES[k]?k:"floor";S.ui.arg=v;S.ui.fhash=h||"floor";}
|
||
if(prev!==S.ui.view+S.ui.fpage+S.ui.arg+S.ui.cust)window.scrollTo(0,0);
|
||
layout();
|
||
}
|
||
function toast(msg){const t=$("#toast");t.textContent=msg;t.hidden=false;clearTimeout(toast.h);toast.h=setTimeout(()=>{t.hidden=true;},4200);}
|
||
const ACT={
|
||
view(v){location.hash=v==="customer"?"customer="+(S.ui.cust||DEMO.press):(S.ui.fhash||"floor");},
|
||
speed(v){S.speed=+v;S.run=true;topRender();},
|
||
play(){S.run=!S.run;topRender();},
|
||
filter(v){S.ui.filter=v;if(S.ui.fpage!=="orders")location.hash="orders";else render();},
|
||
approve(id){const o=S.byId[id];const g=o&&STAGES[o.si].gate;if(!g)return;approve(o,g,S.t,"Customer portal");if(g===3)o.prio=1;enter(o,nextIdx(o),S.t);
|
||
toast(g===3?"Approved. Your pressing slot is booked; the start time is on the order now.":"Approved. The next step starts now.");render();},
|
||
problem(){toast("In the live portal this opens a message thread with your engineer, with your notes and timestamps. The order waits at this gate until it is resolved.");},
|
||
redry(id){const L=S.lots.find(x=>x.id===id);if(!L||L.st!=="hold")return;L.st="dry";L.dryDone=S.t+4*HR;log(S.t,"lab",`${L.id} re-drying for 4 h, then retest`,"Lab",null);render();},
|
||
trace(){toast("The trace report is a PDF of everything on this page, signed with the log hash. Not generated in the demo.");}
|
||
};
|
||
document.addEventListener("pointerdown",()=>{freezeUntil=performance.now()+650;},true);
|
||
document.addEventListener("click",e=>{
|
||
const a=e.target.closest("[data-act]");
|
||
if(a&&!a.disabled){if(a.tagName==="A")e.preventDefault();ACT[a.dataset.act]&&ACT[a.dataset.act](a.dataset.arg,a);return;}
|
||
const r=e.target.closest("tr[data-go]");if(r&&!e.target.closest("a")){location.hash=r.dataset.go;}
|
||
});
|
||
window.addEventListener("hashchange",route);
|
||
/* theme */
|
||
(function(){
|
||
const b=$("#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";b.setAttribute("aria-label",b.title);}
|
||
set(de.getAttribute("data-theme")||"dark",false);
|
||
b.addEventListener("click",()=>{set(de.getAttribute("data-theme")==="light"?"dark":"light",true);b.blur();});
|
||
const mq=window.matchMedia&&matchMedia("(prefers-color-scheme: light)");
|
||
if(mq&&mq.addEventListener)mq.addEventListener("change",e=>{let s=null;try{s=localStorage.getItem("cnx-theme")}catch(_){}if(!s)set(e.matches?"light":"dark",false);});
|
||
})();
|
||
function topH(){document.documentElement.style.setProperty("--toph",$("#top").offsetHeight+"px");}
|
||
window.addEventListener("resize",topH);
|
||
/* loop */
|
||
let lastF=performance.now(),lastR=0;
|
||
function frame(now){
|
||
const dt=Math.min(250,now-lastF);lastF=now;
|
||
if(S.run)advanceSim(dt*S.speed);
|
||
if(now-lastR>350&&now>freezeUntil){lastR=now;render();}
|
||
requestAnimationFrame(frame);
|
||
}
|
||
seed();topH();route();requestAnimationFrame(frame);
|
||
window.__fc={S,STAGES,SI,DEMO,PAGES,advanceSim,render,route,eta,pById,ACT,get now(){return S.t;}};
|
||
</script>
|
||
</body>
|
||
</html>
|