Deploy test site / deploy (push) Successful in 36s
User, October 2026: add bright/dark mode to the site for both tools. - Each page sets data-theme on <html> before first paint: the saved choice (localStorage "cnx-theme", shared by all cnxvinyl.lol pages) or, without one, the system setting (prefers-color-scheme), which it keeps following until the user picks a mode. Toggle: header button (sun / moon). - Plant overview: light overrides for every SVG class; pipe colours are now CSS variables (--p-*), used by the pipes and the legend, darkened in light mode where the dark-mode tints were too pale (DI water, granulate, stamper sets, cooling return); press state text and the VEX zone fills use variables instead of inline hex, so they flip with the theme. - Fleet calculator and landing page: light palettes; input, flag, badge and ladder-highlight colours moved to variables. Checked headless in Edge: both system schemes pick the right mode, the toggle persists across pages, computed colours flip (press text, zones, pipes, legend), no text overflow in either mode, no JS errors.
628 lines
37 KiB
HTML
628 lines
37 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">
|
||
<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;
|
||
}
|
||
.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>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, 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"],
|
||
["pdpuLo", "Parts per plating unit a day, low", 20, "", "doc", "galvanics.md: 20 to 32 per Pheenix PDPU on two shifts"],
|
||
["pdpuHi", "Parts per plating unit a day, high", 32, "", "doc", "galvanics.md"],
|
||
]);
|
||
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);
|
||
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,
|
||
pdpu: [partsDay > 0 ? Math.ceil(partsDay / Math.max(1, A.pdpuHi)) : 0, partsDay > 0 ? Math.ceil(partsDay / Math.max(1, A.pdpuLo)) : 0],
|
||
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 => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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("Parts per working day", f1(r.partsDay), "");
|
||
R("Plating units (Pheenix PDPU)", r.pdpu[0] === r.pdpu[1] ? r.pdpu[0] : `${r.pdpu[0]} to ${r.pdpu[1]}`, `${f0(A.pdpuLo)} to ${f0(A.pdpuHi)} parts a day each, two shifts; plus one for redundancy`);
|
||
$("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>";
|
||
|
||
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();
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 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(); });
|
||
$("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>
|