Files
cnx_vinyl/docs/fleet-calculator.html
T
grabowski 7d7a378c8b
Deploy test site / deploy (push) Successful in 34s
Fleet calculator: machine park schematic with the stamper line feeding it
User, October 2026: add a visualization of the machine park and the
connected stamper production to the tool.

- New card "Machine park and stamper production": one live SVG that
  follows every input. Top: the stamper line, lathe, Pheenix PASU, PPPU,
  PDPU (2 cells), PMPB, Sibert VSF, VSP, VSC, each with its count, flow a
  day and how busy it is. Middle: the press hall, each Duplex cell drawn as
  in the cell sketch (presses either side of the operator, EX 50 and EX 35
  behind, trimmer in front) and each Classic with its built-in extruder,
  wrapping into rows and scaling down for big fleets; compound in on the
  left, stamper rack in and finishing out on the right. Bottom: central
  hydraulics, boiler, cooling, air and electrical piped up into the hall,
  amber where they exceed the plant as planned today.
- Stamper production is now counted per station from the cycle times in
  galvanics.md (silvering, pre-plate four at a time, father, mother,
  stamper plus handling per PDPU cell, PMPB, Sibert steps; usable share of
  the hours 80 %, estimate) instead of a flat 20 to 32 parts per PDPU.
  At the defaults: 1 PDPU at ~43 % busy, as galvanics.md concludes.
  New assumptions with sources; the lathe rate (8 sides a day) is an
  estimate.
- Requirements table: per-station counts and load; the URL hash now also
  reloads in an open tab (hashchange).

Checked headless in Edge, both themes: no JS errors, no text overlap or
overflow in the schematic at the default, 8 M and 30 M records, no
horizontal overflow at 390 px.
2026-10-10 19:30:05 +07:00

832 lines
51 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>CNX Vinyl · press fleet calculator</title>
<script>
(function(){var t=null;try{t=localStorage.getItem("cnx-theme")}catch(e){}
if(t!=="light"&&t!=="dark")t=window.matchMedia&&matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";
document.documentElement.setAttribute("data-theme",t);})();
</script>
<style>
:root {
--bg: #0a0f1a; --panel: #0f1626; --panel2: #131c30; --stroke: #2a3a56;
--line: #1c2840; --fg: #d6deeb; --mute: #8a97b0; --accent: #3ddc84;
--warn: #ffb547; --bad: #ff6b6b; --dspec: #c58bff; --dstd: #6aa8ff;
--classic: #3ddc84;
}
:root { color-scheme: dark; --input: #0a1120; --flagw: #1b1a12; --flago: #0f1a14; --flagb: #1d1214; --cur: #12261c;
--bv: #9fd0ff; --bvb: #2c4c73; --bd: #b5e8c7; --bdb: #2b5a3d; --be: #ffd59a; --beb: #6b5426; --bu: #e2c4ff; --bub: #563a73; }
html[data-theme=light] {
color-scheme: light;
--bg: #f4f6fa; --panel: #ffffff; --panel2: #eef2f8; --stroke: #cfd8e6; --line: #e4e9f1;
--fg: #1b2533; --mute: #5b6880; --accent: #0e8f4f; --warn: #c27c00; --bad: #c62828;
--dspec: #9b5de5; --dstd: #2f7de1; --classic: #1fae68;
--input: #ffffff; --flagw: #fff6e0; --flago: #e8f6ee; --flagb: #fdeaea; --cur: #e1f3e8;
--bv: #1f5f9e; --bvb: #9cc0e6; --bd: #1e7a45; --bdb: #9fd3b3; --be: #8a5a00; --beb: #e3c27a; --bu: #6b3fa0; --bub: #c9a8ec;
}
:root { --pk-press: #2a3a56; --pk-pressS: #6d86b3; --pk-glyph: #3a4d70; }
html[data-theme=light] { --pk-press: #c7d1df; --pk-pressS: #8e9db4; --pk-glyph: #b8c4d6; }
#park { display: block; width: 100%; height: auto; min-width: 900px; }
#park text { font-family: inherit; }
.pk-box { fill: var(--panel2); stroke: var(--stroke); }
.pk-box.warn { stroke: var(--warn); stroke-width: 2; }
.pk-hall { fill: none; stroke: var(--stroke); stroke-width: 1.4; stroke-dasharray: 7 5; }
.pk-cell { fill: var(--panel); stroke: var(--stroke); }
.pk-press { fill: var(--pk-press); stroke: var(--pk-pressS); stroke-width: 1.2; }
.pk-col { fill: var(--pk-pressS); }
.pk-ex50 { fill: var(--dstd); } .pk-ex35 { fill: var(--dspec); }
.pk-op { fill: #e8b400; } .pk-trim { fill: var(--mute); } .pk-disc { fill: #111; stroke: var(--mute); }
.pk-glyph { fill: var(--pk-glyph); stroke: var(--pk-pressS); }
.pk-feed { stroke: var(--mute); stroke-width: 1; fill: none; stroke-dasharray: 2 3; }
.pk-t { fill: var(--fg); font-size: 12px; font-weight: 600; }
.pk-s { fill: var(--mute); font-size: 10.5px; }
.pk-v { fill: var(--fg); font-size: 15px; font-weight: 700; }
.pk-n { fill: var(--fg); font-size: 19px; font-weight: 700; }
.pk-lane { fill: var(--mute); font-size: 11px; letter-spacing: 0.12em; font-weight: 600; }
.pk-arrow { stroke: var(--mute); stroke-width: 1.6; fill: none; }
.pk-head { fill: var(--mute); }
.pk-util { fill: var(--line); } .pk-utilv { fill: var(--accent); } .pk-utilv.hi { fill: var(--warn); }
.pk-pipe { fill: none; stroke-width: 3; stroke-dasharray: 8 5; opacity: 0.85; }
.pk-steam { stroke: #e2462a; } .pk-cool { stroke: #2f7de1; } .pk-hyd { stroke: #d48600; }
.pk-air { stroke: #8a97b0; } .pk-pow { stroke: #c9a800; }
.themebtn {
position: absolute; top: 24px; right: 20px; background: var(--panel); color: var(--fg);
border: 1px solid var(--stroke); border-radius: 5px; padding: 6px 12px; font: inherit; font-size: 14px; cursor: pointer;
}
.themebtn:hover { border-color: var(--accent); }
header { position: relative; }
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--fg);
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
a { color: var(--accent); }
header, .wrap { max-width: 1320px; margin: 0 auto; padding: 0 20px; }
header { padding-top: 28px; }
h1 { margin: 0; font-size: 26px; letter-spacing: 0.02em; }
.tag {
display: inline-block; margin-top: 6px; padding: 2px 8px;
border: 1px solid var(--stroke); border-radius: 4px; color: var(--mute); font-size: 13px;
}
.lead { color: var(--mute); max-width: 920px; margin: 14px 0 22px; }
.grid { display: grid; grid-template-columns: 370px 1fr; gap: 18px; align-items: start; }
@media (max-width: 960px) { .grid { grid-template-columns: 1fr; } }
.card {
background: var(--panel); border: 1px solid var(--stroke); border-radius: 8px;
padding: 16px 18px; margin-bottom: 18px;
}
h2 {
font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--mute); margin: 0 0 12px;
}
.row { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin: 9px 0; }
.row > span { flex: 1; }
.row small, .hint { color: var(--mute); font-size: 12.5px; }
.hint { margin: 6px 0 0; }
input[type=number] {
width: 118px; background: var(--input); color: var(--fg); border: 1px solid var(--stroke);
border-radius: 4px; padding: 5px 7px; font: inherit; text-align: right;
font-variant-numeric: tabular-nums;
}
input[type=number]:focus { outline: none; border-color: var(--accent); }
input[type=range] { width: 100%; accent-color: var(--accent); }
.seg { display: flex; border: 1px solid var(--stroke); border-radius: 6px; overflow: hidden; margin: 6px 0 10px; }
.seg button {
flex: 1; background: transparent; color: var(--mute); border: 0; padding: 7px 8px;
font: inherit; font-size: 14px; cursor: pointer;
}
.seg button + button { border-left: 1px solid var(--stroke); }
.seg button.on { background: var(--panel2); color: var(--accent); }
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.pair .row { margin: 4px 0; }
.pair input[type=number] { width: 70px; }
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; }
.kpi { background: var(--panel2); border: 1px solid var(--stroke); border-radius: 6px; padding: 10px 12px; }
.kpi b { display: block; font-size: 24px; font-variant-numeric: tabular-nums; }
.kpi span { color: var(--mute); font-size: 13px; }
.kpi.d b { color: var(--dstd); } .kpi.c b { color: var(--classic); }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { padding: 6px 8px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
th { color: var(--mute); font-weight: 500; font-size: 12.5px; }
td.n, th.n { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
tr.grp td {
color: var(--accent); font-size: 12px; text-transform: uppercase; letter-spacing: 0.07em;
padding-top: 14px; border-bottom-color: var(--stroke);
}
td.src { color: var(--mute); font-size: 12.5px; }
.flag { border-left: 3px solid var(--warn); background: var(--flagw); padding: 7px 10px; margin: 8px 0 0; font-size: 14px; }
.flag.ok { border-color: var(--accent); background: var(--flago); }
.flag.bad { border-color: var(--bad); background: var(--flagb); }
.bar { display: flex; height: 24px; border-radius: 4px; overflow: hidden; margin: 14px 0 8px; background: var(--panel2); }
.bar div { height: 100%; }
.legend { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 13.5px; color: var(--mute); }
.legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 6px; }
#ladT tbody tr { cursor: pointer; }
#ladT tbody tr:hover td { background: var(--panel2); }
#ladT tbody tr.cur td { background: var(--cur); }
.badge {
display: inline-block; font-size: 11px; padding: 0 6px; border-radius: 3px;
border: 1px solid var(--stroke); color: var(--mute); white-space: nowrap;
}
.badge.vendor { color: var(--bv); border-color: var(--bvb); }
.badge.doc { color: var(--bd); border-color: var(--bdb); }
.badge.est { color: var(--be); border-color: var(--beb); }
.badge.user { color: var(--bu); border-color: var(--bub); }
#asT input[type=number] { width: 92px; }
#asT tr.chg input { border-color: var(--warn); }
.btns { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 12px; }
.btns button {
background: var(--panel2); color: var(--fg); border: 1px solid var(--stroke); border-radius: 5px;
padding: 7px 12px; font: inherit; font-size: 14px; cursor: pointer;
}
.btns button:hover { border-color: var(--accent); }
.note { color: var(--mute); font-size: 13.5px; }
.note p { margin: 6px 0; }
footer { color: var(--mute); font-size: 13px; padding: 10px 20px 40px; max-width: 1320px; margin: 0 auto; }
[hidden] { display: none !important; }
.grid > div { min-width: 0; }
.tscroll { overflow-x: auto; }
.tscroll table { min-width: 560px; }
#ladT { min-width: 860px; }
</style>
</head>
<body>
<header>
<button class="themebtn" id="themeBtn" type="button">Light mode</button>
<h1>Press fleet calculator</h1>
<span class="tag">planning estimates · not a quote</span>
<p class="lead">
Set the records you want a year and the share of splatter and other
specials. The calculator sizes the press fleet from Newbilt Duplex systems
(two HRP 12 presses, an EX 50 and an EX 35 colour extruder each) and Newbilt
Classic automatic presses, starting from the planned two Duplex systems and
one Classic. It then lists what that fleet needs: people, power, central
hydraulics, steam, cooling, compressed air, floor, compound and stampers.
Vendor figures are from Newbilt's Duplex site requirements and offer; the
rest are planning figures and estimates from the CNX Vinyl documents. Every
figure can be changed under Assumptions.
</p>
</header>
<div class="wrap grid">
<div>
<section class="card">
<h2>What you want</h2>
<div class="seg" id="mode">
<button data-v="target">From a target output</button>
<button data-v="fleet">From a fleet</button>
</div>
<div id="targetBox">
<label class="row"><span>Records per year</span><input type="number" id="target" min="0" step="50000"></label>
<input type="range" id="targetR" min="0" max="10000000" step="50000" aria-label="Records per year">
<div class="row" style="margin-top:12px"><span>Grow the fleet with</span></div>
<div class="seg" id="grow">
<button data-v="classic">Classics</button>
<button data-v="duplex">Duplex systems</button>
</div>
<div class="hint" style="margin-bottom:6px">Start fleet, never fewer than:</div>
<div class="pair">
<label class="row"><span>Duplex</span><input type="number" id="dMin" min="0" step="1"></label>
<label class="row"><span>Classic</span><input type="number" id="cMin" min="0" step="1"></label>
</div>
</div>
<div id="fleetBox" hidden>
<div class="pair">
<label class="row"><span>Duplex systems</span><input type="number" id="dFleet" min="0" step="1"></label>
<label class="row"><span>Classics</span><input type="number" id="cFleet" min="0" step="1"></label>
</div>
<p class="hint">The output is what this fleet makes at the set effectiveness and specials share.</p>
</div>
<label class="row" style="margin-top:14px"><span>Splatter and specials, share of output</span><b id="specV"></b></label>
<input type="range" id="spec" min="0" max="100" step="1" aria-label="Specials share">
<p class="hint">Splatter, multi-colour and effect records run only on the Duplex, with
its EX 35 colour extruder (Newbilt recommends the Duplex for splatter). Black and
single-colour records run on either press.</p>
</section>
<section class="card">
<h2>Operation</h2>
<label class="row"><span>Shifts per day</span><input type="number" id="shifts" min="1" max="3" step="1"></label>
<label class="row"><span>Hours per shift</span><input type="number" id="hrs" min="1" max="12" step="0.5"></label>
<label class="row"><span>Working days per year</span><input type="number" id="days" min="1" max="365" step="1"></label>
<label class="row"><span>Overall effectiveness, %<br><small>scale.md: 60 to 70 % (changeovers, stamper changes, QC, breaks)</small></span><input type="number" id="eff" min="1" max="100" step="1"></label>
</section>
<section class="card">
<h2>Press rates</h2>
<label class="row"><span>Duplex system, records per minute<br><small>both presses together; 3 = 40 s per press cycle. Newbilt quotes about 30 s per press</small></span><input type="number" id="rateD" min="0" step="0.1"></label>
<label class="row"><span>Classic, records per minute<br><small>2 = 30 s cycle; Newbilt flyer: 28 s at 180 g</small></span><input type="number" id="rateC" min="0" step="0.1"></label>
<label class="row"><span>Specials run at, % of the standard rate<br><small>no vendor figure; lower it to test slower splatter runs</small></span><input type="number" id="specF" min="10" max="100" step="5"></label>
</section>
</div>
<div>
<section class="card">
<h2>Fleet</h2>
<div class="kpis" id="fleetK"></div>
<div id="flags"></div>
</section>
<section class="card">
<h2>Output and machine mix</h2>
<div class="kpis" id="outK"></div>
<div class="bar" id="bar"></div>
<div class="legend" id="legend"></div>
<div class="tscroll" style="margin-top:12px"><table id="mixT"></table></div>
</section>
<section class="card">
<h2>What the fleet needs</h2>
<div class="tscroll"><table id="reqT"></table></div>
</section>
</div>
</div>
<div class="wrap">
<section class="card">
<h2>Machine park and stamper production</h2>
<p class="note" style="margin-top:0">The fleet above as a schematic, not to scale: each Duplex cell as
in the cell sketch (presses either side of the operator, EX 50 and EX 35 behind him, trimmer in
front), each Classic with its built-in extruder; above it the stamper line that feeds the presses,
sized from the cycle times in galvanics.md; below it the central utilities. Bars show how busy
each station is; an amber box exceeds the plant as planned today.</p>
<div class="tscroll"><svg id="park" viewBox="0 0 1200 600" role="img" aria-label="Machine park and stamper production schematic"></svg></div>
</section>
<section class="card">
<h2>From small to fully equipped</h2>
<p class="note" style="margin-top:0">Fleets from the start fleet to a full Payap press hall (about 870 m²:
the two Duplex bays plus the Classic and ten more Classic positions, or the same floor
with more Duplex systems), at the current settings. Click a row to load it.</p>
<div class="tscroll"><table id="ladT"></table></div>
</section>
<section class="card">
<h2>Assumptions</h2>
<p class="note" style="margin-top:0">Every result above comes from these figures. Changed values are
kept in the link, so a scenario can be shared with "Copy link".</p>
<div class="tscroll"><table id="asT"></table></div>
<div class="btns">
<button id="copy">Copy link to this scenario</button>
<button id="reset">Reset everything</button>
</div>
</section>
<section class="card note">
<h2>Basis and limits</h2>
<p><b>Classic utilities.</b> Newbilt has published no power, steam, cooling or weight figures for
the Classic, so it counts here as one HRP 12 press plus one extruder on the central hydraulics.
Cross-check from the Toolex Alpha leaflet the Classic is based on: 1.2 kg steam, 15 l cooling
water, 18 l air and 3.15 l hydraulic oil per cycle (about 144 kg/h of steam at 2 a minute),
2.18 × 1.30 m, 1,850 kg. These go into the re-quote questions in pressing-line.md.</p>
<p><b>EX 35.</b> Counted at the EX 50's electrical, air and water figures; it is the smaller
extruder, so this errs high.</p>
<p><b>Central hydraulics.</b> Newbilt's aggregate serves two presses; the per-press figures are
half of it, summed without any diversity. Whether the Classic can run from the shared plant
is an open question for Newbilt.</p>
<p><b>Peaks add up.</b> Steam, cooling and air are summed at each machine's peak, as Newbilt
states them. A real plant staggers cycles, so the boiler and chiller can be sized lower once
a utility engineer has the cycle data.</p>
<p><b>Not scaled here:</b> mastering (1 to 2), QC (1 to 2), maintenance (2) and admin, sales and
logistics (3 to 5), from scale.md; cover for leave and sickness; the boiler fuel and the PV
share of the power.</p>
<p>Sources in the CNX Vinyl planning repository: equipment/pressing-line.md,
equipment/newbilt/ (Duplex site requirements V14, offer 240620-04, 2026 flyers),
equipment/galvanics.md (cycle times per step; the stamper line counts each station from them,
before remakes and test pressings), scale.md, finance/revenue-model.md, site-candidates.md (Payap scenario model).</p>
</section>
</div>
<footer>CNX Vinyl planning repository · docs/fleet-calculator.html · figures are planning estimates, October 2026.</footer>
<script>
"use strict";
// ---------------------------------------------------------------- inputs
const S0 = {
mode: "target", target: 1000000, spec: 20, grow: "classic", dMin: 2, cMin: 1,
dFleet: 2, cFleet: 1, shifts: 2, hrs: 8, days: 300, eff: 65, rateD: 3, rateC: 2, specF: 100,
};
// [group, id, label, value, unit, kind, source]
const AS = [];
function grp(g, rows) { rows.forEach(r => AS.push([g].concat(r))); }
grp("Personnel", [
["opD", "Operators per Duplex system, per shift", 1, "", "doc", "Duplex cell layout: one operator in the middle of each cell (pressing-line.md)"],
["cPerAtt", "Classics one attendant looks after", 4, "", "est", "Assumption: Newbilt has not stated it"],
["finRate", "Finishing output per person", 200, "records/h", "est", "Inspection, sleeving, packing; in line with scale.md (4 to 6 press and finishing people per shift for the start fleet)"],
["galvRate", "Electroformed parts per galvanics operator", 20, "a day", "vendor", "OMS rule of thumb: one person runs about 20 stampers a day (galvanics.md)"],
]);
grp("Per press (HRP 12; a Classic counts as one)", [
["pressKVA", "Connected load", 1, "kVA", "vendor", "Newbilt Duplex site requirements 3.2.1"],
["pressKW", "Average power", 0.5, "kW", "vendor", "Site requirements 3.2.1"],
["steamLo", "Steam, low", 150, "kg/h", "vendor", "Site requirements 3.2.3: 150 to 180 kg/h, 10 bar, 180 °C"],
["steamHi", "Steam, high", 180, "kg/h", "vendor", "Site requirements 3.2.3"],
["coolPress", "Cooling capacity, peak", 125, "kW", "vendor", "Site requirements 3.2.2: up to 125 kW depending on cycle time"],
["waterTyp", "Cooling water, typical", 53, "l/min", "vendor", "Site requirements 3.2.2: about 53 l/min used"],
["waterPeak", "Cooling water, peak", 100, "l/min", "vendor", "Site requirements 3.2.2: up to 100 l/min"],
["airPress", "Compressed air", 6, "m³/h", "vendor", "Site requirements 3.2.3: 6 to 10 bar"],
]);
grp("Per extruder (EX 50; EX 35 and Classic extruder at the same figures)", [
["extKVA", "Connected load", 15, "kVA", "vendor", "Site requirements 3.3.1 (EX 35 and Classic: estimate)"],
["extKW", "Average power", 8, "kW", "vendor", "Site requirements 3.3.1"],
["extAir", "Compressed air", 2, "m³/h", "vendor", "Site requirements 3.3.2"],
["extWater", "Cooling water", 4, "l/min", "vendor", "Site requirements 3.3.3"],
["extCool", "Cooling capacity", 3, "kW", "vendor", "Site requirements 3.3.3: about 2 to 3 kW"],
]);
grp("Central hydraulics, per press (Newbilt's aggregate serves two)", [
["hydFlow", "Pump flow", 16, "l/min", "vendor", "Offer 240620-04: 32 l/min pump per aggregate"],
["hydMotor", "Installed motor power", 7.5, "kW", "vendor", "Offer 240620-04: 11/15 kW motor per aggregate"],
["hydKVA", "Connected load", 7.5, "kVA", "vendor", "Site requirements 3.4.1: 15 kVA per aggregate"],
["hydKW", "Average power", 6, "kW", "vendor", "Site requirements 3.4.1: 12 kW per aggregate"],
["hydOil", "Oil fill (HLP 46)", 100, "l", "vendor", "Site requirements 3.4.3: 200 l per aggregate, 250 l tank"],
["hydAcc", "Accumulator volume", 25, "l", "vendor", "Offer 240620-04: 50 l per aggregate, up to 200 bar"],
["hydWater", "Oil cooler water", 4, "l/min", "vendor", "Site requirements 3.4.2: about 8 l/min per aggregate"],
["hydCool", "Oil cooler capacity", 2.5, "kW", "vendor", "Site requirements 3.4.2: up to 5 kW per aggregate"],
]);
grp("Utilities to supply it", [
["cop", "Chiller COP", 4, "", "est", "Estimate for a water-cooled chiller"],
["compSpec", "Compressor specific power", 0.11, "kW per m³/h", "est", "Estimate: screw compressor at about 6.5 kW per m³/min"],
]);
grp("Floor", [
["floorD", "Press floor per Duplex system", 110, "m²", "doc", "Payap model bay, 9.5 × 11.4 m; Newbilt recommends about 200 m² including storage and side rooms"],
["floorC", "Press floor per Classic", 50, "m²", "est", "Payap model position, 5.5 × 9 m; pressing-line.md estimates 50 to 75 m²"],
]);
grp("Material", [
["weight", "Average record weight", 160, "g", "est", "Estimate across the 140 and 180 g formats"],
["trim", "Edge trim per record", 30, "g", "vendor", "Newbilt granulator flyer: 20 to 40 g per 12-inch record, back as regrind"],
["reject", "Rejects", 5, "%", "est", "Estimate; Newbilt claims at least 95 % yield for the Classic"],
]);
grp("Stampers", [
["runLen", "Records per title", 3000, "", "doc", "Revenue model: 2,500 to 3,300 records per title"],
["stLife", "Pressings per stamper", 1500, "", "doc", "galvanics.md: 1,000 to 2,000"],
]);
grp("Mastering, galvanics and stamper prep", [
["lathe", "Lacquer sides one lathe cuts a day", 8, "sides", "est", "Estimate: about 20 min cutting per side plus setup and checks, one shift"],
["galvEff", "Usable share of galvanics and prep hours", 80, "%", "est", "Estimate; OMS: 6 to 8 runs on 4 cells in a 7.5-hour day (galvanics.md). Galvanics runs the plant's shifts"],
["silverMin", "Silvering per lacquer (PASU)", 15, "min", "est", "galvanics.md: ~15 min, estimate"],
["preMin", "Pre-plate per master (PPPU, four at once)", 25, "min", "doc", "galvanics.md: 15 to 30 min"],
["fatherMin", "Father in a PDPU cell", 75, "min", "doc", "galvanics.md: ~1 h 15 min"],
["motherMin", "Mother in a PDPU cell", 70, "min", "doc", "galvanics.md: ~1 to 1 1/4 h"],
["stamperMin", "Stamper in a PDPU cell", 45, "min", "doc", "galvanics.md: ~45 min"],
["handleMin", "Loading, rinsing, unloading per part", 15, "min", "doc", "galvanics.md: ~15 min on top of deposition"],
["pmpbMin", "Separation, passivation, cleaning per part (PMPB)", 12, "min", "est", "galvanics.md: ~10 to 15 min, estimate"],
["vsfMin", "Back-finish per stamper (Sibert VSF)", 8, "min", "est", "galvanics.md: 5 to 10 min, estimate; Sibert publishes none"],
["vspMin", "Centre punch per stamper (Sibert VSP)", 4, "min", "est", "galvanics.md: 3 to 5 min, estimate"],
["vscMin", "Coin per stamper (Sibert VSC)", 2, "min", "est", "galvanics.md: 1 to 2 min, estimate"],
]);
grp("Planned plant, for the checks", [
["boiler", "Steam boiler", 400, "kg/h", "doc", "scale.md: sized for one Duplex system"],
["chiller", "Chiller", 250, "kW", "doc", "scale.md: sized for one Duplex system"],
["hall", "Payap press hall", 870, "m²", "doc", "site-candidates.md, Payap scenario model"],
]);
const A0 = Object.fromEntries(AS.map(r => [r[1], r[3]]));
const KIND = { vendor: "vendor", doc: "doc", est: "estimate", user: "your figure" };
const LADDER = [
["Start fleet (current plan)", 2, 1], ["Step 1", 2, 3], ["Step 2", 2, 5], ["Step 3", 2, 8],
["Full hall, Classic-led", 2, 11], ["Full hall, mixed", 4, 7], ["Full hall, specials-led", 6, 3],
];
let S = { ...S0 }, A = { ...A0 };
// ---------------------------------------------------------------- model
function basis(S) {
const hoursYr = S.shifts * S.hrs * S.days, eff = S.eff / 100;
return {
hoursYr,
capD: Math.max(0, S.rateD) * 60 * hoursYr * eff,
capC: Math.max(0, S.rateC) * 60 * hoursYr * eff,
s: Math.min(1, Math.max(0, S.spec / 100)),
f: Math.min(1, Math.max(0.1, S.specF / 100)),
};
}
// Largest yearly output a fleet makes at specials share s: specials only on
// Duplex (at rate factor f), standard on Classics first, then on spare Duplex time.
function capacityOf(D, C, b) {
const x1 = (D * b.capD + C * b.capC) / (b.s / b.f + 1 - b.s);
const x2 = b.s > 0 ? D * b.capD * b.f / b.s : Infinity;
return Math.max(0, Math.min(x1, x2));
}
function sizeFleet(S, b) {
const T = Math.max(0, S.target), sp = T * b.s, st = T - sp;
const dEq = b.capD > 0 ? sp / (b.capD * b.f) : 0;
const dMin = Math.max(0, Math.round(S.dMin)), cMin = Math.max(0, Math.round(S.cMin));
let D, C;
if (b.capD <= 0) return { D: dMin, C: b.capC > 0 ? Math.max(cMin, Math.ceil(st / b.capC - 1e-9)) : cMin };
if (S.grow === "classic" && b.capC > 0) {
D = Math.max(dMin, Math.ceil(dEq - 1e-9));
const spare = (D - dEq) * b.capD;
C = Math.max(cMin, Math.ceil(Math.max(0, st - spare) / b.capC - 1e-9));
} else {
C = cMin;
const stC = Math.min(st, C * b.capC);
D = Math.max(dMin, Math.ceil(dEq + (st - stC) / b.capD - 1e-9));
}
return { D, C };
}
function compute(D, C, X, b) {
const sp = X * b.s, st = X - sp;
const stC = Math.min(st, C * b.capC), stD = st - stC;
const dUse = D > 0 && b.capD > 0 ? (sp / (b.capD * b.f) + stD / b.capD) / D : 0;
const cUse = C > 0 && b.capC > 0 ? stC / (C * b.capC) : 0;
const P = 2 * D + C, E = 2 * D + C; // presses; extruders (EX 50 + EX 35, Classic built in)
const perHour = b.hoursYr > 0 ? X / b.hoursYr : 0;
const att = C > 0 ? Math.ceil(C / Math.max(1, A.cPerAtt)) : 0;
const fin = X > 0 ? Math.ceil(perHour / Math.max(1, A.finRate)) : 0;
const perShift = D * A.opD + att + fin;
const perSide = Math.ceil(A.runLen / Math.max(1, A.stLife));
const partsTitle = 2 * (2 + perSide);
const titles = X / Math.max(1, A.runLen);
const partsYr = titles * partsTitle, partsDay = partsYr / Math.max(1, S.days);
// stamper production per working day, stations sized from galvanics.md cycle times
const tDay = titles / Math.max(1, S.days);
const flow = { titlesDay: tDay, lacq: 2 * tDay, fathers: 2 * tDay, mothers: 2 * tDay, stampers: 2 * perSide * tDay };
flow.parts = flow.fathers + flow.mothers + flow.stampers;
const avail = S.shifts * S.hrs * 60 * Math.max(1, A.galvEff) / 100;
const need = (dem, cap) => dem <= 0 || cap <= 0 ? { n: 0, u: 0 }
: (n => ({ n, u: dem / (n * cap) }))(Math.max(1, Math.ceil(dem / cap - 1e-9)));
const galv = {
lathe: need(flow.lacq, A.lathe),
pasu: need(flow.lacq * A.silverMin, avail),
pppu: need(flow.lacq * (A.preMin + A.handleMin) / 4, avail),
pdpu: need(flow.fathers * (A.fatherMin + A.handleMin) + flow.mothers * (A.motherMin + A.handleMin)
+ flow.stampers * (A.stamperMin + A.handleMin), 2 * avail),
pmpb: need(flow.parts * A.pmpbMin, avail),
vsf: need(flow.stampers * A.vsfMin, avail),
vsp: need(flow.stampers * A.vspMin, avail),
vsc: need(flow.stampers * A.vscMin, avail),
};
const coolPeak = P * A.coolPress + E * A.extCool + P * A.hydCool;
const air = P * A.airPress + E * A.extAir;
const kvaMach = P * A.pressKVA + E * A.extKVA, kwMach = P * A.pressKW + E * A.extKW;
const kvaHyd = P * A.hydKVA, kwHyd = P * A.hydKW;
return {
D, C, X, P, E, sp, st, stC, stD, dUse, cUse, perHour, att, fin, perShift,
staff: perShift * S.shifts, galv: partsDay > 0 ? Math.ceil(partsDay / Math.max(1, A.galvRate)) : 0,
perSide, partsTitle, titles, partsYr, partsDay, flow, gst: galv,
kvaMach, kwMach, kvaHyd, kwHyd, kva: kvaMach + kvaHyd, kw: kwMach + kwHyd,
mwh: (kwMach + kwHyd) * b.hoursYr / 1000,
chillerKW: coolPeak / Math.max(0.5, A.cop), compKW: air * A.compSpec,
steam: [P * A.steamLo, P * A.steamHi], coolPeak,
water: [P * A.waterTyp + E * A.extWater + P * A.hydWater, P * A.waterPeak + E * A.extWater + P * A.hydWater],
air, hyd: { flow: P * A.hydFlow, motor: P * A.hydMotor, oil: P * A.hydOil, acc: P * A.hydAcc },
floor: D * A.floorD + C * A.floorC,
compound: X * A.weight * (1 + A.reject / 100) / 1e6,
extKgH: perHour * (A.weight + A.trim) * (1 + A.reject / 100) / 1000,
trimT: X * (1 + A.reject / 100) * A.trim / 1e6,
};
}
function scenario() {
const b = basis(S);
let D, C, X, cap;
if (S.mode === "fleet") {
D = Math.max(0, Math.round(S.dFleet)); C = Math.max(0, Math.round(S.cFleet));
if (D === 0 && b.s > 0) { b.s = 0; b.noSpecials = true; }
cap = capacityOf(D, C, b); X = cap;
} else {
({ D, C } = sizeFleet(S, b));
cap = capacityOf(D, C, b); X = Math.min(Math.max(0, S.target), cap);
}
return { b, cap, r: compute(D, C, X, b) };
}
// ---------------------------------------------------------------- format
const nf = d => new Intl.NumberFormat("en-US", { maximumFractionDigits: d, minimumFractionDigits: 0 });
const f0 = x => nf(0).format(Math.round(x));
const f1 = x => nf(1).format(x);
const big = x => x >= 1e6 ? nf(2).format(x / 1e6) + " M" : f0(x);
const pct = x => Math.round(x * 100) + " %";
const esc = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
const $ = id => document.getElementById(id);
// ---------------------------------------------------------------- render
function kpi(v, l, cls) { return `<div class="kpi ${cls || ""}"><b>${v}</b><span>${l}</span></div>`; }
function render() {
const { b, cap, r } = scenario();
$("specV").textContent = S.spec + " %";
$("targetBox").hidden = S.mode !== "target";
$("fleetBox").hidden = S.mode !== "fleet";
for (const g of ["mode", "grow"]) for (const bt of $(g).children) bt.classList.toggle("on", bt.dataset.v === S[g]);
$("fleetK").innerHTML =
kpi(r.D, `Duplex systems<br>${2 * r.D} HRP 12 presses, ${r.D} EX 50, ${r.D} EX 35, ${r.D} trimmers`, "d") +
kpi(r.C, "Newbilt Classic<br>automatic presses", "c") +
kpi(r.P, "presses in total") +
kpi(r.perShift, `people at the presses and in finishing, per shift (${r.staff} on ${S.shifts} shift${S.shifts > 1 ? "s" : ""})`);
const flags = [];
if (b.noSpecials)
flags.push(["bad", "No Duplex system: this fleet cannot press splatter or specials, so the output shown is black and single colour only."]);
if (S.mode === "target" && S.target > cap + 1)
flags.push(["bad", `The fleet cannot reach ${big(S.target)} records: check the press rates.`]);
if (S.mode === "target" && b.s > 0 && r.D > Math.max(0, Math.round(S.dMin)) && S.grow === "classic")
flags.push(["warn", `Specials alone need ${r.D} Duplex systems at this share.`]);
if (r.steam[1] > A.boiler)
flags.push(["warn", `Steam up to ${f0(r.steam[1])} kg/h: the planned ${f0(A.boiler)} kg/h boiler covers ${pct(A.boiler / r.steam[1])} of the peak.`]);
if (r.coolPeak > A.chiller)
flags.push(["warn", `Cooling up to ${f0(r.coolPeak)} kW: the planned ${f0(A.chiller)} kW chiller covers ${pct(A.chiller / r.coolPeak)} of the peak.`]);
if (r.floor > A.hall)
flags.push(["warn", `About ${f0(r.floor)} m² of press floor: more than the ${f0(A.hall)} m² Payap press hall.`]);
else flags.push(["ok", `About ${f0(r.floor)} m² of press floor: fits the ${f0(A.hall)} m² Payap press hall (${pct(r.floor / A.hall)}).`]);
$("flags").innerHTML = flags.map(([c, t]) => `<div class="flag ${c}">${esc(t)}</div>`).join("");
const use = cap > 0 ? r.X / cap : 0;
$("outK").innerHTML =
kpi(big(r.X), S.mode === "target" ? "records a year (target)" : "records a year at this mix") +
kpi(big(cap), "fleet capacity a year") +
kpi(pct(use), "of capacity used") +
kpi(f0(r.X / Math.max(1, S.days)), "records per working day") +
kpi(f0(r.perHour), "records per operating hour");
const W = r.X > 0 ? r.X : 1;
$("bar").innerHTML =
`<div style="width:${100 * r.sp / W}%;background:var(--dspec)" title="Duplex, specials"></div>` +
`<div style="width:${100 * r.stD / W}%;background:var(--dstd)" title="Duplex, standard"></div>` +
`<div style="width:${100 * r.stC / W}%;background:var(--classic)" title="Classic, standard"></div>`;
$("legend").innerHTML =
`<span><i style="background:var(--dspec)"></i>Duplex, specials ${big(r.sp)} (${pct(r.sp / W)})</span>` +
`<span><i style="background:var(--dstd)"></i>Duplex, black and single colour ${big(r.stD)} (${pct(r.stD / W)})</span>` +
`<span><i style="background:var(--classic)"></i>Classic, black and single colour ${big(r.stC)} (${pct(r.stC / W)})</span>`;
$("mixT").innerHTML =
`<thead><tr><th>Machine</th><th class="n">Units</th><th class="n">Capacity a year</th><th class="n">Makes a year</th><th class="n">Utilisation</th><th class="n">Share of output</th></tr></thead><tbody>` +
`<tr><td>Duplex system (2 presses)</td><td class="n">${r.D}</td><td class="n">${big(r.D * b.capD)}</td><td class="n">${big(r.sp + r.stD)}</td><td class="n">${pct(r.dUse)}</td><td class="n">${pct((r.sp + r.stD) / W)}</td></tr>` +
`<tr><td>Classic</td><td class="n">${r.C}</td><td class="n">${big(r.C * b.capC)}</td><td class="n">${big(r.stC)}</td><td class="n">${pct(r.cUse)}</td><td class="n">${pct(r.stC / W)}</td></tr></tbody>`;
const rows = [];
const G = t => rows.push(`<tr class="grp"><td colspan="3">${t}</td></tr>`);
const R = (l, v, s) => rows.push(`<tr><td>${l}</td><td class="n">${v}</td><td class="src">${s || ""}</td></tr>`);
G("People");
R("Duplex operators, per shift", r.D * A.opD, "one operator per Duplex cell");
R("Classic attendants, per shift", r.att, `one per ${f0(A.cPerAtt)} Classics (assumption)`);
R("Finishing: inspection, sleeving, packing, per shift", r.fin, `at ${f0(A.finRate)} records an hour per person (estimate)`);
R("Production staff on all shifts", r.staff, `${r.perShift} per shift × ${S.shifts}; add cover for leave`);
R("Galvanics operators (day work)", r.galv, `${f1(r.partsDay)} parts a day at about ${f0(A.galvRate)} per person (OMS)`);
R("Not scaled here", "7 to 11", "mastering, QC, maintenance, admin and logistics (scale.md)");
G("Machines");
R("HRP 12 presses (Duplex)", 2 * r.D, "");
R("Classic automatic presses", r.C, "extruder built in; trimming not stated by Newbilt");
R("Extruders", `${r.D} EX 50 + ${r.D} EX 35`, "one main and one colour extruder per Duplex system");
R("Trimmers", r.D, "one per Duplex cell");
R("Heaviest lift", r.C > 0 ? "about 1.85 t" : "1.35 t", r.C > 0 ? "Classic, if it weighs like the Toolex Alpha (unconfirmed)" : "HRP 12 press (Newbilt)");
G("Electrical");
R("Connected load, presses and extruders", f0(r.kvaMach) + " kVA", `${r.P} presses at ${f1(A.pressKVA)} kVA, ${r.E} extruders at ${f1(A.extKVA)} kVA`);
R("Connected load, central hydraulics", f0(r.kvaHyd) + " kVA", `${f1(A.hydKVA)} kVA per press`);
R("Average power while running", f0(r.kw) + " kW", "Newbilt average figures, machines and hydraulics");
R("Energy a year, machines and hydraulics", f0(r.mwh) + " MWh", `${f0(b.hoursYr)} operating hours`);
R("Chiller power at peak cooling", f0(r.chillerKW) + " kW", `COP ${f1(A.cop)} (estimate)`);
R("Air compressors", f1(r.compKW) + " kW", "estimate");
G("Central hydraulics");
R("Pump flow", f0(r.hyd.flow) + " l/min", "up to 200 bar");
R("Installed motor power", f1(r.hyd.motor) + " kW", "");
R("Oil fill, HLP 46", f0(r.hyd.oil) + " l", "");
R("Accumulator volume", f0(r.hyd.acc) + " l", "gas loaded");
G("Steam");
R("Saturated steam, 10 bar, 180 °C", `${f0(r.steam[0])} to ${f0(r.steam[1])} kg/h`, `planned boiler ${f0(A.boiler)} kg/h`);
G("Cooling");
R("Cooling capacity, peak", f0(r.coolPeak) + " kW", `about 98 % for the moulds; planned chiller ${f0(A.chiller)} kW`);
R("Cooling water, typical to peak", `${f0(r.water[0])} to ${f0(r.water[1])} l/min`, "inlet 20 to 25 °C, 10 bar");
G("Compressed air");
R("Compressed air", f0(r.air) + " m³/h", "6 to 10 bar, dew point 3 °C, filtered to 5 µm");
G("Floor and room");
R("Press floor", f0(r.floor) + " m²", `Payap press hall ${f0(A.hall)} m²`);
R("Press room climate", "20 to 26 °C", "35 to 60 % r.H., at least 2.5 m clear height (Newbilt)");
G("Material");
R("PVC compound a year", f0(r.compound) + " t", `${f0(A.weight)} g a record plus ${f0(A.reject)} % rejects`);
R("Through the extruders", f0(r.extKgH) + " kg/h", "record plus trim, per operating hour");
R("Trim back to the granulator", f0(r.trimT) + " t a year", `${f0(A.trim)} g a record, reused as regrind`);
G("Stampers and galvanics");
R("Titles a year", f0(r.titles), `${f0(A.runLen)} records a title`);
R("Electroformed parts a year", f0(r.partsYr), `${r.partsTitle} a title: father, mother and ${r.perSide} stamper${r.perSide > 1 ? "s" : ""} per side`);
R("Per working day", `${f1(r.flow.lacq)} / ${f1(r.flow.fathers)} / ${f1(r.flow.mothers)} / ${f1(r.flow.stampers)}`, "lacquers / fathers / mothers / stampers, before remakes");
const GS = r.gst, su = k => `${GS[k].n} · ${pct(GS[k].u)}`;
R("Lathes", su("lathe"), `units · busy; ${f0(A.lathe)} lacquer sides a day each`);
R("Silvering, Pheenix PASU", su("pasu"), "");
R("Pre-plating, Pheenix PPPU", su("pppu"), "four masters at once");
R("Plating, Pheenix PDPU (2 cells)", su("pdpu"), "a second unit adds redundancy, not capacity, at low load");
R("Passivation, Pheenix PMPB", su("pmpb"), "");
R("Sibert VSF / VSP / VSC", `${GS.vsf.n} / ${GS.vsp.n} / ${GS.vsc.n}`, `busiest ${pct(Math.max(GS.vsf.u, GS.vsp.u, GS.vsc.u))}`);
$("reqT").innerHTML = `<tbody>${rows.join("")}</tbody>`;
$("ladT").innerHTML =
`<thead><tr><th>Fleet</th><th class="n">Duplex</th><th class="n">Classic</th><th class="n">Presses</th><th class="n">Records a year</th><th class="n">Specials a year</th><th class="n">Staff per shift</th><th class="n">Steam, kg/h</th><th class="n">Cooling, kW</th><th class="n">Connected, kVA</th><th class="n">Press floor, m²</th></tr></thead><tbody>` +
LADDER.map(([n, d, c], i) => {
const x = capacityOf(d, c, b), q = compute(d, c, x, b);
const cur = r.D === d && r.C === c ? " cur" : "";
return `<tr class="lad${cur}" data-i="${i}"><td>${n}</td><td class="n">${d}</td><td class="n">${c}</td><td class="n">${q.P}</td><td class="n">${big(x)}</td><td class="n">${big(q.sp)}</td><td class="n">${q.perShift}</td><td class="n">${f0(q.steam[1])}</td><td class="n">${f0(q.coolPeak)}</td><td class="n">${f0(q.kva)}</td><td class="n">${f0(q.floor)}</td></tr>`;
}).join("") + "</tbody>";
drawPark(r, b);
for (const [, id] of AS) {
const tr = document.querySelector(`#asT tr[data-id="${id}"]`);
if (tr) tr.classList.toggle("chg", A[id] !== A0[id]);
}
writeHash();
}
// ---------------------------------------------------------------- machine park schematic
function drawPark(r, b) {
const o = [];
const T = (x, y, c, t, a) => o.push(`<text x="${x}" y="${y}" class="${c}"${a ? ` text-anchor="${a}"` : ""}>${t}</text>`);
const box = (x, y, w, h, c, rx) => o.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${rx == null ? 6 : rx}" class="${c}"/>`);
const arrow = (x1, y1, x2, y2) => o.push(`<path d="M${x1},${y1} L${x2},${y2}" class="pk-arrow" marker-end="url(#pkA)"/>`);
const bar = (x, y, w, u) => { box(x, y, w, 5, "pk-util", 2); box(x, y, Math.max(0, w * Math.min(u, 1)), 5, "pk-utilv" + (u > 0.9 ? " hi" : ""), 2); };
o.push(`<defs><marker id="pkA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" class="pk-head"/></marker></defs>`);
const glyph = (k, x, y) => {
if (k === "lathe") o.push(`<circle cx="${x + 10}" cy="${y + 11}" r="9" class="pk-glyph"/><circle cx="${x + 10}" cy="${y + 11}" r="2" class="pk-head"/>`);
else if (k === "pdpu") o.push(`<rect x="${x}" y="${y}" width="9" height="22" rx="2" class="pk-glyph"/><rect x="${x + 11}" y="${y}" width="9" height="22" rx="2" class="pk-glyph"/>`);
else if (k === "pppu") o.push(`<rect x="${x}" y="${y}" width="20" height="22" rx="2" class="pk-glyph"/>` + [0, 1, 2, 3].map(i => `<circle cx="${x + 5 + (i % 2) * 10}" cy="${y + 6 + Math.floor(i / 2) * 10}" r="2.4" class="pk-head"/>`).join(""));
else if (k === "pmpb") o.push(`<rect x="${x}" y="${y + 7}" width="20" height="15" rx="2" class="pk-glyph"/>`);
else o.push(`<rect x="${x}" y="${y}" width="20" height="22" rx="2" class="pk-glyph"/><circle cx="${x + 10}" cy="${y + 11}" r="6" class="pk-head" opacity=".55"/>`);
};
const f = r.flow, g = r.gst, days = Math.max(1, S.days);
// 1. stamper production
T(17, 18, "pk-lane", `STAMPER PRODUCTION · ${f1(f.titlesDay)} TITLES A WORKING DAY (${f0(r.titles)} A YEAR) · ${f1(f.parts)} ELECTROFORMED PARTS A DAY`);
const ST = [
["Mastering", "lathe", g.lathe, `${f1(f.lacq)} lacquer sides`, "lathe"],
["Silvering", "Pheenix PASU", g.pasu, `${f1(f.lacq)} lacquers`, "pasu"],
["Pre-plating", "Pheenix PPPU", g.pppu, `${f1(f.lacq)} masters`, "pppu"],
["Plating", "Pheenix PDPU, 2 cells", g.pdpu, `${f1(f.parts)} parts`, "pdpu"],
["Passivation", "Pheenix PMPB", g.pmpb, `${f1(f.parts)} parts`, "pmpb"],
["Back-finish", "Sibert VSF", g.vsf, `${f1(f.stampers)} stampers`, "vsf"],
["Centre punch", "Sibert VSP", g.vsp, `${f1(f.stampers)} stampers`, "vsp"],
["Coin", "Sibert VSC", g.vsc, `${f1(f.stampers)} stampers`, "vsc"],
];
const bw = 130, gp = 18, y0 = 28, bh = 112;
ST.forEach(([t, sub, u, fl, k], i) => {
const x = 17 + i * (bw + gp);
box(x, y0, bw, bh, "pk-box");
T(x + 8, y0 + 17, "pk-t", t); T(x + 8, y0 + 33, "pk-s", sub);
for (let j = 0; j < Math.min(u.n, 3); j++) glyph(k, x + 8 + j * 24, y0 + 38);
T(x + bw - 8, y0 + 58, "pk-n", u.n + "×", "end");
T(x + 8, y0 + 80, "pk-s", fl + " a day");
bar(x + 8, y0 + 88, bw - 16, u.u);
T(x + 8, y0 + 106, "pk-s", pct(u.u) + " busy");
if (i < ST.length - 1) arrow(x + bw + 2, y0 + bh / 2, x + bw + gp - 2, y0 + bh / 2);
});
// 2. press hall, with compound in (left), stampers in and records out (right)
const items = [];
for (let i = 0; i < r.D; i++) items.push(["D", i + 1]);
for (let i = 0; i < r.C; i++) items.push(["C", i + 1]);
const hx = 165, hw = 870, iw = { D: 150, C: 84 }, ih = 104, innerW = hw - 30;
const lay = sc => { let x = 0, y = 0; const pos = [];
for (const it of items) { const w = iw[it[0]] * sc; if (x > 0 && x + w > innerW + 0.5) { x = 0; y += (ih + 12) * sc; } pos.push([x, y]); x += w + 12 * sc; }
return { pos, h: items.length ? y + ih * sc : 0 }; };
let sc = 1, L = lay(1);
while (L.h > 480 && sc > 0.3) { sc *= 0.85; L = lay(sc); }
const y1 = y0 + bh + 44, hh = Math.max(230, 46 + L.h + 18);
box(hx, y1, hw, hh, "pk-hall", 8);
T(hx + 14, y1 + 20, "pk-lane", `PRESS HALL · ${r.D} DUPLEX SYSTEM${r.D === 1 ? "" : "S"} + ${r.C} CLASSIC${r.C === 1 ? "" : "S"} · ${r.P} PRESSES · ~${f0(r.floor)} M² PRESS FLOOR`);
o.push(`<g transform="translate(${hx + hw - 300},${y1 + 10})"><rect width="10" height="10" class="pk-ex50"/><text x="14" y="9" class="pk-s">EX 50</text>` +
`<rect x="62" width="10" height="10" class="pk-ex35"/><text x="76" y="9" class="pk-s">EX 35 colour</text>` +
`<circle cx="160" cy="5" r="5" class="pk-op"/><text x="169" y="9" class="pk-s">operator</text>` +
`<rect x="225" width="10" height="10" class="pk-trim"/><text x="239" y="9" class="pk-s">trimmer</text></g>`);
if (!items.length) T(hx + hw / 2, y1 + hh / 2, "pk-s", "no presses", "middle");
items.forEach(([k, n], i) => {
const [px, py] = L.pos[i], tx = hx + 15 + px, ty = y1 + 34 + py;
if (k === "D") o.push(`<g transform="translate(${tx},${ty}) scale(${sc})">` +
`<rect width="150" height="104" rx="6" class="pk-cell"/>` +
`<path d="M60,24 L42,36 M74,26 L108,36" class="pk-feed"/>` +
`<rect x="-15" y="-15" width="30" height="30" class="pk-press" transform="translate(32,46) rotate(45)"/>` +
`<rect x="-15" y="-15" width="30" height="30" class="pk-press" transform="translate(118,46) rotate(45)"/>` +
`<rect x="57" y="10" width="14" height="30" rx="2" class="pk-ex50"/>` +
`<rect x="77" y="14" width="11" height="24" rx="2" class="pk-ex35"/>` +
`<circle cx="73" cy="57" r="5.5" class="pk-op"/>` +
`<rect x="48" y="66" width="13" height="13" rx="1" class="pk-trim"/>` +
`<text x="75" y="96" class="pk-s" text-anchor="middle">Duplex ${n} · ${pct(r.dUse)}</text></g>`);
else o.push(`<g transform="translate(${tx},${ty}) scale(${sc})">` +
`<rect width="84" height="104" rx="6" class="pk-cell"/>` +
`<rect x="9" y="16" width="11" height="30" rx="2" class="pk-ex50"/>` +
`<rect x="28" y="22" width="36" height="36" rx="3" class="pk-press"/>` +
`<circle cx="33" cy="27" r="3.5" class="pk-col"/><circle cx="59" cy="27" r="3.5" class="pk-col"/><circle cx="46" cy="53" r="3.5" class="pk-col"/>` +
`<circle cx="66" cy="73" r="7" class="pk-disc"/>` +
`<text x="42" y="96" class="pk-s" text-anchor="middle">Classic ${n} · ${pct(r.cUse)}</text></g>`);
});
// compound in
const cy = y1 + 20;
box(17, cy, 130, 112, "pk-box");
T(25, cy + 17, "pk-t", "Compound"); T(25, cy + 33, "pk-s", "colour store and silos");
T(25, cy + 56, "pk-v", `${f0(r.compound)} t a year`);
T(25, cy + 74, "pk-s", `${f0(r.extKgH)} kg/h extruded`);
T(25, cy + 90, "pk-s", `trim back: ${f0(r.trimT)} t a year`);
arrow(149, cy + 56, hx - 2, cy + 56);
// stamper rack, fed from the coin press above, into the hall
const rx0 = 1053, ry = y1 + 6;
arrow(17 + 7 * (bw + gp) + bw / 2, y0 + bh + 2, 17 + 7 * (bw + gp) + bw / 2, ry - 2);
box(rx0, ry, 130, 88, "pk-box");
T(rx0 + 8, ry + 17, "pk-t", "Stamper rack"); T(rx0 + 8, ry + 33, "pk-s", "sets to the presses");
T(rx0 + 8, ry + 54, "pk-v", `${f1(f.stampers)} a day`);
T(rx0 + 8, ry + 72, "pk-s", `${r.perSide} per side a title`);
arrow(rx0 - 2, ry + 44, hx + hw + 2, ry + 44);
// finished records out to finishing
const fy = ry + 108;
box(rx0, fy, 130, 104, "pk-box");
T(rx0 + 8, fy + 17, "pk-t", "Finishing"); T(rx0 + 8, fy + 33, "pk-s", "QC, sleeves, packing");
T(rx0 + 8, fy + 54, "pk-v", `${f0(r.X / days)} a day`);
T(rx0 + 8, fy + 72, "pk-s", `${r.fin} people per shift`);
T(rx0 + 8, fy + 88, "pk-s", `${f0(r.perHour)} records an hour`);
arrow(hx + hw + 2, fy + 52, rx0 - 2, fy + 52);
// 3. utilities, piped up into the hall
const y3 = y1 + hh + 46, uw = 158, ug = (hw - 5 * uw) / 4;
T(hx, y3 - 12, "pk-lane", "CENTRAL UTILITIES");
const U = [
["Central hydraulics", `${f0(r.hyd.flow)} l/min`, `${f1(r.hyd.motor)} kW motors`, `${f0(r.hyd.oil)} l oil, up to 200 bar`, "pk-hyd", false],
["Steam boiler", `${f0(r.steam[1])} kg/h peak`, `from ${f0(r.steam[0])} kg/h, 10 bar`, `planned ${f0(A.boiler)} kg/h`, "pk-steam", r.steam[1] > A.boiler],
["Cooling", `${f0(r.coolPeak)} kW peak`, `${f0(r.water[0])} to ${f0(r.water[1])} l/min`, `planned ${f0(A.chiller)} kW chiller`, "pk-cool", r.coolPeak > A.chiller],
["Compressed air", `${f0(r.air)} m³/h`, "6 to 10 bar, dry", `${f1(r.compKW)} kW compressors`, "pk-air", false],
["Electrical", `${f0(r.kva)} kVA`, `${f0(r.kw)} kW average`, `${f0(r.mwh)} MWh a year`, "pk-pow", false],
];
U.forEach(([t, v, s1, s2, cls, warn], i) => {
const x = hx + i * (uw + ug);
o.push(`<path d="M${x + uw / 2},${y3} L${x + uw / 2},${y1 + hh}" class="pk-pipe ${cls}"/>`);
box(x, y3, uw, 96, "pk-box" + (warn ? " warn" : ""));
T(x + 8, y3 + 17, "pk-t", t);
T(x + 8, y3 + 42, "pk-v", v);
T(x + 8, y3 + 62, "pk-s", s1);
T(x + 8, y3 + 78, "pk-s", s2);
});
const H = y3 + 96 + 12;
const svg = $("park");
svg.setAttribute("viewBox", `0 0 1200 ${H}`);
svg.innerHTML = o.join("");
}
// ---------------------------------------------------------------- inputs and state
const NUM = ["target", "dMin", "cMin", "dFleet", "cFleet", "shifts", "hrs", "days", "eff", "rateD", "rateC", "specF"];
function syncInputs() {
for (const k of NUM) $(k).value = S[k];
$("targetR").value = S.target;
$("spec").value = S.spec;
for (const [, id] of AS) { const el = $("a_" + id); if (el) el.value = A[id]; }
}
function buildAssumptions() {
let last = "", html = `<thead><tr><th>Figure</th><th class="n">Value</th><th>Unit</th><th>Basis</th></tr></thead><tbody>`;
for (const [g, id, label, , unit, kind, src] of AS) {
if (g !== last) { html += `<tr class="grp"><td colspan="4">${esc(g)}</td></tr>`; last = g; }
html += `<tr data-id="${id}"><td>${esc(label)}</td><td class="n"><input type="number" id="a_${id}" step="any" aria-label="${esc(label)}"></td>` +
`<td>${esc(unit)}</td><td class="src"><span class="badge ${kind}">${KIND[kind]}</span> ${esc(src)}</td></tr>`;
}
$("asT").innerHTML = html + "</tbody>";
}
function writeHash() {
const p = [];
for (const k in S0) if (S[k] !== S0[k]) p.push(k + "=" + encodeURIComponent(S[k]));
for (const k in A0) if (A[k] !== A0[k]) p.push("a." + k + "=" + encodeURIComponent(A[k]));
const h = p.length ? "#" + p.join("&") : "";
if (location.hash !== h) history.replaceState(null, "", location.pathname + location.search + h);
}
function readHash() {
for (const part of location.hash.replace(/^#/, "").split("&")) {
if (!part) continue;
const [k, v0] = part.split("="), v = decodeURIComponent(v0 || "");
if (k.startsWith("a.")) { const id = k.slice(2); if (id in A0 && v !== "" && isFinite(+v)) A[id] = +v; }
else if (k in S0) S[k] = typeof S0[k] === "number" ? (v !== "" && isFinite(+v) ? +v : S0[k]) : v;
}
if (!["target", "fleet"].includes(S.mode)) S.mode = "target";
if (!["classic", "duplex"].includes(S.grow)) S.grow = "classic";
}
function num(el, fallback) { const v = parseFloat(el.value); return isFinite(v) ? v : fallback; }
buildAssumptions();
readHash();
syncInputs();
for (const k of NUM) $(k).addEventListener("input", e => { S[k] = num(e.target, S0[k]); if (k === "target") $("targetR").value = S.target; render(); });
$("targetR").addEventListener("input", e => { S.target = +e.target.value; $("target").value = S.target; render(); });
$("spec").addEventListener("input", e => { S.spec = +e.target.value; render(); });
for (const g of ["mode", "grow"]) $(g).addEventListener("click", e => {
const v = e.target.dataset && e.target.dataset.v; if (!v) return;
if (g === "mode" && v === "fleet" && S.mode === "target") { const { r } = scenario(); S.dFleet = r.D; S.cFleet = r.C; syncInputs(); }
S[g] = v; render();
});
$("asT").addEventListener("input", e => {
const id = e.target.id.replace(/^a_/, ""); if (!(id in A0)) return;
A[id] = num(e.target, A0[id]); render();
});
$("ladT").addEventListener("click", e => {
const tr = e.target.closest("tr.lad"); if (!tr) return;
const [, d, c] = LADDER[+tr.dataset.i];
S.mode = "fleet"; S.dFleet = d; S.cFleet = c; syncInputs(); render();
});
$("reset").addEventListener("click", () => { S = { ...S0 }; A = { ...A0 }; syncInputs(); render(); });
addEventListener("hashchange", () => { S = { ...S0 }; A = { ...A0 }; readHash(); syncInputs(); render(); });
$("copy").addEventListener("click", () => {
const done = () => { $("copy").textContent = "Link copied"; setTimeout(() => { $("copy").textContent = "Copy link to this scenario"; }, 1600); };
if (navigator.clipboard) navigator.clipboard.writeText(location.href).then(done, done); else done();
});
render();
(function(){
var b=document.getElementById("themeBtn"),de=document.documentElement;
function set(t,save){de.setAttribute("data-theme",t);if(save){try{localStorage.setItem("cnx-theme",t)}catch(e){}}
b.textContent=t==="light"?"☾ Dark mode":"☀ Light mode";b.title="Switch to "+(t==="light"?"dark":"light")+" mode";}
set(de.getAttribute("data-theme")||"dark",false);
b.addEventListener("click",function(){set(de.getAttribute("data-theme")==="light"?"dark":"light",true);b.blur();});
var mq=window.matchMedia&&matchMedia("(prefers-color-scheme: light)");
if(mq&&mq.addEventListener)mq.addEventListener("change",function(e){var s=null;try{s=localStorage.getItem("cnx-theme")}catch(_){}if(!s)set(e.matches?"light":"dark",false);});
})();
</script>
</body>
</html>